• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2025 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.systemui.statusbar.pipeline.battery.ui.composable
18 
19 import android.graphics.Rect
20 import androidx.compose.foundation.Canvas
21 import androidx.compose.foundation.layout.fillMaxSize
22 import androidx.compose.runtime.Composable
23 import androidx.compose.runtime.getValue
24 import androidx.compose.runtime.mutableFloatStateOf
25 import androidx.compose.runtime.mutableStateOf
26 import androidx.compose.runtime.remember
27 import androidx.compose.runtime.setValue
28 import androidx.compose.ui.Modifier
29 import androidx.compose.ui.geometry.CornerRadius
30 import androidx.compose.ui.geometry.Offset
31 import androidx.compose.ui.geometry.Size
32 import androidx.compose.ui.graphics.drawscope.clipRect
33 import androidx.compose.ui.graphics.drawscope.inset
34 import androidx.compose.ui.graphics.drawscope.scale
35 import androidx.compose.ui.layout.onLayoutRectChanged
36 import com.android.systemui.common.ui.compose.load
37 import com.android.systemui.lifecycle.rememberViewModel
38 import com.android.systemui.statusbar.phone.domain.interactor.IsAreaDark
39 import com.android.systemui.statusbar.pipeline.battery.shared.ui.BatteryColors
40 import com.android.systemui.statusbar.pipeline.battery.shared.ui.BatteryFrame
41 import com.android.systemui.statusbar.pipeline.battery.shared.ui.BatteryGlyph
42 import com.android.systemui.statusbar.pipeline.battery.shared.ui.PathSpec
43 import com.android.systemui.statusbar.pipeline.battery.ui.viewmodel.BatteryViewModel
44 import kotlin.math.ceil
45 
46 /**
47  * Draws a battery directly on to a [Canvas]. The canvas is scaled to fill its container, and the
48  * resulting battery is scaled using a FIT_CENTER type scaling that preserves the aspect ratio.
49  */
50 @Composable
51 fun BatteryCanvas(
52     path: PathSpec,
53     innerWidth: Float,
54     innerHeight: Float,
55     glyphs: List<BatteryGlyph>,
56     level: Int,
57     isFull: Boolean,
58     colorsProvider: () -> BatteryColors,
59     modifier: Modifier = Modifier,
60     contentDescription: String = "",
61 ) {
62 
63     val totalWidth by
64         remember(glyphs) {
65             mutableFloatStateOf(
66                 if (glyphs.isEmpty()) {
67                     0f
68                 } else {
69                     // Pads in between each glyph, skipping the first
70                     glyphs.drop(1).fold(glyphs.first().width) { acc: Float, next: BatteryGlyph ->
71                         acc + INTER_GLYPH_PADDING_PX + next.width
72                     }
73                 }
74             )
75         }
76 
77     Canvas(modifier = modifier.fillMaxSize(), contentDescription = contentDescription) {
78         val scale = path.scaleTo(size.width, size.height)
79         val colors = colorsProvider()
80 
81         scale(scale, pivot = Offset.Zero) {
82             if (isFull) {
83                 // Saves a layer since we don't need background here
84                 drawPath(path = path.path, color = colors.fill)
85             } else {
86                 // First draw the body
87                 drawPath(path.path, colors.background)
88                 // Then draw the body, clipped to the fill level
89                 clipRect(0f, 0f, innerWidth, innerHeight) {
90                     drawRoundRect(
91                         color = colors.fill,
92                         topLeft = Offset.Zero,
93                         size = Size(width = level.scaledLevel(), height = innerHeight),
94                         cornerRadius = CornerRadius(2f),
95                     )
96                 }
97             }
98 
99             // Now draw the glyphs
100             var horizontalOffset = (BatteryFrame.innerWidth - totalWidth) / 2
101             for (glyph in glyphs) {
102                 // Move the glyph to the right spot
103                 val verticalOffset = (BatteryFrame.innerHeight - glyph.height) / 2
104                 inset(
105                     // Never try and inset more than half of the available size - see b/400246091.
106                     minOf(horizontalOffset, size.width / 2),
107                     minOf(verticalOffset, size.height / 2),
108                 ) {
109                     glyph.draw(this, colors)
110                 }
111 
112                 horizontalOffset += glyph.width + INTER_GLYPH_PADDING_PX
113             }
114         }
115     }
116 }
117 
118 // Experimentally-determined value
119 private const val INTER_GLYPH_PADDING_PX = 0.8f
120 
121 @Composable
UnifiedBatterynull122 fun UnifiedBattery(
123     viewModelFactory: BatteryViewModel.Factory,
124     isDark: IsAreaDark,
125     modifier: Modifier = Modifier,
126 ) {
127     val viewModel = rememberViewModel(traceName = "UnifiedBattery") { viewModelFactory.create() }
128     val path = viewModel.batteryFrame
129 
130     var bounds by remember { mutableStateOf(Rect()) }
131 
132     val colorProvider = {
133         if (isDark.isDark(bounds)) {
134             viewModel.colorProfile.dark
135         } else {
136             viewModel.colorProfile.light
137         }
138     }
139 
140     BatteryCanvas(
141         path = path,
142         innerWidth = viewModel.innerWidth,
143         innerHeight = viewModel.innerHeight,
144         glyphs = viewModel.glyphList,
145         level = viewModel.level,
146         isFull = viewModel.isFull,
147         colorsProvider = colorProvider,
148         modifier =
149             modifier.onLayoutRectChanged { relativeLayoutBounds ->
150                 bounds =
151                     with(relativeLayoutBounds.boundsInScreen) { Rect(left, top, right, bottom) }
152             },
153         contentDescription = viewModel.contentDescription.load() ?: "",
154     )
155 }
156 
157 /** Calculate the right-edge of the clip for the fill-rect, based on a level of [0-100] */
scaledLevelnull158 private fun Int.scaledLevel(): Float {
159     val endSide = BatteryFrame.innerWidth
160     return ceil((toFloat() / 100f) * endSide)
161 }
162