Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion app/src/main/kotlin/org/openstrap/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import org.openstrap.app.ui.components.Motion
import org.openstrap.app.ui.components.ListDetail
import org.openstrap.app.ui.components.SideBySideWidth
import androidx.compose.foundation.pager.HorizontalPager
import org.openstrap.app.ui.components.sinkBehind
import androidx.compose.foundation.pager.rememberPagerState
import org.openstrap.app.ui.theme.OpenStrapTheme
import org.openstrap.app.ui.theme.AppIcons
Expand Down Expand Up @@ -493,6 +494,8 @@ private fun AppRoot(
// The first page over the tab grows out of the header button tapped for it, if one was (spec 3.5).
val growOrigin = remember { org.openstrap.app.ui.components.GrowOrigin() }
var growFrom by remember { mutableStateOf<androidx.compose.ui.geometry.Rect?>(null) }
// How far that page is open, for the tab behind it to sink as it comes forward.
val pageGrow = org.openstrap.app.ui.components.rememberGrowProgress(pages.isNotEmpty())
val openPage: (Page) -> Unit = { p ->
val tapped = growOrigin.take()
if (pages.isEmpty()) growFrom = tapped
Expand Down Expand Up @@ -919,7 +922,8 @@ private fun AppRoot(
// Every tab stays composed, so a swipe never builds a whole dashboard mid-gesture (with only the neighbours
// kept, crossing halfway built the next one); glows on tabs off screen, or while the pager moves, hold still.
HorizontalPager(
pager, androidx.compose.ui.Modifier.padding(start = if (rail) org.openstrap.app.ui.components.NavRailWidth else 0.dp).hazeSource(navGlass),
pager, androidx.compose.ui.Modifier.padding(start = if (rail) org.openstrap.app.ui.components.NavRailWidth else 0.dp)
.sinkBehind(pageGrow).hazeSource(navGlass),
userScrollEnabled = !subScreenOpen, key = { it }, beyondViewportPageCount = Tab.entries.size - 1,
) { page -> androidx.compose.runtime.CompositionLocalProvider(
// Remembered: a new lambda each time would be a new static local, recomposing the whole tab under it.
Expand Down Expand Up @@ -1030,6 +1034,7 @@ private fun AppRoot(
org.openstrap.app.ui.components.GrowFromButton(
pages.isNotEmpty(), growFrom,
androidx.compose.ui.Modifier.fillMaxSize().padding(start = if (rail) org.openstrap.app.ui.components.NavRailWidth else 0.dp),
progress = pageGrow,
) { closing -> androidx.compose.foundation.layout.Box(
androidx.compose.ui.Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).hazeSource(navGlass, zIndex = 1f),
) {
Expand Down
54 changes: 29 additions & 25 deletions app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Color
import org.openstrap.app.ui.glow.ChoicePills
import org.openstrap.app.ui.glow.DestructiveInk
import org.openstrap.app.ui.glow.GlowLayout
import org.openstrap.app.ui.glow.GlowType
Expand All @@ -37,6 +36,7 @@ import org.openstrap.app.ui.glow.PageConfirm
import org.openstrap.app.ui.glow.PageSheet
import org.openstrap.app.ui.glow.PageTextButton
import org.openstrap.app.ui.glow.PageHeader
import org.openstrap.app.ui.glow.PageMenuRow
import org.openstrap.app.ui.glow.PageNote
import org.openstrap.app.ui.glow.PagePanel
import org.openstrap.app.ui.dashboard.ScreenList
Expand Down Expand Up @@ -197,39 +197,43 @@ private fun AlarmsPanel(
@Composable
private fun HeartAlertsPanel(state: AlarmsUiState, vm: AlarmsViewModel) {
val g = state.health ?: return
val alerts = state.alertsAvailable
val off: (Int) -> Boolean = { it == 0 }
PagePanel(title = "Heart rate and blood oxygen") {
g[SettingsCodec.KEY_HR_INTERVAL]?.let { s ->
ChoiceRow("Heart rate measuring", s.choices.orEmpty(), s.number?.toInt(), state.busy, ::intervalLabel) { vm.setHrInterval(it) }
}
val alerts = state.alertsAvailable
if (!alerts) PageNote("Heart rate alerts need continuous or every-minute measuring.")
g[SettingsCodec.KEY_HIGH_HR_ALERT]?.let { s ->
ChoiceRow("High heart rate alert", s.choices.orEmpty(), s.number?.toInt(), state.busy || !alerts, { if (it == 0) "Off" else "Above $it" }) {
vm.setEnum(SettingsCodec.KEY_HIGH_HR_ALERT, it)
// Rows with a menu each, in place of 21 chips; the measuring note only when it turns the heart rate alerts off.
Column {
var first = true
fun divider() = !first.also { first = false }
g[SettingsCodec.KEY_HR_INTERVAL]?.let { s ->
PageMenuRow(
"Heart rate measuring", s.choices.orEmpty(), s.number?.toInt(), ::intervalLabel, { vm.setHrInterval(it) },
divider = divider(), enabled = !state.busy, quiet = off,
note = if (alerts) null else "Heart rate alerts need Continuous or every minute.",
)
}
}
g[SettingsCodec.KEY_LOW_HR_ALERT]?.let { s ->
ChoiceRow("Low heart rate alert", s.choices.orEmpty(), s.number?.toInt(), state.busy || !alerts, { if (it == 0) "Off" else "Below $it" }) {
vm.setEnum(SettingsCodec.KEY_LOW_HR_ALERT, it)
g[SettingsCodec.KEY_HIGH_HR_ALERT]?.let { s ->
PageMenuRow(
"High heart rate alert", s.choices.orEmpty(), s.number?.toInt(), { if (it == 0) "Off" else "Above $it" },
{ vm.setEnum(SettingsCodec.KEY_HIGH_HR_ALERT, it) }, divider = divider(), enabled = !state.busy && alerts, quiet = off,
)
}
}
g[SettingsCodec.KEY_LOW_SPO2_ALERT]?.let { s ->
ChoiceRow("Low blood oxygen alert", s.choices.orEmpty(), s.number?.toInt(), state.busy, { if (it == 0) "Off" else "Below $it%" }) {
vm.setEnum(SettingsCodec.KEY_LOW_SPO2_ALERT, it)
g[SettingsCodec.KEY_LOW_HR_ALERT]?.let { s ->
PageMenuRow(
"Low heart rate alert", s.choices.orEmpty(), s.number?.toInt(), { if (it == 0) "Off" else "Below $it" },
{ vm.setEnum(SettingsCodec.KEY_LOW_HR_ALERT, it) }, divider = divider(), enabled = !state.busy && alerts, quiet = off,
)
}
g[SettingsCodec.KEY_LOW_SPO2_ALERT]?.let { s ->
PageMenuRow(
"Low blood oxygen alert", s.choices.orEmpty(), s.number?.toInt(), { if (it == 0) "Off" else "Below $it%" },
{ vm.setEnum(SettingsCodec.KEY_LOW_SPO2_ALERT, it) }, divider = divider(), enabled = !state.busy, quiet = off,
)
}
}
PageNote("The strap vibrates when an alert goes off. OpenStrap isn't a medical device.")
}
}

@Composable
private fun ChoiceRow(title: String, choices: List<Int>, current: Int?, disabled: Boolean, label: (Int) -> String, onPick: (Int) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = GlowType.body, color = Color.White)
ChoicePills(choices, current, label, onPick, enabled = !disabled)
}
}

/**
* A new alarm or one being changed, in a sheet: the time, the days as chips, then Cancel and Save, with Delete as quiet
* text under them. The time wheel is Material's clock, recoloured to white on the sheet.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ fun PhoneAlertsScreen(alerts: PhoneAlerts, settings: SettingsStore, contentPaddi
item {
PagePanel {
PageSwitchRow(
"Incoming calls", "The strap buzzes while your phone rings, and for calls from apps like WhatsApp with notification access. With Do Not Disturb on, only if it allows calls from anyone.",
"Incoming calls", "Buzzes while your phone rings. App calls like WhatsApp need notification access.",
calls && callPermission,
{ on ->
when {
Expand All @@ -117,12 +117,12 @@ fun PhoneAlertsScreen(alerts: PhoneAlerts, settings: SettingsStore, contentPaddi
callPreviewError?.let { PageNote(it, color = MetricColors.warn) }
}
}
PageSwitchRow("Notifications", "The strap buzzes for notifications that would make a sound.", notifications && notificationAccess, { on ->
PageSwitchRow("Notifications", "Buzzes for notifications that make a sound.", notifications && notificationAccess, { on ->
scope.launch { settings.setBuzzOnNotifications(on) }
if (on && !notificationAccess) openNotificationAccess()
})
if (notifications && !notificationAccess) {
PageNote("Allow notification access for OpenStrap to turn this on.", color = MetricColors.warn)
PageNote("Needs notification access.", color = MetricColors.warn)
PageButton("Open settings", openNotificationAccess, Modifier.fillMaxWidth())
}
if (notifications && notificationAccess) {
Expand All @@ -139,7 +139,8 @@ fun PhoneAlertsScreen(alerts: PhoneAlerts, settings: SettingsStore, contentPaddi
previewError?.let { PageNote(it, color = MetricColors.warn) }
}
}
PageNote("Silent apps and Do Not Disturb are followed. OpenStrap never reads what a notification says.")
// Do Not Disturb is said once, here, for calls and notifications both.
PageNote("Follows Do Not Disturb and silent apps. Never reads what a notification says.")
}
}
}
Expand Down
130 changes: 103 additions & 27 deletions app/src/main/kotlin/org/openstrap/app/ui/components/GrowFromButton.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
Expand All @@ -25,10 +26,16 @@ import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

/*
* Avatar to You, strap chip to Strap (spec 3.5): the page over the tab grows out of the round button tapped, and
* closing it shrinks it back in. The same springs as card to detail, 500 in and 300 out, with no bounce.
* closing it shrinks it back in, as a container transform. The same springs as card to detail, 500 in and 300 out,
* with no bounce.
*/

/**
Expand Down Expand Up @@ -59,55 +66,124 @@ fun growClick(onClick: () -> Unit): GrowClick {
}
}

/** How far a page has grown out of its button: its clip at [progress], from the button's [from] to [page]. */
/**
* How far a page has grown out of its button, as a container transform: the outline goes from the button to the page,
* the page's content scales with it (to the outline's width) and is clipped to it, and the tab behind dims and sinks.
*/
object GrowMotion {
/** By this much of the way the page is fully opaque: it fades in over the start of its growth only. */
const val FADE_PART = 0.25f
/** The surface comes up over this first bit of the way, so the button turns into it rather than flashing. */
const val SURFACE_PART = 0.06f
/** The content fades in over this stretch, once the outline has some size to show it in. */
const val CONTENT_FROM = 0.15f
const val CONTENT_TO = 0.45f
/** The outline keeps a card's round corners until this far, then squares off to the screen. */
const val SQUARE_FROM = 0.85f
/** The tab behind at the page fully open: dimmed by this much black, and scaled down to this. */
const val SCRIM = 0.5f
const val SINK = 0.94f

/** The page's outline at [progress] (0 the button, 1 the page): the rect between them, round as the button at the start. */
fun outline(from: Rect, page: Rect, progress: Float): RoundRect {
/**
* The page's outline at [progress] (0 the button, 1 the page): the rect between them, round as the button at the
* start, easing to [cornerRadius] (a card's) on the way and square only at the end.
*/
fun outline(from: Rect, page: Rect, progress: Float, cornerRadius: Float = 0f): RoundRect {
val p = progress.coerceIn(0f, 1f)
val r = lerp(from, page, p)
val radius = from.minDimension / 2f * (1f - p)
return RoundRect(r, CornerRadius(radius))
val button = from.minDimension / 2f
val radius = if (p < SQUARE_FROM) button + (cornerRadius - button) * (p / SQUARE_FROM)
else cornerRadius * (1f - (p - SQUARE_FROM) / (1f - SQUARE_FROM))
return RoundRect(r, CornerRadius(radius.coerceAtLeast(0f)))
}

fun alpha(progress: Float): Float = (progress / FADE_PART).coerceIn(0f, 1f)
/** The page's surface: up at once, so it reads as the button growing. */
fun alpha(progress: Float): Float = (progress / SURFACE_PART).coerceIn(0f, 1f)

/** The page's content: none while the outline is button sized, all of it before half way. */
fun contentAlpha(progress: Float): Float = ((progress - CONTENT_FROM) / (CONTENT_TO - CONTENT_FROM)).coerceIn(0f, 1f)

/** The scale the content is drawn at inside an outline [outlineWidth] wide on a page [pageWidth] wide. */
fun contentScale(outlineWidth: Float, pageWidth: Float): Float = if (pageWidth <= 0f) 1f else (outlineWidth / pageWidth).coerceIn(0.01f, 1f)

fun scrim(progress: Float): Float = SCRIM * progress.coerceIn(0f, 1f)

fun sink(progress: Float): Float = 1f - (1f - SINK) * progress.coerceIn(0f, 1f)

/** Spring stiffness opening (500) and closing (300), as card to detail. */
fun stiffness(opening: Boolean): Float = if (opening) CardMotion.STIFFNESS_IN else CardMotion.STIFFNESS_OUT
}

/** How far the page over the tab is open, shared by [GrowFromButton] and the tab behind it ([sinkBehind]). */
class GrowProgress internal constructor(open: Boolean) {
internal val anim = Animatable(if (open) 1f else 0f)
val value: Float get() = anim.value
}

@Composable
fun rememberGrowProgress(open: Boolean): GrowProgress = remember { GrowProgress(open) }

/** The tab behind a growing page: it sinks a little as the page comes forward (the dimming is the page's scrim). */
fun Modifier.sinkBehind(progress: GrowProgress): Modifier = graphicsLayer {
val s = GrowMotion.sink(progress.value)
scaleX = s; scaleY = s
}

/**
* [content] while [visible], growing out of [from] (a button's bounds in the window) as it turns visible and shrinking
* back into it as it turns hidden; with no [from] it fades. [content] stays composed until it has shrunk away, told
* it's closing meanwhile (so the caller can keep back away from a page on its way out). With the system's animations
* off it jumps.
* back into it as it turns hidden; with no [from] it fades. The outline goes from the button, on [buttonColor], to the
* page, round as a card ([cornerRadius]) until the end, with the content scaled to it and fading in once it has some
* size; a scrim dims what's behind. [content] stays composed until it has shrunk away, told it's closing meanwhile
* (so the caller can keep back away from a page on its way out). With the system's animations off it jumps.
*/
@Composable
fun GrowFromButton(visible: Boolean, from: Rect?, modifier: Modifier = Modifier, content: @Composable (closing: Boolean) -> Unit) {
fun GrowFromButton(
visible: Boolean,
from: Rect?,
modifier: Modifier = Modifier,
progress: GrowProgress = rememberGrowProgress(visible),
buttonColor: Color = Color(0xFF1F1F22),
pageColor: Color = Color.Black,
cornerRadius: Dp = 28.dp,
content: @Composable (closing: Boolean) -> Unit,
) {
val animate = rememberAnimationsOn()
val progress = remember { Animatable(if (visible) 1f else 0f) }
val anim = progress.anim
LaunchedEffect(visible, animate) {
val target = if (visible) 1f else 0f
if (!animate) progress.snapTo(target)
else progress.animateTo(target, spring(Spring.DampingRatioNoBouncy, GrowMotion.stiffness(visible)))
if (!animate) anim.snapTo(target)
else anim.animateTo(target, spring(Spring.DampingRatioNoBouncy, GrowMotion.stiffness(visible)))
}
val shrunk by remember { derivedStateOf { progress.value == 0f && !progress.isRunning } }
val shrunk by remember { derivedStateOf { anim.value == 0f && !anim.isRunning } }
if (!visible && shrunk) return
val self = remember { arrayOf(Rect.Zero) }
val origin by rememberUpdatedState(from)
Box(
modifier.onGloballyPositioned { self[0] = it.boundsInRoot() }.graphicsLayer {
val p = progress.value
alpha = GrowMotion.alpha(p)
val start = origin?.translate(-self[0].topLeft)
if (start != null && p < 1f) {
shape = GrowShape(GrowMotion.outline(start, Rect(Offset.Zero, size), p))
Box(modifier.onGloballyPositioned { self[0] = it.boundsInRoot() }) {
Box(Modifier.matchParentSize().drawBehind { drawRect(Color.Black, alpha = GrowMotion.scrim(anim.value)) })
Box(
Modifier.fillMaxSize().graphicsLayer {
val p = anim.value
val start = origin?.translate(-self[0].topLeft)
if (start == null || p >= 1f) {
alpha = if (start == null) p else 1f
return@graphicsLayer
}
// The content laid out at the page's size, scaled to the outline's width and moved onto it, then
// clipped to the outline in its own (unscaled) coordinates.
val o = GrowMotion.outline(start, Rect(Offset.Zero, size), p, cornerRadius.toPx())
val k = GrowMotion.contentScale(o.width, size.width)
transformOrigin = TransformOrigin(0f, 0f)
scaleX = k; scaleY = k
translationX = o.left; translationY = o.top
alpha = GrowMotion.alpha(p)
shape = GrowShape(RoundRect(0f, 0f, size.width, o.height / k, CornerRadius(o.topLeftCornerRadius.x / k)))
clip = true
}
},
) { content(!visible) }
}.drawBehind {
// The surface turns from the button's colour to the page's while the content is still coming in.
drawRect(androidx.compose.ui.graphics.lerp(buttonColor, pageColor, GrowMotion.contentAlpha(anim.value)))
},
) {
Box(Modifier.fillMaxSize().graphicsLayer { alpha = if (origin == null) 1f else GrowMotion.contentAlpha(anim.value) }) { content(!visible) }
}
}
}

/** A clip of exactly [outline], wherever the layer is. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ fun PullToSync(
}
}

/** The gap a pull opens in the header: a strap icon that turns as you pull and spins while syncing. */
/** The gap a pull opens in the header: the sync arrows, turning as you pull and spinning while syncing. */
@Composable
fun PullSyncReveal() {
val pull = LocalPullSync.current ?: return
Expand All @@ -142,7 +142,7 @@ fun PullSyncReveal() {
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
AppIcons.Watch, null,
AppIcons.Refresh04, null,
Modifier.size(18.dp).graphicsLayer { rotationZ = if (syncing) turn else pull.fraction() * 180f },
tint = if (ready || syncing) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Expand Down
Loading
Loading