mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(widget): Migrate from RemoteViews to Jetpack Glance
Replace the legacy AppWidgetProvider/RemoteViews implementation with Glance AppWidget for reactive updates, proper centering, and a unified render state model. - Add BatteryGlanceWidget with collectAsState for live device/profile/upgrade data - Add WidgetRenderState sealed class and WidgetRenderStateMapper - Add GlanceWidgetContent (Glance composables) and ComposeWidgetPreview (config preview) - Fix centering by passing spacing via modifier param (no trailing padding on last item) - Remove old XML widget layouts and RemoteViews rendering code
This commit is contained in:
@@ -188,6 +188,7 @@ dependencies {
|
||||
addNavigation()
|
||||
|
||||
addCompose()
|
||||
addGlance()
|
||||
addNavigation3()
|
||||
addSerialization()
|
||||
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
package eu.darken.capod.screenshots
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.main.ui.widget.ComposeWidgetPreview
|
||||
import eu.darken.capod.main.ui.widget.WidgetRenderState
|
||||
import eu.darken.capod.reaction.ui.popup.PopUpContent as PopUpCard
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationScreen
|
||||
import eu.darken.capod.main.ui.widget.WidgetConfigurationViewModel
|
||||
@@ -234,116 +225,12 @@ internal fun HomescreenWidgetContent() {
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
WidgetDualCompact()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetDualCompact() {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 2.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
WidgetPodRow(
|
||||
icon = R.drawable.device_airpods_pro2_left,
|
||||
percent = 85,
|
||||
charging = false,
|
||||
inEar = true,
|
||||
)
|
||||
WidgetPodRow(
|
||||
icon = R.drawable.device_airpods_pro2_right,
|
||||
percent = 92,
|
||||
charging = true,
|
||||
inEar = false,
|
||||
)
|
||||
WidgetCaseRow(
|
||||
icon = R.drawable.device_airpods_pro2_case,
|
||||
percent = 100,
|
||||
charging = false,
|
||||
)
|
||||
Text(
|
||||
text = "My AirPods Pro",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetPodRow(
|
||||
@DrawableRes icon: Int,
|
||||
percent: Int,
|
||||
charging: Boolean,
|
||||
inEar: Boolean,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = "$percent%",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
if (inEar) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetCaseRow(
|
||||
@DrawableRes icon: Int,
|
||||
percent: Int,
|
||||
charging: Boolean,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.padding(vertical = 2.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Text(
|
||||
text = "$percent%",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
ComposeWidgetPreview(
|
||||
state = WidgetRenderState.previewDualPod(
|
||||
bgColor = MaterialTheme.colorScheme.surface.toArgb(),
|
||||
textColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
||||
iconColor = MaterialTheme.colorScheme.onSurface.toArgb(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
@@ -42,6 +43,8 @@ open class App : Application() {
|
||||
|
||||
monitorControl.startMonitor(forceStart = true)
|
||||
|
||||
appScope.launch { widgetManager.refreshWidgets() }
|
||||
|
||||
podMonitor.devicesWithProfiles()
|
||||
.distinctUntilChanged()
|
||||
.throttleLatest(1000)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.LocalSize
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.SizeMode
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import dagger.hilt.EntryPoint
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
|
||||
class BatteryGlanceWidget : GlanceAppWidget() {
|
||||
|
||||
override val sizeMode = SizeMode.Exact
|
||||
|
||||
@EntryPoint
|
||||
@InstallIn(SingletonComponent::class)
|
||||
interface WidgetEntryPoint {
|
||||
fun podMonitor(): PodMonitor
|
||||
fun upgradeRepo(): UpgradeRepo
|
||||
fun widgetSettings(): WidgetSettings
|
||||
fun deviceProfilesRepo(): DeviceProfilesRepo
|
||||
}
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val ep = EntryPointAccessors.fromApplication(context, WidgetEntryPoint::class.java)
|
||||
|
||||
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
|
||||
log(TAG, VERBOSE) { "provideGlance(appWidgetId=$appWidgetId)" }
|
||||
|
||||
// Pre-load initial values (runs once per session, suspend OK)
|
||||
val initialIsPro = ep.upgradeRepo().isPro()
|
||||
val initialProfileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
val cachedDevice = initialProfileId?.let { ep.podMonitor().getDeviceForProfile(it) }
|
||||
|
||||
provideContent {
|
||||
// Composable reads — must be outside try-catch
|
||||
val devices by ep.podMonitor().devices.collectAsState(initial = emptyList())
|
||||
val profiles by ep.deviceProfilesRepo().profiles.collectAsState(initial = emptyList())
|
||||
val upgradeInfo by ep.upgradeRepo().upgradeInfo.collectAsState(initial = null)
|
||||
val widthDp = LocalSize.current.width
|
||||
|
||||
val state = try {
|
||||
val profileId = ep.widgetSettings().getWidgetProfile(appWidgetId)
|
||||
val theme = WidgetTheme.fromBundle(
|
||||
AppWidgetManager.getInstance(context).getAppWidgetOptions(appWidgetId)
|
||||
)
|
||||
|
||||
val isPro = upgradeInfo?.isPro ?: initialIsPro
|
||||
|
||||
val liveDevice = devices.firstOrNull { it.meta.profile?.id == profileId }
|
||||
val device = liveDevice ?: cachedDevice?.takeIf { it.meta.profile?.id == profileId }
|
||||
|
||||
val profileLabel = profileId?.let { pid ->
|
||||
profiles.firstOrNull { it.id == pid }?.label
|
||||
}
|
||||
|
||||
val isWide = getCellsForSize(widthDp.value.toInt()) >= 5
|
||||
|
||||
WidgetRenderStateMapper.map(
|
||||
context = context,
|
||||
device = device,
|
||||
theme = theme,
|
||||
isPro = isPro,
|
||||
hasConfiguredProfile = profileId != null,
|
||||
profileLabel = profileLabel,
|
||||
isWide = isWide,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "provideGlance failed: ${e.asLog()}" }
|
||||
WidgetRenderState.Message(
|
||||
theme = WidgetTheme.DEFAULT,
|
||||
resolvedBgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
resolvedTextColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||
resolvedIconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||
primaryText = context.getString(eu.darken.capod.R.string.widget_error_loading_label),
|
||||
)
|
||||
}
|
||||
|
||||
GlanceWidgetContent(state = state, context = context)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun providePreview(context: Context, widgetCategory: Int) {
|
||||
val previewState = WidgetRenderState.previewDualPod(
|
||||
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, WidgetTheme.DEFAULT),
|
||||
textColor = WidgetRenderStateMapper.resolvedTextColor(context, WidgetTheme.DEFAULT),
|
||||
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, WidgetTheme.DEFAULT),
|
||||
)
|
||||
|
||||
provideContent {
|
||||
GlanceWidgetContent(state = previewState, context = context)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns number of cells needed for given size of the widget.
|
||||
* https://developer.android.com/guide/practices/ui_guidelines/widget_design
|
||||
*/
|
||||
private fun getCellsForSize(size: Int): Int {
|
||||
var n = 2
|
||||
while (70 * n - 30 < size) {
|
||||
++n
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = logTag("Widget", "Glance")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun ComposeWidgetPreview(
|
||||
state: WidgetRenderState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (state) {
|
||||
is WidgetRenderState.DualPod -> DualPodPreview(state, modifier)
|
||||
is WidgetRenderState.SinglePod -> SinglePodPreview(state, modifier)
|
||||
is WidgetRenderState.Message -> MessagePreview(state, modifier)
|
||||
is WidgetRenderState.Loading -> LoadingPreview(state, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckerboardBackground(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val lightColor = Color(0xFFE8E8E8)
|
||||
val darkColor = Color(0xFFD0D0D0)
|
||||
|
||||
Box(
|
||||
modifier = modifier.clipToBounds().drawBehind {
|
||||
val cellSize = 8.dp.toPx()
|
||||
val cols = (size.width / cellSize).toInt() + 1
|
||||
val rows = (size.height / cellSize).toInt() + 1
|
||||
drawRect(lightColor)
|
||||
for (row in 0 until rows) {
|
||||
for (col in 0 until cols) {
|
||||
if ((row + col) % 2 == 0) {
|
||||
drawRect(
|
||||
color = darkColor,
|
||||
topLeft = Offset(col * cellSize, row * cellSize),
|
||||
size = Size(cellSize, cellSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DualPodPreview(
|
||||
state: WidgetRenderState.DualPod,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bgColor = Color(state.resolvedBgColor)
|
||||
val textColor = Color(state.resolvedTextColor)
|
||||
val iconColor = Color(state.resolvedIconColor)
|
||||
val iconTint = ColorFilter.tint(iconColor)
|
||||
|
||||
if (state.isWide) {
|
||||
// Wide layout: left | case | right in a horizontal row
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Left pod
|
||||
PodItemRow(
|
||||
icon = state.leftIcon,
|
||||
percent = state.leftPercent,
|
||||
charging = state.leftCharging,
|
||||
inEar = state.leftInEar,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
iconSize = 40,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
// Case
|
||||
PodItemRow(
|
||||
icon = state.caseIcon,
|
||||
percent = state.casePercent,
|
||||
charging = state.caseCharging,
|
||||
inEar = false,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
iconSize = 40,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
// Right pod
|
||||
PodItemRow(
|
||||
icon = state.rightIcon,
|
||||
percent = state.rightPercent,
|
||||
charging = state.rightCharging,
|
||||
inEar = state.rightInEar,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
iconSize = 40,
|
||||
)
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Compact layout: vertical stack
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
PodItemRow(
|
||||
icon = state.leftIcon,
|
||||
percent = state.leftPercent,
|
||||
charging = state.leftCharging,
|
||||
inEar = state.leftInEar,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
PodItemRow(
|
||||
icon = state.rightIcon,
|
||||
percent = state.rightPercent,
|
||||
charging = state.rightCharging,
|
||||
inEar = state.rightInEar,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
PodItemRow(
|
||||
icon = state.caseIcon,
|
||||
percent = state.casePercent,
|
||||
charging = state.caseCharging,
|
||||
inEar = false,
|
||||
textColor = textColor,
|
||||
iconTint = iconTint,
|
||||
)
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SinglePodPreview(
|
||||
state: WidgetRenderState.SinglePod,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bgColor = Color(state.resolvedBgColor)
|
||||
val textColor = Color(state.resolvedTextColor)
|
||||
val iconTint = ColorFilter.tint(Color(state.resolvedIconColor))
|
||||
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(state.batteryIcon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(state.percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (state.worn) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
DeviceLabel(
|
||||
label = state.deviceLabel,
|
||||
visible = state.theme.showDeviceLabel,
|
||||
textColor = textColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessagePreview(
|
||||
state: WidgetRenderState.Message,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bgColor = Color(state.resolvedBgColor)
|
||||
val textColor = Color(state.resolvedTextColor)
|
||||
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Text(
|
||||
text = state.primaryText,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (state.secondaryText != null) {
|
||||
Text(
|
||||
text = state.secondaryText,
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingPreview(
|
||||
state: WidgetRenderState.Loading,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bgColor = Color(state.resolvedBgColor)
|
||||
val textColor = Color(state.resolvedTextColor)
|
||||
|
||||
WidgetContainer(bgColor = bgColor, modifier = modifier) {
|
||||
Text(
|
||||
text = "…",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetContainer(
|
||||
bgColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(bgColor)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PodItemRow(
|
||||
icon: Int,
|
||||
percent: Float?,
|
||||
charging: Boolean,
|
||||
inEar: Boolean,
|
||||
textColor: Color,
|
||||
iconTint: ColorFilter,
|
||||
iconSize: Int = 20,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(iconSize.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatPercent(percent),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (inEar) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceLabel(
|
||||
label: String?,
|
||||
visible: Boolean,
|
||||
textColor: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (visible && label != null) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatPercent(percent: Float?): String {
|
||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualCompact() = PreviewWrapper {
|
||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod())
|
||||
}
|
||||
|
||||
@Preview2
|
||||
@Composable
|
||||
private fun PreviewDualWide() = PreviewWrapper {
|
||||
ComposeWidgetPreview(state = WidgetRenderState.previewDualPod(isWide = true))
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceModifier
|
||||
import androidx.glance.Image
|
||||
import androidx.glance.ImageProvider
|
||||
import androidx.glance.action.clickable
|
||||
import androidx.glance.appwidget.action.actionStartActivity
|
||||
import androidx.glance.background
|
||||
import androidx.glance.unit.ColorProvider
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Column
|
||||
import androidx.glance.layout.Row
|
||||
import androidx.glance.layout.fillMaxSize
|
||||
import androidx.glance.layout.fillMaxWidth
|
||||
import androidx.glance.layout.padding
|
||||
import androidx.glance.layout.size
|
||||
import androidx.glance.text.FontWeight
|
||||
import androidx.glance.text.Text
|
||||
import androidx.glance.text.TextAlign
|
||||
import androidx.glance.text.TextStyle
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun GlanceWidgetContent(
|
||||
state: WidgetRenderState,
|
||||
context: android.content.Context,
|
||||
) {
|
||||
val openApp = actionStartActivity(
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
)
|
||||
|
||||
when (state) {
|
||||
is WidgetRenderState.DualPod -> GlanceDualPod(state, GlanceModifier.clickable(openApp))
|
||||
is WidgetRenderState.SinglePod -> GlanceSinglePod(state, GlanceModifier.clickable(openApp))
|
||||
is WidgetRenderState.Message -> GlanceMessage(state, GlanceModifier.clickable(openApp))
|
||||
is WidgetRenderState.Loading -> GlanceLoading(state, GlanceModifier.clickable(openApp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceDualPod(
|
||||
state: WidgetRenderState.DualPod,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
val textStyle = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
|
||||
|
||||
if (state.isWide) {
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(top = 8.dp, bottom = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint, iconSize = 40, modifier = GlanceModifier.padding(end = 12.dp))
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint, iconSize = 40)
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
} else {
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
GlancePodItem(state.leftIcon, state.leftPercent, state.leftCharging, state.leftInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.rightIcon, state.rightPercent, state.rightCharging, state.rightInEar, textStyle, iconTint)
|
||||
GlancePodItem(state.caseIcon, state.casePercent, state.caseCharging, false, textStyle, iconTint)
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceSinglePod(
|
||||
state: WidgetRenderState.SinglePod,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
val textStyle = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
val iconTint = ColorFilter.tint(fixedColor(state.resolvedIconColor))
|
||||
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Row(
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(state.batteryIcon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(state.percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 8.dp),
|
||||
)
|
||||
if (state.charging) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (state.worn) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
GlanceDeviceLabel(state.deviceLabel, state.theme.showDeviceLabel, state.resolvedTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceMessage(
|
||||
state: WidgetRenderState.Message,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
val textStyle = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Text(
|
||||
text = state.primaryText,
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
)
|
||||
if (state.secondaryText != null) {
|
||||
Text(
|
||||
text = state.secondaryText,
|
||||
style = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
maxLines = 4,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceLoading(
|
||||
state: WidgetRenderState.Loading,
|
||||
clickModifier: GlanceModifier,
|
||||
) {
|
||||
GlanceWidgetRoot(state.resolvedBgColor, clickModifier) {
|
||||
Text(
|
||||
text = "…",
|
||||
style = TextStyle(
|
||||
color = fixedColor(state.resolvedTextColor),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
modifier = GlanceModifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceWidgetRoot(
|
||||
bgColor: Int,
|
||||
clickModifier: GlanceModifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = clickModifier
|
||||
.fillMaxSize()
|
||||
.background(fixedColor(bgColor))
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlancePodItem(
|
||||
icon: Int,
|
||||
percent: Float?,
|
||||
charging: Boolean,
|
||||
inEar: Boolean,
|
||||
textStyle: TextStyle,
|
||||
iconTint: ColorFilter,
|
||||
iconSize: Int = 20,
|
||||
modifier: GlanceModifier = GlanceModifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
provider = ImageProvider(icon),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(iconSize.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
Text(
|
||||
text = formatGlancePercent(percent),
|
||||
style = textStyle,
|
||||
modifier = GlanceModifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
if (charging) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_power_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
if (inEar) {
|
||||
Image(
|
||||
provider = ImageProvider(R.drawable.ic_baseline_hearing_24),
|
||||
contentDescription = null,
|
||||
modifier = GlanceModifier.size(20.dp),
|
||||
colorFilter = iconTint,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlanceDeviceLabel(
|
||||
label: String?,
|
||||
visible: Boolean,
|
||||
textColor: Int,
|
||||
) {
|
||||
if (visible && label != null) {
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
color = fixedColor(textColor),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixedColor(argb: Int): ColorProvider = ColorProvider(Color(argb))
|
||||
|
||||
private fun formatGlancePercent(percent: Float?): String {
|
||||
return percent?.let { "${(it * 100).roundToInt()}%" } ?: "—"
|
||||
}
|
||||
@@ -14,6 +14,8 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.compose.waitForState
|
||||
@@ -23,6 +25,7 @@ import eu.darken.capod.common.theming.CapodTheme
|
||||
import eu.darken.capod.common.uix.Activity2
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.main.core.GeneralSettings
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -99,15 +102,12 @@ class WidgetConfigurationActivity : Activity2() {
|
||||
val resultValue = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
|
||||
setResult(RESULT_OK, resultValue)
|
||||
|
||||
val appWidgetManager = AppWidgetManager.getInstance(appContext)
|
||||
|
||||
WidgetProvider.updateWidget(
|
||||
context = appContext,
|
||||
appWidgetManager = appWidgetManager,
|
||||
widgetId = widgetId
|
||||
)
|
||||
|
||||
finish()
|
||||
lifecycleScope.launch {
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
val glanceId = manager.getGlanceIdBy(widgetId)
|
||||
BatteryGlanceWidget().update(appContext, glanceId)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.Shader
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
@@ -78,15 +70,10 @@ import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import androidx.core.view.isVisible
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
@@ -126,6 +113,7 @@ fun WidgetConfigurationScreen(
|
||||
Text(
|
||||
text = stringResource(R.string.widget_config_screen_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = screenHPad, end = screenHPad, bottom = 16.dp),
|
||||
@@ -196,7 +184,7 @@ fun WidgetConfigurationScreen(
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Live preview
|
||||
WidgetPreview(
|
||||
WidgetConfigPreview(
|
||||
theme = state.theme,
|
||||
deviceLabel = state.profiles.firstOrNull { it.id == state.selectedProfile }?.label,
|
||||
)
|
||||
@@ -445,41 +433,21 @@ private fun ProfileSelectionItem(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WidgetPreview(
|
||||
private fun WidgetConfigPreview(
|
||||
theme: WidgetTheme,
|
||||
deviceLabel: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val hasTransparency = theme.backgroundColor != null && theme.backgroundAlpha < 255
|
||||
|
||||
val checkerboardDrawable = remember(density) {
|
||||
val cellSize = (8 * context.resources.displayMetrics.density).toInt()
|
||||
val bitmap = createBitmap(cellSize * 2, cellSize * 2)
|
||||
val canvas = Canvas(bitmap)
|
||||
val paint = Paint()
|
||||
paint.color = 0xFFE8E8E8.toInt()
|
||||
canvas.drawRect(0f, 0f, (cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint)
|
||||
paint.color = 0xFFD0D0D0.toInt()
|
||||
canvas.drawRect(0f, 0f, cellSize.toFloat(), cellSize.toFloat(), paint)
|
||||
canvas.drawRect(
|
||||
cellSize.toFloat(), cellSize.toFloat(),
|
||||
(cellSize * 2).toFloat(), (cellSize * 2).toFloat(), paint,
|
||||
)
|
||||
bitmap.toDrawable(context.resources).apply {
|
||||
tileModeX = Shader.TileMode.REPEAT
|
||||
tileModeY = Shader.TileMode.REPEAT
|
||||
}
|
||||
}
|
||||
|
||||
val resolvedBgColor = remember(context) {
|
||||
resolveThemeColor(context, android.R.attr.colorBackground)
|
||||
}
|
||||
val resolvedTextColor = remember(context) {
|
||||
resolveThemeColor(context, android.R.attr.textColorPrimary)
|
||||
}
|
||||
val resolvedAccentColor = remember(context) {
|
||||
resolveThemeColor(context, android.R.attr.colorAccent)
|
||||
val previewState = remember(theme, deviceLabel) {
|
||||
WidgetRenderState.previewDualPod(
|
||||
theme = theme,
|
||||
bgColor = WidgetRenderStateMapper.resolvedBgColor(context, theme),
|
||||
textColor = WidgetRenderStateMapper.resolvedTextColor(context, theme),
|
||||
iconColor = WidgetRenderStateMapper.resolvedIconColor(context, theme),
|
||||
).copy(deviceLabel = deviceLabel)
|
||||
}
|
||||
|
||||
Surface(
|
||||
@@ -488,95 +456,24 @@ private fun WidgetPreview(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
tonalElevation = 2.dp,
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { ctx ->
|
||||
val container = android.widget.FrameLayout(ctx)
|
||||
|
||||
// Outer container for checkerboard
|
||||
val outerPadding = (24 * ctx.resources.displayMetrics.density).toInt()
|
||||
container.setPadding(outerPadding, outerPadding, outerPadding, outerPadding)
|
||||
|
||||
// Clip wrapper — rounded background + clipToOutline so the inner content is clipped
|
||||
val clipWrapper = android.widget.FrameLayout(ctx).apply {
|
||||
setBackgroundResource(R.drawable.widget_preview_bg)
|
||||
clipToOutline = true
|
||||
}
|
||||
|
||||
// Inner preview content
|
||||
LayoutInflater.from(ctx).inflate(R.layout.widget_config_preview, clipWrapper, true)
|
||||
|
||||
container.addView(
|
||||
clipWrapper, android.widget.FrameLayout.LayoutParams(
|
||||
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
android.widget.FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
android.view.Gravity.CENTER,
|
||||
)
|
||||
)
|
||||
|
||||
container
|
||||
},
|
||||
update = { container ->
|
||||
val hasTransparency = theme.backgroundColor != null && theme.backgroundAlpha < 255
|
||||
container.background = if (hasTransparency) {
|
||||
checkerboardDrawable
|
||||
} else {
|
||||
androidx.appcompat.content.res.AppCompatResources.getDrawable(
|
||||
context, R.drawable.widget_preview_checkerboard
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasTransparency) {
|
||||
CheckerboardBackground {
|
||||
ComposeWidgetPreview(
|
||||
state = previewState,
|
||||
modifier = Modifier.padding(6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
val clipWrapper = container.getChildAt(0) ?: return@AndroidView
|
||||
val widgetRoot = clipWrapper.findViewById<View>(R.id.preview_widget_root) ?: return@AndroidView
|
||||
|
||||
// Background color applied to the inner view — clipWrapper's outline clips the corners
|
||||
val bgColor = theme.backgroundColor
|
||||
if (bgColor != null) {
|
||||
widgetRoot.setBackgroundColor(WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha))
|
||||
} else {
|
||||
widgetRoot.setBackgroundColor(resolvedBgColor)
|
||||
}
|
||||
|
||||
// Foreground colors
|
||||
val fgColor = theme.foregroundColor
|
||||
val textColor = fgColor ?: resolvedTextColor
|
||||
val iconColor = fgColor ?: resolvedAccentColor
|
||||
|
||||
val textIds = intArrayOf(
|
||||
R.id.preview_left_label, R.id.preview_right_label,
|
||||
R.id.preview_case_label, R.id.preview_device_label,
|
||||
)
|
||||
val iconIds = intArrayOf(
|
||||
R.id.preview_left_icon, R.id.preview_right_icon, R.id.preview_case_icon,
|
||||
)
|
||||
|
||||
for (id in textIds) {
|
||||
clipWrapper.findViewById<TextView>(id)?.setTextColor(textColor)
|
||||
}
|
||||
for (id in iconIds) {
|
||||
clipWrapper.findViewById<ImageView>(id)?.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN)
|
||||
}
|
||||
|
||||
// Device label
|
||||
val labelView = clipWrapper.findViewById<TextView>(R.id.preview_device_label)
|
||||
labelView?.isVisible = theme.showDeviceLabel
|
||||
labelView?.text = deviceLabel ?: ""
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
ComposeWidgetPreview(state = previewState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveThemeColor(context: android.content.Context, attr: Int): Int {
|
||||
val wrapper = androidx.appcompat.view.ContextThemeWrapper(
|
||||
context,
|
||||
com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight,
|
||||
)
|
||||
val typedArray = wrapper.theme.obtainStyledAttributes(intArrayOf(attr))
|
||||
val color = typedArray.getColor(0, android.graphics.Color.BLACK)
|
||||
typedArray.recycle()
|
||||
return color
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun PresetChips(
|
||||
@@ -638,8 +535,8 @@ private fun PresetChips(
|
||||
FilterChip(
|
||||
selected = isCustomMode,
|
||||
onClick = {
|
||||
val defaultBg = resolveThemeColor(context, android.R.attr.colorBackground)
|
||||
val defaultFg = resolveThemeColor(context, android.R.attr.textColorPrimary)
|
||||
val defaultBg = WidgetRenderStateMapper.resolveThemeColor(context, android.R.attr.colorBackground)
|
||||
val defaultFg = WidgetRenderStateMapper.resolveThemeColor(context, android.R.attr.textColorPrimary)
|
||||
onEnterCustomMode(defaultBg, defaultFg)
|
||||
},
|
||||
label = { Text(stringResource(R.string.widget_config_custom_label)) },
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import javax.inject.Inject
|
||||
@@ -17,25 +14,12 @@ class WidgetManager @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
|
||||
private val widgetManager by lazy { AppWidgetManager.getInstance(context) }
|
||||
|
||||
private val currentWidgetIds: IntArray
|
||||
get() = widgetManager.getAppWidgetIds(ComponentName(context, PROVIDER_CLASS))
|
||||
|
||||
suspend fun refreshWidgets() {
|
||||
log(TAG) { "refreshWidgets()" }
|
||||
|
||||
log(TAG, VERBOSE) { "Notifying these widget IDs: ${currentWidgetIds.toList()}" }
|
||||
|
||||
val intent = Intent(context, PROVIDER_CLASS).apply {
|
||||
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
||||
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, currentWidgetIds)
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
BatteryGlanceWidget().updateAll(context)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val PROVIDER_CLASS = WidgetProvider::class.java
|
||||
val TAG = logTag("Widget", "Manager")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,474 +1,29 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.RemoteViews
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.coroutine.AppScope
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.asLog
|
||||
import androidx.glance.appwidget.GlanceAppWidget
|
||||
import androidx.glance.appwidget.GlanceAppWidgetReceiver
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.upgrade.UpgradeRepo
|
||||
import eu.darken.capod.common.upgrade.isPro
|
||||
import eu.darken.capod.main.ui.MainActivity
|
||||
import eu.darken.capod.monitor.core.PodDeviceCache
|
||||
import eu.darken.capod.monitor.core.PodMonitor
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.PodFactory
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.profiles.core.ProfileId
|
||||
import finish2
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.time.Duration
|
||||
import javax.inject.Inject
|
||||
|
||||
class WidgetProvider : GlanceAppWidgetReceiver() {
|
||||
|
||||
@AndroidEntryPoint
|
||||
class WidgetProvider : AppWidgetProvider() {
|
||||
|
||||
@Inject lateinit var podMonitor: PodMonitor
|
||||
@Inject lateinit var podDeviceCache: PodDeviceCache
|
||||
@Inject lateinit var podFactory: PodFactory
|
||||
@Inject lateinit var upgradeRepo: UpgradeRepo
|
||||
@Inject lateinit var widgetSettings: WidgetSettings
|
||||
@Inject lateinit var deviceProfilesRepo: DeviceProfilesRepo
|
||||
@AppScope @Inject lateinit var appScope: CoroutineScope
|
||||
|
||||
private fun executeAsync(
|
||||
tag: String,
|
||||
timeout: Duration = Duration.ofSeconds(7),
|
||||
block: suspend () -> Unit
|
||||
) {
|
||||
val start = System.currentTimeMillis()
|
||||
val asyncBarrier = goAsync()
|
||||
log(TAG, VERBOSE) { "executeAsync($tag) starting asyncBarrier=$asyncBarrier " }
|
||||
|
||||
appScope.launch {
|
||||
try {
|
||||
withTimeout(timeout.toMillis()) { block() }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, ERROR) { "executeAsync($tag) failed: ${e.asLog()}" }
|
||||
} finally {
|
||||
asyncBarrier.finish2()
|
||||
val stop = System.currentTimeMillis()
|
||||
log(TAG, VERBOSE) { "executeAsync($tag) DONE (${stop - start}ms) " }
|
||||
}
|
||||
}
|
||||
|
||||
log(TAG, VERBOSE) { "executeAsync($block) leaving" }
|
||||
}
|
||||
|
||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||
log(TAG) { "onUpdate(appWidgetIds=${appWidgetIds.toList()})" }
|
||||
executeAsync("onUpdate") {
|
||||
appWidgetIds.forEach { appWidgetId ->
|
||||
updateWidget(context, appWidgetManager, appWidgetId, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAppWidgetOptionsChanged(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetId: Int,
|
||||
newOptions: Bundle?
|
||||
) {
|
||||
log(TAG) { "onAppWidgetOptionsChanged(appWidgetId=$appWidgetId, newOptions=$newOptions)" }
|
||||
executeAsync("onAppWidgetOptionsChanged") {
|
||||
updateWidget(context, appWidgetManager, appWidgetId, newOptions)
|
||||
}
|
||||
}
|
||||
override val glanceAppWidget: GlanceAppWidget = BatteryGlanceWidget()
|
||||
|
||||
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
|
||||
log(TAG) { "onDeleted(appWidgetIds=${appWidgetIds.toList()})" }
|
||||
executeAsync("onDeleted") {
|
||||
appWidgetIds.forEach { widgetId ->
|
||||
widgetSettings.removeWidget(widgetId)
|
||||
}
|
||||
super.onDeleted(context, appWidgetIds)
|
||||
val ep = EntryPointAccessors.fromApplication(
|
||||
context.applicationContext,
|
||||
BatteryGlanceWidget.WidgetEntryPoint::class.java,
|
||||
)
|
||||
appWidgetIds.forEach { widgetId ->
|
||||
ep.widgetSettings().removeWidget(widgetId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns number of cells needed for given size of the widget.
|
||||
*
|
||||
* The value is determined in accordance with official guidelines
|
||||
* for designing widgets, see:
|
||||
* https://developer.android.com/guide/practices/ui_guidelines/widget_design
|
||||
*
|
||||
* Thanks to Jakub S. on Stackoverflow for this solution:
|
||||
* https://stackoverflow.com/a/37522648/10866268
|
||||
*
|
||||
* @param size Widget size in dp.
|
||||
* @return Size in number of cells.
|
||||
*/
|
||||
private fun getCellsForSize(size: Int): Int {
|
||||
var n = 2
|
||||
while (70 * n - 30 < size) {
|
||||
++n
|
||||
}
|
||||
return n - 1
|
||||
}
|
||||
|
||||
private suspend fun updateWidget(
|
||||
context: Context,
|
||||
widgetManager: AppWidgetManager,
|
||||
widgetId: Int,
|
||||
options: Bundle?
|
||||
) {
|
||||
val profileId: ProfileId? = widgetSettings.getWidgetProfile(widgetId)
|
||||
log(TAG) { "updateWidget(widgetId=$widgetId, profileId=$profileId options=$options)" }
|
||||
|
||||
val device: PodDevice? = profileId?.let { podMonitor.getDeviceForProfile(it) }
|
||||
val profileLabel: String? = profileId?.let { id ->
|
||||
deviceProfilesRepo.profiles.first().firstOrNull { it.id == id }?.label
|
||||
}
|
||||
|
||||
val theme = WidgetTheme.fromBundle(widgetManager.getAppWidgetOptions(widgetId))
|
||||
log(TAG, VERBOSE) { "updateWidget: theme=$theme" }
|
||||
|
||||
val layout = when {
|
||||
!upgradeRepo.isPro() -> createUpgradeRequiredLayout(context, widgetId, theme)
|
||||
device is DualPodDevice -> {
|
||||
val minWidth = widgetManager.getAppWidgetOptions(widgetId)
|
||||
.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH)
|
||||
val columns = getCellsForSize(minWidth)
|
||||
|
||||
/* Enable wide widgets only when we are provided with 5 or
|
||||
* more columns of space.
|
||||
* Although the minimum size is only 2x1, the safeguards are
|
||||
* added if these restrictions will ever be loosened in the future.
|
||||
*/
|
||||
val layout = when (columns) {
|
||||
in 1..4 -> R.layout.widget_pod_dual_compact_layout
|
||||
else -> R.layout.widget_pod_dual_wide_layout
|
||||
}
|
||||
|
||||
createDualPodLayout(context, device, layout, widgetId, theme, profileLabel)
|
||||
}
|
||||
|
||||
device is SinglePodDevice -> createSinglePodLayout(context, device, widgetId, theme, profileLabel)
|
||||
device is PodDevice -> createUnknownPodLayout(context, device, widgetId, theme)
|
||||
else -> createNoDeviceLayout(context, profileId != null, widgetId, theme)
|
||||
}
|
||||
widgetManager.updateAppWidget(widgetId, layout)
|
||||
}
|
||||
|
||||
private fun applyThemeColors(
|
||||
context: Context,
|
||||
views: RemoteViews,
|
||||
theme: WidgetTheme,
|
||||
textViewIds: List<Int>,
|
||||
iconViewIds: List<Int>,
|
||||
hasDeviceLabel: Boolean,
|
||||
) {
|
||||
// Always explicitly set background color to ensure previous custom colors are overwritten
|
||||
val bgColor = theme.backgroundColor
|
||||
if (bgColor != null) {
|
||||
val colorWithAlpha = WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha)
|
||||
views.setInt(R.id.widget_root, "setBackgroundColor", colorWithAlpha)
|
||||
} else {
|
||||
// Reset to theme default — resolve ?android:attr/colorBackground
|
||||
val defaultBg = resolveThemeColor(context, android.R.attr.colorBackground)
|
||||
views.setInt(R.id.widget_root, "setBackgroundColor", defaultBg)
|
||||
}
|
||||
|
||||
// Always explicitly set text/icon colors
|
||||
val fgColor = theme.foregroundColor
|
||||
if (fgColor != null) {
|
||||
for (textViewId in textViewIds) {
|
||||
views.setTextColor(textViewId, fgColor)
|
||||
}
|
||||
for (iconViewId in iconViewIds) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
views.setColorStateList(iconViewId, "setImageTintList", ColorStateList.valueOf(fgColor))
|
||||
} else {
|
||||
views.setInt(iconViewId, "setColorFilter", fgColor)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset to theme defaults
|
||||
val defaultTextColor = resolveThemeColor(context, android.R.attr.textColorPrimary)
|
||||
val defaultIconColor = resolveThemeColor(context, android.R.attr.colorAccent)
|
||||
for (textViewId in textViewIds) {
|
||||
views.setTextColor(textViewId, defaultTextColor)
|
||||
}
|
||||
for (iconViewId in iconViewIds) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
views.setColorStateList(iconViewId, "setImageTintList", ColorStateList.valueOf(defaultIconColor))
|
||||
} else {
|
||||
views.setInt(iconViewId, "setColorFilter", defaultIconColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDeviceLabel) {
|
||||
views.setViewVisibility(
|
||||
R.id.headphones_label,
|
||||
if (theme.showDeviceLabel) View.VISIBLE else View.GONE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveThemeColor(context: Context, attr: Int): Int {
|
||||
val themedContext = ContextThemeWrapper(context, com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight)
|
||||
val typedArray = themedContext.theme.obtainStyledAttributes(intArrayOf(attr))
|
||||
val color = typedArray.getColor(0, android.graphics.Color.BLACK)
|
||||
typedArray.recycle()
|
||||
return color
|
||||
}
|
||||
|
||||
private suspend fun createUpgradeRequiredLayout(
|
||||
context: Context,
|
||||
widgetId: Int,
|
||||
theme: WidgetTheme,
|
||||
) = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
||||
log(TAG, VERBOSE) { "createUpgradeRequiredLayout(context=$context)" }
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
widgetId,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
||||
|
||||
setTextViewText(R.id.primary, context.getString(R.string.upgrade_capod_label))
|
||||
setTextViewText(R.id.secondary, context.getString(R.string.upgrade_capod_description))
|
||||
setViewVisibility(R.id.secondary, View.VISIBLE)
|
||||
|
||||
applyThemeColors(
|
||||
context = context,
|
||||
views = this,
|
||||
theme = theme,
|
||||
textViewIds = listOf(R.id.primary, R.id.secondary),
|
||||
iconViewIds = emptyList(),
|
||||
hasDeviceLabel = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createUnknownPodLayout(
|
||||
context: Context,
|
||||
podDevice: PodDevice,
|
||||
widgetId: Int,
|
||||
theme: WidgetTheme,
|
||||
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
||||
log(TAG, VERBOSE) { "createUnknownPodLayout(context=$context, podDevice=$podDevice)" }
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
widgetId,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
||||
|
||||
setTextViewText(R.id.primary, context.getString(R.string.pods_unknown_label))
|
||||
|
||||
applyThemeColors(
|
||||
context = context,
|
||||
views = this,
|
||||
theme = theme,
|
||||
textViewIds = listOf(R.id.primary),
|
||||
iconViewIds = emptyList(),
|
||||
hasDeviceLabel = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createNoDeviceLayout(
|
||||
context: Context,
|
||||
hasConfiguredProfile: Boolean = false,
|
||||
widgetId: Int,
|
||||
theme: WidgetTheme,
|
||||
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_message_layout).apply {
|
||||
log(TAG, VERBOSE) { "createNoDeviceLayout(context=$context, hasConfiguredProfile=$hasConfiguredProfile)" }
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
widgetId,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
||||
|
||||
val messageRes = if (hasConfiguredProfile) {
|
||||
R.string.widget_no_data_label
|
||||
} else {
|
||||
R.string.overview_nomaindevice_label
|
||||
}
|
||||
setTextViewText(R.id.primary, context.getString(messageRes))
|
||||
|
||||
applyThemeColors(
|
||||
context = context,
|
||||
views = this,
|
||||
theme = theme,
|
||||
textViewIds = listOf(R.id.primary),
|
||||
iconViewIds = emptyList(),
|
||||
hasDeviceLabel = false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createDualPodLayout(
|
||||
context: Context,
|
||||
podDevice: DualPodDevice,
|
||||
@LayoutRes layout: Int,
|
||||
widgetId: Int,
|
||||
theme: WidgetTheme,
|
||||
profileLabel: String?,
|
||||
): RemoteViews = RemoteViews(context.packageName, layout).apply {
|
||||
log(TAG, VERBOSE) { "createDualPodLayout(context=$context, podDevice=$podDevice), layout=${layout}" }
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
widgetId,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
||||
|
||||
setTextViewText(R.id.headphones_label, profileLabel ?: podDevice.getLabel(context))
|
||||
|
||||
// Left
|
||||
val leftPercent = podDevice.batteryLeftPodPercent
|
||||
setImageViewResource(R.id.pod_left_icon, podDevice.leftPodIcon)
|
||||
setTextViewText(R.id.pod_left_label, formatBatteryPercent(context, leftPercent))
|
||||
setViewVisibility(
|
||||
R.id.pod_left_charging,
|
||||
if (podDevice is HasChargeDetectionDual && podDevice.isLeftPodCharging) View.VISIBLE else View.GONE
|
||||
)
|
||||
setViewVisibility(
|
||||
R.id.pod_left_ear,
|
||||
if (podDevice is HasEarDetectionDual && podDevice.isLeftPodInEar) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
// Case
|
||||
(podDevice as? HasCase)?.let { setImageViewResource(R.id.pod_case_icon, it.caseIcon) }
|
||||
val casePercent = (podDevice as? HasCase)?.batteryCasePercent
|
||||
setTextViewText(R.id.pod_case_label, formatBatteryPercent(context, casePercent))
|
||||
setViewVisibility(
|
||||
R.id.pod_case_charging,
|
||||
if (podDevice is HasCase && podDevice.isCaseCharging) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
// Right
|
||||
val rightPercent = podDevice.batteryRightPodPercent
|
||||
setImageViewResource(R.id.pod_right_icon, podDevice.rightPodIcon)
|
||||
setTextViewText(R.id.pod_right_label, formatBatteryPercent(context, rightPercent))
|
||||
setViewVisibility(
|
||||
R.id.pod_right_charging,
|
||||
if (podDevice is HasChargeDetectionDual && podDevice.isRightPodCharging) View.VISIBLE else View.GONE
|
||||
)
|
||||
setViewVisibility(
|
||||
R.id.pod_right_ear,
|
||||
if (podDevice is HasEarDetectionDual && podDevice.isRightPodInEar) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
applyThemeColors(
|
||||
context = context,
|
||||
views = this,
|
||||
theme = theme,
|
||||
textViewIds = listOf(
|
||||
R.id.headphones_label,
|
||||
R.id.pod_left_label,
|
||||
R.id.pod_right_label,
|
||||
R.id.pod_case_label,
|
||||
),
|
||||
iconViewIds = listOf(
|
||||
R.id.pod_left_icon,
|
||||
R.id.pod_left_charging,
|
||||
R.id.pod_left_ear,
|
||||
R.id.pod_case_icon,
|
||||
R.id.pod_case_charging,
|
||||
R.id.pod_right_icon,
|
||||
R.id.pod_right_charging,
|
||||
R.id.pod_right_ear,
|
||||
),
|
||||
hasDeviceLabel = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createSinglePodLayout(
|
||||
context: Context,
|
||||
podDevice: SinglePodDevice,
|
||||
widgetId: Int,
|
||||
theme: WidgetTheme,
|
||||
profileLabel: String?,
|
||||
): RemoteViews = RemoteViews(context.packageName, R.layout.widget_pod_single_layout).apply {
|
||||
log(TAG, VERBOSE) { "createSinglePodLayout(context=$context, podDevice=$podDevice)" }
|
||||
val pendingIntent: PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
widgetId,
|
||||
Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
||||
|
||||
val headsetPercent = podDevice.batteryHeadsetPercent
|
||||
setTextViewText(R.id.headphones_label, profileLabel ?: podDevice.getLabel(context))
|
||||
setImageViewResource(R.id.headphones_icon, podDevice.iconRes)
|
||||
setImageViewResource(R.id.headphones_battery_icon, getBatteryDrawable(headsetPercent))
|
||||
setTextViewText(R.id.headphones_battery_label, formatBatteryPercent(context, headsetPercent))
|
||||
|
||||
setViewVisibility(
|
||||
R.id.headphones_worn,
|
||||
if (podDevice is HasEarDetection && podDevice.isBeingWorn) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
setViewVisibility(
|
||||
R.id.headphones_charging,
|
||||
if (podDevice is HasChargeDetectionDual && podDevice.isHeadsetBeingCharged) View.VISIBLE else View.GONE
|
||||
)
|
||||
|
||||
applyThemeColors(
|
||||
context = context,
|
||||
views = this,
|
||||
theme = theme,
|
||||
textViewIds = listOf(
|
||||
R.id.headphones_label,
|
||||
R.id.headphones_battery_label,
|
||||
),
|
||||
iconViewIds = listOf(
|
||||
R.id.headphones_icon,
|
||||
R.id.headphones_battery_icon,
|
||||
R.id.headphones_charging,
|
||||
R.id.headphones_worn,
|
||||
),
|
||||
hasDeviceLabel = true,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
val TAG = logTag("Widget", "Provider")
|
||||
|
||||
fun updateWidget(context: Context, appWidgetManager: AppWidgetManager, widgetId: Int) {
|
||||
val intent = Intent(context, WidgetProvider::class.java).apply {
|
||||
action = AppWidgetManager.ACTION_APPWIDGET_UPDATE
|
||||
putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, intArrayOf(widgetId))
|
||||
}
|
||||
context.sendBroadcast(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.DrawableRes
|
||||
import eu.darken.capod.R
|
||||
|
||||
sealed class WidgetRenderState {
|
||||
abstract val theme: WidgetTheme
|
||||
@get:ColorInt abstract val resolvedBgColor: Int
|
||||
@get:ColorInt abstract val resolvedTextColor: Int
|
||||
@get:ColorInt abstract val resolvedIconColor: Int
|
||||
|
||||
data class DualPod(
|
||||
override val theme: WidgetTheme,
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
val isWide: Boolean,
|
||||
val deviceLabel: String?,
|
||||
@DrawableRes val leftIcon: Int,
|
||||
val leftPercent: Float?,
|
||||
val leftCharging: Boolean,
|
||||
val leftInEar: Boolean,
|
||||
@DrawableRes val rightIcon: Int,
|
||||
val rightPercent: Float?,
|
||||
val rightCharging: Boolean,
|
||||
val rightInEar: Boolean,
|
||||
@DrawableRes val caseIcon: Int,
|
||||
val casePercent: Float?,
|
||||
val caseCharging: Boolean,
|
||||
) : WidgetRenderState()
|
||||
|
||||
data class SinglePod(
|
||||
override val theme: WidgetTheme,
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
val deviceLabel: String?,
|
||||
@DrawableRes val headsetIcon: Int,
|
||||
val percent: Float?,
|
||||
@DrawableRes val batteryIcon: Int,
|
||||
val charging: Boolean,
|
||||
val worn: Boolean,
|
||||
) : WidgetRenderState()
|
||||
|
||||
data class Message(
|
||||
override val theme: WidgetTheme,
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
val primaryText: String,
|
||||
val secondaryText: String? = null,
|
||||
) : WidgetRenderState()
|
||||
|
||||
data class Loading(
|
||||
override val theme: WidgetTheme,
|
||||
@ColorInt override val resolvedBgColor: Int,
|
||||
@ColorInt override val resolvedTextColor: Int,
|
||||
@ColorInt override val resolvedIconColor: Int,
|
||||
) : WidgetRenderState()
|
||||
|
||||
companion object {
|
||||
fun previewDualPod(
|
||||
theme: WidgetTheme = WidgetTheme.DEFAULT,
|
||||
@ColorInt bgColor: Int = 0xFFFFFFFF.toInt(),
|
||||
@ColorInt textColor: Int = 0xFF1E1E1E.toInt(),
|
||||
@ColorInt iconColor: Int = 0xFF1E1E1E.toInt(),
|
||||
isWide: Boolean = false,
|
||||
): DualPod = DualPod(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
isWide = isWide,
|
||||
deviceLabel = "My AirPods Pro",
|
||||
leftIcon = R.drawable.device_airpods_pro2_left,
|
||||
leftPercent = 0.85f,
|
||||
leftCharging = false,
|
||||
leftInEar = true,
|
||||
rightIcon = R.drawable.device_airpods_pro2_right,
|
||||
rightPercent = 0.92f,
|
||||
rightCharging = true,
|
||||
rightInEar = false,
|
||||
caseIcon = R.drawable.device_airpods_pro2_case,
|
||||
casePercent = 1.0f,
|
||||
caseCharging = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package eu.darken.capod.main.ui.widget
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.appcompat.view.ContextThemeWrapper
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.DualPodDevice
|
||||
import eu.darken.capod.pods.core.HasCase
|
||||
import eu.darken.capod.pods.core.HasChargeDetectionDual
|
||||
import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodDevice
|
||||
import eu.darken.capod.pods.core.SinglePodDevice
|
||||
import eu.darken.capod.pods.core.getBatteryDrawable
|
||||
|
||||
object WidgetRenderStateMapper {
|
||||
|
||||
fun map(
|
||||
context: Context,
|
||||
device: PodDevice?,
|
||||
theme: WidgetTheme,
|
||||
isPro: Boolean,
|
||||
hasConfiguredProfile: Boolean,
|
||||
profileLabel: String?,
|
||||
isWide: Boolean = false,
|
||||
): WidgetRenderState {
|
||||
val bgColor = resolvedBgColor(context, theme)
|
||||
val textColor = resolvedTextColor(context, theme)
|
||||
val iconColor = resolvedIconColor(context, theme)
|
||||
|
||||
return when {
|
||||
!isPro -> WidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
primaryText = context.getString(R.string.upgrade_capod_label),
|
||||
secondaryText = context.getString(R.string.upgrade_capod_description),
|
||||
)
|
||||
|
||||
device is DualPodDevice -> WidgetRenderState.DualPod(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
isWide = isWide,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
leftIcon = device.leftPodIcon,
|
||||
leftPercent = device.batteryLeftPodPercent,
|
||||
leftCharging = device is HasChargeDetectionDual && device.isLeftPodCharging,
|
||||
leftInEar = device is HasEarDetectionDual && device.isLeftPodInEar,
|
||||
rightIcon = device.rightPodIcon,
|
||||
rightPercent = device.batteryRightPodPercent,
|
||||
rightCharging = device is HasChargeDetectionDual && device.isRightPodCharging,
|
||||
rightInEar = device is HasEarDetectionDual && device.isRightPodInEar,
|
||||
caseIcon = (device as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case,
|
||||
casePercent = (device as? HasCase)?.batteryCasePercent,
|
||||
caseCharging = device is HasCase && device.isCaseCharging,
|
||||
)
|
||||
|
||||
device is SinglePodDevice -> WidgetRenderState.SinglePod(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
deviceLabel = profileLabel ?: device.getLabel(context),
|
||||
headsetIcon = device.iconRes,
|
||||
percent = device.batteryHeadsetPercent,
|
||||
batteryIcon = getBatteryDrawable(device.batteryHeadsetPercent),
|
||||
charging = device is HasChargeDetectionDual && device.isHeadsetBeingCharged,
|
||||
worn = device is HasEarDetection && device.isBeingWorn,
|
||||
)
|
||||
|
||||
device is PodDevice -> WidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
primaryText = context.getString(R.string.pods_unknown_label),
|
||||
)
|
||||
|
||||
else -> {
|
||||
val messageRes = if (hasConfiguredProfile) {
|
||||
R.string.widget_no_data_label
|
||||
} else {
|
||||
R.string.overview_nomaindevice_label
|
||||
}
|
||||
WidgetRenderState.Message(
|
||||
theme = theme,
|
||||
resolvedBgColor = bgColor,
|
||||
resolvedTextColor = textColor,
|
||||
resolvedIconColor = iconColor,
|
||||
primaryText = context.getString(messageRes),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun resolvedBgColor(context: Context, theme: WidgetTheme): Int {
|
||||
val bgColor = theme.backgroundColor
|
||||
return if (bgColor != null) {
|
||||
WidgetTheme.applyAlpha(bgColor, theme.backgroundAlpha)
|
||||
} else {
|
||||
resolveThemeColor(context, android.R.attr.colorBackground)
|
||||
}
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun resolvedTextColor(context: Context, theme: WidgetTheme): Int {
|
||||
return theme.foregroundColor ?: resolveThemeColor(context, android.R.attr.textColorPrimary)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun resolvedIconColor(context: Context, theme: WidgetTheme): Int {
|
||||
return theme.foregroundColor ?: resolveThemeColor(context, android.R.attr.colorAccent)
|
||||
}
|
||||
|
||||
@ColorInt
|
||||
fun resolveThemeColor(context: Context, attr: Int): Int {
|
||||
val themedContext = ContextThemeWrapper(
|
||||
context,
|
||||
com.google.android.material.R.style.Theme_Material3_DynamicColors_DayNight,
|
||||
)
|
||||
val typedArray = themedContext.theme.obtainStyledAttributes(intArrayOf(attr))
|
||||
val color = typedArray.getColor(0, android.graphics.Color.BLACK)
|
||||
typedArray.recycle()
|
||||
return color
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ data class WidgetTheme(
|
||||
}
|
||||
|
||||
fun matchPreset(theme: WidgetTheme): Preset? = Preset.entries.firstOrNull { preset ->
|
||||
preset.presetBg == theme.backgroundColor && preset.presetFg == theme.foregroundColor && theme.backgroundAlpha == 255
|
||||
preset.presetBg == theme.backgroundColor && preset.presetFg == theme.foregroundColor
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="16dp" />
|
||||
<solid android:color="@android:color/transparent" />
|
||||
</shape>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="16dp" />
|
||||
<solid android:color="?colorSurfaceVariant" />
|
||||
</shape>
|
||||
@@ -1,100 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/preview_widget_root"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minWidth="160dp"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/preview_left_icon"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/device_airpods_gen1_left"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/preview_left_label"
|
||||
style="@style/PodInfoItemText.Notification"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="80%"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/preview_right_icon"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/device_airpods_gen1_right"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/preview_right_label"
|
||||
style="@style/PodInfoItemText.Notification"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="95%"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/preview_case_icon"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/device_airpods_gen1_case"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/preview_case_label"
|
||||
style="@style/PodInfoItemText.Notification"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="100%"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/preview_device_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
tools:text="AirPods Pro"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
tools:ignore="HardcodedText" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/widget_root"
|
||||
style="@style/PodWidget.Container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/primary"
|
||||
style="@style/PodWidget.TextPrimary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center"
|
||||
android:text="@string/pods_unknown_label"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/secondary"
|
||||
style="@style/PodWidget.TextSecondary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:ellipsize="end"
|
||||
android:gravity="center"
|
||||
android:maxLines="4"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone"
|
||||
tools:text="@string/pods_unknown_label"
|
||||
tools:visibility="visible" />
|
||||
</LinearLayout>
|
||||
@@ -1,113 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
style="@style/PodWidget.Container"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_left_icon"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/device_airpods_pro2_left"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pod_left_label"
|
||||
style="@style/PodInfoItemText.Notification"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
tools:text="100%" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_left_charging"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_power_24"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_left_ear"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_hearing_24"
|
||||
tools:ignore="ContentDescription" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_right_icon"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/device_airpods_pro2_right"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pod_right_label"
|
||||
style="@style/PodInfoItemText.Notification"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
tools:text="100%" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_right_charging"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_power_24"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_right_ear"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_hearing_24"
|
||||
tools:ignore="ContentDescription" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_case_icon"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/device_airpods_gen1_case"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pod_case_label"
|
||||
style="@style/PodInfoItemText.Notification"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
tools:text="100%" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_case_charging"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_power_24" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/headphones_label"
|
||||
style="@style/PodWidget.TextPrimary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:textSize="12sp"
|
||||
android:gravity="center"
|
||||
android:textStyle="bold"
|
||||
tools:text="AirPods Max" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,130 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
style="@style/PodWidget.Container"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_left_icon"
|
||||
style="@style/PodInfoItemIcon.Notification.Large"
|
||||
android:src="@drawable/device_airpods_pro2_left"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pod_left_label"
|
||||
style="@style/PodInfoItemText.Notification"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
tools:text="100%" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_left_charging"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_power_24"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_left_ear"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_hearing_24"
|
||||
tools:ignore="ContentDescription" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_case_icon"
|
||||
style="@style/PodInfoItemIcon.Notification.Large"
|
||||
android:src="@drawable/device_airpods_gen1_case"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pod_case_label"
|
||||
style="@style/PodInfoItemText.Notification"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
tools:text="100%" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_case_charging"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_power_24" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_right_icon"
|
||||
style="@style/PodInfoItemIcon.Notification.Large"
|
||||
android:src="@drawable/device_airpods_pro2_right"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pod_right_label"
|
||||
style="@style/PodInfoItemText.Notification"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
tools:text="100%" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_right_charging"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_power_24"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/pod_right_ear"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_hearing_24"
|
||||
tools:ignore="ContentDescription" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/headphones_label"
|
||||
style="@style/PodWidget.TextPrimary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:gravity="center"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="AirPods Max" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,49 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
style="@style/PodWidget.Container"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/headphones_battery_icon"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_battery_unknown_24" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/headphones_battery_label"
|
||||
style="@style/PodInfoItemText.Notification"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="8dp"
|
||||
tools:text="100%" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/headphones_charging"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_power_24"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/headphones_worn"
|
||||
style="@style/PodInfoItemIcon.Notification"
|
||||
android:src="@drawable/ic_baseline_hearing_24"
|
||||
tools:ignore="ContentDescription" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/headphones_label"
|
||||
style="@style/PodWidget.TextPrimary"
|
||||
android:layout_width="wrap_content"
|
||||
android:textStyle="bold"
|
||||
android:gravity="center"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
tools:text="AirPods Max" />
|
||||
</LinearLayout>
|
||||
@@ -123,6 +123,7 @@
|
||||
<string name="common_feature_requires_pro_msg">This feature requires CAPod Pro.</string>
|
||||
|
||||
<string name="widget_no_data_label">No data</string>
|
||||
<string name="widget_error_loading_label">Error loading widget</string>
|
||||
<string name="widget_config_appearance_label">Appearance</string>
|
||||
<string name="widget_config_preset_label">Presets</string>
|
||||
<string name="widget_config_background_color_label">Background color</string>
|
||||
|
||||
@@ -130,6 +130,11 @@ fun DependencyHandlerScope.addSerialization() {
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.Serialization.core}")
|
||||
}
|
||||
|
||||
fun DependencyHandlerScope.addGlance() {
|
||||
implementation("androidx.glance:glance-appwidget:${Versions.Glance.core}")
|
||||
implementation("androidx.glance:glance-material3:${Versions.Glance.core}")
|
||||
}
|
||||
|
||||
fun DependencyHandlerScope.addTesting() {
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ object Versions {
|
||||
const val core = "1.0.0"
|
||||
}
|
||||
|
||||
object Glance {
|
||||
const val core = "1.2.0-rc01"
|
||||
}
|
||||
|
||||
object Serialization {
|
||||
const val core = "1.9.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user