feat: Slim down overview cards and fix cached device icons

Remove timestamps, connection state, and conversation awareness from overview cards. Move signal badge inline with model subtitle. Relocate removed info to device settings screen info card. Fix cached devices showing Gen 1 icons by adding per-pod icon properties to PodModel. Add profile name subtitle to device settings toolbar. Simplify preview coverage to full/minimal/cached variants.
This commit is contained in:
darken
2026-04-03 15:36:27 +02:00
committed by Matthias Urhahn
parent 9d446938bd
commit e014ad8045
11 changed files with 391 additions and 214 deletions
@@ -8,6 +8,7 @@ import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cache.CachedDeviceState
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.DualBlePodSnapshot
import eu.darken.capod.pods.core.apple.ble.SingleBlePodSnapshot
@@ -50,6 +51,7 @@ object MockPodDataProvider {
leftPodIcon = R.drawable.device_airpods_pro2_left,
rightPodIcon = R.drawable.device_airpods_pro2_right,
_caseIcon = R.drawable.device_airpods_pro2_case,
_address = "AA:BB:CC:DD:EE:FF",
)
fun airPodsProMixed(): DualBlePodSnapshot = MockDualBlePodSnapshot(
@@ -127,6 +129,7 @@ object MockPodDataProvider {
_isHeadsetBeingCharged = true,
_isIRKMatch = true,
_hasPrivatePayload = true,
_address = "11:22:33:44:55:66",
)
fun beatsSolo3(): SingleBlePodSnapshot = MockSingleBlePodSnapshot(
@@ -161,6 +164,7 @@ object MockPodDataProvider {
leftPodIcon = R.drawable.device_airpods_pro2_left,
rightPodIcon = R.drawable.device_airpods_pro2_right,
_caseIcon = R.drawable.device_airpods_pro2_case,
_address = "AA:BB:CC:DD:EE:FF",
)
// --- PodDevice wrappers ---
@@ -186,7 +190,20 @@ object MockPodDataProvider {
fun dualPodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-dual-aap",
ble = airPodsProWithKeys(),
aap = AapPodState(connectionState = AapPodState.ConnectionState.READY),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
),
),
),
),
)
fun singlePodMonitored(): PodDevice = PodDevice(
@@ -195,6 +212,24 @@ object MockPodDataProvider {
aap = null,
)
fun singlePodMonitoredWithAap(): PodDevice = PodDevice(
profileId = "preview-single-aap",
ble = airPodsMaxCharging(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
),
),
),
),
)
fun unknownMonitored(): PodDevice = PodDevice(
profileId = null,
ble = unknownDevice(),
@@ -269,6 +304,7 @@ private class MockDualBlePodSnapshot(
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
private val _caseIcon: Int = R.drawable.device_airpods_gen1_case,
private val _address: String? = null,
rssi: Int = -50,
) : DualBlePodSnapshot, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
@@ -281,7 +317,7 @@ private class MockDualBlePodSnapshot(
override val signalQuality: Float = 0.75f
override val iconRes: Int = _model.iconRes
override val meta: BlePodSnapshot.Meta = object : BlePodSnapshot.Meta {
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model)
override val profile: DeviceProfile = AppleDeviceProfile(label = _label, model = _model, address = _address)
}
override fun getLabel(context: Context): String = _model.label
@@ -376,6 +412,7 @@ private class MockSingleAppleBlePodSnapshot(
private val _isBeingWorn: Boolean = false,
private val _isIRKMatch: Boolean = false,
private val _hasPrivatePayload: Boolean = false,
private val _address: String? = null,
rssi: Int = -50,
) : SingleBlePodSnapshot, ApplePods, HasChargeDetection, HasEarDetection {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
@@ -389,7 +426,7 @@ private class MockSingleAppleBlePodSnapshot(
override val iconRes: Int = _model.iconRes
override val meta: ApplePods.AppleMeta = ApplePods.AppleMeta(
isIRKMatch = _isIRKMatch,
profile = AppleDeviceProfile(label = _label, model = _model),
profile = AppleDeviceProfile(label = _label, model = _model, address = _address),
)
override val payload: ProximityPayload = ProximityPayload(
public = ProximityPayload.Public(UByteArray(9)),
@@ -424,6 +461,7 @@ private class MockDualAppleBlePodSnapshot(
override val leftPodIcon: Int = R.drawable.device_airpods_gen1_left,
override val rightPodIcon: Int = R.drawable.device_airpods_gen1_right,
private val _caseIcon: Int = R.drawable.device_airpods_gen1_case,
private val _address: String? = null,
rssi: Int = -50,
) : DualBlePodSnapshot, ApplePods, HasCase, HasChargeDetectionDual, HasEarDetectionDual, HasDualMicrophone {
override val identifier: BlePodSnapshot.Id = BlePodSnapshot.Id()
@@ -437,7 +475,7 @@ private class MockDualAppleBlePodSnapshot(
override val iconRes: Int = _model.iconRes
override val meta: ApplePods.AppleMeta = ApplePods.AppleMeta(
isIRKMatch = _isIRKMatch,
profile = AppleDeviceProfile(label = _label, model = _model),
profile = AppleDeviceProfile(label = _label, model = _model, address = _address),
)
override val payload: ProximityPayload = ProximityPayload(
public = ProximityPayload.Public(UByteArray(9)),
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.ArrowBack
@@ -41,6 +40,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -48,15 +48,24 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.common.compose.preview.MOCK_NOW
import eu.darken.capod.common.error.ErrorEventHandler
import eu.darken.capod.common.navigation.NavigationEventHandler
import eu.darken.capod.common.settings.SettingsCategoryHeader
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.overview.cards.AncModeSelector
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.firstSeenFormatted
import eu.darken.capod.monitor.core.lastSeenFormatted
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
import java.time.Duration
@Composable
fun DeviceSettingsScreenHost(
@@ -113,11 +122,23 @@ fun DeviceSettingsScreen(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.device_settings_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Column {
Text(
text = stringResource(R.string.device_settings_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val profileName = device?.label
if (profileName != null) {
Text(
text = profileName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
},
navigationIcon = {
IconButton(onClick = onNavigateUp) {
@@ -134,10 +155,21 @@ fun DeviceSettingsScreen(
modifier = Modifier.padding(paddingValues),
) {
// Device Info
val deviceInfo = device?.deviceInfo
if (deviceInfo != null) {
if (device != null) {
item("device_info") {
DeviceInfoCard(deviceInfo = deviceInfo)
val context = LocalContext.current
val stateDetection = device.ble as? HasStateDetection
val seenFirst = device.seenFirstAt
val seenLast = device.seenLastAt
val firstSeen = if (seenFirst != null && seenLast != null && Duration.between(seenFirst, seenLast).toMinutes() >= 1) {
device.firstSeenFormatted(state.now)
} else null
DeviceInfoCard(
deviceInfo = device.deviceInfo,
connectionStateLabel = stateDetection?.state?.getLabel(context),
lastSeen = device.lastSeenFormatted(state.now),
firstSeen = firstSeen,
)
}
}
@@ -322,7 +354,12 @@ fun DeviceSettingsScreen(
}
@Composable
private fun DeviceInfoCard(deviceInfo: AapDeviceInfo) {
private fun DeviceInfoCard(
deviceInfo: AapDeviceInfo?,
connectionStateLabel: String?,
lastSeen: String?,
firstSeen: String?,
) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
@@ -330,22 +367,42 @@ private fun DeviceInfoCard(deviceInfo: AapDeviceInfo) {
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
if (deviceInfo.name.isNotBlank()) {
if (deviceInfo != null) {
if (deviceInfo.name.isNotBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_name_label),
value = deviceInfo.name,
)
}
if (deviceInfo.serialNumber.isNotBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_serial_label),
value = deviceInfo.serialNumber,
)
}
if (deviceInfo.firmwareVersion.isNotBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_firmware_label),
value = deviceInfo.firmwareVersion,
)
}
}
if (connectionStateLabel != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_name_label),
value = deviceInfo.name,
label = stringResource(R.string.device_settings_info_status_label),
value = connectionStateLabel,
)
}
if (deviceInfo.serialNumber.isNotBlank()) {
if (lastSeen != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_serial_label),
value = deviceInfo.serialNumber,
label = stringResource(R.string.device_settings_info_last_seen_label),
value = lastSeen,
)
}
if (deviceInfo.firmwareVersion.isNotBlank()) {
if (firstSeen != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_firmware_label),
value = deviceInfo.firmwareVersion,
label = stringResource(R.string.device_settings_info_first_seen_label),
value = firstSeen,
)
}
}
@@ -354,16 +411,15 @@ private fun DeviceInfoCard(deviceInfo: AapDeviceInfo) {
@Composable
private fun InfoRow(label: String, value: String) {
Row(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
.padding(vertical = 2.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(120.dp),
)
Text(
text = value,
@@ -563,3 +619,82 @@ private fun CallControlOption(
}
}
}
@Preview2
@Composable
private fun DeviceSettingsFullPreview() = PreviewWrapper {
DeviceSettingsScreen(
state = DeviceSettingsViewModel.State(
device = PodDevice(
profileId = "preview",
label = "My AirPods Pro",
ble = MockPodDataProvider.airPodsProWithKeys(),
aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
deviceInfo = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
),
settings = mapOf(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
),
),
AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled = true),
AapSetting.NcWithOneAirPod::class to AapSetting.NcWithOneAirPod(enabled = true),
AapSetting.PersonalizedVolume::class to AapSetting.PersonalizedVolume(enabled = false),
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 75),
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = 50),
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = AapSetting.PressSpeed.Value.DEFAULT),
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = AapSetting.PressHoldDuration.Value.DEFAULT),
AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled = true),
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = AapSetting.VolumeSwipeLength.Value.DEFAULT),
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(
muteMic = AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS,
endCall = AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS,
),
),
),
),
now = MOCK_NOW,
),
onNavigateUp = {},
)
}
@Preview2
@Composable
private fun DeviceSettingsInfoOnlyPreview() = PreviewWrapper {
DeviceSettingsScreen(
state = DeviceSettingsViewModel.State(
device = PodDevice(
profileId = "preview-info",
label = "My AirPods Pro",
ble = MockPodDataProvider.airPodsProMixed(),
aap = null,
),
now = MOCK_NOW,
),
onNavigateUp = {},
)
}
@Preview2
@Composable
private fun DeviceSettingsCachedOnlyPreview() = PreviewWrapper {
DeviceSettingsScreen(
state = DeviceSettingsViewModel.State(
device = MockPodDataProvider.dualPodCachedOnly(),
now = MOCK_NOW,
),
onNavigateUp = {},
)
}
@@ -11,10 +11,14 @@ import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
@@ -33,15 +37,26 @@ class DeviceSettingsViewModel @Inject constructor(
targetAddress.value = address
}
private val updateTicker = channelFlow<Unit> {
while (isActive) {
trySend(Unit)
delay(3000)
}
}
val state = targetAddress.flatMapLatest { address ->
if (address == null) return@flatMapLatest flowOf(State(device = null))
deviceMonitor.devices.map { devices ->
State(device = devices.firstOrNull { it.address == address })
combine(updateTicker, deviceMonitor.devices) { _, devices ->
State(
device = devices.firstOrNull { it.address == address },
now = Instant.now(),
)
}
}.asLiveState()
data class State(
val device: PodDevice?,
val now: Instant = Instant.now(),
)
private fun send(command: AapCommand) {
@@ -130,7 +130,6 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
onConversationAwarenessChange = { device, enabled -> vm.setConversationalAwareness(device, enabled) },
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
)
}
@@ -144,7 +143,6 @@ fun OverviewScreen(
onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
onConversationAwarenessChange: (PodDevice, Boolean) -> Unit = { _, _ -> },
onDeviceSettings: (PodDevice) -> Unit = {},
) {
Scaffold(
@@ -235,7 +233,6 @@ fun OverviewScreen(
showDebug = state.isDebugMode,
now = state.now,
onAncModeChange = { mode -> onAncModeChange(device, mode) },
onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) },
onDeviceSettings = { onDeviceSettings(device) },
)
}
@@ -278,7 +275,6 @@ fun OverviewScreen(
showDebug = state.isDebugMode,
now = state.now,
onAncModeChange = { mode -> onAncModeChange(device, mode) },
onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) },
onDeviceSettings = { onDeviceSettings(device) },
)
}
@@ -295,14 +291,12 @@ private fun PodDeviceCard(
showDebug: Boolean,
now: Instant,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
onConversationAwarenessChange: (Boolean) -> Unit = {},
onDeviceSettings: (() -> Unit)? = null,
) {
when {
device.hasDualPods -> DualPodsCard(
device = device, showDebug = showDebug, now = now,
onAncModeChange = onAncModeChange,
onConversationAwarenessChange = onConversationAwarenessChange,
onDeviceSettings = onDeviceSettings,
)
device.model != PodModel.UNKNOWN -> SinglePodsCard(
@@ -310,7 +304,7 @@ private fun PodDeviceCard(
onAncModeChange = onAncModeChange,
onDeviceSettings = onDeviceSettings,
)
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now)
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug)
}
}
@@ -182,18 +182,6 @@ class OverviewViewModel @Inject constructor(
}
}
fun setConversationalAwareness(device: PodDevice, enabled: Boolean) {
val address = device.address ?: return
launch {
try {
aapManager.sendCommand(address, AapCommand.SetConversationalAwareness(enabled))
log(TAG) { "Conversation awareness set to $enabled for $address" }
} catch (e: Exception) {
log(TAG) { "Failed to set conversation awareness: ${e.message}" }
}
}
}
companion object {
private const val FREE_DEVICE_LIMIT = 1
private val TAG = logTag("Overview", "VM")
@@ -47,18 +47,14 @@ import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.monitor.core.firstSeenFormatted
import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.monitor.core.lastSeenFormatted
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
import eu.darken.capod.pods.core.apple.ble.devices.HasStateDetection
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.HasPodStyle
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
import java.time.Duration
import java.time.Instant
@Composable
@@ -67,7 +63,6 @@ fun DualPodsCard(
showDebug: Boolean,
now: Instant,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onConversationAwarenessChange: ((Boolean) -> Unit)? = null,
onDeviceSettings: (() -> Unit)? = null,
) {
val context = LocalContext.current
@@ -80,9 +75,7 @@ fun DualPodsCard(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
Column(
modifier = Modifier
.padding(16.dp)
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
modifier = Modifier.padding(16.dp),
) {
// Header
Row(
@@ -92,10 +85,10 @@ fun DualPodsCard(
Image(
painter = painterResource(device.iconRes),
contentDescription = null,
modifier = Modifier.size(48.dp),
modifier = Modifier.size(44.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -115,22 +108,27 @@ fun DualPodsCard(
append(" [${dualApple.primaryPod.name}]")
}
}
Text(
text = deviceLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = deviceLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(modifier = Modifier.width(6.dp))
SignalBadge(
signalText = device.getSignalQuality(context),
bleKeyState = device.bleKeyState,
isAapConnected = device.isAapConnected,
isLive = device.isLive,
)
}
}
SignalBadge(
signalText = device.getSignalQuality(context),
bleKeyState = device.bleKeyState,
isAapConnected = device.isAapConnected,
isLive = device.isLive,
)
if (device.address != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) {
Icon(
@@ -142,31 +140,15 @@ fun DualPodsCard(
}
}
Spacer(modifier = Modifier.height(4.dp))
// Timestamps
Text(
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val seenFirst = device.seenFirstAt
val seenLast = device.seenLastAt
if (seenFirst != null && seenLast != null && Duration.between(seenFirst, seenLast).toMinutes() >= 1) {
Text(
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(16.dp))
// Circular battery gauges side by side
Surface(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
shape = RoundedCornerShape(12.dp),
tonalElevation = 1.dp,
tonalElevation = 4.dp,
) {
Column(
modifier = Modifier.padding(16.dp),
@@ -201,7 +183,7 @@ fun DualPodsCard(
// Case row
if (device.hasCase) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 12.dp),
modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
@@ -217,24 +199,14 @@ fun DualPodsCard(
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Connection state
val stateDetection = device.ble as? HasStateDetection
if (stateDetection != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stateDetection.state.getLabel(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End),
)
}
// ANC mode selector
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(8.dp))
AncModeSelector(
currentMode = ancMode.current,
supportedModes = ancMode.supported,
@@ -243,16 +215,6 @@ fun DualPodsCard(
)
}
// Conversation awareness toggle
val convAwareness = device.conversationalAwareness
if (device.isAapConnected && device.model.features.hasConversationAwareness && convAwareness != null) {
Spacer(modifier = Modifier.height(8.dp))
ConversationAwarenessToggle(
enabled = convAwareness.enabled,
onToggle = { onConversationAwarenessChange?.invoke(it) },
)
}
// Debug info
if (showDebug) {
DebugSection(rawDataHex = device.rawDataHex)
@@ -294,12 +256,12 @@ private fun PodGauge(
// Ring with icon inside
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(80.dp),
modifier = Modifier.size(68.dp),
) {
// Track ring
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.size(80.dp),
modifier = Modifier.size(68.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
strokeWidth = 6.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -310,7 +272,7 @@ private fun PodGauge(
if (clamped >= 0f) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(80.dp),
modifier = Modifier.size(68.dp),
color = ringColor,
strokeWidth = 6.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -417,36 +379,31 @@ private fun CaseRow(
@Preview2
@Composable
private fun DualPodsCardFullChargePreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.dualPodMonitored(), showDebug = false, now = Instant.now())
private fun DualPodsCardFullPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodMonitoredWithAap(),
showDebug = false,
now = Instant.now(),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun DualPodsCardMixedBatteryPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.dualPodMonitoredMixed(), showDebug = false, now = Instant.now())
private fun DualPodsCardMinimalPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodMonitored(),
showDebug = false,
now = Instant.now(),
)
}
@Preview2
@Composable
private fun DualPodsCardDebugPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.dualPodMonitoredMixed(), showDebug = true, now = Instant.now())
}
@Preview2
@Composable
private fun DualPodsCardWithKeysPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.dualPodMonitoredWithKeys(), showDebug = false, now = Instant.now())
}
@Preview2
@Composable
private fun DualPodsCardWithAapPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.dualPodMonitoredWithAap(), showDebug = false, now = Instant.now())
}
@Preview2
@Composable
private fun DualPodsCardCachedOnlyPreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.dualPodCachedOnly(), showDebug = false, now = Instant.now())
private fun DualPodsCardCachedPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodCachedOnly(),
showDebug = false,
now = Instant.now(),
)
}
@@ -47,12 +47,9 @@ import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.monitor.core.firstSeenFormatted
import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.monitor.core.lastSeenFormatted
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import java.time.Duration
import java.time.Instant
@OptIn(ExperimentalLayoutApi::class)
@@ -88,9 +85,7 @@ fun SinglePodsCard(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
Column(
modifier = Modifier
.padding(16.dp)
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
modifier = Modifier.padding(16.dp),
) {
// Header
Row(
@@ -100,10 +95,10 @@ fun SinglePodsCard(
Image(
painter = painterResource(device.iconRes),
contentDescription = null,
modifier = Modifier.size(48.dp),
modifier = Modifier.size(44.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -112,22 +107,27 @@ fun SinglePodsCard(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = device.getLabel(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = device.getLabel(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(modifier = Modifier.width(6.dp))
SignalBadge(
signalText = device.getSignalQuality(context),
bleKeyState = device.bleKeyState,
isAapConnected = device.isAapConnected,
isLive = device.isLive,
)
}
}
SignalBadge(
signalText = device.getSignalQuality(context),
bleKeyState = device.bleKeyState,
isAapConnected = device.isAapConnected,
isLive = device.isLive,
)
if (device.address != null && onDeviceSettings != null) {
IconButton(onClick = onDeviceSettings) {
Icon(
@@ -139,31 +139,15 @@ fun SinglePodsCard(
}
}
Spacer(modifier = Modifier.height(4.dp))
// Timestamps
Text(
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val seenFirst = device.seenFirstAt
val seenLast = device.seenLastAt
if (seenFirst != null && seenLast != null && Duration.between(seenFirst, seenLast).toMinutes() >= 1) {
Text(
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(12.dp))
// Central gauge
Surface(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
shape = RoundedCornerShape(12.dp),
tonalElevation = 1.dp,
tonalElevation = 4.dp,
) {
Column(
modifier = Modifier
@@ -173,12 +157,12 @@ fun SinglePodsCard(
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(100.dp),
modifier = Modifier.size(88.dp),
) {
// Track ring
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.size(100.dp),
modifier = Modifier.size(88.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
strokeWidth = 8.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -189,7 +173,7 @@ fun SinglePodsCard(
if (clamped != null) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(100.dp),
modifier = Modifier.size(88.dp),
color = ringColor,
strokeWidth = 8.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -200,7 +184,7 @@ fun SinglePodsCard(
// Battery text inside ring
Text(
text = formatBatteryPercent(context, device.batteryHeadset),
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.headlineSmall,
color = if (device.batteryHeadset != null) {
MaterialTheme.colorScheme.onSurface
} else {
@@ -240,6 +224,7 @@ fun SinglePodsCard(
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End),
)
}
@@ -265,18 +250,31 @@ fun SinglePodsCard(
@Preview2
@Composable
private fun SinglePodsCardPreview() = PreviewWrapper {
SinglePodsCard(device = MockPodDataProvider.singlePodMonitored(), showDebug = false, now = Instant.now())
private fun SinglePodsCardFullPreview() = PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodMonitoredWithAap(),
showDebug = false,
now = Instant.now(),
onDeviceSettings = {},
)
}
@Preview2
@Composable
private fun SinglePodsCardDebugPreview() = PreviewWrapper {
SinglePodsCard(device = MockPodDataProvider.singlePodMonitored(), showDebug = true, now = Instant.now())
private fun SinglePodsCardMinimalPreview() = PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodMonitored(),
showDebug = false,
now = Instant.now(),
)
}
@Preview2
@Composable
private fun SinglePodsCardCachedOnlyPreview() = PreviewWrapper {
SinglePodsCard(device = MockPodDataProvider.singlePodCachedOnly(), showDebug = false, now = Instant.now())
private fun SinglePodsCardCachedPreview() = PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodCachedOnly(),
showDebug = false,
now = Instant.now(),
)
}
@@ -26,15 +26,12 @@ import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.monitor.core.lastSeenFormatted
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
import java.time.Instant
@Composable
fun UnknownPodDeviceCard(
device: PodDevice,
showDebug: Boolean,
now: Instant,
) {
val context = LocalContext.current
@@ -71,14 +68,6 @@ fun UnknownPodDeviceCard(
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
@@ -111,11 +100,11 @@ fun UnknownPodDeviceCard(
@Preview2
@Composable
private fun UnknownPodDeviceCardPreview() = PreviewWrapper {
UnknownPodDeviceCard(device = MockPodDataProvider.unknownMonitored(), showDebug = false, now = Instant.now())
UnknownPodDeviceCard(device = MockPodDataProvider.unknownMonitored(), showDebug = false)
}
@Preview2
@Composable
private fun UnknownPodDeviceCardDebugPreview() = PreviewWrapper {
UnknownPodDeviceCard(device = MockPodDataProvider.unknownMonitored(), showDebug = true, now = Instant.now())
UnknownPodDeviceCard(device = MockPodDataProvider.unknownMonitored(), showDebug = true)
}
@@ -197,13 +197,19 @@ data class PodDevice(
val iconRes: Int get() = ble?.iconRes ?: model.iconRes
val leftPodIcon: Int
get() = (ble as? DualBlePodSnapshot)?.leftPodIcon ?: R.drawable.device_airpods_gen1_left
get() = (ble as? DualBlePodSnapshot)?.leftPodIcon
?: model.leftPodIconRes
?: R.drawable.device_airpods_gen1_left
val rightPodIcon: Int
get() = (ble as? DualBlePodSnapshot)?.rightPodIcon ?: R.drawable.device_airpods_gen1_right
get() = (ble as? DualBlePodSnapshot)?.rightPodIcon
?: model.rightPodIconRes
?: R.drawable.device_airpods_gen1_right
val caseIcon: Int
get() = (ble as? HasCase)?.caseIcon ?: R.drawable.device_airpods_gen1_case
get() = (ble as? HasCase)?.caseIcon
?: model.caseIconRes
?: R.drawable.device_airpods_gen1_case
fun getLabel(context: Context): String = ble?.getLabel(context) ?: model.label
@@ -11,6 +11,9 @@ enum class PodModel(
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
val features: Features = Features(),
val modelNumbers: Set<String> = emptySet(),
@DrawableRes val leftPodIconRes: Int? = null,
@DrawableRes val rightPodIconRes: Int? = null,
@DrawableRes val caseIconRes: Int? = null,
) {
@SerialName("airpods.gen1")
AIRPODS_GEN1(
@@ -21,6 +24,9 @@ enum class PodModel(
hasCase = true,
),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
@SerialName("airpods.gen2")
@@ -32,6 +38,9 @@ enum class PodModel(
hasCase = true,
),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
@SerialName("airpods.gen3")
@@ -43,6 +52,9 @@ enum class PodModel(
hasCase = true,
),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
caseIconRes = R.drawable.device_airpods_gen3_case,
),
@SerialName("airpods.gen4")
@@ -55,6 +67,9 @@ enum class PodModel(
hasEarDetection = true,
),
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
caseIconRes = R.drawable.device_airpods_gen3_case,
),
@SerialName("airpods.gen4.anc")
@@ -78,6 +93,9 @@ enum class PodModel(
needsInitExt = true,
),
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
rightPodIconRes = R.drawable.device_airpods_gen4anc_right,
caseIconRes = R.drawable.device_airpods_gen4anc_case,
),
@SerialName("airpods.pro")
@@ -96,6 +114,9 @@ enum class PodModel(
hasEndCallMuteMic = true,
),
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("airpods.pro2")
@@ -121,6 +142,9 @@ enum class PodModel(
needsInitExt = true,
),
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("airpods.pro2.usbc")
@@ -146,6 +170,9 @@ enum class PodModel(
needsInitExt = true,
),
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("airpods.pro3")
@@ -171,6 +198,9 @@ enum class PodModel(
needsInitExt = true,
),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("airpods.max")
@@ -318,6 +348,9 @@ enum class PodModel(
hasEarDetection = true,
),
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
leftPodIconRes = R.drawable.device_powerbeats_pro_left,
rightPodIconRes = R.drawable.device_powerbeats_pro_right,
caseIconRes = R.drawable.device_powerbeats_pro_case,
),
@SerialName("beats.powerbeats.pro2")
@@ -331,6 +364,9 @@ enum class PodModel(
hasAncControl = true,
),
modelNumbers = setOf("A3157", "A3158", "A3159"), // L/R earbuds + case
leftPodIconRes = R.drawable.device_powerbeats_pro2_left,
rightPodIconRes = R.drawable.device_powerbeats_pro2_right,
caseIconRes = R.drawable.device_powerbeats_pro2_case,
),
@SerialName("beats.fit.pro")
@@ -344,6 +380,9 @@ enum class PodModel(
hasAncControl = true,
),
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
leftPodIconRes = R.drawable.device_beats_fitpro_left,
rightPodIconRes = R.drawable.device_beats_fitpro_right,
caseIconRes = R.drawable.device_beats_fitpro_case,
),
@SerialName("fakes.tws.i99999")
@@ -354,6 +393,9 @@ enum class PodModel(
hasDualPods = true,
hasCase = true,
),
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
@SerialName("fakes.generic.airpods.gen2")
@@ -364,6 +406,9 @@ enum class PodModel(
hasDualPods = true,
hasCase = true,
),
leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right,
caseIconRes = R.drawable.device_airpods_gen1_case,
),
@SerialName("fakes.generic.airpods.gen3")
@@ -374,6 +419,9 @@ enum class PodModel(
hasDualPods = true,
hasCase = true,
),
leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right,
caseIconRes = R.drawable.device_airpods_gen3_case,
),
@SerialName("fakes.varunr.airpodspro")
@@ -386,6 +434,9 @@ enum class PodModel(
hasEarDetection = true,
hasAncControl = true,
),
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("fakes.generic.airpods.pro2")
@@ -398,6 +449,9 @@ enum class PodModel(
hasEarDetection = true,
hasAncControl = true,
),
leftPodIconRes = R.drawable.device_airpods_pro2_left,
rightPodIconRes = R.drawable.device_airpods_pro2_right,
caseIconRes = R.drawable.device_airpods_pro2_case,
),
@SerialName("unknown")
+3
View File
@@ -425,6 +425,9 @@
<string name="device_settings_info_name_label">Name</string>
<string name="device_settings_info_serial_label">Serial Number</string>
<string name="device_settings_info_firmware_label">Firmware</string>
<string name="device_settings_info_status_label">Status</string>
<string name="device_settings_info_last_seen_label">Last Seen</string>
<string name="device_settings_info_first_seen_label">First Seen</string>
<string name="device_settings_category_sound_label">Sound</string>
<string name="device_settings_category_controls_label">Controls</string>
<string name="device_settings_nc_one_airpod_label">Noise Cancellation with One AirPod</string>