diff --git a/core/keys/src/androidMain/res/values/strings.xml b/core/keys/src/androidMain/res/values/strings.xml index c403763d3c4c..40e11f45cfc4 100644 --- a/core/keys/src/androidMain/res/values/strings.xml +++ b/core/keys/src/androidMain/res/values/strings.xml @@ -159,7 +159,7 @@ Include IOB in wizard Authorize custom watchfaces Watchface installed on watch - Complications watchface + Dashboard watchface Custom watchface Glucose circle watchface Notify on SMB diff --git a/core/keys/src/commonMain/kotlin/app/aaps/core/keys/PushedWatchfaceId.kt b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/PushedWatchfaceId.kt index e94a06b3d46d..3a9978795c0e 100644 --- a/core/keys/src/commonMain/kotlin/app/aaps/core/keys/PushedWatchfaceId.kt +++ b/core/keys/src/commonMain/kotlin/app/aaps/core/keys/PushedWatchfaceId.kt @@ -16,4 +16,7 @@ object PushedWatchfaceId { /** The face with the overview's glucose circle */ const val CIRCLE = "circle" + + /** Every face, in the order the phone offers them */ + val ALL = listOf(CWF, WFS, CIRCLE) } diff --git a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreen.kt b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreen.kt index 26e264432ee6..966961d11d44 100644 --- a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreen.kt +++ b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreen.kt @@ -7,12 +7,18 @@ import app.aaps.plugins.sync.SyncStrings import android.content.Intent import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.BorderStroke 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.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -20,9 +26,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -30,10 +39,12 @@ import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Upload +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider @@ -41,7 +52,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -51,14 +61,21 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import androidx.compose.ui.util.lerp import androidx.core.net.toUri +import kotlin.math.absoluteValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import app.aaps.core.keys.KeysStrings import app.aaps.core.keys.PushedWatchfaceId @@ -231,11 +248,21 @@ internal fun WearMainContent( uiState.customWatchfaceNotShown?.let { name -> OkDialog( title = pushedWatchfaceLabel(PushedWatchfaceId.CWF), - message = stringResource(SyncStrings.wear_custom_watchface_not_shown, name), + message = stringResource(SyncStrings.wear_custom_watchface_not_shown_on, pushedWatchfaceLabel(uiState.selectedWatchface), name), onDismiss = onDismissCustomWatchfaceNotShown ) } + // The face cards. The pager starts on the face that is on the watch and follows it after a + // change, so the outlined card is the centred one when the screen opens. + val pushedFaces = PushedWatchfaceId.ALL + val pagerState = rememberPagerState(initialPage = pushedFaces.indexOf(uiState.selectedWatchface).coerceAtLeast(0)) { pushedFaces.size } + LaunchedEffect(uiState.selectedWatchface) { + val page = pushedFaces.indexOf(uiState.selectedWatchface) + if (page >= 0 && page != pagerState.currentPage) pagerState.animateScrollToPage(page) + } + val pushedFacesShown = uiState.isDeviceConnected && uiState.watchFacePushSupported + Column( modifier = modifier .fillMaxSize() @@ -270,7 +297,7 @@ internal fun WearMainContent( // Pushed Watchface Card: only on a watch that reported Watch Face Push (Wear OS 6+). Below // that the pushed faces cannot exist, and a choice that does nothing would only mislead. - if (uiState.isDeviceConnected && uiState.watchFacePushSupported) { + if (pushedFacesShown) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) @@ -278,60 +305,44 @@ internal fun WearMainContent( Column( modifier = Modifier .fillMaxWidth() - .padding(AapsSpacing.large), + .padding(vertical = AapsSpacing.large), verticalArrangement = Arrangement.spacedBy(AapsSpacing.medium) ) { Text( text = stringResource(StringKey.WearPushedWatchface.title), style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = AapsSpacing.small) + modifier = Modifier.padding(horizontal = AapsSpacing.extraLarge) ) // Watch Face Push gives the app one slot, so this is a choice between the - // embedded faces, not a set of switches. The tap asks first, see pendingWatchface. - // While the watch still holds the other face - the seconds an install takes, - // or longer after a reinstall until the preferences reach it - the chosen row - // says so, quietly: it is progress, not a fault. + // embedded faces, not a set of switches: one card per face, the one on the + // watch outlined, a button on the others. The button asks first, see + // pendingWatchface. While the watch still holds the other face - the seconds + // an install takes, or longer after a reinstall until the preferences reach + // it - the chosen card says so, quietly: it is progress, not a fault. val selectedId = uiState.selectedWatchface val installing = uiState.installedWatchface != null && uiState.installedWatchface != selectedId - Column(modifier = Modifier.selectableGroup()) { - listOf(PushedWatchfaceId.CWF, PushedWatchfaceId.WFS, PushedWatchfaceId.CIRCLE).forEach { face -> - val selected = face == selectedId - WatchfaceChoiceRow( - label = pushedWatchfaceLabel(face), - selected = selected, - hint = if (selected && installing) stringResource(SyncStrings.wear_pushed_watchface_installing) else null, - onSelect = { if (!selected) pendingWatchface = face } - ) - } - } - - // What the wrist shows with a built-in face chosen. The custom face needs no - // picture here: its own preview is in its own card below. - val preview = when (selectedId) { - PushedWatchfaceId.WFS -> R.drawable.wfs_watchface_preview - PushedWatchfaceId.CIRCLE -> R.drawable.circle_watchface_preview - else -> null - } - if (preview != null) { - Spacer(modifier = Modifier.height(AapsSpacing.small)) - Image( - painter = painterResource(preview), - contentDescription = pushedWatchfaceLabel(selectedId), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = AapsSpacing.extraLarge), - contentScale = ContentScale.FillWidth + WatchfaceCarousel(state = pagerState, faces = pushedFaces) { page -> + val face = pushedFaces[page] + val selected = face == selectedId + WatchfaceCard( + face = face, + customImage = uiState.watchfaceImage, + selected = selected, + hint = if (selected && installing) stringResource(SyncStrings.wear_pushed_watchface_installing) else null, + onUse = { pendingWatchface = face } ) } } } } - // Custom Watchface Card (visible only when connected), whatever face is chosen above: a - // watch below Wear OS 6 runs the code-based face, a watch like the Galaxy Watch 5 runs it - // beside the pushed faces, and a zip can be loaded before switching - if (uiState.isDeviceConnected) { + // Custom Watchface Card (visible only when connected): a watch below Wear OS 6 runs the + // code-based face, a watch like the Galaxy Watch 5 runs it beside the pushed faces, and a + // zip can be loaded before switching. With the face cards above it belongs to the custom + // face's card, so it shows while that card is the centred one. + val customCardShown = uiState.isDeviceConnected && (!pushedFacesShown || pushedFaces[pagerState.currentPage] == PushedWatchfaceId.CWF) + if (customCardShown) { Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) @@ -391,34 +402,184 @@ private fun pushedWatchfaceLabel(face: String): String = } ) -/** One radio row; [hint] is a quiet note after the label, for a state that will pass by itself */ +/** One line on what a face is for, under its name on the card */ +@Composable +private fun pushedWatchfaceSummary(face: String): String = + stringResource( + when (face) { + PushedWatchfaceId.CWF -> SyncStrings.wear_pushed_watchface_cwf_summary + PushedWatchfaceId.CIRCLE -> SyncStrings.wear_pushed_watchface_circle_summary + else -> SyncStrings.wear_pushed_watchface_wfs_summary + } + ) + +/** + * Metrics of the face cards. The same numbers as the management carousel in :ui, which this + * module does not depend on; the peek is smaller so the picture of a face can be larger. + */ +private object WatchfaceCarouselDefaults { + + /** Largest size of the face picture; on a narrow phone it is the card's width instead */ + val ImageSize = 200.dp + + /** Height of a card without its picture and texts: the button, the three gaps and the padding */ + val ButtonAndGapsHeight = 92.dp + + /** Horizontal peek so the neighbouring cards stay partially visible */ + val ContentPadding = 64.dp + + /** Gap between adjacent cards */ + val PageSpacing = 16.dp + + /** Scale and alpha of a fully off-centre card; the centred card renders at 1f */ + const val MIN_SCALE = 0.85f + const val MIN_ALPHA = 0.5f +} + +/** The face cards side by side, the centred one full size, and the page dots under them */ +@Composable +private fun WatchfaceCarousel( + state: PagerState, + faces: List, + card: @Composable (page: Int) -> Unit +) { + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + // The pager needs a fixed height. It is the height of the tallest card, measured with the + // picture's real size on this phone and the texts at the wearer's font size: a fixed + // number squeezed the button on a phone with large text and left a gap on one with small. + val pageWidth = maxWidth - WatchfaceCarouselDefaults.ContentPadding * 2 + val contentWidth = pageWidth - AapsSpacing.large * 2 + val imageSize = min(WatchfaceCarouselDefaults.ImageSize, contentWidth) + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + val titleStyle = MaterialTheme.typography.titleMedium + val summaryStyle = MaterialTheme.typography.bodySmall + val textConstraints = Constraints(maxWidth = with(density) { contentWidth.roundToPx() }) + val textHeightPx = faces.maxOf { face -> + textMeasurer.measure(pushedWatchfaceLabel(face), titleStyle, constraints = textConstraints).size.height + + textMeasurer.measure(pushedWatchfaceSummary(face), summaryStyle, constraints = textConstraints).size.height + } + val cardHeight = imageSize + with(density) { textHeightPx.toDp() } + WatchfaceCarouselDefaults.ButtonAndGapsHeight + Column(modifier = Modifier.fillMaxWidth()) { + HorizontalPager( + state = state, + modifier = Modifier + .fillMaxWidth() + .height(cardHeight), + contentPadding = PaddingValues(horizontal = WatchfaceCarouselDefaults.ContentPadding), + pageSpacing = WatchfaceCarouselDefaults.PageSpacing + ) { page -> + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + val pageOffset = ((state.currentPage - page) + state.currentPageOffsetFraction).absoluteValue + val fraction = 1f - pageOffset.coerceIn(0f, 1f) + val scale = lerp(WatchfaceCarouselDefaults.MIN_SCALE, 1f, fraction) + scaleX = scale + scaleY = scale + alpha = lerp(WatchfaceCarouselDefaults.MIN_ALPHA, 1f, fraction) + } + ) { + card(page) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = AapsSpacing.medium), + horizontalArrangement = Arrangement.Center + ) { + repeat(state.pageCount) { page -> + val isSelected = page == state.currentPage + Box( + modifier = Modifier + .padding(horizontal = AapsSpacing.small) + .width(if (isSelected) 24.dp else 8.dp) + .height(8.dp) + .background( + color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + shape = CircleShape + ) + ) + } + } + } + } +} + +/** + * One face: its picture, name and summary. The face on the watch is outlined and says so; the + * others get the button that installs them. [hint] is a quiet note for a state that passes by + * itself. [customImage] is the wearer's own zip preview, shown on the custom face's card when one + * is loaded. + */ @Composable -private fun WatchfaceChoiceRow( - label: String, +private fun WatchfaceCard( + face: String, + customImage: ImageBitmap?, selected: Boolean, hint: String?, - onSelect: () -> Unit + onUse: () -> Unit ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .selectable(selected = selected, onClick = onSelect, role = Role.RadioButton) - .padding(vertical = AapsSpacing.small, horizontal = AapsSpacing.small) + val label = pushedWatchfaceLabel(face) + Card( + modifier = Modifier.fillMaxSize(), + colors = CardDefaults.cardColors( + containerColor = if (selected) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh + ), + border = if (selected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null ) { - RadioButton(selected = selected, onClick = null) - Text( - text = label, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(start = AapsSpacing.medium) - ) - if (hint != null) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(AapsSpacing.large), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(AapsSpacing.medium) + ) { + val imageModifier = Modifier + .widthIn(max = WatchfaceCarouselDefaults.ImageSize) + .fillMaxWidth() + .aspectRatio(1f) + if (face == PushedWatchfaceId.CWF && customImage != null) { + Image(bitmap = customImage, contentDescription = label, modifier = imageModifier, contentScale = ContentScale.Fit) + } else { + val preview = when (face) { + PushedWatchfaceId.CWF -> R.drawable.cwf_watchface_preview + PushedWatchfaceId.CIRCLE -> R.drawable.circle_watchface_preview + else -> R.drawable.wfs_watchface_preview + } + Image(painter = painterResource(preview), contentDescription = label, modifier = imageModifier, contentScale = ContentScale.Fit) + } + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center + ) Text( - text = hint, + text = pushedWatchfaceSummary(face), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = AapsSpacing.small) + textAlign = TextAlign.Center ) + // The button and the "on the watch" line sit at the bottom whatever the summary's length + Spacer(modifier = Modifier.weight(1f)) + if (selected) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(AapsSpacing.small)) { + Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary) + Text( + text = hint ?: stringResource(SyncStrings.wear_pushed_watchface_active), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + } + } else { + Button(onClick = onUse) { + Icon(Icons.Default.PlayArrow, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(modifier = Modifier.width(6.dp)) + Text(text = stringResource(SyncStrings.wear_pushed_watchface_use)) + } + } } } } diff --git a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreenPreviews.kt b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreenPreviews.kt index 5a96824da219..5193d0062435 100644 --- a/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreenPreviews.kt +++ b/plugins/sync/src/androidMain/kotlin/app/aaps/plugins/sync/wear/compose/WearScreenPreviews.kt @@ -35,11 +35,11 @@ internal fun WearMainContentPreview() { @Preview(showBackground = true) @Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable -internal fun WearMainContentComplicationsFacePreview() { +internal fun WearMainContentDashboardFacePreview() { MaterialTheme { WearMainContent( - // The complications face chosen while the watch still holds the custom one: the chosen - // row carries the "Installing…" note, and its picture shows under the choice + // The Dashboard face chosen while the watch still holds the custom one: its card is + // centred and outlined and carries the "Installing…" note; the zip card is hidden uiState = WearUiState( connectedDevice = "Galaxy Watch 8 (c3d4)", isDeviceConnected = true, diff --git a/plugins/sync/src/androidMain/res/drawable-nodpi/cwf_watchface_preview.png b/plugins/sync/src/androidMain/res/drawable-nodpi/cwf_watchface_preview.png new file mode 100644 index 000000000000..9678177c64ab Binary files /dev/null and b/plugins/sync/src/androidMain/res/drawable-nodpi/cwf_watchface_preview.png differ diff --git a/plugins/sync/src/androidMain/res/values/strings.xml b/plugins/sync/src/androidMain/res/values/strings.xml index 6bd905b8d30c..4745866155f5 100644 --- a/plugins/sync/src/androidMain/res/values/strings.xml +++ b/plugins/sync/src/androidMain/res/values/strings.xml @@ -124,10 +124,15 @@ Custom Watchface Settings Custom Watchface: %1$s Installing… - The watch shows the Complications watchface. Select Custom watchface to see %1$s on the watch. + On the watch + Use on watch + The watchface you load below. Pick a design from the community or make your own. Not changeable on the watch. + All your data at a glance: BG graph, glucose, IOB, COB, basal and more in AAPS complication slots. + A large glucose circle like the overview, with time, date and a few slots for complications. + The watch shows the %1$s. Select Custom watchface to see %2$s on the watch. Change the watchface on the watch? - %1$s will replace the %2$s on the watch. - %1$s will replace the %2$s on the watch. Complications you changed in the watch face editor go back to the defaults. + %1$s will replace the %2$s on the watch. + %1$s will replace the %2$s on the watch. Complications you changed in the watch face editor go back to the defaults. Load Watchface Infos Watchface Export template