diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..ea9f260d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*.{kt,kts}] +indent_size = 4 +max_line_length = 140 +ktlint_code_style = intellij_idea +ktlint_function_naming_ignore_when_annotated_with = Composable diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5bdf844a..08959b33 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -31,6 +31,32 @@ jobs: - name: Test run: ./gradlew :protocol:test :app:testDebugUnitTest + # Android lint and detekt fail on any finding not in their baselines (app/lint-baseline.xml, */detekt-baseline-*.xml). + - name: Lint and static analysis + run: ./gradlew :app:lintDebug detektMain + + # ktlint's baseline pins findings by line and column, so any edit that moves lines brings old findings back. + # It reports here without failing the build until the code is formatted once. + - name: Formatting + continue-on-error: true + run: ./gradlew ktlintCheck + + # A German locale and a zone with a 45 minute offset, so a comma decimal mark or a fixed zone fails the build. + - name: Tests under a German locale + run: ./gradlew :app:testDebugUnitTest -PtestLocale=de-DE + + - uses: actions/upload-artifact@v5 + if: failure() + with: + name: test-reports + path: app/build/reports/tests/ + + # Only when lint ran: a failed test step stops before it, and a missing report would hide that failure. + - uses: github/codeql-action/upload-sarif@v4 + if: ${{ always() && hashFiles('app/build/reports/lint-results-debug.sarif') != '' }} + with: + sarif_file: app/build/reports/lint-results-debug.sarif + - name: Build debug APK if: ${{ !startsWith(github.ref, 'refs/tags/v') }} run: ./gradlew :app:assembleDebug @@ -70,3 +96,5 @@ jobs: permissions: contents: write + # SARIF upload of the lint report. + security-events: write diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55b699bb..69b1ac89 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,11 @@ +import java.util.Locale + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) } fun env(name: String): String? = providers.environmentVariable(name).orNull?.takeIf { it.isNotBlank() } @@ -21,7 +25,7 @@ android { defaultConfig { applicationId = "org.openstrap.app" - minSdk = 28 + minSdk = 31 targetSdk = 36 versionCode = appVersionCode versionName = appVersionName @@ -71,6 +75,58 @@ android { compose = true buildConfig = true } + + // Android lint. Old findings live in the baseline (refresh with ./gradlew :app:updateLintBaseline), new ones fail + // the build. SARIF reports are always written on AGP 9. The extra error ids are the Compose and dead resource checks from the November 2026 audit. + lint { + baseline = file("lint-baseline.xml") + abortOnError = true + checkDependencies = true + error += listOf( + "StateFlowValueCalledInComposition", "FlowOperatorInvokedInComposition", + "CoroutineCreationDuringComposition", "ComposeModifierMissing", + "ComposeParameterOrder", "ComposeUnstableCollections", "UnusedResources", + ) + // LogNotTimber comes in with a dependency's lint checks, but the app logs with android.util.Log on purpose + // and has no Timber. NewerVersionAvailable asks the network, so its answer changes from run to run. + disable += listOf("LogNotTimber", "NewerVersionAvailable") + } +} + +// Stability reports, so recomposition problems are visible rather than guessed (build/compose_compiler). +composeCompiler { + reportsDestination = layout.buildDirectory.dir("compose_compiler") + metricsDestination = layout.buildDirectory.dir("compose_compiler") +} + +// detekt with the Compose rules. Old findings live in detekt-baseline-debug.xml and detekt-baseline-release.xml +// (./gradlew :app:detektBaselineMain). detektMain is the type resolved run the coroutine rules need. +detekt { + buildUponDefaultConfig = true + config.setFrom(rootProject.file("config/detekt.yml")) + baseline = file("detekt-baseline.xml") +} + +// BuildConfig is generated Java, which the Kotlin analysis only sees as a compiled class on the classpath. Without it +// detekt reports "25 compiler errors" (unresolved BuildConfig) and type resolved rules miss findings in those files. +// The plugin sets its classpath as a convention (Kotlin classes plus the Kotlin task's libraries), and adding to it +// replaces that convention, so the same two parts are restated here next to the javac output. +listOf("Debug", "Release").forEach { variant -> + tasks.withType().matching { it.name == "detekt$variant" }.configureEach { + val kotlinCompile = tasks.named("compile${variant}Kotlin", org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java) + val javac = tasks.named("compile${variant}JavaWithJavac", JavaCompile::class.java) + classpath.setFrom( + kotlinCompile.flatMap { it.destinationDirectory }, + kotlinCompile.map { it.libraries }, + javac.flatMap { it.destinationDirectory }, + ) + } +} + +// Formatting only, from .editorconfig. Old violations live in the baseline, so nothing is reformatted. +ktlint { + version = libs.versions.ktlint.get() + baseline = file("ktlint-baseline.xml") } ksp { @@ -79,7 +135,24 @@ ksp { // The backtest harness (BacktestReplay, docs/algorithms/readiness-calibration.md): // ./gradlew testDebugUnitTest --tests '*BacktestReplay*' -Pbacktest=/path/openstrap.db [-PbacktestProfile=1990,Male] +// Source scan tests read app/src/main from here, so they do not depend on the working directory. +// `-PtestLocale=de-DE` runs the suite under that locale and a zone with a non whole hour offset, which flushes out +// formatting that ignores the locale and dates that assume a whole hour offset. +val appSrcMain = layout.projectDirectory.dir("src/main").asFile.absolutePath +val testLocale = providers.gradleProperty("testLocale").orNull tasks.withType().configureEach { + systemProperty("openstrap.srcRoot", appSrcMain) + // A failure prints its message and the assertion's values, so a CI only failure can be read from the log. + testLogging { exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL } + // Robolectric's SQLite is native: its 'localtime' follows the process's TZ, not Java's default zone or a test's + // ZoneRule. UTC on every machine, so a day grouped in SQL is the same day on a Mac in Dubai and on CI. + environment("TZ", "UTC") + testLocale?.let { tag -> + val locale = Locale.forLanguageTag(tag) + systemProperty("user.language", locale.language) + systemProperty("user.country", locale.country) + systemProperty("user.timezone", "Asia/Kathmandu") + } listOf("backtest", "backtestOut", "backtestProfile").forEach { name -> providers.gradleProperty(name).orNull?.let { systemProperty("openstrap.$name", it) } } @@ -125,4 +198,11 @@ dependencies { // A real Room database in unit tests (CSV restore). testImplementation(libs.robolectric) testImplementation(libs.androidx.test.core) + // Compose UI tests under Robolectric, for saved state across a recreate (StateRestorationTester). + testImplementation(libs.compose.ui.test.junit4) + // Migration tests (MigrationTestHelper). + testImplementation(libs.room.testing) + + lintChecks(libs.slack.compose.lint) + detektPlugins(libs.compose.rules.detekt) } diff --git a/app/detekt-baseline-debug.xml b/app/detekt-baseline-debug.xml new file mode 100644 index 00000000..196fa6d1 --- /dev/null +++ b/app/detekt-baseline-debug.xml @@ -0,0 +1,3202 @@ + + + + + ArrayPrimitive:WidgetStatusLine.kt:Spread.Companion$Float? + ComplexCondition:BodySignals.kt:BodySignals$k > 0 && out[k].flags.none { it.core } && out[k].state == State.Alert && nightsBetween(sorted[k - 1].day, sorted[k].day) - 1 <= 1 + ComplexCondition:GlowMetricDetail.kt:!night && metric == Metric.HeartRate && isToday && canUseLive && liveEnabled + ComplexCondition:GlowMetricDetail.kt:!night && metric == Metric.HeartRate && isToday && canUseLive && range == Range.Day + ComplexCondition:GlowTodayScreen.kt:it == HeroKind.Waking && ctx.state.loaded && ctx.recoveryFailure == null && wakingLead(ctx.firstNights, ctx.state.asleepSeconds, ctx.isToday) == WakingLead.Charge + ComplexCondition:HistorySync.kt:HistorySync$received.any { it is WorkoutSummary || it is WorkoutDetail || it is SportEffect || it is RecoveryHeartRate } + ComplexCondition:LiveIntervals.kt:m.phase is Phase.Work && m.left != null && workLength != null && workLength > 0 + ComplexCondition:LiveSets.kt:!compact && target != null && moment.done >= target && !r.inSet + ComplexCondition:MainActivity.kt:historyBadges && workoutState is org.openstrap.app.device.WorkoutState.Idle && readySport == null && !choosing + ComplexCondition:Recovery.kt:Recovery$m.sleepStage == null && m.workout == null && m.stress != null && m.stress >= params.stressFrom + ComplexCondition:Recovery.kt:Recovery$own != null && strap != null && strap > 0 && abs(own - strap) / strap > HRV_QA_FRACTION + ComplexCondition:SyncStates.kt:!isToday || !stopped || newest == null || now - newest < staleAfter + ComplexCondition:Vo2max.kt:Vo2max$w == null || h == null || measured == null || day - measured > BODY_MAX_AGE_DAYS * dayS + ComplexCondition:WidgetGalleryActivity.kt:WidgetGalleryActivity$WidgetRefresh.debugMood != null || WidgetRefresh.debugMoment != null || WidgetRefresh.debugPlaced != null || WidgetRefresh.debugStrap != null + ComplexCondition:WorkoutsViewModel.kt:WorkoutsViewModel$backfill && w.end != null && w.epoc == null && hr.isNotEmpty() + ComposableNaming:GlowWorkoutSummary.kt:@Composable internal fun SplitsList: Boolean + ComposableNaming:SummarySections.kt:@Composable internal fun FamilySection: Boolean + ComposableNaming:SummarySections.kt:@Composable private fun IndoorHeartCard: Boolean + ComposableParamOrder:Adaptive.kt:@Composable fun <S> ListDetail + ComposableParamOrder:GlowLayout.kt:@Composable internal fun TitleRow + ComposableParamOrder:GlowParts.kt:@Composable fun DotMatrixText + ComposableParamOrder:Insights.kt:@Composable fun FloatingNavBar + ComposableParamOrder:Insights.kt:@Composable fun FloatingNavRail + ComposableParamOrder:PageParts.kt:@Composable fun PageProgress + ComposableParamOrder:ReadyCardio.kt:@Composable private fun TypedNumber + ComposableParamOrder:SummarySections.kt:@Composable internal fun WeekDots + ComposableParamOrder:SummarySections.kt:@Composable private fun SmoothLine + ComposableParamOrder:TodayCards.kt:@Composable internal fun HeroFigure + ComposableParamOrder:ZeppSignIn.kt:@Composable fun ZeppSignInDialog + CompositionLocalAllowlist:CardToDetail.kt:/** * False where a card's tap opens a detail without growing: the rows inside a detail (Related rows), which crossfade * to the next detail instead (spec 3.5). */ val LocalCardsGrow = compositionLocalOf { true } + CompositionLocalAllowlist:CardToDetail.kt:/** The screen's share in the card to detail motion; null outside a tab's stack or with animations off. */ val LocalCardShare = compositionLocalOf<CardShare?> { null } + CompositionLocalAllowlist:CardToDetail.kt:val LocalCardNumbers = compositionLocalOf<CardNumbers?> { null } + CompositionLocalAllowlist:EdgeCard.kt:val LocalEdgeOverride = staticCompositionLocalOf { EdgeOverride() } + CompositionLocalAllowlist:Glass.kt:/** The overlay host, provided at the app's root. */ val LocalGlassHost = staticCompositionLocalOf<GlassHost> { error("No GlassHost provided") } + CompositionLocalAllowlist:GlowCard.kt:/** * Whether glow cards should hold still right now; a scrolling screen provides its scroll state, so * the cores pause while the list moves and the scroll gets every frame to itself. */ val LocalGlowPaused = staticCompositionLocalOf<() -> Boolean> { { false } } + CompositionLocalAllowlist:GlowCard.kt:/** * Whether the page holding these cards is off screen or the tab pager is moving; provided by the pager, apart from * [LocalGlowPaused] so a screen's own scroll state doesn't override it. Cores hold still then, so a swipe between * tabs gets every frame to itself and the pages kept ready either side don't animate unseen. */ val LocalGlowPagePaused = staticCompositionLocalOf<() -> Boolean> { { false } } + CompositionLocalAllowlist:GlowLayout.kt:/** * The widest a page's column gets ([GlowLayout.PageMaxWidth] over the app); [ScreenList] and the workout pages centre * their content in it. A dashboard lays out its own width, so [GlowScreenList] clears it. */ val LocalPageColumn = compositionLocalOf { GlowLayout.PageMaxWidth } + CompositionLocalAllowlist:GlowLayout.kt:/** * Whether the card around this content opens something when tapped. [GlowCard] and [EdgeCard] provide it from their * `onClick`, so every card title ([CardTitle]) shows the tap caret on its own (spec 2.5, Tappable says so). */ val LocalCardOpens = compositionLocalOf { false } + CompositionLocalAllowlist:GlowStyle.kt:/** * The mood for the cards on this screen; every [GlowCard] and [EdgeCard] under it follows. Only Today (by its moment) * and the Sleep tab provide one; everywhere else it's [GlowMood.Day]. */ val LocalGlowMood = staticCompositionLocalOf { GlowMood.Day } + CompositionLocalAllowlist:GrowFromButton.kt:val LocalGrowOrigin = staticCompositionLocalOf<GrowOrigin?> { null } + CompositionLocalAllowlist:PullToSync.kt:/** Set by [PullToSync]; [PullSyncReveal] reads it to open a gap under the wordmark. */ val LocalPullSync = staticCompositionLocalOf<PullSyncState?> { null } + CompositionLocalAllowlist:Routes.kt:/** Your daily goals, provided once at the top of the app so every screen measures against the same ones. */ val LocalGoals = compositionLocalOf { Goals() } + CompositionLocalAllowlist:WidgetParts.kt:/** * How many times the launcher has resized this kind of widget ([WidgetRefresh.resized]). The card reads it so a new size * lays the widget out again, since the session's own options can stay at the empty ones it started with. */ internal val LocalResized = staticCompositionLocalOf { 0 } + CompositionLocalAllowlist:WidgetParts.kt:/** The strap status line every widget's frame reads ([WidgetFrame.status]), null while all is well. */ internal val LocalStatusLine = staticCompositionLocalOf<StatusLine?> { null } + ContentSlotReused:Adaptive.kt:detail: @Composable (S) -> Unit + ContentSlotReused:Adaptive.kt:list: @Composable () -> Unit + ContentSlotReused:GlowLayout.kt:start: @Composable () -> Unit + ContentSlotReused:TabParts.kt:draw: @Composable (T, Modifier) -> Unit + CyclomaticComplexMethod:ActivityDetail.kt:@Composable private fun CaloriesDetail + CyclomaticComplexMethod:ActivityDetail.kt:@Composable private fun HeartPointsDetail + CyclomaticComplexMethod:ActivityDetail.kt:@Composable private fun LoadDetail + CyclomaticComplexMethod:BodySignals.kt:BodySignals$fun evaluate: List<Day> + CyclomaticComplexMethod:CardLooks.kt:fun neighbours: Set<kotlin.Pair<Int, Int>> + CyclomaticComplexMethod:Charts.kt:@Composable fun LineChart + CyclomaticComplexMethod:DayViewModel.kt:DayViewModel$private fun stateFor: Flow<DayUiState> + CyclomaticComplexMethod:DemoData.kt:DemoData$suspend fun fill + CyclomaticComplexMethod:DeviceScreen.kt:@Composable fun DeviceScreen + CyclomaticComplexMethod:EnergyExpenditure.kt:EnergyExpenditure$fun day: Day? + CyclomaticComplexMethod:GlowActivityScreen.kt:@Composable private fun FactsList + CyclomaticComplexMethod:GlowCard.kt:@Composable fun GlowCard + CyclomaticComplexMethod:GlowCharts.kt:@Composable fun GlowHypnogram + CyclomaticComplexMethod:GlowCharts.kt:@Composable internal fun NormalTrend + CyclomaticComplexMethod:GlowHealthspan.kt:@Composable fun HabitAgeEdgeCard + CyclomaticComplexMethod:GlowMetricDetail.kt:@Composable fun GlowMetricDetailScreen + CyclomaticComplexMethod:GlowMetricDetail.kt:@Composable private fun DayChart + CyclomaticComplexMethod:GlowTodayScreen.kt:@Composable fun GlowTodayScreen + CyclomaticComplexMethod:GlowTodayScreen.kt:@Composable private fun Hero + CyclomaticComplexMethod:GlowTodayScreen.kt:@Composable private fun TodayRow + CyclomaticComplexMethod:GlowWorkoutScreen.kt:@Composable fun GlowWorkoutDetailScreen + CyclomaticComplexMethod:GlowWorkoutScreen.kt:@Composable private fun PastSummary + CyclomaticComplexMethod:GlowWorkoutSummary.kt:@Composable fun GlowWorkoutSummaryScreen + CyclomaticComplexMethod:GlowWorkoutSummary.kt:@Composable internal fun SplitsList: Boolean + CyclomaticComplexMethod:Healthspan.kt:Healthspan$fun estimate: Pair<Result?, Withheld?> + CyclomaticComplexMethod:HealthspanEngine.kt:HealthspanEngine$private suspend fun daily: Healthspan.Daily + CyclomaticComplexMethod:HistorySync.kt:HistorySync$private suspend fun store + CyclomaticComplexMethod:HistorySync.kt:HistorySync$suspend fun run + CyclomaticComplexMethod:LightSpec.kt:fun glowPaint: GlowPaint + CyclomaticComplexMethod:LiveDistance.kt:@Composable internal fun LiveDistanceScreen + CyclomaticComplexMethod:LiveDistance.kt:@Composable private fun RepPage + CyclomaticComplexMethod:LiveFrame.kt:@Composable internal fun LiveFrame + CyclomaticComplexMethod:LiveGlance.kt:Glance$fun of: LiveGlance + CyclomaticComplexMethod:LiveGlance.kt:Glance$private fun interval: LiveGlance + CyclomaticComplexMethod:LiveIntervals.kt:@Composable internal fun LiveIntervalsScreen + CyclomaticComplexMethod:LiveSets.kt:@Composable internal fun LiveStrengthScreen + CyclomaticComplexMethod:MainActivity.kt:@Composable private fun AppRoot + CyclomaticComplexMethod:MainActivity.kt:@Composable private fun OverPages + CyclomaticComplexMethod:MainActivity.kt:@Composable private fun androidx.compose.foundation.layout.BoxScope.WorkoutLayer + CyclomaticComplexMethod:MainActivity.kt:MainActivity$private fun handleDebugIntent + CyclomaticComplexMethod:MomentWidget.kt:MomentWidget.Companion$internal suspend fun load: MomentWidgetModel + CyclomaticComplexMethod:MomentWidgetModel.kt:MomentLayout$private fun ladders: List<List<Fit>> + CyclomaticComplexMethod:NotificationCopy.kt:NotificationCopy$fun card: WorkoutCard + CyclomaticComplexMethod:PhoneAlertsScreen.kt:@Composable fun PhoneAlertsScreen + CyclomaticComplexMethod:ProfileScreen.kt:@Composable fun ProfileScreen + CyclomaticComplexMethod:ReadyScreen.kt:@Composable fun GlowWorkoutReadyScreen + CyclomaticComplexMethod:Recovery.kt:Recovery$fun charge: ChargeDay + CyclomaticComplexMethod:Recovery.kt:Recovery$fun readiness: Readiness + CyclomaticComplexMethod:RecoveryEngine.kt:RecoveryEngine$suspend fun compute: RecoveryResult + CyclomaticComplexMethod:Rewards.kt:Rewards$fun badges: List<BadgeRow> + CyclomaticComplexMethod:SampleData.kt:SampleData$suspend fun load + CyclomaticComplexMethod:ScoreDetail.kt:@Composable private fun ChargeDetail + CyclomaticComplexMethod:ScoreDetail.kt:@Composable private fun RecoveryDetail + CyclomaticComplexMethod:ShareCards.kt:internal fun storyCard: StoryCard + CyclomaticComplexMethod:ShareScreen.kt:@Composable private fun ShareScreen + CyclomaticComplexMethod:SleepDetail.kt:@Composable internal fun NapsDetail + CyclomaticComplexMethod:SleepDetail.kt:@Composable internal fun SleepScoreDetail + CyclomaticComplexMethod:SportIcons.kt:SportIcons$fun of: ImageVector + CyclomaticComplexMethod:StrapManager.kt:StrapManager$private suspend fun afterAuth + CyclomaticComplexMethod:StrapService.kt:StrapService$override fun onStartCommand: Int + CyclomaticComplexMethod:StrapService.kt:StrapService$private fun idleContent: IdleContent + CyclomaticComplexMethod:SummarySections.kt:@Composable internal fun FamilySection: Boolean + CyclomaticComplexMethod:SummarySections.kt:internal fun summaryHero: SummaryHero + CyclomaticComplexMethod:TodayLooks.kt:private fun ownLook: CardLook + CyclomaticComplexMethod:WorkoutCapsule.kt:@Composable internal fun LiveHero + CyclomaticComplexMethod:WorkoutDetailCopy.kt:WorkoutDetailCopy$fun effectSheet: EffectSheet + CyclomaticComplexMethod:WorkoutEffect.kt:WorkoutEffect$fun vo2max: Float? + CyclomaticComplexMethod:WorkoutSession.kt:WorkoutSession$private suspend fun watchEffort + DestructuringDeclarationWithTooManyEntries:DayViewModel.kt:DayViewModel$val (session, stages, naps, napStages) = sleepParts + DestructuringDeclarationWithTooManyEntries:Phases.kt:Structure.Companion$(v, unit, r, n) + DestructuringDeclarationWithTooManyEntries:StrapService.kt:StrapService$(c, hr, w, restored, m) + DestructuringDeclarationWithTooManyEntries:WorkoutsViewModel.kt:WorkoutsViewModel$val (w, hr, maxHr, all) = q + DoubleMutabilityForCollection:ReleaseNotes.kt:ReleaseNotes$var lines = mutableListOf<String>() + DoubleMutabilityForCollection:WorkoutSuggestions.kt:WorkoutSuggestions$var run = mutableListOf<Pair<Long, Int>>() + EmptyElseBlock:GlowCharts.kt:if (l > 0f) out += lerp(points[i], points[i + 1], left / l) + EmptyElseBlock:MainActivity.kt:if (Page.Firmware in pages) firmwareVm.reset() + EmptyElseBlock:Recovery.kt:Recovery$if (wakesSeen > 0) wakes += c + ForEachOnRange:HeartZonesScreen.kt:0 until Workouts.ZONES + ForEachOnRange:ReadyCardio.kt:0 until Workouts.ZONES + ForEachOnRange:TodayCards.kt:CheckIn.MIN..CheckIn.MAX + ForbiddenComment:Recovery.kt:Recovery$* Not-worn drift per minute: the mean of Zepp's default no-wear slopes for its two pools (native 03 §4.7), physical * −0.04596 with mental −0.0505 at or above 50 and −0.02386 below: about −2.9 an hour above 50, −2.1 below. * Zepp learns per-user slopes from rest minutes; OpenStrap keeps the defaults. * * TODO: learn the user's own drift rate from their history, as Zepp does (native 03 §4.7): each day, measure the * mean per-minute change of Charge over quiet, awake, worn minutes; once about 30 days and enough minutes have * built up, replace these defaults with the minute-weighted mean of those days, keeping the defaults until then. + ForbiddenComment:TabCopy.kt:TabCopy$* A workout row's second line: its fact, then when it started ("3.4 km, 17:35"), or the time alone. * TODO: a personal best ("Fastest 5 km since March") goes after the fact in the mock's warm colour (0xFFFFD08A) * once the app detects them; it doesn't yet. + ForbiddenMethodCall/collectAsState:ActivityDetail.kt:collectAsState(emptyMap()) + ForbiddenMethodCall/collectAsState:ActivityDetail.kt:collectAsState(null) + ForbiddenMethodCall/collectAsState:DeviceScreen.kt:collectAsState() + ForbiddenMethodCall/collectAsState:DeviceScreen.kt:collectAsState(false) + ForbiddenMethodCall/collectAsState:DeviceScreen.kt:collectAsState(true) + ForbiddenMethodCall/collectAsState:FirmwareScreen.kt:collectAsState() + ForbiddenMethodCall/collectAsState:GlowActivityScreen.kt:collectAsState(emptyMap()) + ForbiddenMethodCall/collectAsState:GlowMetricDetail.kt:collectAsState(null) + ForbiddenMethodCall/collectAsState:GlowRecoveryScreen.kt:collectAsState(emptyList()) + ForbiddenMethodCall/collectAsState:GlowWorkoutMode.kt:collectAsState() + ForbiddenMethodCall/collectAsState:PhoneAlertsScreen.kt:collectAsState() + ForbiddenMethodCall/collectAsState:PhoneAlertsScreen.kt:collectAsState(BuzzPattern.TWO_SHORT) + ForbiddenMethodCall/collectAsState:PhoneAlertsScreen.kt:collectAsState(CallBuzz.STEADY) + ForbiddenMethodCall/collectAsState:PhoneAlertsScreen.kt:collectAsState(QuietHours()) + ForbiddenMethodCall/collectAsState:PhoneAlertsScreen.kt:collectAsState(false) + ForbiddenMethodCall/collectAsState:ReadyScreen.kt:collectAsState() + ForbiddenMethodCall/collectAsState:ScoreDetail.kt:collectAsState(emptyList()) + ForbiddenMethodCall/collectAsState:TabParts.kt:collectAsState(emptyList()) + ForbiddenMethodCall/collectAsState:TodayCards.kt:collectAsState(emptyList()) + ForbiddenMethodCall/uiClock:ActivityDetail.kt:ActivityDetailContext$now() + ForbiddenMethodCall/uiClock:ActivityDetail.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:AllMetricsScreen.kt:now() + ForbiddenMethodCall/uiClock:BackupPage.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:Celebrations.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:DayCalendar.kt:now() + ForbiddenMethodCall/uiClock:DayViewModel.kt:DayUiState$now() + ForbiddenMethodCall/uiClock:DayViewModel.kt:DayViewModel$now() + ForbiddenMethodCall/uiClock:DeletedSleepPage.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:DeviceScreen.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:DeviceViewModel.kt:DeviceViewModel$currentTimeMillis() + ForbiddenMethodCall/uiClock:FirmwareViewModel.kt:FirmwareViewModel$currentTimeMillis() + ForbiddenMethodCall/uiClock:Frame.kt:now() + ForbiddenMethodCall/uiClock:GlowActivityScreen.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:GlowActivityScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowHealthspanScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowMetricDetail.kt:now() + ForbiddenMethodCall/uiClock:GlowRecoveryScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowSleepScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowTodayScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowWorkoutMode.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:GlowWorkoutScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowWorkoutSummary.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:GoalsAndBests.kt:now() + ForbiddenMethodCall/uiClock:HealthConnectPage.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:LiveFrame.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:LiveGlance.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:LiveHeart.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:LiveMind.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:MomentHeader.kt:now() + ForbiddenMethodCall/uiClock:ProfileScreen.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:ProfileScreen.kt:now() + ForbiddenMethodCall/uiClock:RewardsCopy.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:RewardsCopy.kt:now(f.zone) + ForbiddenMethodCall/uiClock:ScoreDetail.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:ScoreDetail.kt:now() + ForbiddenMethodCall/uiClock:ShareScreen.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:SleepDetail.kt:now() + ForbiddenMethodCall/uiClock:SummarySections.kt:now() + ForbiddenMethodCall/uiClock:TodayCards.kt:now() + ForbiddenMethodCall/uiClock:UpdatesPage.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:WorkoutsViewModel.kt:WorkoutsViewModel$currentTimeMillis() + ForbiddenMethodCall/uiClock:WorkoutsViewModel.kt:WorkoutsViewModel$now() + ForbiddenMethodCall/uiClock:YouPage.kt:currentTimeMillis() + ImplicitDefaultLocale:ActivityCopy.kt:ActivityCopy$"%,d".format(atRest) + ImplicitDefaultLocale:ActivityCopy.kt:ActivityCopy$"%,d".format(steps) + ImplicitDefaultLocale:ActivityCopy.kt:ActivityCopy$"steps, %02d:00 to %02d:00".format(hour, (hour + 1) % 24) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(Math.round(e.hourly.total[it])) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(Math.round(e.totalKcal - e.activeKcal)) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(Math.round(e.totalKcal)) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(Math.round(it.sum())) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(active) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(goal) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(it) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(s.kcal) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(summary.activeADay) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(summary.activeTotal) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(totals.stepsADay) + ImplicitDefaultLocale:ActivityDetail.kt:"%.1f".format(km) + ImplicitDefaultLocale:ActivityDetail.kt:"%.1f".format(r.strain) + ImplicitDefaultLocale:ActivityDetail.kt:"%.2f".format(it) + ImplicitDefaultLocale:AlarmsScreen.kt:"%02d:%02d %s".format(first.hour, first.minute, daysLabel(first.days).lowercase()) + ImplicitDefaultLocale:AlarmsScreen.kt:"%02d:%02d".format(a.hour, a.minute) + ImplicitDefaultLocale:AppUpdater.kt:AppUpdater.Companion$"%02x".format(it) + ImplicitDefaultLocale:BackupCopy.kt:BackupCopy$"$what %,d rows couldn't be read.".format(skipped) + ImplicitDefaultLocale:BackupCopy.kt:BackupCopy$"Restored %,d records and your settings.".format(records) + ImplicitDefaultLocale:BackupCopy.kt:BackupCopy$"Restored %,d records.".format(records) + ImplicitDefaultLocale:BodySignals.kt:BodySignals$"%.1f".format(d) + ImplicitDefaultLocale:CaloriesCopy.kt:CaloriesCopy$"%d h %02d".format(m / 60, m % 60) + ImplicitDefaultLocale:CaloriesCopy.kt:CaloriesCopy$"active kcal, %02d:00 to %02d:00".format(hour, (hour + 1) % 24) + ImplicitDefaultLocale:CsvTables.kt:"%02x".format(it) + ImplicitDefaultLocale:DeviceViewModel.kt:DeviceViewModel$"Restored %,d records from Health Connect.".format(n) + ImplicitDefaultLocale:FirmwareCloud.kt:FirmwareCloud.Companion$"%02x".format(it) + ImplicitDefaultLocale:FirmwareScreen.kt:"%,d bytes".format(image.bytes.size) + ImplicitDefaultLocale:FirmwareScreen.kt:"%,d".format(it.length / 1024) + ImplicitDefaultLocale:GattStrapLink.kt:StrapUuids$"0000%04x-0000-1000-8000-00805f9b34fb".format(id) + ImplicitDefaultLocale:GattStrapLink.kt:StrapUuids$"0000%04x-0000-3512-2118-0009af100700".format(id) + ImplicitDefaultLocale:GlowActivityScreen.kt:"%,d".format(if (h != null && picked != null) Math.round(picked) else state.steps) + ImplicitDefaultLocale:GlowActivityScreen.kt:"%,d".format(it) + ImplicitDefaultLocale:GlowActivityScreen.kt:"%.1f".format(it / 1000f) + ImplicitDefaultLocale:GlowActivityScreen.kt:"Goal %,d".format(goal) + ImplicitDefaultLocale:GlowHealthspan.kt:"%,d".format(((ref ?: 0f) / 100).roundToInt() * 100) + ImplicitDefaultLocale:GlowHealthspan.kt:"%,d".format(v.roundToInt()) + ImplicitDefaultLocale:GlowMetricDetail.kt:"%,d".format(Math.round(state.hourlySteps[it])) + ImplicitDefaultLocale:GlowMetricDetail.kt:"steps, %02d:00 to %02d:00".format(it, (it + 1) % 24) + ImplicitDefaultLocale:GlowSleepScreen.kt:"%.1f".format(b.perHour) + ImplicitDefaultLocale:GlowTodayScreen.kt:"%,d".format(ctx.state.steps) + ImplicitDefaultLocale:GlowWorkoutMode.kt:"%d:%02d".format(seconds / 60, seconds % 60) + ImplicitDefaultLocale:GlowWorkoutMode.kt:"%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) + ImplicitDefaultLocale:Goals.kt:Goals$"%,d steps, %,d active kcal, %s sleep, %d active hours".format(steps, calories, sleep, activeHours) + ImplicitDefaultLocale:GoalsAndBests.kt:"%,d".format(Math.round(kcal)) + ImplicitDefaultLocale:GoalsAndBests.kt:"%,d".format(it) + ImplicitDefaultLocale:GoalsAndBests.kt:"%d:%02d".format(seconds / 3600, seconds / 60 % 60) + ImplicitDefaultLocale:GoalsSheet.kt:"%,d active kcal".format(value) + ImplicitDefaultLocale:GoalsSheet.kt:"Suggested %,d, from your last 4 weeks".format(suggested) + ImplicitDefaultLocale:InfoCopy.kt:InfoCopy$"$night Your usual is %.1f.".format(usual) + ImplicitDefaultLocale:InfoCopy.kt:InfoCopy$"${if (today) "Last night" else "That night"} %.1f an hour.".format(b.perHour) + ImplicitDefaultLocale:InfoCopy.kt:InfoCopy$"%.1f".format(this) + ImplicitDefaultLocale:InfoCopy.kt:InfoCopy$"Your last 7 days against the last 4 weeks come to %.2f, the acute to chronic ratio. It's shown for ".format(it) + ImplicitDefaultLocale:LiveGlance.kt:Glance$"%d:%02d".format(s / 60, s % 60) + ImplicitDefaultLocale:LiveIntervals.kt:"%02d".format(it) + ImplicitDefaultLocale:MomentWidgetModel.kt:MomentWidgetModel.Content$"Bedtime %02d:%02d".format(it.hour, it.minute) + ImplicitDefaultLocale:PhoneAlertsScreen.kt:"%02d:%02d".format(minutes / 60, minutes % 60) + ImplicitDefaultLocale:Recovery.kt:Recovery$"%.${decimals}f".format(abs(v)) + ImplicitDefaultLocale:Recovery.kt:Recovery$"%.1f".format(o) + ImplicitDefaultLocale:Recovery.kt:Recovery$"%02d".format(m % 60) + ImplicitDefaultLocale:RewardsCopy.kt:RewardsCopy$"%,d of %,d %s".format(have, need, unit) + ImplicitDefaultLocale:RewardsCopy.kt:RewardsCopy$"%,d".format(Math.round(diff)) + ImplicitDefaultLocale:RewardsCopy.kt:RewardsCopy$"%,d".format(Math.round(value)) + ImplicitDefaultLocale:Routes.kt:Metric$"%,d".format(Math.round(v)) + ImplicitDefaultLocale:Routes.kt:Metric$"%.${decimals}f".format(v) + ImplicitDefaultLocale:ShareCards.kt:"%,d".format(Math.round(it)) + ImplicitDefaultLocale:SleepCopy.kt:SleepCopy$"%02d:%02d".format(it / 60, it % 60) + ImplicitDefaultLocale:SleepDetail.kt:"%.${decimals}f".format(v) + ImplicitDefaultLocale:SleepDetail.kt:"Average %.1f br/min".format(breath.map { it.y }.average()) + ImplicitDefaultLocale:SleepPanels.kt:"%02d:%02d".format(it / 60, it % 60) + ImplicitDefaultLocale:SleepPanels.kt:"%02d:%02d".format(it.hour, it.minute) + ImplicitDefaultLocale:SleepRegularity.kt:SleepRegularity$"%02d:%02d".format(minutes / 60, abs(minutes % 60)) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"%02x".format(type) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"%08x".format(crc32(file)) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"%tT %s".format(System.currentTimeMillis(), message) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"cmd %04x > %s < %s".format(endpoint, request.toHex(), reply.toHex()) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"cmd %04x > %s".format(Endpoints.FIND_DEVICE, request.toHex()) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"mask %02x g2 %s alg %s".format(it.mask, it.group2?.toHex(), it.algorithm) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"probe %04x < %s".format(endpoint, it.toHex()) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"probe %04x > %s".format(endpoint, payload.toHex()) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"probe %04x ack < %s".format(endpoint, it.toHex()) + ImplicitDefaultLocale:StrapService.kt:StrapService.Companion$"%d:%02d".format(seconds / 60, seconds % 60) + ImplicitDefaultLocale:StrapService.kt:StrapService.Companion$"%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) + ImplicitDefaultLocale:SummarySections.kt:"%,d".format(Math.round(it)) + ImplicitDefaultLocale:SummarySections.kt:"%,d".format(Math.round(lifted)) + ImplicitDefaultLocale:TabCopy.kt:TabCopy$"%.1f km".format(meters / 1000f) + ImplicitDefaultLocale:TabCopy.kt:TabCopy$"%02d:%02d".format(it / 60, it % 60) + ImplicitDefaultLocale:TabCopy.kt:TabCopy$"%d:%02d a km".format(s / 60, s % 60) + ImplicitDefaultLocale:TabCopy.kt:TabCopy$"Usual %.1f, early estimate".format(usual) + ImplicitDefaultLocale:TabCopy.kt:TabCopy$"Your usual is %.1f an hour".format(usual) + ImplicitDefaultLocale:TodayCards.kt:"%,d".format(it.roundToInt()) + ImplicitDefaultLocale:TodayCards.kt:"%,d".format(state.steps) + ImplicitDefaultLocale:TodayCards.kt:"%d:%02d".format(seconds / 3600, (seconds % 3600) / 60) + ImplicitDefaultLocale:TodayCards.kt:"Goal %,d".format(goal) + ImplicitDefaultLocale:TodayCopy.kt:TodayCopy$"$h h %02d".format(m) + ImplicitDefaultLocale:YouPage.kt:"%,d steps, %,d active kcal, %d active hours".format(goals.steps, goals.calories, goals.activeHours) + ImplicitDefaultLocale:YouPage.kt:"%02d".format(minutes % 60) + InjectDispatcher:AppUpdater.kt:AppUpdater$IO + InjectDispatcher:AutoBackup.kt:AutoBackup$IO + InjectDispatcher:DayViewModel.kt:DayViewModel$Default + InjectDispatcher:DayViewModel.kt:DayViewModel$IO + InjectDispatcher:DeviceViewModel.kt:DeviceViewModel$IO + InjectDispatcher:FirmwareCloud.kt:FirmwareCloud$IO + InjectDispatcher:FirmwareViewModel.kt:FirmwareViewModel$IO + InjectDispatcher:GlowCard.kt:Default + InjectDispatcher:MaxHeartRates.kt:MaxHeartRates$Default + InjectDispatcher:OpenStrapApp.kt:OpenStrapApp$Default + InjectDispatcher:PhoneAlerts.kt:PhoneAlerts$Default + InjectDispatcher:RewardsCopy.kt:Default + InjectDispatcher:RewardsKeeper.kt:RewardsKeeper$Default + InjectDispatcher:RewardsKeeper.kt:RewardsKeeper$IO + InjectDispatcher:ShareScreen.kt:IO + InjectDispatcher:StrapManager.kt:StrapManager$Default + InjectDispatcher:StrapManager.kt:StrapManager$IO + InjectDispatcher:StrapService.kt:StrapService$Default + InjectDispatcher:WidgetUpdater.kt:WidgetUpdater$Default + InjectDispatcher:WorkoutSession.kt:WorkoutSession$Default + InjectDispatcher:WorkoutsViewModel.kt:WorkoutsViewModel$Default + InjectDispatcher:ZeppCloud.kt:ZeppCloud$IO + InstanceOfCheckForException:HistorySync.kt:HistorySync$e is FastRechargeModeException + LambdaParameterEventTrailing:ActivityDetail.kt:onPick: (Int?) -> Unit + LambdaParameterEventTrailing:ChooseScreen.kt:onChange: (String) -> Unit + LambdaParameterEventTrailing:ChooseScreen.kt:onClick: () -> Unit + LambdaParameterEventTrailing:GlowActivityScreen.kt:onClick: () -> Unit + LambdaParameterEventTrailing:GlowRecoveryScreen.kt:onOpen: () -> Unit + LambdaParameterEventTrailing:GlowSleepScreen.kt:onOpen: () -> Unit + LambdaParameterEventTrailing:LiveSets.kt:onMore: () -> Unit + LambdaParameterEventTrailing:ShareScreen.kt:onClick: () -> Unit + LambdaParameterEventTrailing:ZeppSignIn.kt:onClick: () -> Unit + LambdaParameterEventTrailing:ZeppSignIn.kt:onThirdParty: (provider: String) -> Unit + LambdaParameterInRestartableEffect:GlowLayout.kt:onFocused: () -> Unit = {} + LambdaParameterInRestartableEffect:ReadyCardio.kt:onDone: () -> Unit + LambdaParameterInRestartableEffect:SleepDetail.kt:onGone: () -> Unit + LargeClass:Recovery.kt:Recovery + LargeClass:StrapManager.kt:StrapManager : StrapController + LargeClass:WorkoutSession.kt:WorkoutSession + LongMethod:DayViewModel.kt:DayViewModel$private fun stateFor: Flow<DayUiState> + LongMethod:DemoData.kt:DemoData$suspend fun fill + LongMethod:HistorySync.kt:HistorySync$private suspend fun store + LongMethod:InfoCopy.kt:InfoCopy$fun of: Info + LongMethod:LightSpec.kt:fun glowPaint: GlowPaint + LongMethod:MainActivity.kt:MainActivity$private fun handleDebugIntent + LongMethod:Recovery.kt:Recovery$fun charge: ChargeDay + LongMethod:Recovery.kt:Recovery$fun readiness: Readiness + LongMethod:RecoveryEngine.kt:RecoveryEngine$suspend fun compute: RecoveryResult + LongMethod:StrapManager.kt:StrapManager$private suspend fun afterAuth + LongMethod:StrapService.kt:StrapService$override fun onStartCommand: Int + LongParameterList:ActivityDetail.kt:ActivityDetailContext + LongParameterList:ChartSpec.kt:LineChartGeometry + LongParameterList:GlowStyle.kt:GlowLight + LongParameterList:GlowTodayScreen.kt:TodayContext + LongParameterList:NotificationCopy.kt:NotificationCopy$fun card: WorkoutCard + LongParameterList:Recovery.kt:Recovery$fun minutes: List<Minute> + LongParameterList:RewardsCopy.kt:RewardsCopy$fun summary: SummaryRewards + LongParameterList:Route.kt:Route$private fun smoothPiece + LongParameterList:WidgetParts.kt:WidgetFrame + LongParameterList:WorkoutDetailCopy.kt:WorkoutDetailCopy$fun effectSheet: EffectSheet + LongParameterList:WorkoutEffect.kt:WorkoutEffect$fun compute: Result + LongParameterList:WorkoutRecorder.kt:WorkoutRecorder$fun kcalMinutes: List<EnergyExpenditure.WorkoutMinute>? + LongParameterList:WorkoutRecorder.kt:WorkoutRecorder$fun liveKcalTick: Kcal? + LongParameterList:WorkoutSession.kt:WorkoutSession$fun start + LoopWithTooManyJumpStatements:CsvImporter.kt:CsvImporter$while + LoopWithTooManyJumpStatements:GlowWorkoutMode.kt:for + LoopWithTooManyJumpStatements:GlowWorkoutScreen.kt:for + LoopWithTooManyJumpStatements:HealthConnectExporter.kt:HealthConnectExporter$for + LoopWithTooManyJumpStatements:HistorySync.kt:HistorySync$for + LoopWithTooManyJumpStatements:Recovery.kt:Recovery$for + LoopWithTooManyJumpStatements:Rewards.kt:Rewards$for + LoopWithTooManyJumpStatements:StartWidgetModel.kt:StartLayout$for + LoopWithTooManyJumpStatements:StepsWidgetModel.kt:StepsLayout$for + LoopWithTooManyJumpStatements:WorkoutEffectEngine.kt:WorkoutEffectEngine$for + LoopWithTooManyJumpStatements:WorkoutSuggestions.kt:WorkoutSuggestions$for + MagicNumber:Activity.kt:Activity$60 + MagicNumber:Activity.kt:Activity$60L + MagicNumber:Activity.kt:Activity$7_000 + MagicNumber:Activity.kt:Activity$8_000 + MagicNumber:ActivityCopy.kt:ActivityCopy$100 + MagicNumber:ActivityCopy.kt:ActivityCopy$23 + MagicNumber:ActivityCopy.kt:ActivityCopy$24 + MagicNumber:ActivityCopy.kt:ActivityCopy$50 + MagicNumber:ActivityDetail.kt:3f + MagicNumber:ActivityDetail.kt:5 + MagicNumber:ActivityDetail.kt:6 + MagicNumber:AlarmsScreen.kt:7 + MagicNumber:AppUpdater.kt:AppUpdater$15_000 + MagicNumber:AppUpdater.kt:AppUpdater$200 + MagicNumber:AppUpdater.kt:AppUpdater$20_000 + MagicNumber:AppUpdater.kt:AppUpdater$299 + MagicNumber:AppUpdater.kt:AppUpdater$30_000 + MagicNumber:AutoBackup.kt:AutoBackup$10_000 + MagicNumber:AutoHealthConnect.kt:AutoHealthConnect$3600 + MagicNumber:AutoHealthConnect.kt:HealthConnectAutoSync$1000 + MagicNumber:AutoStart.kt:AutoStart$10 + MagicNumber:AutoStart.kt:AutoStart$3 + MagicNumber:AutoStart.kt:AutoStart$5 + MagicNumber:AutoStart.kt:AutoStart$60 + MagicNumber:CalorieRecords.kt:CalorieRecords$23 + MagicNumber:CaloriesCopy.kt:CaloriesCopy$100 + MagicNumber:CaloriesCopy.kt:CaloriesCopy$24 + MagicNumber:CaloriesCopy.kt:CaloriesCopy$60 + MagicNumber:CanvasDraw.kt:0.57735f + MagicNumber:CanvasDraw.kt:0.5f + MagicNumber:CanvasDraw.kt:255 + MagicNumber:CardMotion.kt:CardMotion$3 + MagicNumber:CardMotion.kt:CardMotion$4 + MagicNumber:CardMotion.kt:CardMotion$5 + MagicNumber:Cards.kt:60 + MagicNumber:Celebrations.kt:0.35f + MagicNumber:Celebrations.kt:3 + MagicNumber:Celebrations.kt:4 + MagicNumber:ChartSpec.kt:0.5f + MagicNumber:ChartSpec.kt:9f + MagicNumber:Charts.kt:0.001f + MagicNumber:Charts.kt:15 + MagicNumber:Charts.kt:4 + MagicNumber:Charts.kt:60 + MagicNumber:ChooseScreen.kt:4 + MagicNumber:Coaching.kt:Cue.AboveTarget$120 + MagicNumber:Coaching.kt:Cue.BackInTarget$200 + MagicNumber:Coaching.kt:Cue.BelowTarget$700 + MagicNumber:ComponentGalleryActivity.kt:0.3f + MagicNumber:ComponentGalleryActivity.kt:0.7f + MagicNumber:ComponentGalleryActivity.kt:0xFF7C6CF0 + MagicNumber:ComponentGalleryActivity.kt:100 + MagicNumber:ComponentGalleryActivity.kt:64 + MagicNumber:CsvExporter.kt:CsvExporter.Companion$3 + MagicNumber:CsvImporter.kt:CsvImporter$2_000 + MagicNumber:CsvTables.kt:CsvRow$16 + MagicNumber:DailyEnergyHistory.kt:DailyEnergyHistory$1000 + MagicNumber:DataStateParts.kt:0.001f + MagicNumber:DataStateParts.kt:0.999f + MagicNumber:DataStateParts.kt:650 + MagicNumber:DataStateParts.kt:700 + MagicNumber:DataStates.kt:100 + MagicNumber:DataStates.kt:55 + MagicNumber:DataStates.kt:60L + MagicNumber:DayCalendar.kt:160 + MagicNumber:DayCalendar.kt:220 + MagicNumber:DayCalendar.kt:4 + MagicNumber:DayCalendar.kt:6 + MagicNumber:DayCalendar.kt:7 + MagicNumber:DayViewModel.kt:DayUiState$24 + MagicNumber:DemoData.kt:DemoData$0.25 + MagicNumber:DemoData.kt:DemoData$0.3f + MagicNumber:DemoData.kt:DemoData$0.75 + MagicNumber:DemoData.kt:DemoData$10 + MagicNumber:DemoData.kt:DemoData$100 + MagicNumber:DemoData.kt:DemoData$111_320 + MagicNumber:DemoData.kt:DemoData$12 + MagicNumber:DemoData.kt:DemoData$13 + MagicNumber:DemoData.kt:DemoData$14 + MagicNumber:DemoData.kt:DemoData$148.0 + MagicNumber:DemoData.kt:DemoData$15 + MagicNumber:DemoData.kt:DemoData$150 + MagicNumber:DemoData.kt:DemoData$16 + MagicNumber:DemoData.kt:DemoData$18 + MagicNumber:DemoData.kt:DemoData$180 + MagicNumber:DemoData.kt:DemoData$1800L + MagicNumber:DemoData.kt:DemoData$192 + MagicNumber:DemoData.kt:DemoData$20 + MagicNumber:DemoData.kt:DemoData$25 + MagicNumber:DemoData.kt:DemoData$3 + MagicNumber:DemoData.kt:DemoData$30 + MagicNumber:DemoData.kt:DemoData$300L + MagicNumber:DemoData.kt:DemoData$320L + MagicNumber:DemoData.kt:DemoData$33.2f + MagicNumber:DemoData.kt:DemoData$34.4f + MagicNumber:DemoData.kt:DemoData$35 + MagicNumber:DemoData.kt:DemoData$3600 + MagicNumber:DemoData.kt:DemoData$3600L + MagicNumber:DemoData.kt:DemoData$38 + MagicNumber:DemoData.kt:DemoData$4 + MagicNumber:DemoData.kt:DemoData$40 + MagicNumber:DemoData.kt:DemoData$400 + MagicNumber:DemoData.kt:DemoData$40f + MagicNumber:DemoData.kt:DemoData$45 + MagicNumber:DemoData.kt:DemoData$45f + MagicNumber:DemoData.kt:DemoData$48 + MagicNumber:DemoData.kt:DemoData$5 + MagicNumber:DemoData.kt:DemoData$56 + MagicNumber:DemoData.kt:DemoData$58 + MagicNumber:DemoData.kt:DemoData$6 + MagicNumber:DemoData.kt:DemoData$60 + MagicNumber:DemoData.kt:DemoData$600 + MagicNumber:DemoData.kt:DemoData$60L + MagicNumber:DemoData.kt:DemoData$7 + MagicNumber:DemoData.kt:DemoData$7200L + MagicNumber:DemoData.kt:DemoData$8 + MagicNumber:DemoData.kt:DemoData$9 + MagicNumber:DemoData.kt:DemoData$94 + MagicNumber:DemoData.kt:DemoData$95 + MagicNumber:DemoData.kt:DemoData$96 + MagicNumber:DetailCopy.kt:DetailCopy$6 + MagicNumber:DeviceScreen.kt:3 + MagicNumber:DeviceViewModel.kt:DeviceViewModel.Companion$32 + MagicNumber:EdgeCard.kt:EdgeGlow$1.702f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$0.2f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$10f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$1440f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$200f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$3.5f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$3600 + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$5f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$6.25f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$60 + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$60L + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$86_400L + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure.Hourly$24 + MagicNumber:FamilySummary.kt:FamilySummary$100 + MagicNumber:FamilySummary.kt:FamilySummary$1000.0 + MagicNumber:FamilySummary.kt:FamilySummary$100f + MagicNumber:FamilySummary.kt:FamilySummary$11 + MagicNumber:FamilySummary.kt:FamilySummary$17 + MagicNumber:FamilySummary.kt:FamilySummary$23 + MagicNumber:FamilySummary.kt:FamilySummary$3 + MagicNumber:FamilySummary.kt:FamilySummary$4 + MagicNumber:FamilySummary.kt:FamilySummary$60 + MagicNumber:FamilySummary.kt:FamilySummary$60.0 + MagicNumber:FamilySummary.kt:FamilySummary$7L + MagicNumber:FirmwareCloud.kt:FirmwareCloud$15_000 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$200 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$20_000 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$299 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$30_000 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$401 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$403 + MagicNumber:FirmwareScreen.kt:100 + MagicNumber:FirmwareScreen.kt:1024 + MagicNumber:FirmwareViewModel.kt:FirmwareViewModel$1024 + MagicNumber:FirmwareViewModel.kt:FirmwareViewModel$16 + MagicNumber:Frame.kt:11 + MagicNumber:Frame.kt:143 + MagicNumber:Frame.kt:15 + MagicNumber:Frame.kt:215 + MagicNumber:Frame.kt:224 + MagicNumber:Frame.kt:23 + MagicNumber:Frame.kt:24 + MagicNumber:Frame.kt:3 + MagicNumber:Frame.kt:4 + MagicNumber:Frame.kt:40 + MagicNumber:Frame.kt:48 + MagicNumber:Frame.kt:6 + MagicNumber:Frame.kt:60 + MagicNumber:Frame.kt:7 + MagicNumber:Frame.kt:8 + MagicNumber:GattClient.kt:GattClient$37 + MagicNumber:GattClient.kt:GattClient$500 + MagicNumber:Glass.kt:0xFF101012 + MagicNumber:Glass.kt:0xFF1A1A1D + MagicNumber:Glass.kt:220 + MagicNumber:GlowActivityScreen.kt:1000f + MagicNumber:GlowCard.kt:0.035f + MagicNumber:GlowCard.kt:0.03f + MagicNumber:GlowCard.kt:0.08f + MagicNumber:GlowCard.kt:0.09f + MagicNumber:GlowCard.kt:0.30f + MagicNumber:GlowCard.kt:0.55f + MagicNumber:GlowCard.kt:13 + MagicNumber:GlowCard.kt:15 + MagicNumber:GlowCard.kt:24 + MagicNumber:GlowCard.kt:255 + MagicNumber:GlowCard.kt:60f + MagicNumber:GlowCard.kt:8 + MagicNumber:GlowCharts.kt:0.6f + MagicNumber:GlowCharts.kt:180f + MagicNumber:GlowCharts.kt:48 + MagicNumber:GlowGrid.kt:3 + MagicNumber:GlowHealthspan.kt:0.05f + MagicNumber:GlowHealthspan.kt:0.375f + MagicNumber:GlowHealthspan.kt:0.75f + MagicNumber:GlowHealthspan.kt:100 + MagicNumber:GlowHealthspan.kt:60 + MagicNumber:GlowHealthspan.kt:8000f + MagicNumber:GlowHealthspanScreen.kt:0.05f + MagicNumber:GlowHealthspanScreen.kt:7 + MagicNumber:GlowList.kt:360f + MagicNumber:GlowList.kt:90f + MagicNumber:GlowMetricDetail.kt:0.1f + MagicNumber:GlowMetricDetail.kt:0.25f + MagicNumber:GlowMetricDetail.kt:0.2f + MagicNumber:GlowMetricDetail.kt:0.47f + MagicNumber:GlowMetricDetail.kt:0.5f + MagicNumber:GlowMetricDetail.kt:0.7f + MagicNumber:GlowMetricDetail.kt:0xFF121214 + MagicNumber:GlowMetricDetail.kt:24 + MagicNumber:GlowMetricDetail.kt:50 + MagicNumber:GlowParts.kt:0.4f + MagicNumber:GlowRecoveryScreen.kt:100f + MagicNumber:GlowStyle.kt:0.5f + MagicNumber:GlowStyle.kt:1e-6f + MagicNumber:GlowStyle.kt:GlowPalette$0.01f + MagicNumber:GlowStyle.kt:GlowPalette$0.03f + MagicNumber:GlowStyle.kt:GlowPalette$0.04f + MagicNumber:GlowStyle.kt:GlowPalette$0.08f + MagicNumber:GlowStyle.kt:GlowPalette$0.45f + MagicNumber:GlowStyle.kt:GlowPalette$0.6f + MagicNumber:GlowStyle.kt:GlowPalette$0.7f + MagicNumber:GlowStyle.kt:GlowPalette$0.85f + MagicNumber:GlowStyle.kt:GlowPalette$0.8f + MagicNumber:GlowStyle.kt:GlowPalette$0.9f + MagicNumber:GlowStyle.kt:GlowPalette$360f + MagicNumber:GlowStyle.kt:GlowPalette$4f + MagicNumber:GlowStyle.kt:GlowStyle$40f + MagicNumber:GlowTodayScreen.kt:0xFF4E8F86 + MagicNumber:GlowTodayScreen.kt:300 + MagicNumber:GlowWorkoutMode.kt:1000 + MagicNumber:GlowWorkoutMode.kt:300 + MagicNumber:GlowWorkoutMode.kt:3600 + MagicNumber:GlowWorkoutMode.kt:4 + MagicNumber:GlowWorkoutMode.kt:50 + MagicNumber:GlowWorkoutMode.kt:60 + MagicNumber:GlowWorkoutSummary.kt:1000 + MagicNumber:GlowWorkoutSummary.kt:450 + MagicNumber:GlowWorkoutSummary.kt:5 + MagicNumber:GlowWorkoutSummary.kt:900 + MagicNumber:Goals.kt:CalorieGoal$1.1f + MagicNumber:Goals.kt:CalorieGoal$10 + MagicNumber:Goals.kt:CalorieGoal$10f + MagicNumber:GoalsAndBests.kt:100_000 + MagicNumber:GoalsAndBests.kt:100_001 + MagicNumber:GoalsAndBests.kt:100_002 + MagicNumber:GoalsAndBests.kt:100_003 + MagicNumber:GoalsAndBests.kt:3 + MagicNumber:GoalsAndBests.kt:3600 + MagicNumber:GoalsAndBests.kt:4 + MagicNumber:GoalsAndBests.kt:6 + MagicNumber:GoalsAndBests.kt:60 + MagicNumber:GoalsAndBests.kt:7L + MagicNumber:GoalsSheet.kt:4 + MagicNumber:GrowFromButton.kt:0xFF1F1F22 + MagicNumber:GrowFromButton.kt:GrowMotion$0.01f + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$100 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$1000 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$11 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$143 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$15 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$200 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$215 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$222 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$224 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$225 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$226 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$23 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$24 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$3 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$3600 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$4 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$40 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$49 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$50 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$500 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$52 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$6 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$60 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$61 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$7 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$70 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$8 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$86_400 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$9 + MagicNumber:HealthRepository.kt:DayWindow.Companion$24 + MagicNumber:HealthRepository.kt:DayWindow.Companion$3600 + MagicNumber:HealthRepository.kt:HealthRepository$1000 + MagicNumber:HealthRepository.kt:HealthRepository$23 + MagicNumber:HealthRepository.kt:HealthRepository$3600 + MagicNumber:Healthspan.kt:Healthspan$0.5f + MagicNumber:Healthspan.kt:Healthspan$4 + MagicNumber:Healthspan.kt:Healthspan$8 + MagicNumber:Healthspan.kt:Healthspan.Value.Companion$0.5f + MagicNumber:Healthspan.kt:Healthspan.Value.Companion$1.5f + MagicNumber:HealthspanEngine.kt:HealthspanEngine$1000 + MagicNumber:HealthspanEngine.kt:HealthspanEngine$60 + MagicNumber:HeartZones.kt:HeartZones$100f + MagicNumber:HeartZones.kt:HeartZones.Companion$100f + MagicNumber:HeartZonesScreen.kt:0.5f + MagicNumber:HeartZonesScreen.kt:3 + MagicNumber:HistorySync.kt:HistorySync$0xFF + MagicNumber:HistorySync.kt:HistorySync$100 + MagicNumber:HistorySync.kt:HistorySync$1000 + MagicNumber:HistorySync.kt:HistorySync$120 + MagicNumber:HistorySync.kt:HistorySync$20 + MagicNumber:HistorySync.kt:HistorySync$30 + MagicNumber:HistorySync.kt:HistorySync$32 + MagicNumber:HistorySync.kt:HistorySync$3600 + MagicNumber:HistorySync.kt:HistorySync$4 + MagicNumber:HistorySync.kt:HistorySync$40 + MagicNumber:HistorySync.kt:HistorySync$400.0 + MagicNumber:HistorySync.kt:HistorySync$60 + MagicNumber:HistorySync.kt:HistorySync$70 + MagicNumber:InfoCopy.kt:InfoCopy$25 + MagicNumber:InfoCopy.kt:InfoCopy$3 + MagicNumber:InfoCopy.kt:InfoCopy$60 + MagicNumber:InfoCopy.kt:InfoCopy$60L + MagicNumber:InfoCopy.kt:InfoCopy$70 + MagicNumber:InfoCopy.kt:InfoCopy$80 + MagicNumber:InfoCopy.kt:InfoCopy$85 + MagicNumber:InfoCopy.kt:InfoCopy$90 + MagicNumber:InfoSheet.kt:0xFF141416 + MagicNumber:LightSpec.kt:0.6f + MagicNumber:LiveDistance.kt:3600 + MagicNumber:LiveDistance.kt:50 + MagicNumber:LiveFrame.kt:1000 + MagicNumber:LiveFrame.kt:200 + MagicNumber:LiveFrame.kt:360f + MagicNumber:LiveFrame.kt:50 + MagicNumber:LiveFrame.kt:90f + MagicNumber:LiveGame.kt:5 + MagicNumber:LiveGame.kt:50 + MagicNumber:LiveGlance.kt:1000.0 + MagicNumber:LiveGlance.kt:50 + MagicNumber:LiveGlance.kt:Glance$1000 + MagicNumber:LiveGlance.kt:Glance$1000f + MagicNumber:LiveGlance.kt:Glance$3600 + MagicNumber:LiveGlance.kt:Glance$60 + MagicNumber:LiveHeart.kt:0.45f + MagicNumber:LiveHeart.kt:100 + MagicNumber:LiveHeart.kt:2.6f + MagicNumber:LiveIndoor.kt:0.04f + MagicNumber:LiveIndoor.kt:50 + MagicNumber:LiveIntervals.kt:6 + MagicNumber:LiveIntervals.kt:<no name provided>$0.08f + MagicNumber:LiveIntervals.kt:<no name provided>$0.3f + MagicNumber:LiveIntervals.kt:<no name provided>$0.62f + MagicNumber:LiveIntervals.kt:<no name provided>$0.6f + MagicNumber:LiveIntervals.kt:<no name provided>$0.7f + MagicNumber:LiveIntervals.kt:<no name provided>$0.88f + MagicNumber:LiveIntervals.kt:<no name provided>$1.08f + MagicNumber:LiveMap.kt:1.3f + MagicNumber:LiveMap.kt:50 + MagicNumber:LiveMind.kt:0.5f + MagicNumber:LiveMind.kt:0.6f + MagicNumber:LiveMind.kt:360f + MagicNumber:LiveMind.kt:60 + MagicNumber:LiveMind.kt:90f + MagicNumber:LiveSets.kt:10 + MagicNumber:LiveSets.kt:360f + MagicNumber:LiveSets.kt:4 + MagicNumber:LiveSets.kt:90f + MagicNumber:MainActivity.kt:1000 + MagicNumber:MainActivity.kt:190 + MagicNumber:MainActivity.kt:2.5f + MagicNumber:MainActivity.kt:30_000 + MagicNumber:MainActivity.kt:3f + MagicNumber:MainActivity.kt:MainActivity$100 + MagicNumber:MainActivity.kt:MainActivity$80 + MagicNumber:MetricInfo.kt:Range.Month$30 + MagicNumber:MetricInfo.kt:Range.Week$7 + MagicNumber:MetricInfo.kt:Range.Year$365 + MagicNumber:MomentHeader.kt:0.4f + MagicNumber:MomentHeader.kt:3 + MagicNumber:MomentResolver.kt:MomentResolver$1440 + MagicNumber:MomentResolver.kt:MomentResolver$60 + MagicNumber:MomentResolver.kt:MomentResolver$60L + MagicNumber:MomentResolver.kt:MomentResolver$720 + MagicNumber:MomentWidgetModel.kt:MomentLayout.Fit$0.01f + MagicNumber:MomentWidgetModel.kt:MomentWidgetModel.Companion$1440 + MagicNumber:MomentWidgetModel.kt:MomentWidgetModel.Companion$60 + MagicNumber:MomentWidgetModel.kt:MomentWidgetModel.Companion$720 + MagicNumber:Motion.kt:4 + MagicNumber:Motion.kt:8 + MagicNumber:MovingBorder.kt:1e6f + MagicNumber:MovingBorder.kt:360f + MagicNumber:MovingBorder.kt:90f + MagicNumber:Narrative.kt:Narrative$100f + MagicNumber:Narrative.kt:Narrative$60L + MagicNumber:Narrative.kt:Narrative$70 + MagicNumber:Narrative.kt:Narrative$80 + MagicNumber:Narrative.kt:Narrative$85 + MagicNumber:Narrative.kt:Narrative$90 + MagicNumber:NotificationCopy.kt:NotificationCopy$3 + MagicNumber:NotificationCopy.kt:NotificationCopy$4 + MagicNumber:PageParts.kt:0.45f + MagicNumber:PageParts.kt:0.4f + MagicNumber:Pai.kt:Pai$1e-9 + MagicNumber:Pai.kt:Pai$7 + MagicNumber:PhoneAlerts.kt:PhoneAlerts$1000 + MagicNumber:PhoneAlerts.kt:PhoneAlerts$60 + MagicNumber:PhoneAlertsScreen.kt:60 + MagicNumber:ProfileScreen.kt:4 + MagicNumber:ProfileScreen.kt:5 + MagicNumber:PullToSync.kt:180f + MagicNumber:ReadyCardio.kt:0.5f + MagicNumber:ReadyCardio.kt:1.2f + MagicNumber:ReadyCardio.kt:120 + MagicNumber:ReadyCardio.kt:3 + MagicNumber:ReadyCardio.kt:3600 + MagicNumber:ReadyCardio.kt:40 + MagicNumber:ReadyCardio.kt:6 + MagicNumber:ReadyCardio.kt:60 + MagicNumber:ReadyCardio.kt:80 + MagicNumber:ReadyPlans.kt:60 + MagicNumber:Recovery.kt:Recovery$0.5f + MagicNumber:Recovery.kt:Recovery$0.64f + MagicNumber:Recovery.kt:Recovery$0.86f + MagicNumber:Recovery.kt:Recovery$0.8f + MagicNumber:Recovery.kt:Recovery$0x7E + MagicNumber:Recovery.kt:Recovery$1.2f + MagicNumber:Recovery.kt:Recovery$1.67f + MagicNumber:Recovery.kt:Recovery$1.92f + MagicNumber:Recovery.kt:Recovery$10 + MagicNumber:Recovery.kt:Recovery$100 + MagicNumber:Recovery.kt:Recovery$100.0 + MagicNumber:Recovery.kt:Recovery$100f + MagicNumber:Recovery.kt:Recovery$140 + MagicNumber:Recovery.kt:Recovery$225 + MagicNumber:Recovery.kt:Recovery$3 + MagicNumber:Recovery.kt:Recovery$30 + MagicNumber:Recovery.kt:Recovery$50f + MagicNumber:Recovery.kt:Recovery$6 + MagicNumber:Recovery.kt:Recovery$60 + MagicNumber:Recovery.kt:Recovery$60L + MagicNumber:Recovery.kt:Recovery$65 + MagicNumber:Recovery.kt:Recovery$7 + MagicNumber:Recovery.kt:Recovery.Factor.BloodOxygen$0.03f + MagicNumber:Recovery.kt:Recovery.Factor.Breathing$0.04f + MagicNumber:Recovery.kt:Recovery.Factor.HrvBalance$0.30f + MagicNumber:Recovery.kt:Recovery.Factor.PreviousDayLoad$0.04f + MagicNumber:Recovery.kt:Recovery.Factor.RecoveryIndex$0.03f + MagicNumber:Recovery.kt:Recovery.Factor.RestingHr$0.22f + MagicNumber:Recovery.kt:Recovery.Factor.Sleep$0.16f + MagicNumber:Recovery.kt:Recovery.Factor.SleepBalance$0.12f + MagicNumber:Recovery.kt:Recovery.Factor.Temperature$0.06f + MagicNumber:Recovery.kt:Recovery.MinuteRanges$60 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$0.10f + MagicNumber:RecoveryEngine.kt:RecoveryEngine$10 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$1000 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$1440 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$30 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$60 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$60f + MagicNumber:RecoveryEngine.kt:RecoveryEngine$720 + MagicNumber:RecoveryEngine.kt:RecoveryEngine.Companion$43_200 + MagicNumber:RecoveryEngine.kt:RecoveryEngine.Companion$60 + MagicNumber:RecoveryEngine.kt:RecoveryEngine.Companion$86_400 + MagicNumber:RecoveryWidgetModel.kt:RecoveryLayout$100 + MagicNumber:RecoveryWidgetModel.kt:RecoveryLayout$100f + MagicNumber:RecoveryWidgetModel.kt:RecoveryLayout$4f + MagicNumber:Releases.kt:Releases$3 + MagicNumber:Rewards.kt:Rewards$1000.0 + MagicNumber:Rewards.kt:Rewards$7 + MagicNumber:Rewards.kt:Rewards$7L + MagicNumber:Rewards.kt:Rewards.Stretch.Companion$3 + MagicNumber:RewardsCopy.kt:1000 + MagicNumber:RewardsCopy.kt:RewardsCopy$1000 + MagicNumber:RewardsCopy.kt:RewardsCopy$1000.0 + MagicNumber:RewardsCopy.kt:RewardsCopy$21097 + MagicNumber:RewardsCopy.kt:RewardsCopy$3 + MagicNumber:RewardsCopy.kt:RewardsCopy$6 + MagicNumber:RewardsCopy.kt:RewardsCopy$60 + MagicNumber:Route.kt:Route$10 + MagicNumber:Route.kt:Route$1000 + MagicNumber:Route.kt:Route$3 + MagicNumber:SampleData.kt:SampleData$10.0 + MagicNumber:SampleData.kt:SampleData$12 + MagicNumber:SampleData.kt:SampleData$15 + MagicNumber:SampleData.kt:SampleData$1800 + MagicNumber:SampleData.kt:SampleData$20 + MagicNumber:SampleData.kt:SampleData$24 + MagicNumber:SampleData.kt:SampleData$25 + MagicNumber:SampleData.kt:SampleData$30 + MagicNumber:SampleData.kt:SampleData$3600L + MagicNumber:SampleData.kt:SampleData$45 + MagicNumber:SampleData.kt:SampleData$48 + MagicNumber:SampleData.kt:SampleData$5 + MagicNumber:SampleData.kt:SampleData$6 + MagicNumber:SampleData.kt:SampleData$60 + MagicNumber:SampleData.kt:SampleData$8 + MagicNumber:SampleData.kt:SampleData$90 + MagicNumber:SampleData.kt:SampleData$95 + MagicNumber:ScoreDetail.kt:100f + MagicNumber:SettingsStore.kt:BuzzPattern.ONE_LONG$600 + MagicNumber:SettingsStore.kt:BuzzPattern.ONE_SHORT$200 + MagicNumber:SettingsStore.kt:BuzzPattern.THREE_SHORT$120 + MagicNumber:SettingsStore.kt:BuzzPattern.TWO_SHORT$150 + MagicNumber:SettingsStore.kt:QuietHours$22 + MagicNumber:SettingsStore.kt:QuietHours$60 + MagicNumber:SettingsStore.kt:QuietHours$7 + MagicNumber:SettingsStore.kt:SettingsStore$24 + MagicNumber:SettingsStore.kt:SettingsStore$60 + MagicNumber:ShareCards.kt:0.32f + MagicNumber:ShareCards.kt:0.35f + MagicNumber:ShareCards.kt:0.38f + MagicNumber:ShareCards.kt:0.3f + MagicNumber:ShareCards.kt:0.62f + MagicNumber:ShareCards.kt:0.6f + MagicNumber:ShareCards.kt:0.7f + MagicNumber:ShareCards.kt:0.95f + MagicNumber:ShareCards.kt:10 + MagicNumber:ShareCards.kt:40 + MagicNumber:ShareCards.kt:60 + MagicNumber:ShareScreen.kt:StoryFiles$100 + MagicNumber:SleepCopy.kt:SleepCopy$1440 + MagicNumber:SleepCopy.kt:SleepCopy$60 + MagicNumber:SleepCopy.kt:SleepCopy$60L + MagicNumber:SleepDetail.kt:100f + MagicNumber:SleepDetail.kt:60 + MagicNumber:SleepEditMigration.kt:SleepEditMigration$1000 + MagicNumber:SleepEdits.kt:SleepEdits$60 + MagicNumber:SleepEdits.kt:SleepEdits$86_399 + MagicNumber:SleepEdits.kt:SleepEdits$86_400 + MagicNumber:SleepEdits.kt:SleepEdits$86_400L + MagicNumber:SleepHistory.kt:SleepHistory$12 + MagicNumber:SleepHistory.kt:SleepHistory$24 + MagicNumber:SleepHistory.kt:SleepHistory$3600 + MagicNumber:SleepHistory.kt:SleepHistory$6 + MagicNumber:SleepHistory.kt:SleepHistory$60 + MagicNumber:SleepHistory.kt:SleepHistory$60L + MagicNumber:SleepPanels.kt:12 + MagicNumber:SleepPanels.kt:24 + MagicNumber:SleepPanels.kt:60 + MagicNumber:SleepRegularity.kt:SleepRegularity$60 + MagicNumber:SleepRegularity.kt:SleepRegularity$72f + MagicNumber:SleepRegularity.kt:SleepRegularity$79f + MagicNumber:SleepRegularity.kt:SleepRegularity$87f + MagicNumber:SleepWidget.kt:255 + MagicNumber:SleepWidget.kt:26 + MagicNumber:SleepWidgetModel.kt:SleepLayout$0.01f + MagicNumber:SleepWidgetModel.kt:SleepLayout$3 + MagicNumber:SportIcons.kt:SportIcons$10 + MagicNumber:SportIcons.kt:SportIcons$108 + MagicNumber:SportIcons.kt:SportIcons$109 + MagicNumber:SportIcons.kt:SportIcons$11 + MagicNumber:SportIcons.kt:SportIcons$12 + MagicNumber:SportIcons.kt:SportIcons$130 + MagicNumber:SportIcons.kt:SportIcons$143 + MagicNumber:SportIcons.kt:SportIcons$15 + MagicNumber:SportIcons.kt:SportIcons$17 + MagicNumber:SportIcons.kt:SportIcons$18 + MagicNumber:SportIcons.kt:SportIcons$204 + MagicNumber:SportIcons.kt:SportIcons$205 + MagicNumber:SportIcons.kt:SportIcons$215 + MagicNumber:SportIcons.kt:SportIcons$222 + MagicNumber:SportIcons.kt:SportIcons$224 + MagicNumber:SportIcons.kt:SportIcons$225 + MagicNumber:SportIcons.kt:SportIcons$23 + MagicNumber:SportIcons.kt:SportIcons$4 + MagicNumber:SportIcons.kt:SportIcons$49 + MagicNumber:SportIcons.kt:SportIcons$50 + MagicNumber:SportIcons.kt:SportIcons$51 + MagicNumber:SportIcons.kt:SportIcons$53 + MagicNumber:SportIcons.kt:SportIcons$55 + MagicNumber:SportIcons.kt:SportIcons$57 + MagicNumber:SportIcons.kt:SportIcons$58 + MagicNumber:SportIcons.kt:SportIcons$6 + MagicNumber:SportIcons.kt:SportIcons$60 + MagicNumber:SportIcons.kt:SportIcons$61 + MagicNumber:SportIcons.kt:SportIcons$7 + MagicNumber:SportIcons.kt:SportIcons$70 + MagicNumber:SportIcons.kt:SportIcons$76 + MagicNumber:SportIcons.kt:SportIcons$8 + MagicNumber:SportIcons.kt:SportIcons$83 + MagicNumber:SportIcons.kt:SportIcons$85 + MagicNumber:SportIcons.kt:SportIcons$88 + MagicNumber:SportIcons.kt:SportIcons$89 + MagicNumber:SportIcons.kt:SportIcons$9 + MagicNumber:SportIcons.kt:SportIcons$92 + MagicNumber:SportIcons.kt:SportIcons$97 + MagicNumber:StartCopy.kt:StartCopy$10 + MagicNumber:StartCopy.kt:StartCopy$100 + MagicNumber:StartCopy.kt:StartCopy$1000 + MagicNumber:StartCopy.kt:StartCopy$1000.0 + MagicNumber:StartCopy.kt:StartCopy$10L + MagicNumber:StartCopy.kt:StartCopy$120 + MagicNumber:StartCopy.kt:StartCopy$30L + MagicNumber:StartCopy.kt:StartCopy$3600 + MagicNumber:StartCopy.kt:StartCopy$3600L + MagicNumber:StartCopy.kt:StartCopy$4 + MagicNumber:StartCopy.kt:StartCopy$50 + MagicNumber:StartCopy.kt:StartCopy$50_000 + MagicNumber:StartCopy.kt:StartCopy$5L + MagicNumber:StartCopy.kt:StartCopy$60 + MagicNumber:StartCopy.kt:StartCopy$600L + MagicNumber:StartCopy.kt:StartCopy$60L + MagicNumber:StartCopy.kt:StartCopy$7 + MagicNumber:StartCopy.kt:StartCopy$8L + MagicNumber:StartCopy.kt:StartCopy$900 + MagicNumber:StartCopy.kt:StartCopy$90L + MagicNumber:StartWidget.kt:0.5f + MagicNumber:StartWidgetModel.kt:StartLayout$3 + MagicNumber:StartWidgetModel.kt:StartWidgetModel.Companion$1000L + MagicNumber:StartWidgetModel.kt:StartWidgetModel.Companion$10f + MagicNumber:StepsWidgetModel.kt:StepsLayout$3 + MagicNumber:StepsWidgetModel.kt:StepsWidgetModel.Companion$7L + MagicNumber:StrapDrawing.kt:0.45f + MagicNumber:StrapDrawing.kt:0xFF0C0F0E + MagicNumber:StrapDrawing.kt:1.2f + MagicNumber:StrapDrawing.kt:1.4f + MagicNumber:StrapDrawing.kt:1.5f + MagicNumber:StrapDrawing.kt:110f + MagicNumber:StrapDrawing.kt:14f + MagicNumber:StrapDrawing.kt:150f + MagicNumber:StrapDrawing.kt:22f + MagicNumber:StrapDrawing.kt:25 + MagicNumber:StrapDrawing.kt:3.2f + MagicNumber:StrapDrawing.kt:34f + MagicNumber:StrapDrawing.kt:36f + MagicNumber:StrapDrawing.kt:39f + MagicNumber:StrapDrawing.kt:3f + MagicNumber:StrapDrawing.kt:4.5f + MagicNumber:StrapDrawing.kt:40f + MagicNumber:StrapDrawing.kt:42f + MagicNumber:StrapDrawing.kt:43f + MagicNumber:StrapDrawing.kt:44f + MagicNumber:StrapDrawing.kt:45f + MagicNumber:StrapDrawing.kt:48f + MagicNumber:StrapDrawing.kt:49f + MagicNumber:StrapDrawing.kt:4f + MagicNumber:StrapDrawing.kt:52f + MagicNumber:StrapDrawing.kt:56f + MagicNumber:StrapDrawing.kt:5f + MagicNumber:StrapDrawing.kt:60f + MagicNumber:StrapDrawing.kt:64f + MagicNumber:StrapDrawing.kt:68f + MagicNumber:StrapDrawing.kt:70f + MagicNumber:StrapDrawing.kt:72f + MagicNumber:StrapDrawing.kt:75f + MagicNumber:StrapDrawing.kt:77f + MagicNumber:StrapDrawing.kt:90f + MagicNumber:StrapDrawing.kt:9f + MagicNumber:StrapManager.kt:StrapManager$0x04 + MagicNumber:StrapManager.kt:StrapManager$0x05 + MagicNumber:StrapManager.kt:StrapManager$1000 + MagicNumber:StrapManager.kt:StrapManager$1000L + MagicNumber:StrapManager.kt:StrapManager$1_000 + MagicNumber:StrapManager.kt:StrapManager$23 + MagicNumber:StrapManager.kt:StrapManager$247 + MagicNumber:StrapManager.kt:StrapManager$3 + MagicNumber:StrapManager.kt:StrapManager$400 + MagicNumber:StrapManager.kt:StrapManager$5_000 + MagicNumber:StrapManager.kt:StrapManager$60 + MagicNumber:StrapManager.kt:StrapManager$60_000 + MagicNumber:StrapManager.kt:StrapManager$86_400L + MagicNumber:StrapService.kt:StrapService$100 + MagicNumber:StrapService.kt:StrapService$1000 + MagicNumber:StrapService.kt:StrapService$36 + MagicNumber:StrapService.kt:StrapService$5 + MagicNumber:StrapService.kt:StrapService$77 + MagicNumber:StrapService.kt:StrapService.Companion$10 + MagicNumber:StrapService.kt:StrapService.Companion$100 + MagicNumber:StrapService.kt:StrapService.Companion$3600 + MagicNumber:StrapService.kt:StrapService.Companion$4 + MagicNumber:StrapService.kt:StrapService.Companion$50 + MagicNumber:StrapService.kt:StrapService.Companion$60 + MagicNumber:StrapStatus.kt:7 + MagicNumber:Summaries.kt:Summaries$23 + MagicNumber:Summaries.kt:Summaries$230 + MagicNumber:Summaries.kt:Summaries$30 + MagicNumber:Summaries.kt:Summaries$5 + MagicNumber:Summaries.kt:Summaries$60 + MagicNumber:SummaryCopy.kt:SummaryCopy$100 + MagicNumber:SummaryCopy.kt:SummaryCopy$1000 + MagicNumber:SummaryCopy.kt:SummaryCopy$60.0 + MagicNumber:SummaryCopy.kt:SummaryCopy$90 + MagicNumber:SummarySections.kt:0.5f + MagicNumber:SummarySections.kt:10 + MagicNumber:SummarySections.kt:1000 + MagicNumber:SummarySections.kt:4 + MagicNumber:SummarySections.kt:60 + MagicNumber:TabCopy.kt:TabCopy$1000f + MagicNumber:TabCopy.kt:TabCopy$1440 + MagicNumber:TabCopy.kt:TabCopy$3600f + MagicNumber:TabCopy.kt:TabCopy$60 + MagicNumber:TabCopy.kt:TabCopy$70 + MagicNumber:TabCopy.kt:TabCopy$80 + MagicNumber:TabCopy.kt:TabCopy$85 + MagicNumber:TabCopy.kt:TabCopy$90 + MagicNumber:Theme.kt:MetricColors$40 + MagicNumber:TodayCards.kt:100f + MagicNumber:TodayCards.kt:3 + MagicNumber:TodayCards.kt:3600 + MagicNumber:TodayCards.kt:4 + MagicNumber:TodayCards.kt:5 + MagicNumber:TodayCards.kt:50 + MagicNumber:TodayCards.kt:60 + MagicNumber:TodayCopy.kt:TodayCopy$0.8 + MagicNumber:TodayCopy.kt:TodayCopy$1440 + MagicNumber:TodayCopy.kt:TodayCopy$6 + MagicNumber:TodayCopy.kt:TodayCopy$60 + MagicNumber:TodayCopy.kt:TodayCopy$60L + MagicNumber:TodayCopy.kt:TodayCopy$70 + MagicNumber:TodayCopy.kt:TodayCopy$720 + MagicNumber:TodayCopy.kt:TodayCopy$85 + MagicNumber:Units.kt:Units$12 + MagicNumber:UpdateCopy.kt:UpdateCopy$100 + MagicNumber:UpdateCopy.kt:UpdateCopy$1_000_000.0 + MagicNumber:UsualSteps.kt:UsualSteps$24 + MagicNumber:UsualSteps.kt:UsualSteps$59 + MagicNumber:UsualSteps.kt:UsualSteps$60f + MagicNumber:WidgetGalleryActivity.kt:WidgetGalleryActivity$12f + MagicNumber:WidgetGalleryActivity.kt:WidgetGalleryActivity$16 + MagicNumber:WidgetGalleryActivity.kt:WidgetGalleryActivity$24 + MagicNumber:WidgetGalleryActivity.kt:WidgetGalleryActivity$48 + MagicNumber:WidgetGalleryActivity.kt:WidgetGalleryActivity$8 + MagicNumber:WidgetRefresh.kt:WidgetRefresh$12 + MagicNumber:WidgetRefresh.kt:WidgetRefresh$3 + MagicNumber:WidgetRefresh.kt:WidgetRefresh$3600 + MagicNumber:WidgetRefresh.kt:WidgetRefresh$4 + MagicNumber:WidgetRefresh.kt:WidgetRefresh$86_400 + MagicNumber:WidgetSamples.kt:WidgetSamples$10_000 + MagicNumber:WidgetSamples.kt:WidgetSamples$14 + MagicNumber:WidgetSamples.kt:WidgetSamples$190 + MagicNumber:WidgetSamples.kt:WidgetSamples$8_432 + MagicNumber:WidgetSizes.kt:WidgetDots$0.5f + MagicNumber:WidgetSizes.kt:WidgetSizes$3 + MagicNumber:WidgetSizes.kt:WidgetSizes$4 + MagicNumber:WidgetSizes.kt:WidgetText$0.27 + MagicNumber:WidgetSizes.kt:WidgetText$0.37 + MagicNumber:WidgetSizes.kt:WidgetText$0.57 + MagicNumber:WidgetSizes.kt:WidgetText$0.58 + MagicNumber:WidgetSizes.kt:WidgetText$0.68 + MagicNumber:WidgetSizes.kt:WidgetText$0.88 + MagicNumber:WidgetStatusLine.kt:Spread$0.01f + MagicNumber:WidgetUpdater.kt:WidgetUpdater$1000 + MagicNumber:WidgetUpdater.kt:WidgetUpdater$3 + MagicNumber:WorkoutCapsule.kt:4 + MagicNumber:WorkoutCapsule.kt:50 + MagicNumber:WorkoutCopy.kt:WorkoutCopy$0.35f + MagicNumber:WorkoutCopy.kt:WorkoutCopy$0.65f + MagicNumber:WorkoutCopy.kt:WorkoutCopy$1000 + MagicNumber:WorkoutCopy.kt:WorkoutCopy$1000f + MagicNumber:WorkoutCopy.kt:WorkoutCopy$60 + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$0.5f + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$0.875f + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$1000 + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$10f + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$3 + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$3f + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$4 + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$4f + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$5f + MagicNumber:WorkoutEffect.kt:WorkoutEffect$10 + MagicNumber:WorkoutEffect.kt:WorkoutEffect$100 + MagicNumber:WorkoutEffect.kt:WorkoutEffect$3.5 + MagicNumber:WorkoutEffect.kt:WorkoutEffect$50 + MagicNumber:WorkoutEffect.kt:WorkoutEffect$5f + MagicNumber:WorkoutEffect.kt:WorkoutEffect$60 + MagicNumber:WorkoutEffectEngine.kt:WorkoutEffectEngine$60 + MagicNumber:WorkoutGapFill.kt:WorkoutGapFill$1000 + MagicNumber:WorkoutLocation.kt:WorkoutLocation.Companion$1000 + MagicNumber:WorkoutMap.kt:0.25f + MagicNumber:WorkoutMap.kt:18f + MagicNumber:WorkoutMap.kt:3 + MagicNumber:WorkoutMap.kt:3f + MagicNumber:WorkoutMap.kt:5f + MagicNumber:WorkoutMap.kt:6f + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$1000 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$120 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$240 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$250 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$30 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$59 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$60 + MagicNumber:WorkoutSession.kt:WorkoutSession$1000 + MagicNumber:WorkoutSession.kt:WorkoutSession$100L + MagicNumber:WorkoutSession.kt:WorkoutSession$1_000 + MagicNumber:WorkoutSession.kt:WorkoutSession$250 + MagicNumber:WorkoutSession.kt:WorkoutSession$5f + MagicNumber:WorkoutSession.kt:WorkoutSession$60 + MagicNumber:WorkoutSetup.kt:WorkoutSetup$3 + MagicNumber:WorkoutSetup.kt:WorkoutSetup$400 + MagicNumber:WorkoutSetup.kt:WorkoutSetup$6 + MagicNumber:WorkoutSetup.kt:WorkoutSetup$90 + MagicNumber:WorkoutStyle.kt:3 + MagicNumber:WorkoutSuggestions.kt:WorkoutSuggestions$59 + MagicNumber:WorkoutSuggestions.kt:WorkoutSuggestions$60 + MagicNumber:WorkoutSuggestions.kt:WorkoutSuggestions$60L + MagicNumber:WorkoutTileService.kt:WorkoutTileService$34 + MagicNumber:Workouts.kt:Workouts$1000 + MagicNumber:Workouts.kt:Workouts$120 + MagicNumber:Workouts.kt:Workouts$5 + MagicNumber:Workouts.kt:Workouts$60 + MagicNumber:Workouts.kt:Workouts$60L + MagicNumber:WorkoutsMigration.kt:WorkoutsMigration$25 + MagicNumber:WorkoutsMigration.kt:WorkoutsMigration$26 + MagicNumber:WorkoutsViewModel.kt:WorkoutsViewModel$1000 + MagicNumber:WorkoutsViewModel.kt:WorkoutsViewModel$60 + MagicNumber:YouPage.kt:60 + MagicNumber:ZeppCloud.kt:ZeppCloud$1000 + MagicNumber:ZeppCloud.kt:ZeppCloud$15_000 + MagicNumber:ZeppCloud.kt:ZeppCloud$200 + MagicNumber:ZeppCloud.kt:ZeppCloud$20_000 + MagicNumber:ZeppCloud.kt:ZeppCloud$299 + MagicNumber:ZeppCloud.kt:ZeppCloud$3 + MagicNumber:ZeppCloud.kt:ZeppCloud$32 + MagicNumber:ZeppCloud.kt:ZeppCloud$401 + MagicNumber:ZeppCloud.kt:ZeppCloud$403 + MagicNumber:ZeppSignIn.kt:1000 + MagicNumber:ZeppSignIn.kt:4 + MagicNumber:ZeppSignIn.kt:8 + MapGetWithNotNullAssertionOperator:EnergyExpenditure.kt:EnergyExpenditure$hrSum[m.t]!! + MapGetWithNotNullAssertionOperator:RewardsCopy.kt:RewardsView$progress[b.id]!! + MatchingDeclarationName:ActivityDetail.kt:ActivityDetailContext + MatchingDeclarationName:ChooseScreen.kt:ChooseSport + MatchingDeclarationName:Daos.kt:HealthDao + MatchingDeclarationName:DayCalendar.kt:DayPicker + MatchingDeclarationName:PullToSync.kt:PullSyncState + MatchingDeclarationName:SummarySections.kt:SummaryHero + MatchingDeclarationName:Theme.kt:MetricColors + MaxLineLength:Activity.kt:Activity.Mvpa$operator fun plus + MaxLineLength:ActivityCopy.kt:ActivityCopy$if (fromWorkout) "From your latest workout the strap could read it from." else "Estimated from your profile and resting heart rate, updated weekly." + MaxLineLength:ActivityCopy.kt:ActivityCopy$m.moderateMinutes == 0 && m.vigorousMinutes == 0 -> if (today) "No active minutes so far today." else "No active minutes that day." + MaxLineLength:ActivityDetail.kt:(e.pal?.takeIf { !ctx.isToday }?.let { "%.2f".format(it) to "Activity level" } ?: ("%,d".format(goal) to "Goal")) + MaxLineLength:ActivityDetail.kt:KcalBars(stacks, listOf(color, WalkingKcal, EverydayKcal), goal = null, labels = listOf("00", "06", "12", "18", "24"), spread = true, Modifier.padding(top = 16.dp), height = 120.dp, onPick = onPick) + MaxLineLength:ActivityDetail.kt:Range.Month -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { bars[((bars.size - 1) * it).toInt()].first.format(DateTimeFormatter.ofPattern("d MMM")) } + MaxLineLength:ActivityDetail.kt:Score.ActiveMinutes.title + MaxLineLength:ActivityDetail.kt:Score.HeartPoints.title + MaxLineLength:ActivityDetail.kt:Text(text, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 8.dp)) + MaxLineLength:ActivityDetail.kt:androidx.compose.foundation.layout + MaxLineLength:ActivityDetail.kt:bars.map { it.second ?: emptyList() } + MaxLineLength:ActivityDetail.kt:contentPadding = ctx.contentPadding + MaxLineLength:ActivityDetail.kt:divider = i > 0 + MaxLineLength:ActivityDetail.kt:drawLine(Color.White.copy(alpha = 0.08f), Offset(0f, size.height - 0.5.dp.toPx()), Offset(size.width, size.height - 0.5.dp.toPx()), 1.dp.toPx()) + MaxLineLength:ActivityDetail.kt:else + MaxLineLength:ActivityDetail.kt:fun avg + MaxLineLength:ActivityDetail.kt:horizontalArrangement = if (spread) androidx.compose.foundation.layout.Arrangement.SpaceBetween else androidx.compose.foundation.layout.Arrangement.Start + MaxLineLength:ActivityDetail.kt:if (latest != null && est != null) ListRow("From your profile", color, "${est.low}-${est.high}", "Estimated weekly", divider = !first) + MaxLineLength:ActivityDetail.kt:if (oldest > 0f) drawRoundRect(Color.Black.copy(alpha = 0.35f * a), Offset(x, yOf(oldest)), Size(w, size.height - yOf(oldest)), r) + MaxLineLength:ActivityDetail.kt:if (range == Range.Day) flowOf(null) else ctx.energyHistory?.invoke(start.minusDays(range.days.toLong()), date) ?: flowOf(emptyMap()) + MaxLineLength:ActivityDetail.kt:item { HourlyKcal(e, color) { h -> scrub = h?.let { DetailHeadline("%,d".format(Math.round(e.hourly.total[it])), CaloriesCopy.hourLabel(it), null) } } } + MaxLineLength:ActivityDetail.kt:labels = listOf(0f, 1 / 3f, 2 / 3f, 1f).map { month[((month.size - 1) * it).toInt()].format(DateTimeFormatter.ofPattern("d MMM")) } + MaxLineLength:ActivityDetail.kt:onClick = { ctx.onOpen(Detail.Workout(w.workout.start)) } + MaxLineLength:ActivityDetail.kt:onScrub = { i -> scrub = i?.let { DetailHeadline(Math.round(values[it]).toString(), "minutes, ${week[it].format(DAY)}", null) } } + MaxLineLength:ActivityDetail.kt:onScrub = { i -> scrub = i?.let { DetailHeadline(Math.round(values[it]).toString(), "steps, ${days[it].date.format(DAY)}", null) } } + MaxLineLength:ActivityDetail.kt:onScrub = { i -> scrub = i?.let { totals[it] }?.let { v -> DetailHeadline(v.toString(), "7-day total, ${month[i].format(DAY)}", ActivityCopy.heartPointsWord(v)) } } + MaxLineLength:ActivityDetail.kt:onScrub = { i -> scrub = i?.let { values[it] }?.let { v -> DetailHeadline(Math.round(v).toString(), days[i].format(DAY), loadWordOf(v, usual)) } } + MaxLineLength:ActivityDetail.kt:range == Range.Day -> DetailHeadline(Math.round(r.load).toString(), if (ctx.isToday) "today" else "that day", status?.takeIf { it != TodayCopy.LoadStatus.SoFar }?.text) + MaxLineLength:ActivityDetail.kt:range == Range.Week -> DetailHeadline(a.weekEquivalent.toString(), "minutes, last 7 days", ActivityCopy.activeWord(a.weekEquivalent)) + MaxLineLength:ActivityDetail.kt:scrub = i?.let { DetailHeadline(Math.round(daily[it] ?: 0f).toString(), ActivityCopy.earnedLabel(week[it].format(DAY)), null) } + MaxLineLength:ActivityDetail.kt:summary.activeADay != null -> DetailHeadline("%,d".format(summary.activeADay), CaloriesCopy.rangeLabel(range), CaloriesCopy.goalDays(summary)) + MaxLineLength:ActivityDetail.kt:val ended = source?.let { w -> Instant.ofEpochSecond(w.end).atZone(zone).let { ActivityCopy.ended(it.toLocalDate(), it.format(CLOCK), ctx.today) } } + MaxLineLength:ActivityDetail.kt:val label = if (range == Range.Year) "active kcal a day, week of ${d.format(DateTimeFormatter.ofPattern("d MMM"))}" else "active kcal, ${d.format(DAY)}" + MaxLineLength:ActivityDetail.kt:values + MaxLineLength:AlarmsScreen.kt:PageButton("Save", { onSave(alarm.copy(hour = time.hour, minute = time.minute, days = days, enabled = true)) }, Modifier.weight(1f), primary = true) + MaxLineLength:AlarmsScreen.kt:PageTextButton("Delete all", onDeleteAll, Modifier.align(Alignment.CenterHorizontally), enabled = !state.busy, color = DestructiveInk) + MaxLineLength:AlarmsScreen.kt:Row + MaxLineLength:AlarmsScreen.kt:Text(daysLabel(a.days), style = GlowType.secondary, color = Color.White.copy(alpha = 0.6f), modifier = Modifier.padding(top = 6.dp)) + MaxLineLength:AlarmsViewModel.kt:AlarmsUiState$val + MaxLineLength:AlarmsViewModel.kt:AlarmsViewModel$if (value == SettingsCodec.HR_CONTINUOUS && g[SettingsCodec.KEY_ACTIVITY_HR] != null) add(SettingsCodec.boolSetting(SettingsCodec.KEY_ACTIVITY_HR, true)) + MaxLineLength:AlarmsViewModel.kt:AlarmsViewModel${ (caps, alarms, health) -> s.copy(loading = false, busy = false, error = error, caps = caps, alarms = alarms, health = health) } + MaxLineLength:AllMetricsScreen.kt:Column + MaxLineLength:AllMetricsScreen.kt:ListRow(r.name, r.color, r.value, r.status, onClick = { onOpen(r.target) }, divider = i > 0, icon = r.target.rowIcon()) + MaxLineLength:AllMetricsScreen.kt:Text("All metrics", color = Color.White, style = GlowType.cardTitle.copy(fontSize = 20.sp, lineHeight = 26.sp), maxLines = 1) + MaxLineLength:AllMetricsScreen.kt:listOf(row("Sleep", MetricColors.sleepDeep, state.sleep?.score?.toString(), "Score for the night", IndexTarget.Home(HomeTab.Sleep))) + MaxLineLength:AllMetricsScreen.kt:metric(Metric.SkinTemperature, "Skin temperature", MetricColors.temperature, state.skinTemperature.takeIf { it.isNotEmpty() }?.map { it.y }?.average()?.toFloat(), "Day average") + MaxLineLength:AllMetricsScreen.kt:row("Habit Age", MetricColors.habitAge, habitValue, habitStatus, IndexTarget.Open(Detail.Healthspan), waiting = habitStatus) + MaxLineLength:AllMetricsScreen.kt:shown.word.takeIf { shown.estimate } ?: status ?: "Score for the day" + MaxLineLength:AppIcons.kt:AppIcons$val + MaxLineLength:AppIcons.kt:AppIcons$val ArrowCircleUp: ImageVector by lazy { line("ArrowCircleUp", "M2 12A10 10 0 1 0 22 12A10 10 0 1 0 2 12Z", "M16 13.5C16 13.5 13.054 10.5 12 10.5C10.9459 10.5 8 13.5 8 13.5") } + MaxLineLength:AppIcons.kt:AppIcons$val ArrowCounterClockwise: ImageVector by lazy { line("ArrowCounterClockwise", "M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C8.66873 3 5.76018 4.80989 4.20404 7.5", "M3 3V4.27816C3 6.47004 3 7.56599 3.70725 8.16512C4.4145 8.76425 5.49553 8.58408 7.6576 8.22373L9 8") } + MaxLineLength:AppIcons.kt:AppIcons$val ArrowLeft: ImageVector by lazy { line("ArrowLeft", "M5.5 12.002H19", "M10.9999 18.002C10.9999 18.002 4.99998 13.583 4.99997 12.0019C4.99996 10.4208 11 6.00195 11 6.00195", autoMirror = true) } + MaxLineLength:AppIcons.kt:AppIcons$val Backpack: ImageVector by lazy { line("Backpack", "M5 12C5 8.13401 8.13401 5 12 5C15.866 5 19 8.13401 19 12V16.3333C19 17.8847 19 18.6604 18.7877 19.2858C18.388 20.4633 17.4633 21.388 16.2858 21.7877C15.6604 22 14.8847 22 13.3333 22H10.6667C9.11529 22 8.3396 22 7.71424 21.7877C6.53668 21.388 5.61201 20.4633 5.21228 19.2858C5 18.6604 5 17.8847 5 16.3333V12Z", "M5 17C6.64996 15.17 9.17273 14 12 14C14.8273 14 17.35 15.17 19 17", "M11 10H13", "M9 6V5C9 4.06812 9 3.60218 9.15224 3.23463C9.35523 2.74458 9.74458 2.35523 10.2346 2.15224C10.6022 2 11.0681 2 12 2C12.9319 2 13.3978 2 13.7654 2.15224C14.2554 2.35523 14.6448 2.74458 14.8478 3.23463C15 3.60218 15 4.06812 15 5V6", "M5 19H3.71429C2.76751 19 2 18.2325 2 17.2857L2 16C2 14.3431 3.34315 13 5 13", "M19 19H20.2857C21.2325 19 22 18.2325 22 17.2857L22 16C22 14.3431 20.6569 13 19 13") } + MaxLineLength:AppIcons.kt:AppIcons$val Barbell: ImageVector by lazy { line("Barbell", "M7.73438 13.7323C7.73438 13.7323 8.63984 12.5102 9.23438 12.2319C11.0292 11.3915 11.3943 11.0263 12.2344 9.23091C12.5127 8.63618 13.7344 7.73044 13.7344 7.73044M10.2344 16.2331C10.2344 16.2331 11.4561 15.3273 11.7344 14.7326C12.5745 12.9373 12.9396 12.5721 14.7344 11.7317C15.3289 11.4533 16.2344 10.2312 16.2344 10.2312", "M14.4311 2.89207C14.938 2.38343 15.7611 2.38212 16.2696 2.88916L21.0814 7.68726C21.5899 8.1943 21.5912 9.01767 21.0843 9.52632L19.5557 11.0603C19.0488 11.5689 18.2257 11.5702 17.7172 11.0632L12.9054 6.26507C12.397 5.75803 12.3957 4.93466 12.9025 4.42601L14.4311 2.89207Z", "M4.41377 12.9022C4.92065 12.3936 5.74376 12.3923 6.25225 12.8993L11.0641 17.6974C11.5725 18.2045 11.5738 19.0278 11.067 19.5365L9.53836 21.0704C9.03148 21.5791 8.20837 21.5804 7.69988 21.0733L2.88808 16.2752C2.37959 15.7682 2.37829 14.9448 2.88517 14.4362L4.41377 12.9022Z", "M17.9377 3.45254C19.8201 0.985467 23.14 3.8401 20.5431 6.02872M3.37811 17.9773C0.998473 19.9687 3.99782 23.1586 6.06742 20.4657") } + MaxLineLength:AppIcons.kt:AppIcons$val Basketball: ImageVector by lazy { line("Basketball", "M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z", "M2 12.9506C8.14512 13.5607 13.5577 8.11477 12.9506 2", "M11.0507 22.0012C10.4406 15.856 15.8866 10.4434 22.0013 11.0505", "M17 20C17 12.8203 11.1797 7 4 7") } + MaxLineLength:AppIcons.kt:AppIcons$val BatteryCharging: ImageVector by lazy { line("BatteryCharging", "M2 12C2 9.17157 2 7.75736 2.87868 6.87868C3.75736 6 5.17157 6 8 6H13C15.8284 6 17.2426 6 18.1213 6.87868C19 7.75736 19 9.17157 19 12C19 14.8284 19 16.2426 18.1213 17.1213C17.2426 18 15.8284 18 13 18H8C5.17157 18 3.75736 18 2.87868 17.1213C2 16.2426 2 14.8284 2 12Z", "M10.8282 9L9.08572 11.1749C8.89899 11.4079 9.03283 11.7433 9.33733 11.8053L11.1627 12.1773C11.4873 12.2434 11.6111 12.6147 11.3842 12.8413L9.22216 15", "M19 9.5L20.0272 9.6712C20.7085 9.78475 21.0491 9.84152 21.3076 10.0067C21.5618 10.1691 21.7612 10.4044 21.8796 10.6819C22 10.964 22 11.3093 22 12C22 12.6907 22 13.036 21.8796 13.3181C21.7612 13.5956 21.5618 13.8309 21.3076 13.9933C21.0491 14.1585 20.7085 14.2153 20.0272 14.3288L19 14.5") } + MaxLineLength:AppIcons.kt:AppIcons$val Bicycle: ImageVector by lazy { line("Bicycle", "M6 20.0026C8.20914 20.0026 10 18.2118 10 16.0026C10 13.7935 8.20914 12.0026 6 12.0026C3.79086 12.0026 2 13.7935 2 16.0026C2 18.2118 3.79086 20.0026 6 20.0026Z", "M18 20.0026C20.2091 20.0026 22 18.2118 22 16.0026C22 13.7935 20.2091 12.0026 18 12.0026C15.7909 12.0026 14 13.7935 14 16.0026C14 18.2118 15.7909 20.0026 18 20.0026Z", "M6 16.0026H10.3706C10.7302 16.0026 11.0622 15.8095 11.2399 15.4968L15.5 8.00262", "M12 13.0026L7 7.00262M7 7.00262H5M7 7.00262H9", "M20.0039 6.21868C19.7999 5.64268 19.4399 4.74268 18.2399 4.32268C17.4599 4.02268 15.5399 3.90268 15.2999 4.08268C14.9527 4.16949 14.9399 4.56268 15.1079 5.10268C15.2444 5.68163 15.4559 6.42824 15.6479 7.14268C16.1399 8.97348 17.2199 12.9387 18.0239 15.9987") } + MaxLineLength:AppIcons.kt:AppIcons$val Bluetooth: ImageVector by lazy { line("Bluetooth", "M12.4742 12L16.2428 9.05534C17.3189 8.21451 17.857 7.79409 17.9716 7.24865C18.0144 7.04517 18.0154 6.83493 17.9748 6.63101C17.8657 6.08438 17.332 5.65832 16.2645 4.8062C14.6552 3.52156 13.8505 2.87924 13.1738 3.01878C12.9267 3.06975 12.6962 3.18351 12.504 3.34942C11.9779 3.80362 11.9779 4.84315 11.9779 6.92221V11.6122M12.4742 12L11.9779 12.3877M12.4742 12L16.2428 14.9446C17.319 15.7855 17.857 16.2059 17.9716 16.7513C18.0144 16.9548 18.0155 17.165 17.9748 17.369C17.8658 17.9156 17.332 18.3417 16.2645 19.1938C14.6552 20.4784 13.8505 21.1208 13.1738 20.9812C12.9266 20.9302 12.6962 20.8165 12.504 20.6506C11.9779 20.1964 11.9779 19.1568 11.9779 17.0778V12.3877M12.4742 12L11.9779 11.6122M11.9779 12.3877L6.00452 17.055M11.9779 12.3877V11.6122M11.9779 11.6122L6.00452 6.94494", "M5.37952 12H5.25452M18.8795 12H18.7545M5.50452 12C5.50452 12.1381 5.39259 12.25 5.25452 12.25C5.11645 12.25 5.00452 12.1381 5.00452 12C5.00452 11.8619 5.11645 11.75 5.25452 11.75C5.39259 11.75 5.50452 11.8619 5.50452 12ZM19.0045 12C19.0045 12.1381 18.8926 12.25 18.7545 12.25C18.6164 12.25 18.5045 12.1381 18.5045 12C18.5045 11.8619 18.6164 11.75 18.7545 11.75C18.8926 11.75 19.0045 11.8619 19.0045 12Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Boat: ImageVector by lazy { line("Boat", "M20.8198 3.1806C10.9252 1.49952 2.07124 11.6154 3.03263 20.9246C12.3645 21.8836 22.505 13.0512 20.8198 3.1806Z", "M6.95898 16.9961H6.96796", "M16.959 6.99612H16.968", "M9.4958 14.4781C8.80348 13.7875 8.80348 12.6678 9.4958 11.9772L11.9649 9.51407C12.6572 8.82344 13.7796 8.82344 14.4719 9.51407C15.1643 10.2047 15.1643 11.3244 14.4719 12.015L12.0028 14.4781C11.3105 15.1688 10.1881 15.1688 9.4958 14.4781Z", "M5.7582 5.68399C5.88051 3.29259 5.22238 2.32989 4.60371 2.05808C4.24623 1.90102 3.85728 2.09995 3.54202 2.33003C2.94876 2.76299 2.62277 3.08516 2.23608 3.68002C2.03886 3.98341 1.91293 4.36064 2.06947 4.68678C2.59441 5.78048 4.68496 5.93975 5.7582 5.68399ZM5.7582 5.68399L18.24 18.3176M18.24 18.3176C18.1192 20.7091 18.778 21.6714 19.3968 21.9428C19.7544 22.0996 20.1432 21.9005 20.4583 21.6702C21.0513 21.2368 21.3771 20.9145 21.7634 20.3194C21.9604 20.0158 22.0861 19.6385 21.9294 19.3125C21.4037 18.2191 19.3131 18.0612 18.24 18.3176Z") } + MaxLineLength:AppIcons.kt:AppIcons$val BoxingGlove: ImageVector by lazy { line("BoxingGlove", "M5.98148 11.2453C5.89446 13.9678 9.10256 17.3235 12.1105 18.3784M5.98148 11.2453C5.67692 11.768 2.56612 14.2618 2.51173 15.3182C2.24816 17.416 5.81222 21.357 8.56025 21.4915C9.58647 21.6371 11.0099 19.9102 12.4528 18.6272C13.2475 17.9205 14.3928 17.7571 18.9772 16.6194C20.6408 16.3303 22.6394 13.4607 19.6766 11.0283M5.98148 11.2453C6.9097 9.04414 8.51438 4.78884 11.6614 3.10843C12.9394 2.3823 16.2588 1.71372 19.5114 4.80592C20.6304 5.74702 22.5701 7.80848 20.7647 10.1836C19.561 11.5864 17.741 11.1803 17.5642 13.6064") } + MaxLineLength:AppIcons.kt:AppIcons$val Brain02: ImageVector by lazy { line("Brain02", "M4.22222 21.9948V18.4451C4.22222 17.1737 3.88927 16.5128 3.23482 15.4078C2.4503 14.0833 2 12.5375 2 10.8866C2 5.97866 5.97969 2 10.8889 2C15.7981 2 19.7778 5.97866 19.7778 10.8866C19.7778 11.4663 19.7778 11.7562 19.802 11.9187C19.8598 12.3072 20.0411 12.6414 20.2194 12.9873L22 16.4407L20.6006 17.1402C20.195 17.3429 19.9923 17.4443 19.851 17.6314C19.7097 17.8184 19.67 18.0296 19.5904 18.4519L19.5826 18.4931C19.4004 19.4606 19.1993 20.5286 18.6329 21.2024C18.4329 21.4403 18.1853 21.6336 17.9059 21.7699C17.4447 21.9948 16.8777 21.9948 15.7437 21.9948C15.219 21.9948 14.6928 22.0069 14.1682 21.9942C12.9247 21.9639 12 20.9184 12 19.7044", "M14.388 10.5315C13.9617 10.5315 13.5729 10.3702 13.2784 10.1048M14.388 10.5315C14.388 11.6774 13.7241 12.7658 12.4461 12.7658C11.1681 12.7658 10.5043 13.8541 10.5043 15M14.388 10.5315C16.5373 10.5315 16.5373 7.18017 14.388 7.18017C14.1927 7.18017 14.0053 7.21403 13.8312 7.27624C13.9362 4.77819 10.3349 4.1 9.51923 6.44018M10.5043 8.29729C10.5043 7.52323 10.1133 6.8411 9.51923 6.44018M9.51923 6.44018C7.66742 5.19034 5.19883 7.4331 6.37324 9.43277C4.40226 9.72827 4.61299 12.7658 6.6205 12.7658C7.18344 12.7658 7.68111 12.4844 7.98234 12.0538") } + MaxLineLength:AppIcons.kt:AppIcons$val Brain: ImageVector by lazy { line("Brain", "M16.998 7.12652C17.3182 7.04393 17.654 7 18 7C20.2091 7 22 8.79086 22 11C22 13.2091 20.2091 15 18 15C17.6451 15 17.3009 14.9538 16.9733 14.867M16.998 7.12652C16.9993 7.08451 17 7.04233 17 7C17 4.79086 15.2091 3 13 3C11.0824 3 9.47994 4.34939 9.09041 6.15043M16.998 7.12652C16.9769 7.80763 16.7854 8.44584 16.4649 9M16.9733 14.867C16.9909 14.7472 17 14.6247 17 14.5C17 13.2905 16.1411 12.2816 15 12.05M16.9733 14.867C16.7957 16.0737 15.756 17 14.5 17H14C11.7909 17 10 18.7909 10 21M9.09041 6.15043C8.74377 6.05243 8.37801 6 8 6C5.79086 6 4 7.79086 4 10C4 10.3886 4.05542 10.7643 4.15878 11.1195M9.09041 6.15043C10.1015 6.43625 10.9498 7.10965 11.4649 8M4.15878 11.1195C2.9114 11.4832 2 12.6352 2 14C2 15.6569 3.34315 17 5 17C6.30622 17 7.41746 16.1652 7.82929 15M4.15878 11.1195C4.24921 11.4303 4.37632 11.7255 4.53513 12", "M11.8361 11.7435C11.3257 12.2353 10.453 12.3202 9.70713 11.9008C8.9612 11.4814 8.58031 10.6917 8.73535 10") } + MaxLineLength:AppIcons.kt:AppIcons$val Bug: ImageVector by lazy { line("Bug", "M3.01309 4.99084C2.89323 6.05084 3.55249 8.42285 6.48923 8.42285", "M17.5951 8.38081C18.8357 8.57881 21.1132 7.49881 20.9957 5.00281", "M20.9928 20.9989C21.0528 19.9429 20.1777 17.5549 17.599 17.4229", "M6.45163 17.4708C5.65013 17.2308 3.01306 18.3348 3.01306 20.9988", "M9.3299 6.11884C9.35388 5.09884 9.84533 2.99884 12.0029 2.99884C13.9208 2.99884 14.5861 4.61884 14.676 6.11884M6.26131 9.41884C6.38118 8.63884 7.29216 6.81484 9.36586 6.63484C11.4635 6.55564 14.3403 6.58684 14.8797 6.67084C15.5869 6.73377 17.2951 7.43884 17.7506 9.41884C17.9124 10.4388 17.8285 11.8788 17.8524 12.7188C17.8165 13.5588 17.9207 15.2623 17.7565 16.1388C17.6367 17.0988 16.9894 18.4668 16.1024 19.3068C14.7838 20.7228 11.1639 22.2108 8.03534 19.4508C6.41713 17.8908 6.30925 16.3788 6.18939 15.7788C6.15725 15.4571 6.15875 13.8763 6.16541 12.3588C6.14144 11.046 6.17235 9.78063 6.26131 9.41884Z", "M3.01306 12.8988H5.9498", "M20.9929 12.8988L18.1161 12.8988", "M12.0033 16.4988L12.0033 20.2788") } + MaxLineLength:AppIcons.kt:AppIcons$val CalendarBlank: ImageVector by lazy { line("CalendarBlank", "M16 2V6M8 2V6", "M13 4H11C7.22876 4 5.34315 4 4.17157 5.17157C3 6.34315 3 8.22876 3 12V14C3 17.7712 3 19.6569 4.17157 20.8284C5.34315 22 7.22876 22 11 22H13C16.7712 22 18.6569 22 19.8284 20.8284C21 19.6569 21 17.7712 21 14V12C21 8.22876 21 6.34315 19.8284 5.17157C18.6569 4 16.7712 4 13 4Z", "M3 10H21", "M12.1258 14H12.0008M12.1258 18H12.0008M7.625 14H7.5M7.625 18H7.5M16.625 14H16.5M12.2508 14C12.2508 14.1381 12.1389 14.25 12.0008 14.25C11.8628 14.25 11.7508 14.1381 11.7508 14C11.7508 13.8619 11.8628 13.75 12.0008 13.75C12.1389 13.75 12.2508 13.8619 12.2508 14ZM12.2508 18C12.2508 18.1381 12.1389 18.25 12.0008 18.25C11.8628 18.25 11.7508 18.1381 11.7508 18C11.7508 17.8619 11.8628 17.75 12.0008 17.75C12.1389 17.75 12.2508 17.8619 12.2508 18ZM7.75 14C7.75 14.1381 7.63807 14.25 7.5 14.25C7.36193 14.25 7.25 14.1381 7.25 14C7.25 13.8619 7.36193 13.75 7.5 13.75C7.63807 13.75 7.75 13.8619 7.75 14ZM7.75 18C7.75 18.1381 7.63807 18.25 7.5 18.25C7.36193 18.25 7.25 18.1381 7.25 18C7.25 17.8619 7.36193 17.75 7.5 17.75C7.63807 17.75 7.75 17.8619 7.75 18ZM16.75 14C16.75 14.1381 16.6381 14.25 16.5 14.25C16.3619 14.25 16.25 14.1381 16.25 14C16.25 13.8619 16.3619 13.75 16.5 13.75C16.6381 13.75 16.75 13.8619 16.75 14Z") } + MaxLineLength:AppIcons.kt:AppIcons$val CaretLeft: ImageVector by lazy { line("CaretLeft", "M15 6C15 6 9.00001 10.4189 9 12C8.99999 13.5812 15 18 15 18", autoMirror = true) } + MaxLineLength:AppIcons.kt:AppIcons$val CaretRight: ImageVector by lazy { line("CaretRight", "M9.00005 6C9.00005 6 15 10.4189 15 12C15 13.5812 9 18 9 18", autoMirror = true) } + MaxLineLength:AppIcons.kt:AppIcons$val ChartBar: ImageVector by lazy { line("ChartBar", "M7 15V16C7 16.5523 7.44772 17 8 17H14C14.5523 17 15 16.5523 15 16V15C15 14.4477 14.5523 14 14 14H8C7.44772 14 7 14.4477 7 15Z", "M7 8V9C7 9.55228 7.44772 10 8 10H18C18.5523 10 19 9.55228 19 9V8C19 7.44772 18.5523 7 18 7H8C7.44772 7 7 7.44772 7 8Z", "M3 3V13C3 16.7712 3 18.6569 4.17157 19.8284C5.34315 21 7.22876 21 11 21H21") } + MaxLineLength:AppIcons.kt:AppIcons$val ChartLine: ImageVector by lazy { line("ChartLine", "M21 21H10C6.70017 21 5.05025 21 4.02513 19.9749C3 18.9497 3 17.2998 3 14V3", "M5 20C5.43938 16.8438 7.67642 8.7643 10.4282 8.7643C12.3301 8.7643 12.8226 12.6353 14.6864 12.6353C17.8931 12.6353 17.4282 4 21 4") } + MaxLineLength:AppIcons.kt:AppIcons$val ChartLineUp: ImageVector by lazy { line("ChartLineUp", "M21 21H10C6.70017 21 5.05025 21 4.02513 19.9749C3 18.9497 3 17.2998 3 14V3", "M7.99707 16.999C11.5286 16.999 18.9122 15.5348 18.6979 6.43269M16.4886 8.04302L18.3721 6.14612C18.5656 5.95127 18.8798 5.94981 19.0751 6.14286L20.9971 8.04302") } + MaxLineLength:AppIcons.kt:AppIcons$val CheckCircle: ImageVector by lazy { line("CheckCircle", "M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12Z", "M8 12.5L10.5 15L16 9") } + MaxLineLength:AppIcons.kt:AppIcons$val CloudArrowDown: ImageVector by lazy { line("CloudArrowDown", "M17.4776 9.01106C17.485 9.01102 17.4925 9.01101 17.5 9.01101C19.9853 9.01101 22 11.0294 22 13.5193C22 15.8398 20.25 17.7508 18 18M17.4776 9.01106C17.4924 8.84606 17.5 8.67896 17.5 8.51009C17.5 5.46695 15.0376 3 12 3C9.12324 3 6.76233 5.21267 6.52042 8.03192M17.4776 9.01106C17.3753 10.1476 16.9286 11.1846 16.2428 12.0165M6.52042 8.03192C3.98398 8.27373 2 10.4139 2 13.0183C2 15.4417 3.71776 17.4632 6 17.9273M6.52042 8.03192C6.67826 8.01687 6.83823 8.00917 7 8.00917C8.12582 8.00917 9.16474 8.38194 10.0005 9.01101", "M12 21L12 13M12 21C11.2998 21 9.99153 19.0057 9.5 18.5M12 21C12.7002 21 14.0085 19.0057 14.5 18.5") } + MaxLineLength:AppIcons.kt:AppIcons$val Copy: ImageVector by lazy { line("Copy", "M7.5 14.5C7.5 11.2002 7.5 9.55025 8.52513 8.52513C9.55025 7.5 11.2002 7.5 14.5 7.5C17.7998 7.5 19.4497 7.5 20.4749 8.52513C21.5 9.55025 21.5 11.2002 21.5 14.5C21.5 17.7998 21.5 19.4497 20.4749 20.4749C19.4497 21.5 17.7998 21.5 14.5 21.5C11.2002 21.5 9.55025 21.5 8.52513 20.4749C7.5 19.4497 7.5 17.7998 7.5 14.5Z", "M7.5 16.5C6.10355 16.5 5.40533 16.5 4.84402 16.3036C3.83866 15.9518 3.0482 15.1613 2.69641 14.156C2.5 13.5947 2.5 12.8964 2.5 11.5V9.5C2.5 6.20017 2.5 4.55025 3.52513 3.52513C4.55025 2.5 6.20017 2.5 9.5 2.5H11.5C12.8964 2.5 13.5947 2.5 14.156 2.69641C15.1613 3.0482 15.9518 3.83866 16.3036 4.84402C16.5 5.40533 16.5 6.10355 16.5 7.5") } + MaxLineLength:AppIcons.kt:AppIcons$val Cpu: ImageVector by lazy { line("Cpu", "M4 12C4 8.22876 4 6.34315 5.17157 5.17157C6.34315 4 8.22876 4 12 4C15.7712 4 17.6569 4 18.8284 5.17157C20 6.34315 20 8.22876 20 12C20 15.7712 20 17.6569 18.8284 18.8284C17.6569 20 15.7712 20 12 20C8.22876 20 6.34315 20 5.17157 18.8284C4 17.6569 4 15.7712 4 12Z", "M9.5 2V4", "M14.5 2V4", "M9.5 20V22", "M14.5 20V22", "M13 9L9 13", "M15 13L13 15", "M22 14.5L20 14.5", "M4 9.5L2 9.5", "M4 14.5L2 14.5", "M22 9.5L20 9.5") } + MaxLineLength:AppIcons.kt:AppIcons$val Dog: ImageVector by lazy { line("Dog", "M6.4922 6A2 2 0 1 0 10.4922 6A2 2 0 1 0 6.4922 6Z", "M13.4922 6A2 2 0 1 0 17.4922 6A2 2 0 1 0 13.4922 6Z", "M1.9922 11A2 2 0 1 0 5.9922 11A2 2 0 1 0 1.9922 11Z", "M17.9922 11A2 2 0 1 0 21.9922 11A2 2 0 1 0 17.9922 11Z", "M7.31094 20C6.65469 20 6.10417 19.7604 5.65938 19.2813C5.21458 18.8021 4.99219 18.2361 4.99219 17.5833C4.99219 16.8611 5.25104 16.2292 5.76875 15.6875C6.28646 15.1458 6.80052 14.6111 7.31094 14.0833C7.73385 13.6528 8.09844 13.184 8.40469 12.6771C8.71094 12.1701 9.07552 11.6944 9.49844 11.25C9.81927 10.8889 10.1911 10.5903 10.6141 10.3542C11.037 10.1181 11.4964 10 11.9922 10C12.488 10 12.9474 10.1111 13.3703 10.3333C13.7932 10.5556 14.1651 10.8472 14.4859 11.2083C14.8943 11.6528 15.2552 12.1319 15.5688 12.6458C15.8823 13.1597 16.2505 13.6389 16.6734 14.0833C17.1839 14.6111 17.6979 15.1458 18.2156 15.6875C18.7333 16.2292 18.9922 16.8611 18.9922 17.5833C18.9922 18.2361 18.7698 18.8021 18.325 19.2813C17.8802 19.7604 17.3297 20 16.6734 20C15.8859 20 15.1057 19.9375 14.3328 19.8125C13.5599 19.6875 12.7797 19.625 11.9922 19.625C11.2047 19.625 10.4245 19.6875 9.65156 19.8125C8.87865 19.9375 8.09844 20 7.31094 20Z") } + MaxLineLength:AppIcons.kt:AppIcons$val DotsThree: ImageVector by lazy { line("DotsThree", "M6.00449 12.5V12M18.0045 12.5V12M12.0045 12.5V12M7.00449 12.5C7.00449 11.9477 6.55677 11.5 6.00449 11.5C5.4522 11.5 5.00449 11.9477 5.00449 12.5C5.00449 13.0523 5.4522 13.5 6.00449 13.5C6.55677 13.5 7.00449 13.0523 7.00449 12.5ZM19.0045 12.5C19.0045 11.9477 18.5568 11.5 18.0045 11.5C17.4522 11.5 17.0045 11.9477 17.0045 12.5C17.0045 13.0523 17.4522 13.5 18.0045 13.5C18.5568 13.5 19.0045 13.0523 19.0045 12.5ZM13.0045 12.5C13.0045 11.9477 12.5568 11.5 12.0045 11.5C11.4522 11.5 11.0045 11.9477 11.0045 12.5C11.0045 13.0523 11.4522 13.5 12.0045 13.5C12.5568 13.5 13.0045 13.0523 13.0045 12.5Z") } + MaxLineLength:AppIcons.kt:AppIcons$val DownloadSimple: ImageVector by lazy { line("DownloadSimple", "M2.99969 17.0002C2.99969 17.9302 2.99969 18.3952 3.10192 18.7767C3.37932 19.8119 4.18796 20.6206 5.22324 20.898C5.60474 21.0002 6.06972 21.0002 6.99969 21.0002L16.9997 21.0002C17.9297 21.0002 18.3947 21.0002 18.7762 20.898C19.8114 20.6206 20.6201 19.8119 20.8975 18.7767C20.9997 18.3952 20.9997 17.9302 20.9997 17.0002", "M16.4998 11.5002C16.4998 11.5002 13.1856 16.0002 11.9997 16.0002C10.8139 16.0002 7.49976 11.5002 7.49976 11.5002M11.9997 15.0002V3.00016") } + MaxLineLength:AppIcons.kt:AppIcons$val Drop: ImageVector by lazy { line("Drop", "M3.5 13.678C3.5 9.49387 7.08079 5.35907 9.59413 2.97222C10.9591 1.67593 13.0409 1.67593 14.4059 2.97222C16.9192 5.35907 20.5 9.49387 20.5 13.678C20.5 17.7804 17.2812 22 12 22C6.71878 22 3.5 17.7804 3.5 13.678Z", "M16 14C16 16.2091 14.2091 18 12 18") } + MaxLineLength:AppIcons.kt:AppIcons$val FastWind: ImageVector by lazy { line("FastWind", "M2 5.94145C5.5 9.37313 10.5755 7.90241 11.7324 5.94145C11.9026 5.65301 12 5.31814 12 4.96096C12 3.87795 11.1046 3 10 3C8.89543 3 8 3.87795 8 4.96096", "M17 8.92814C17 7.31097 18.1193 6 19.5 6C20.8807 6 22 7.31097 22 8.92814C22 9.6452 21.7799 10.3021 21.4146 10.8111C19.3463 14.1915 9.2764 12.9164 4 11.8563", "M13.0854 19.8873C13.2913 20.5356 13.8469 21 14.5 21C15.3284 21 16 20.2528 16 19.331C16 19.0176 15.9224 18.7244 15.7873 18.4738C14.4999 15.9925 7.99996 14.3239 2 18.7746", "M19 15.5H21") } + MaxLineLength:AppIcons.kt:AppIcons$val Fire: ImageVector by lazy { line("Fire", "M13.8561 22C26.0783 19 19.2338 7 10.9227 2C9.9453 5.5 8.47838 6.5 5.54497 10C1.66121 14.6339 3.5895 20 8.96719 22C8.1524 21 6.04958 18.9008 7.5 16C8 15 9 14 8.5 12C9.47778 12.5 11.5 13 12 15.5C12.8148 14.5 13.6604 12.4 12.8783 10C19 14.5 16.5 19 13.8561 22Z") } + MaxLineLength:AppIcons.kt:AppIcons$val FlagCheckered: ImageVector by lazy { line("FlagCheckered", "M5.67181 13.9095C10 15.9322 14 7.84169 21 11.8869L18 2.78502C13.4239 -0.299918 8.56286 6.85641 3 4.62523L8.00007 22", "M19 7.00073C13.5 3.00076 9 12.0007 4.5 9.00064", "M8 4.90476L10.8235 13M13.1765 3L16 10.619") } + MaxLineLength:AppIcons.kt:AppIcons$val Flask: ImageVector by lazy { line("Flask", "M14.4973 2V8.25401C14.4973 8.72617 14.7093 9.1734 15.0747 9.47239L15.7421 10.0184C18.4717 12.2518 20.2277 15.4577 20.6397 18.9604C20.8301 20.5784 19.5659 22 17.9368 22H6.05789C4.42876 22 3.16459 20.5784 3.35494 18.9604C3.76702 15.4577 5.52295 12.2518 8.25261 10.0184L8.91998 9.47239C9.28541 9.1734 9.49734 8.72617 9.49734 8.25401V2", "M7.99707 2H15.9971", "M5.49707 14.0183C5.74095 14.0063 5.99072 14 6.24707 14C9.99707 14 13.7471 17 17.4971 17C18.2729 17 18.9332 16.9384 19.4971 16.8334") } + MaxLineLength:AppIcons.kt:AppIcons$val FlowerLotus: ImageVector by lazy { line("FlowerLotus", "M9.0923 16C8.78292 16.6819 8.0701 16.986 7.43887 17.3162L3.79262 19.2233C2.32845 19.9891 3.05949 22 4.62985 22C8.12204 22 10.8836 20.3064 14.0404 19C14.835 18.6711 15.2201 18.7415 16 19.0912", "M9 19.0912C9.77995 18.7415 10.165 18.6711 10.9596 19C14.1164 20.3064 16.878 22 20.3702 22C21.9405 22 22.6715 19.9891 21.2074 19.2233L17.5611 17.3162C16.9299 16.986 16.2171 16.6819 15.9077 16", "M10 4C10 5.10457 10.8954 6 12 6C13.1046 6 14 5.10457 14 4C14 2.89543 13.1046 2 12 2C10.8954 2 10 2.89543 10 4Z", "M12 8C8.68629 8 6 10.6863 6 14C9.31371 14 12 11.3137 12 8Z", "M12 8C15.3137 8 18 10.6863 18 14C14.6863 14 12 11.3137 12 8Z") } + MaxLineLength:AppIcons.kt:AppIcons$val FolderOpen: ImageVector by lazy { line("FolderOpen", "M2.5 20V8.87695C2.5 7.58945 2.5 6.9457 2.78533 6.47048C2.9541 6.18939 3.18939 5.9541 3.47048 5.78533C3.9457 5.5 4.59449 5.5 5.89206 5.5C6.52339 5.5 6.83906 5.5 7.12612 5.58819C7.31759 5.64702 7.49914 5.73428 7.66469 5.84705C7.91289 6.01611 8.10859 6.26074 8.5 6.75C8.89141 7.23926 9.08711 7.48389 9.33531 7.65295C9.50086 7.76572 9.68241 7.85298 9.87388 7.91181C10.1609 8 10.4742 8 11.1008 8H15C16.4045 8 17.1067 8 17.6111 8.33706C17.8295 8.48298 18.017 8.67048 18.1629 8.88886C18.5 9.39331 18.5 10.0955 18.5 11.5", "M4.42028 14.0144L3.63368 16.0144C2.65618 18.4998 2.16743 19.7425 2.7524 20.6213C3.33737 21.5 4.65337 21.5 7.28537 21.5H15.1903C16.4249 21.5 17.0422 21.5 17.5295 21.1795C18.0169 20.859 18.2702 20.2865 18.7769 19.1415L19.6618 17.1415C20.7866 14.5992 21.349 13.3281 20.7679 12.4141C20.1868 11.5 18.8163 11.5 16.0752 11.5H8.07196C6.78232 11.5 6.1375 11.5 5.63811 11.8439C5.13872 12.1877 4.89924 12.7966 4.42028 14.0144Z", "M11.5 4.51456C12.4151 3.28409 13.6662 2.55594 15.5125 2.50161C16.1155 2.48386 16.7152 2.61395 17.2682 2.85544C18.5748 3.42601 19.4185 4.15644 20 5.5L21.5 3") } + MaxLineLength:AppIcons.kt:AppIcons$val Footprints: ImageVector by lazy { line("Footprints", "M10.535 10.2187L9.99444 12.4393C9.746 13.4599 9.62179 13.9701 9.24607 14.2823C9.21466 14.3084 9.18215 14.3333 9.14863 14.3568C8.74779 14.6384 8.20467 14.6384 7.11844 14.6384H6.44056C5.71535 14.6384 5.35274 14.6384 5.05823 14.5126C4.7893 14.3978 4.56057 14.2103 4.39963 13.9727C4.22339 13.7125 4.16475 13.3672 4.04748 12.6765L3.5637 9.8272C3.16319 7.46831 4.68869 5.20279 7.08482 4.59797L7.29168 4.54575C8.7774 4.17073 10.2847 5.07654 10.5916 6.52879C10.8492 7.74826 10.8299 9.00715 10.535 10.2187Z", "M5.48863 17.5793H8.32119C8.93314 17.5793 9.42923 18.0581 9.42923 18.6487V19.0641C9.42923 20.4096 8.29905 21.5004 6.90491 21.5004C5.51077 21.5004 4.38059 20.4096 4.38059 19.0641V18.6487C4.38059 18.0581 4.87668 17.5793 5.48863 17.5793Z", "M13.463 8.25779L14.0036 10.4784C14.252 11.4989 14.3763 12.0092 14.752 12.3214C14.7834 12.3475 14.8159 12.3724 14.8494 12.3959C15.2502 12.6775 15.7934 12.6775 16.8796 12.6775H17.5575C18.2827 12.6775 18.6453 12.6775 18.9398 12.5517C19.2087 12.4368 19.4375 12.2493 19.5984 12.0118C19.7747 11.7516 19.8333 11.4063 19.9506 10.7156L20.4343 7.86626C20.8348 5.50737 19.3094 3.24185 16.9132 2.63703L16.7064 2.58482C15.2206 2.20979 13.7133 3.1156 13.4065 4.56785C13.1488 5.78732 13.1681 7.04621 13.463 8.25779Z", "M18.5094 15.6183H15.6769C15.0649 15.6183 14.5688 16.0971 14.5688 16.6877V17.1032C14.5688 18.4487 15.699 19.5394 17.0931 19.5394C18.4873 19.5394 19.6174 18.4487 19.6174 17.1032V16.6877C19.6174 16.0971 19.1214 15.6183 18.5094 15.6183Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Golf: ImageVector by lazy { line("Golf", "M5 9A7 7 0 1 0 19 9A7 7 0 1 0 5 9Z", "M9 18C10.5 18 12 19.4617 12 22C12 19.4617 13.5 18 15 18", "M10.125 7H10M10.25 7C10.25 7.13807 10.1381 7.25 10 7.25C9.86193 7.25 9.75 7.13807 9.75 7C9.75 6.86193 9.86193 6.75 10 6.75C10.1381 6.75 10.25 6.86193 10.25 7Z") } + MaxLineLength:AppIcons.kt:AppIcons$val HandPalm: ImageVector by lazy { line("HandPalm", "M18.5 11.5V8.75C18.5 7.7835 19.2835 7 20.25 7C21.2165 7 22 7.7835 22 8.75V14.8C22 18.7765 18.7764 22 14.8 22H12.0588M8 14L5.61507 11.2176C5.22468 10.7621 4.65476 10.5 4.05489 10.5H3.91898C2.85916 10.5 2 11.3592 2 12.419C2 12.7978 2.11214 13.1682 2.32229 13.4834L5.7812 18.6718C7.16738 20.7511 9.50102 22 12 22", "M18.5 8.5V5.75C18.5 4.7835 17.7165 4 16.75 4C15.7835 4 15 4.7835 15 5.75V10.5", "M15 10.5V3.75C15 2.7835 14.2165 2 13.25 2C12.2835 2 11.5 2.7835 11.5 3.75V10", "M11.5 10V5.75C11.5 4.7835 10.7165 4 9.75 4C8.7835 4 8 4.7835 8 5.75V14") } + MaxLineLength:AppIcons.kt:AppIcons$val Heart: ImageVector by lazy { line("Heart", "M10.4107 19.9677C7.58942 17.858 2 13.0348 2 8.69444C2 5.82563 4.10526 3.5 7 3.5C8.5 3.5 10 4 12 6C14 4 15.5 3.5 17 3.5C19.8947 3.5 22 5.82563 22 8.69444C22 13.0348 16.4106 17.858 13.5893 19.9677C12.6399 20.6776 11.3601 20.6776 10.4107 19.9677Z") } + MaxLineLength:AppIcons.kt:AppIcons$val HeartFill: ImageVector by lazy { bold("HeartFill", "M10.4107 19.9677C7.58942 17.858 2 13.0348 2 8.69444C2 5.82563 4.10526 3.5 7 3.5C8.5 3.5 10 4 12 6C14 4 15.5 3.5 17 3.5C19.8947 3.5 22 5.82563 22 8.69444C22 13.0348 16.4106 17.858 13.5893 19.9677C12.6399 20.6776 11.3601 20.6776 10.4107 19.9677Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Heartbeat: ImageVector by lazy { line("Heartbeat", "M10.4107 19.9677C7.58942 17.858 2 13.0348 2 8.69444C2 5.82563 4.10526 3.5 7 3.5C8.5 3.5 10 4 12 6C14 4 15.5 3.5 17 3.5C19.8947 3.5 22 5.82563 22 8.69444C22 13.0348 16.4106 17.858 13.5893 19.9677C12.6399 20.6776 11.3601 20.6776 10.4107 19.9677Z", "M20.001 13.0001H16.0288C15.8168 13.0001 15.7107 13.0001 15.619 12.9639C15.5691 12.9442 15.5229 12.9169 15.4821 12.8831C15.4072 12.8209 15.3598 12.7303 15.2649 12.5491C14.9921 12.0278 14.8557 11.7672 14.6597 11.7045C14.5567 11.6716 14.4453 11.6716 14.3422 11.7045C14.1462 11.7672 14.0098 12.0278 13.737 12.5491L13.1172 13.7335C12.6442 14.6372 12.4078 15.089 12.0706 15.0624C11.7335 15.0357 11.578 14.5529 11.267 13.5872L10.8024 12.1447C10.4668 11.1027 10.299 10.5817 9.95039 10.5639C9.60176 10.5462 9.377 11.0472 8.92748 12.0493L8.76073 12.4211C8.63475 12.7019 8.57176 12.8423 8.44652 12.9212C8.32129 13.0001 8.16139 13.0001 7.84158 13.0001H4.00098") } + MaxLineLength:AppIcons.kt:AppIcons$val Hourglass: ImageVector by lazy { line("Hourglass", "M4 3H20", "M5.5 3V5.03039C5.5 6.27227 6.07682 7.4437 7.06116 8.20089L12 12L16.9388 8.20089C17.9232 7.44371 18.5 6.27227 18.5 5.03039V3", "M5.5 21V18.9696C5.5 17.7277 6.07682 16.5563 7.06116 15.7991L12 12L16.9388 15.7991C17.9232 16.5563 18.5 17.7277 18.5 18.9696V21", "M4 21H20") } + MaxLineLength:AppIcons.kt:AppIcons$val HouseSimple: ImageVector by lazy { line("HouseSimple", "M3 11.9896V14.5C3 17.7998 3 19.4497 4.02513 20.4749C5.05025 21.5 6.70017 21.5 10 21.5H14C17.2998 21.5 18.9497 21.5 19.9749 20.4749C21 19.4497 21 17.7998 21 14.5V11.9896C21 10.3083 21 9.46773 20.6441 8.74005C20.2882 8.01237 19.6247 7.49628 18.2976 6.46411L16.2976 4.90855C14.2331 3.30285 13.2009 2.5 12 2.5C10.7991 2.5 9.76689 3.30285 7.70242 4.90855L5.70241 6.46411C4.37533 7.49628 3.71179 8.01237 3.3559 8.74005C3 9.46773 3 10.3083 3 11.9896Z") } + MaxLineLength:AppIcons.kt:AppIcons$val HouseSimpleFill: ImageVector by lazy { bold("HouseSimpleFill", "M3 11.9896V14.5C3 17.7998 3 19.4497 4.02513 20.4749C5.05025 21.5 6.70017 21.5 10 21.5H14C17.2998 21.5 18.9497 21.5 19.9749 20.4749C21 19.4497 21 17.7998 21 14.5V11.9896C21 10.3083 21 9.46773 20.6441 8.74005C20.2882 8.01237 19.6247 7.49628 18.2976 6.46411L16.2976 4.90855C14.2331 3.30285 13.2009 2.5 12 2.5C10.7991 2.5 9.76689 3.30285 7.70242 4.90855L5.70241 6.46411C4.37533 7.49628 3.71179 8.01237 3.3559 8.74005C3 9.46773 3 10.3083 3 11.9896Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Info: ImageVector by lazy { line("Info", "M2 12A10 10 0 1 0 22 12A10 10 0 1 0 2 12Z", "M12 16V12", "M12.125 8.25H12M12.25 8.25C12.25 8.11193 12.1381 8 12 8C11.8619 8 11.75 8.11193 11.75 8.25C11.75 8.38807 11.8619 8.5 12 8.5C12.1381 8.5 12.25 8.38807 12.25 8.25Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Key: ImageVector by lazy { line("Key", "M15.5 14.5C18.8137 14.5 21.5 11.8137 21.5 8.5C21.5 5.18629 18.8137 2.5 15.5 2.5C12.1863 2.5 9.5 5.18629 9.5 8.5C9.5 9.38041 9.68962 10.2165 10.0303 10.9697L2.5 18.5V21.5H5.5V19.5H7.5V17.5H9.5L13.0303 13.9697C13.7835 14.3104 14.6196 14.5 15.5 14.5Z", "M17.5 6.5L16.5 7.5") } + MaxLineLength:AppIcons.kt:AppIcons$val Lightning: ImageVector by lazy { line("Lightning", "M5.22576 11.3294L12.224 2.34651C12.7713 1.64397 13.7972 2.08124 13.7972 3.01707V9.96994C13.7972 10.5305 14.1995 10.985 14.6958 10.985H18.0996C18.8729 10.985 19.2851 12.0149 18.7742 12.6706L11.776 21.6535C11.2287 22.356 10.2028 21.9188 10.2028 20.9829V14.0301C10.2028 13.4695 9.80048 13.015 9.3042 13.015H5.90035C5.12711 13.015 4.71494 11.9851 5.22576 11.3294Z") } + MaxLineLength:AppIcons.kt:AppIcons$val LinkBreak: ImageVector by lazy { line("LinkBreak", "M12.1434 10.691L12.3503 10.4841C14.329 8.50532 17.5372 8.50532 19.5159 10.4841C21.4947 12.4628 21.4947 15.671 19.5159 17.6497L16.6497 20.5159C14.671 22.4947 11.4628 22.4947 9.48405 20.5159C7.50532 18.5372 7.50532 15.329 9.48405 13.3503L9.9484 12.886", "M20.0516 11.114L20.5159 10.6497C22.4947 8.67095 22.4947 5.46279 20.5159 3.48405C18.5372 1.50532 15.329 1.50532 13.3503 3.48405L10.4841 6.35031C8.50532 8.32904 8.50532 11.5372 10.4841 13.5159C12.4628 15.4947 15.671 15.4947 17.6497 13.5159L17.8566 13.309", "M4.5 4L6 6M2 8L5 9M3 13.5L5 12") } + MaxLineLength:AppIcons.kt:AppIcons$val Location01: ImageVector by lazy { line("Location01", "M13.6177 21.367C13.1841 21.773 12.6044 22 12.0011 22C11.3978 22 10.8182 21.773 10.3845 21.367C6.41302 17.626 1.09076 13.4469 3.68627 7.37966C5.08963 4.09916 8.45834 2 12.0011 2C15.5439 2 18.9126 4.09916 20.316 7.37966C22.9082 13.4393 17.599 17.6389 13.6177 21.367Z", "M15.5 11C15.5 12.933 13.933 14.5 12 14.5C10.067 14.5 8.5 12.933 8.5 11C8.5 9.067 10.067 7.5 12 7.5C13.933 7.5 15.5 9.067 15.5 11Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Lungs: ImageVector by lazy { line("Lungs", "M7.97769 11C8.38875 11.2063 8.71501 11.5615 8.97397 11.9954M8.97397 11.9954C9.97037 13.665 9.97025 16.5 9.97025 16.5C9.97025 20 8.18605 21 5.98512 21C4.98884 21 2 20.5 2 16C2 9.5 5.48698 5 8.47583 5C10.8669 5 9.97174 10 8.97397 11.9954Z", "M16.0216 11C15.6104 11.2063 15.2841 11.5615 15.0251 11.9954M15.0251 11.9954C14.0286 13.665 14.0287 16.5 14.0287 16.5C14.0287 20 15.8132 21 18.0144 21C19.0108 21 22 20.5 22 16C22 9.5 18.5126 5 15.5234 5C13.132 5 14.0301 10 15.0251 11.9954Z", "M14 7L12 5.66667M12 5.66667L10 7M12 5.66667V3") } + MaxLineLength:AppIcons.kt:AppIcons$val Moon: ImageVector by lazy { line("Moon", "M21.5 14.0784C20.3003 14.7189 18.9301 15.0821 17.4751 15.0821C12.7491 15.0821 8.91792 11.2509 8.91792 6.52485C8.91792 5.06986 9.28105 3.69968 9.92163 2.5C5.66765 3.49698 2.5 7.31513 2.5 11.8731C2.5 17.1899 6.8101 21.5 12.1269 21.5C16.6849 21.5 20.503 18.3324 21.5 14.0784Z") } + MaxLineLength:AppIcons.kt:AppIcons$val MoonFill: ImageVector by lazy { bold("MoonFill", "M21.5 14.0784C20.3003 14.7189 18.9301 15.0821 17.4751 15.0821C12.7491 15.0821 8.91792 11.2509 8.91792 6.52485C8.91792 5.06986 9.28105 3.69968 9.92163 2.5C5.66765 3.49698 2.5 7.31513 2.5 11.8731C2.5 17.1899 6.8101 21.5 12.1269 21.5C16.6849 21.5 20.503 18.3324 21.5 14.0784Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Mountains: ImageVector by lazy { line("Mountains", "M16 20H2L5.22457 10.7557C6.79555 6.25189 7.58104 4 9 4C10.3373 4 11.1119 6 12.5116 10", "M6 20H22L17.4066 12.6585C15.8806 10.2195 15.1176 9 14 9C12.8824 9 12.1194 10.2195 10.5934 12.6585L9.12837 15") } + MaxLineLength:AppIcons.kt:AppIcons$val MusicNotes: ImageVector by lazy { line("MusicNotes", "M11 7.13678V17M11 7.13678C12.8928 8.81698 14.5706 10.0042 16.0063 10.6818C16.6937 11.0062 17.3165 11.0682 18.0198 10.7552C19.7751 9.97419 21 8.20629 21 6.15045C19.0715 7.50911 16.6876 6.77163 14.6847 5.50548C13.0454 4.46918 12.2258 3.95102 11.8569 4.00364C11.5781 4.0434 11.4283 4.1242 11.244 4.33421C11 4.61216 11 5.4537 11 7.13678Z", "M11 17C11 19.2091 9.20914 21 7 21C4.79086 21 3 19.2091 3 17C3 14.7909 4.79086 13 7 13C9.20914 13 11 14.7909 11 17Z") } + MaxLineLength:AppIcons.kt:AppIcons$val PauseFill: ImageVector by lazy { solid("PauseFill", "M4 7C4 5.58579 4 4.87868 4.43934 4.43934C4.87868 4 5.58579 4 7 4C8.41421 4 9.12132 4 9.56066 4.43934C10 4.87868 10 5.58579 10 7V17C10 18.4142 10 19.1213 9.56066 19.5607C9.12132 20 8.41421 20 7 20C5.58579 20 4.87868 20 4.43934 19.5607C4 19.1213 4 18.4142 4 17V7Z", "M14 7C14 5.58579 14 4.87868 14.4393 4.43934C14.8787 4 15.5858 4 17 4C18.4142 4 19.1213 4 19.5607 4.43934C20 4.87868 20 5.58579 20 7V17C20 18.4142 20 19.1213 19.5607 19.5607C19.1213 20 18.4142 20 17 20C15.5858 20 14.8787 20 14.4393 19.5607C14 19.1213 14 18.4142 14 17V7Z") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonArmsSpread: ImageVector by lazy { line("PersonArmsSpread", "M16 5.5C16 6.32843 15.3284 7 14.5 7C13.6716 7 13 6.32843 13 5.5C13 4.67157 13.6716 4 14.5 4C15.3284 4 16 4.67157 16 5.5Z", "M14.3602 15L15.3039 14.454C16.3786 13.8323 16.9159 13.5214 16.9885 13.0784C16.9999 13.0092 17.0028 12.9391 16.9973 12.8694C16.9622 12.4229 16.4524 12.0789 15.4329 11.3907L10.7259 8.21359C8.87718 6.96577 8.45184 4.69114 9.75097 3", "M10.7259 8.21359C8.22588 10.7136 7 17.6324 7 21.0003M13.3725 10L10.7259 8.21359C8.87718 6.96577 8.45184 4.69114 9.75097 3M14.3602 15L15.3039 14.454C16.3786 13.8323 16.9159 13.5214 16.9885 13.0784C16.9999 13.0092 17.0028 12.9391 16.9973 12.8694C16.9622 12.4229 16.4524 12.0789 15.4329 11.3907L13.3725 10M13.3725 10C12.5697 11.0391 12.0164 12.0207 11.6026 12.8942C11.1636 13.8209 10.9441 14.2843 10.9845 14.8132C11.0249 15.3422 11.3666 15.8474 12.0502 16.8578C13.0257 18.3 14.0268 19.8647 15.0002 21.0003M10.9845 14.8132L8 14") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleBike: ImageVector by lazy { line("PersonSimpleBike", "M6 19.9986C8.20914 19.9986 10 18.2112 10 16.0063C10 13.8014 8.20914 12.0139 6 12.0139C3.79086 12.0139 2 13.8014 2 16.0063C2 18.2112 3.79086 19.9986 6 19.9986Z", "M18 19.9986C20.2091 19.9986 22 18.2112 22 16.0063C22 13.8014 20.2091 12.0139 18 12.0139C15.7909 12.0139 14 13.8014 14 16.0063C14 18.2112 15.7909 19.9986 18 19.9986Z", "M6 16.0062H10.3706C10.7302 16.0062 11.0622 15.8135 11.2399 15.5014L15.5 8.02148", "M12 13.012L7 7.02344M7 7.02344H5M7 7.02344H9", "M18.0531 15.9709L14.8057 4.03097C16.2565 3.96037 19.3585 3.83085 19.9908 6.21002") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleRun: ImageVector by lazy { line("PersonSimpleRun", "M17 4.5C17 5.32843 16.3284 6 15.5 6C14.6716 6 14 5.32843 14 4.5C14 3.67157 14.6716 3 15.5 3C16.3284 3 17 3.67157 17 4.5Z", "M15 21.0008L14.3359 18.3848C14.1161 17.5191 13.6615 16.7284 13.0207 16.0974L11.5 14.5998M20 8.19913C17.9627 10.4921 16.1547 10.1433 15 9.27743C14.8878 9.19326 14.7664 9.06672 14.6482 8.92548C14.2356 8.43256 14.0293 8.18609 13.8282 8.09214C13.6271 7.99818 13.3747 7.99813 12.8698 7.99805C12.5444 7.99799 12.2186 7.99877 12 8.00136C8.53767 8.0423 7 9.18366 6 11.1534M12 8.00136L10.7309 9.95956C10.0332 11.0362 9.68429 11.5745 9.67069 12.1397C9.66463 12.3914 9.70617 12.642 9.79313 12.8784C9.98834 13.409 10.4922 13.8059 11.5 14.5998M15 9.27743L11.5 14.5998", "M4 17.7303L4.67822 17.8916C6.40663 18.3028 8.20324 17.5164 9 16") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleSki: ImageVector by lazy { line("PersonSimpleSki", "M19.9983 4.5C19.9983 5.3284 19.3271 6 18.4992 6C17.6712 6 17 5.3284 17 4.5C17 3.67157 17.6712 3 18.4992 3C19.3271 3 19.9983 3.67157 19.9983 4.5Z", "M12.4787 7.81915L13.5537 6.85169C14.0995 6.36044 14.9207 6.33659 15.4942 6.79533L17 8M12.4787 7.81915L15.3 9.7L17 8M12.4787 7.81915L6 3.5M12.5 12.5L11.1068 11.5712C10.4329 11.1219 10.2587 10.2468 10.6307 9.58714M12.5 12.5L14.8608 14.1863C15.4191 14.5851 15.4191 15.4149 14.8608 15.8137L12.5 17.5M12.5 12.5L13.5 11.5M17 8L17.5 11.1667L21 13.5", "M3 12L17 21L19.5 20.5") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleSwim: ImageVector by lazy { line("PersonSimpleSwim", "M10.7184 6.86177L15 16C12.8333 16 12.1739 14.8571 9.91304 13.7143C8.10435 12.8 5.57971 12.9524 4.82609 13.1429L7.85739 10.9998C8.16585 10.7817 8.32008 10.6727 8.36937 10.5067C8.41866 10.3407 8.34893 10.1651 8.20947 9.81406L7.78519 8.74593C7.62654 8.34653 7.54721 8.14683 7.40384 8.00197C7.35388 7.9515 7.2987 7.9065 7.23922 7.86772C7.06848 7.7564 6.8569 7.71887 6.43376 7.64379L3.18315 7.06706C2.4987 6.94562 2 6.35065 2 5.65551C2 4.78222 2.77418 4.11181 3.63851 4.23665L8.0343 4.87152C8.82604 4.98586 9.22191 5.04304 9.5521 5.23177C9.68974 5.31045 9.81754 5.40521 9.93281 5.51407C10.2093 5.7752 10.379 6.13739 10.7184 6.86177Z", "M16 10A3 3 0 1 0 22 10A3 3 0 1 0 16 10Z", "M2 18.0843C3.05556 14.5527 7.7685 16.1736 11.5 18.0843C15.2315 19.995 19 21.2108 21 18.0843") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleTaiChi: ImageVector by lazy { line("PersonSimpleTaiChi", "M10.5054 15.0102L17.14 10.4989C17.3273 10.3598 17.4944 10.1955 17.6363 10.0112C18.1217 9.38041 18.1121 8.50551 17.8044 7.77366C17.1198 6.14514 15.4952 5 13.6 5C12.6644 5 11.7948 5.27908 11.0717 5.75762L3.99219 10.7567", "M5.99514 13.5066C5.99514 14.2026 6.36367 15.5859 8.0358 15.9655C9.04177 16.1939 11.9726 15.2305 10.5386 12.4385C9.10468 9.64659 5.6515 9.63202 4.24682 10.5675C3.3884 11.0889 1.72132 12.7163 2.03737 14.8796C2.14955 16.1903 3.1816 18.8496 6.41238 19.0017H16.3037C17.2255 18.9286 17.416 18.7949 18.1181 18.2562C19.0626 17.408 20.6401 15.8485 21.5565 14.8218C21.7545 14.6 21.9682 14.3717 21.9956 14.0757C22.1413 12.499 19.7488 13.1819 18.0157 12.994") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleThrow: ImageVector by lazy { line("PersonSimpleThrow", "M2.39114 21.6387C1.81409 21.0816 1.8854 20.1398 2.53981 19.6752L6.17145 17.097C6.38333 16.9465 6.6739 16.9737 6.85404 17.1607C7.02764 17.3409 7.0488 17.6183 6.90454 17.8226L4.3387 21.4558C3.88384 22.0999 2.95883 22.1867 2.39114 21.6387Z", "M11.5 12.5L7 17", "M19.4291 12.5774C17.534 14.4725 13.869 14.5529 11.6581 12.3419C9.44711 10.131 9.52748 6.46597 11.4226 4.57087C13.912 2.08144 18.5975 0.980641 20.8084 3.19159C23.0194 5.40254 21.9186 10.088 19.4291 12.5774Z") } + MaxLineLength:AppIcons.kt:AppIcons$val PingPong: ImageVector by lazy { line("PingPong", "M6 8L16 18", "M2.48802 18.1868C4.27193 17.0655 6.999 15.3875 7.40038 13.6847C7.52522 13.1551 7.24468 12.6415 7.00243 12.1542C5.83072 9.79703 5.80432 7.05244 7.54627 5.31272C10.584 2.27886 17.1053 0.495103 20.307 3.69226C23.505 6.88574 21.7222 13.4012 18.6842 16.4348C16.942 18.1746 14.1945 18.149 11.8339 16.9787C11.3457 16.7368 10.8314 16.4565 10.3009 16.5812C8.59557 16.982 6.91469 19.7046 5.79184 21.4859C4.57177 23.4214 0.634453 19.3518 2.48802 18.1868Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Plant: ImageVector by lazy { line("Plant", "M19.4626 3.99352C16.7809 2.3486 14.4404 3.01148 13.0344 4.06738C12.4578 4.50033 12.1696 4.7168 12 4.7168C11.8304 4.7168 11.5422 4.50033 10.9656 4.06738C9.55962 3.01148 7.21909 2.3486 4.53744 3.99352C1.01807 6.1523 0.221719 13.2742 8.33953 19.2827C9.88572 20.4272 10.6588 20.9994 12 20.9994C13.3412 20.9994 14.1143 20.4272 15.6605 19.2827C23.7783 13.2742 22.9819 6.1523 19.4626 3.99352Z", "M12 9V15M9 12L15 12") } + MaxLineLength:AppIcons.kt:AppIcons$val PlantFill: ImageVector by lazy { bold("PlantFill", "M19.4626 3.99352C16.7809 2.3486 14.4404 3.01148 13.0344 4.06738C12.4578 4.50033 12.1696 4.7168 12 4.7168C11.8304 4.7168 11.5422 4.50033 10.9656 4.06738C9.55962 3.01148 7.21909 2.3486 4.53744 3.99352C1.01807 6.1523 0.221719 13.2742 8.33953 19.2827C9.88572 20.4272 10.6588 20.9994 12 20.9994C13.3412 20.9994 14.1143 20.4272 15.6605 19.2827C23.7783 13.2742 22.9819 6.1523 19.4626 3.99352Z", "M12 9V15M9 12L15 12") } + MaxLineLength:AppIcons.kt:AppIcons$val PlayFill: ImageVector by lazy { solid("PlayFill", "M18.8906 12.846C18.5371 14.189 16.8667 15.138 13.5257 17.0361C10.296 18.8709 8.6812 19.7884 7.37983 19.4196C6.8418 19.2671 6.35159 18.9776 5.95624 18.5787C5 17.6139 5 15.7426 5 12C5 8.2574 5 6.3861 5.95624 5.42132C6.35159 5.02245 6.8418 4.73288 7.37983 4.58042C8.6812 4.21165 10.296 5.12907 13.5257 6.96393C16.8667 8.86197 18.5371 9.811 18.8906 11.154C19.0365 11.7084 19.0365 12.2916 18.8906 12.846Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Route01: ImageVector by lazy { line("Route01", "M15 5A3 3 0 1 0 21 5A3 3 0 1 0 15 5Z", "M3 19A3 3 0 1 0 9 19A3 3 0 1 0 3 19Z", "M12 5H8.5C6.567 5 5 6.567 5 8.5C5 10.433 6.567 12 8.5 12H15.5C17.433 12 19 13.567 19 15.5C19 17.433 17.433 19 15.5 19H12") } + MaxLineLength:AppIcons.kt:AppIcons$val ShieldCheck: ImageVector by lazy { line("ShieldCheck", "M18.7088 3.49534C16.8165 2.55382 14.5009 2 12 2C9.4991 2 7.1835 2.55382 5.29116 3.49534C4.36318 3.95706 3.89919 4.18792 3.4496 4.91378C3 5.63965 3 6.34248 3 7.74814V11.2371C3 16.9205 7.54236 20.0804 10.173 21.4338C10.9067 21.8113 11.2735 22 12 22C12.7265 22 13.0933 21.8113 13.8269 21.4338C16.4576 20.0804 21 16.9205 21 11.2371L21 7.74814C21 6.34249 21 5.63966 20.5504 4.91378C20.1008 4.18791 19.6368 3.95706 18.7088 3.49534Z", "M9 11.5C9 11.5 10.4079 11.7519 11 13.5C11 13.5 12.5 10.5 15 9.5") } + MaxLineLength:AppIcons.kt:AppIcons$val ShutDown: ImageVector by lazy { line("ShutDown", "M7.86907 4C4.97674 5.49689 3 8.51664 3 11.9981C3 16.9686 7.02944 20.9981 12 20.9981C16.9706 20.9981 21 16.9686 21 11.9981C21 8.51664 19.0233 5.49689 16.1309 4", "M12 3V10") } + MaxLineLength:AppIcons.kt:AppIcons$val SlidersHorizontal: ImageVector by lazy { line("SlidersHorizontal", "M3.99963 5.00055L9.99963 5.00031", "M12.9996 5.00031L19.9996 5.00031", "M15.9996 9.00031L15.9996 15.0003", "M9.99963 2.00031L9.99963 8.00031", "M11.9996 16.0003L11.9996 22.0003", "M15.9996 12.0001L19.9996 12.0003", "M3.99963 12.0005L12.9996 12.0003", "M11.9996 19.0003L19.9996 19.0003", "M3.99963 19.0005L8.99963 19.0003") } + MaxLineLength:AppIcons.kt:AppIcons$val SneakerMove: ImageVector by lazy { line("SneakerMove", "M12.4059 18.9923C13.4443 19.7399 13.9635 20.1137 14.5623 20.3069C15.1611 20.5 15.8008 20.5 17.0804 20.5H19C20.4142 20.5 21.1213 20.5 21.5607 20.0607C22 19.6213 22 18.9142 22 17.5H16.7902C16.1504 17.5 15.8305 17.5 15.5311 17.4034C15.2318 17.3069 14.9722 17.12 14.453 16.7461L3 8.5L2.30911 9.53634C2.10755 9.83867 2 10.1939 2 10.5572C2 11.1492 2.2847 11.705 2.76507 12.0509L12.4059 18.9923Z", "M3 8.5L6 3.5L6.30704 5.34226C6.42827 6.06965 6.89023 6.69511 7.5498 7.0249C8.64393 7.57197 9.97486 7.16899 10.5818 6.10689L11.1396 5.13069C11.3625 4.74069 11.7772 4.5 12.2264 4.5C12.7005 4.5 13.1339 4.76787 13.346 5.19193L17.2764 13.0528C17.4134 13.3269 17.6936 13.5 18 13.5C20.2091 13.5 22 15.2909 22 17.5", "M12.5 9.5L14.5 8.5", "M14 12L16 11", "M6 20.5H18", "M2 17.5H5") } + MaxLineLength:AppIcons.kt:AppIcons$val SneakerMoveFill: ImageVector by lazy { bold("SneakerMoveFill", "M12.4059 18.9923C13.4443 19.7399 13.9635 20.1137 14.5623 20.3069C15.1611 20.5 15.8008 20.5 17.0804 20.5H19C20.4142 20.5 21.1213 20.5 21.5607 20.0607C22 19.6213 22 18.9142 22 17.5H16.7902C16.1504 17.5 15.8305 17.5 15.5311 17.4034C15.2318 17.3069 14.9722 17.12 14.453 16.7461L3 8.5L2.30911 9.53634C2.10755 9.83867 2 10.1939 2 10.5572C2 11.1492 2.2847 11.705 2.76507 12.0509L12.4059 18.9923Z", "M3 8.5L6 3.5L6.30704 5.34226C6.42827 6.06965 6.89023 6.69511 7.5498 7.0249C8.64393 7.57197 9.97486 7.16899 10.5818 6.10689L11.1396 5.13069C11.3625 4.74069 11.7772 4.5 12.2264 4.5C12.7005 4.5 13.1339 4.76787 13.346 5.19193L17.2764 13.0528C17.4134 13.3269 17.6936 13.5 18 13.5C20.2091 13.5 22 15.2909 22 17.5", "M12.5 9.5L14.5 8.5", "M14 12L16 11", "M6 20.5H18", "M2 17.5H5") } + MaxLineLength:AppIcons.kt:AppIcons$val SoccerBall: ImageVector by lazy { line("SoccerBall", "M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z", "M11.7077 9.34893C11.882 9.21702 12.118 9.21702 12.2923 9.34893L14.545 11.054C14.7193 11.1859 14.7922 11.4197 14.7256 11.6332L13.8652 14.3921C13.7986 14.6055 13.6077 14.75 13.3923 14.75H10.6077C10.3923 14.75 10.2014 14.6055 10.1348 14.3921L9.27437 11.6332C9.20781 11.4197 9.28073 11.1859 9.45499 11.054L11.7077 9.34893Z", "M12 9V5M15 11L19 9.5M14 15L16 18M10 14.5L8 17M9 11.5L5 10.5", "M9 2.5L12.0165 4.62241L15 2.5M2 12.7998L5.19655 10.4388L3.55548 6.72045M19.4703 18.8531L15.6158 18.1555L14.2655 22M20.0298 6.19586L18.8035 9.38978L22 11.7507M8.00992 21.4059L8.05142 17.1665L4.00331 17.21") } + MaxLineLength:AppIcons.kt:AppIcons$val Stairs: ImageVector by lazy { line("Stairs", "M17.5 3.5H18.5C19.4428 3.5 19.9142 3.5 20.2071 3.79289C20.5 4.08579 20.5 4.55719 20.5 5.5V16.5C20.5 18.3856 20.5 19.3284 19.9142 19.9142C19.3284 20.5 18.3856 20.5 16.5 20.5H5.5C4.55719 20.5 4.08579 20.5 3.79289 20.2071C3.5 19.9142 3.5 19.4428 3.5 18.5V17.5C3.5 16.5572 3.5 16.0858 3.79289 15.7929C4.08579 15.5 4.55719 15.5 5.5 15.5H7.5V13.5C7.5 12.5572 7.5 12.0858 7.79289 11.7929C8.08579 11.5 8.55719 11.5 9.5 11.5H11.5V9.5C11.5 8.55719 11.5 8.08579 11.7929 7.79289C12.0858 7.5 12.5572 7.5 13.5 7.5H15.5V5.5C15.5 4.55719 15.5 4.08579 15.7929 3.79289C16.0858 3.5 16.5572 3.5 17.5 3.5Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Steps: ImageVector by lazy { line("Steps", "M10 15H3.5C3.03406 15 2.80109 15 2.61732 15.0761C2.37229 15.1776 2.17761 15.3723 2.07612 15.6173C2 15.8011 2 16.0341 2 16.5C2 16.9659 2 17.1989 2.07612 17.3827C2.17761 17.6277 2.37229 17.8224 2.61732 17.9239C2.80109 18 3.03406 18 3.5 18H10V15Z", "M13 12H7C6.05719 12 5.58579 12 5.29289 12.2929C5 12.5858 5 13.0572 5 14V15H13V12Z", "M10 18H18C19.8856 18 20.8284 18 21.4142 17.4142C22 16.8284 22 15.8856 22 14V8C22 7.05719 22 6.58579 21.7071 6.29289C21.4142 6 20.9428 6 20 6H19", "M16 9H10C9.05719 9 8.58579 9 8.29289 9.29289C8 9.58579 8 10.0572 8 11V12H16V9Z", "M19 6H13C12.0572 6 11.5858 6 11.2929 6.29289C11 6.58579 11 7.05719 11 8V9H19V6Z") } + MaxLineLength:AppIcons.kt:AppIcons$val StopFill: ImageVector by lazy { solid("StopFill", "M4 12C4 8.72077 4 7.08116 4.81382 5.91891C5.1149 5.48891 5.48891 5.1149 5.91891 4.81382C7.08116 4 8.72077 4 12 4C15.2792 4 16.9188 4 18.0811 4.81382C18.5111 5.1149 18.8851 5.48891 19.1862 5.91891C20 7.08116 20 8.72077 20 12C20 15.2792 20 16.9188 19.1862 18.0811C18.8851 18.5111 18.5111 18.8851 18.0811 19.1862C16.9188 20 15.2792 20 12 20C8.72077 20 7.08116 20 5.91891 19.1862C5.48891 18.8851 5.1149 18.5111 4.81382 18.0811C4 16.9188 4 15.2792 4 12Z") } + MaxLineLength:AppIcons.kt:AppIcons$val TennisBall: ImageVector by lazy { line("TennisBall", "M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z", "M5 5C8.99009 8.52068 9.0099 15.4618 5 19", "M19 19C14.9901 15.4618 15.0099 8.52068 19 5") } + MaxLineLength:AppIcons.kt:AppIcons$val TerminalWindow: ImageVector by lazy { line("TerminalWindow", "M7.5 7.5L8.72654 8.55719C9.24218 9.00163 9.5 9.22386 9.5 9.5C9.5 9.77614 9.24218 9.99836 8.72654 10.4428L7.5 11.5", "M11.5 12.5H15.5", "M12 21C15.7497 21 17.6246 21 18.9389 20.0451C19.3634 19.7367 19.7367 19.3634 20.0451 18.9389C21 17.6246 21 15.7497 21 12C21 8.25027 21 6.3754 20.0451 5.06107C19.7367 4.6366 19.3634 4.26331 18.9389 3.95491C17.6246 3 15.7497 3 12 3C8.25027 3 6.3754 3 5.06107 3.95491C4.6366 4.26331 4.26331 4.6366 3.95491 5.06107C3 6.3754 3 8.25027 3 12C3 15.7497 3 17.6246 3.95491 18.9389C4.26331 19.3634 4.6366 19.7367 5.06107 20.0451C6.3754 21 8.25027 21 12 21Z") } + MaxLineLength:AppIcons.kt:AppIcons$val ThermometerSimple: ImageVector by lazy { line("ThermometerSimple", "M13.8801 15.9369L20.6738 8.17267C21.4217 7.31791 21.7956 6.89053 21.9252 6.41326C22.0376 5.99901 22.023 5.56052 21.8833 5.15466C21.7223 4.68707 21.3207 4.28551 20.5176 3.4824C19.7145 2.67928 19.3129 2.27773 18.8453 2.11672C18.4395 1.97696 18.001 1.96236 17.5867 2.07481C17.1095 2.20436 16.6821 2.57832 15.8273 3.32623L8.06312 10.1199C7.10741 10.9562 6.62955 11.3743 6.34846 11.9258C6.06738 12.4774 6.00989 13.1098 5.89492 14.3745L5.87152 14.6319C5.81078 15.3 5.78041 15.6341 5.65293 15.9386C5.52546 16.2431 5.30877 16.4992 4.8754 17.0114L2.27543 20.0841C1.88438 20.5462 1.91286 21.231 2.34094 21.6591C2.76903 22.0871 3.45379 22.1156 3.91594 21.7246L6.98863 19.1246C7.5008 18.6912 7.75689 18.4745 8.06139 18.3471C8.36589 18.2196 8.69998 18.1892 9.36814 18.1285L9.62553 18.1051C10.8902 17.9901 11.5226 17.9326 12.0741 17.6515C12.6257 17.3704 13.0438 16.8926 13.8801 15.9369Z", "M7.78906 9.89453L9.36983 10.843C9.74255 11.0666 9.86341 11.5501 9.63978 11.9228L9.34781 12.4094C8.96198 13.0524 9.06331 13.8755 9.59357 14.4058C10.1238 14.9361 10.9469 15.0374 11.59 14.6516L12.0766 14.3596C12.4493 14.136 12.9328 14.2568 13.1564 14.6296L14.1049 16.2103", "M17.2634 6.7373L14.1055 9.8952") } + MaxLineLength:AppIcons.kt:AppIcons$val Timer: ImageVector by lazy { line("Timer", "M18.0104 7.48959L19.5 6M20.5 13.5C20.5 18.1944 16.6944 22 12 22C7.30558 22 3.5 18.1944 3.5 13.5C3.5 8.80558 7.30558 5 12 5C16.6944 5 20.5 8.80558 20.5 13.5Z", "M14.5 2H9.5", "M12 13.5L15.5 10") } + MaxLineLength:AppIcons.kt:AppIcons$val Trash: ImageVector by lazy { line("Trash", "M19.5 5.5L18.8803 15.5251C18.7219 18.0864 18.6428 19.3671 18.0008 20.2879C17.6833 20.7431 17.2747 21.1273 16.8007 21.416C15.8421 22 14.559 22 11.9927 22C9.42312 22 8.1383 22 7.17905 21.4149C6.7048 21.1257 6.296 20.7408 5.97868 20.2848C5.33688 19.3626 5.25945 18.0801 5.10461 15.5152L4.5 5.5", "M3 5.5H21M16.0557 5.5L15.3731 4.09173C14.9196 3.15626 14.6928 2.68852 14.3017 2.39681C14.215 2.3321 14.1231 2.27454 14.027 2.2247C13.5939 2 13.0741 2 12.0345 2C10.9688 2 10.436 2 9.99568 2.23412C9.8981 2.28601 9.80498 2.3459 9.71729 2.41317C9.32164 2.7167 9.10063 3.20155 8.65861 4.17126L8.05292 5.5", "M9.5 16.5L9.5 10.5", "M14.5 16.5L14.5 10.5") } + MaxLineLength:AppIcons.kt:AppIcons$val UploadSimple: ImageVector by lazy { line("UploadSimple", "M2.99994 17C2.99994 17.93 2.99994 18.395 3.10216 18.7765C3.37956 19.8117 4.18821 20.6204 5.22348 20.8978C5.60498 21 6.06997 21 6.99994 21L16.9999 21C17.9299 21 18.3949 21 18.7764 20.8978C19.8117 20.6204 20.6203 19.8117 20.8977 18.7765C20.9999 18.395 20.9999 17.93 20.9999 17", "M16.5 7.49993C16.5 7.49993 13.1858 2.99997 12 2.99996C10.8141 2.99995 7.50002 7.49996 7.50002 7.49996M12 3.99996V16") } + MaxLineLength:AppIcons.kt:AppIcons$val User: ImageVector by lazy { line("User", "M20 21.0001C19.713 17.269 16.7289 14.3151 12.995 14.0662L12 13.9999C11.6446 14.0096 11.3134 14.0225 11.0008 14.0378C7.3 14.2192 4.28417 17.3057 4 21.0001", "M8 6.9999A4 4 0 1 0 16 6.9999A4 4 0 1 0 8 6.9999Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Vibrate: ImageVector by lazy { line("Vibrate", "M17 15V9C17 6.64298 17 5.46447 16.2678 4.73223C15.5355 4 14.357 4 12 4C9.64298 4 8.46447 4 7.73223 4.73223C7 5.46447 7 6.64298 7 9V15C7 17.357 7 18.5355 7.73223 19.2678C8.46447 20 9.64298 20 12 20C14.357 20 15.5355 20 16.2678 19.2678C17 18.5355 17 17.357 17 15Z", "M22 8L20.3578 9.23167C20.1325 9.40059 20 9.66569 20 9.94721C20 10.286 20.1914 10.5957 20.4944 10.7472L21.5528 11.2764C21.8269 11.4134 22 11.6936 22 12C22 12.3064 21.8269 12.5866 21.5528 12.7236L20.4944 13.2528C20.1914 13.4043 20 13.714 20 14.0528C20 14.3343 20.1325 14.5994 20.3578 14.7683L22 16", "M2 8L3.64223 9.23167C3.86745 9.40059 4 9.66569 4 9.94721C4 10.286 3.80859 10.5957 3.50557 10.7472L2.44721 11.2764C2.17313 11.4134 2 11.6936 2 12C2 12.3064 2.17313 12.5866 2.44721 12.7236L3.50557 13.2528C3.80859 13.4043 4 13.714 4 14.0528C4 14.3343 3.86745 14.5994 3.64223 14.7683L2 16") } + MaxLineLength:AppIcons.kt:AppIcons$val Volleyball: ImageVector by lazy { line("Volleyball", "M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z", "M8.12046 3C7.67458 4.61905 7.76376 8.58571 11.6875 11.5M11.6875 11.5C16.6295 10.17 20.125 10.65 22 12.3498M11.6875 11.5C12.2525 16.1881 8.875 19.15 7 20", "M9 8C11.087 6.11368 16.3261 5.12819 20.5 7.01432", "M16.8885 11C17.4777 13.8947 15.7144 19.1883 12 22", "M10 17C7.37458 15.7396 4.15837 10.9595 4 6") } + MaxLineLength:AppIcons.kt:AppIcons$val Warning: ImageVector by lazy { line("Warning", "M13.9248 21H10.0752C5.44476 21 3.12955 21 2.27636 19.4939C1.42317 17.9879 2.60736 15.9914 4.97574 11.9985L6.90057 8.75333C9.17559 4.91778 10.3131 3 12 3C13.6869 3 14.8244 4.91777 17.0994 8.75332L19.0243 11.9985C21.3926 15.9914 22.5768 17.9879 21.7236 19.4939C20.8704 21 18.5552 21 13.9248 21Z", "M12 9V13", "M12.125 16.75H12M12.25 16.75C12.25 16.8881 12.1381 17 12 17C11.8619 17 11.75 16.8881 11.75 16.75C11.75 16.6119 11.8619 16.5 12 16.5C12.1381 16.5 12.25 16.6119 12.25 16.75Z") } + MaxLineLength:AppIcons.kt:AppIcons$val WarningCircle: ImageVector by lazy { line("WarningCircle", "M2 12A10 10 0 1 0 22 12A10 10 0 1 0 2 12Z", "M12 8V12", "M12.125 15.75H12M12.25 15.75C12.25 15.8881 12.1381 16 12 16C11.8619 16 11.75 15.8881 11.75 15.75C11.75 15.6119 11.8619 15.5 12 15.5C12.1381 15.5 12.25 15.6119 12.25 15.75Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Watch: ImageVector by lazy { line("Watch", "M5 12A7 7 0 1 0 19 12A7 7 0 1 0 5 12Z", "M16 6L15.7276 4.91043C15.3931 3.5724 15.2258 2.90339 14.7499 2.49004C14.6973 2.44433 14.6423 2.40141 14.5852 2.36145C14.0688 2 13.3792 2 12 2C10.6208 2 9.93119 2 9.41476 2.36145C9.35765 2.40141 9.30268 2.44433 9.25006 2.49004C8.77415 2.90339 8.6069 3.5724 8.27239 4.91043L8 6", "M8 18L8.27239 19.0896C8.6069 20.4276 8.77415 21.0966 9.25006 21.51C9.30268 21.5557 9.35765 21.5986 9.41476 21.6386C9.93119 22 10.6208 22 12 22C13.3792 22 14.0688 22 14.5852 21.6386C14.6423 21.5986 14.6973 21.5557 14.7499 21.51C15.2258 21.0966 15.3931 20.4276 15.7276 19.0896L16 18", "M12 10V12.005L13 13") } + MaxLineLength:AppIcons.kt:AppIcons$val WatchFill: ImageVector by lazy { bold("WatchFill", "M5 12A7 7 0 1 0 19 12A7 7 0 1 0 5 12Z", "M16 6L15.7276 4.91043C15.3931 3.5724 15.2258 2.90339 14.7499 2.49004C14.6973 2.44433 14.6423 2.40141 14.5852 2.36145C14.0688 2 13.3792 2 12 2C10.6208 2 9.93119 2 9.41476 2.36145C9.35765 2.40141 9.30268 2.44433 9.25006 2.49004C8.77415 2.90339 8.6069 3.5724 8.27239 4.91043L8 6", "M8 18L8.27239 19.0896C8.6069 20.4276 8.77415 21.0966 9.25006 21.51C9.30268 21.5557 9.35765 21.5986 9.41476 21.6386C9.93119 22 10.6208 22 12 22C13.3792 22 14.0688 22 14.5852 21.6386C14.6423 21.5986 14.6973 21.5557 14.7499 21.51C15.2258 21.0966 15.3931 20.4276 15.7276 19.0896L16 18", "M12 10V12.005L13 13") } + MaxLineLength:AppIcons.kt:AppIcons$val Waves: ImageVector by lazy { line("Waves", "M2 12.1932C2.68524 13.2443 3.57104 13.2443 4.27299 12.1932C6.52985 8.7408 8.67954 14.6764 10.273 12.2321C12.703 8.56944 14.4508 14.9218 16.273 12.1932C18.6492 8.5582 20.1295 14.5776 22 12.5842", "M2 6.1932C2.68524 7.24434 3.57104 7.24434 4.27299 6.1932C6.52985 2.7408 8.67954 8.67642 10.273 6.23213C12.703 2.56944 14.4508 8.92184 16.273 6.1932C18.6492 2.5582 20.1295 8.57758 22 6.58418", "M2 18.1932C2.68524 19.2443 3.57104 19.2443 4.27299 18.1932C6.52985 14.7408 8.67954 20.6764 10.273 18.2321C12.703 14.5694 14.4508 20.9218 16.273 18.1932C18.6492 14.5582 20.1295 20.5776 22 18.5842") } + MaxLineLength:AppIcons.kt:AppIcons$val Wind: ImageVector by lazy { line("Wind", "M6 6C6 4.34315 7.34315 3 9 3C10.6569 3 12 4.34315 12 6C12 7.65685 10.6569 9 9 9H3", "M9 18C9 19.6569 10.3431 21 12 21C13.6569 21 15 19.6569 15 18C15 16.3431 13.6569 15 12 15H3", "M15 9C15 7.34315 16.3431 6 18 6C19.6569 6 21 7.34315 21 9C21 10.6569 19.6569 12 18 12H6.5") } + MaxLineLength:AppUpdater.kt:AppUpdater$info.signingInfo?.apkContentsSigners.orEmpty().mapTo(HashSet()) { hex(MessageDigest.getInstance("SHA-256").digest(it.toByteArray())) } + MaxLineLength:AutoBackup.kt:AutoBackup$if (resolver.persistedUriPermissions.none { it.uri == folder && it.isWritePermission }) throw Failed(BackupFiles.Failure.PermissionLost) + MaxLineLength:AutoDetectNotifier.kt:AutoDetectNotifier$val open = PendingIntent.getActivity(context, w.start.toInt(), intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + MaxLineLength:AutoDetectNotifier.kt:AutoDetectNotifier.Companion$return "${Workouts.sportName(w.sportType)} detected, ${clock.format(Instant.ofEpochSecond(w.start))}–${clock.format(Instant.ofEpochSecond(w.end))}" + MaxLineLength:AutoHealthConnect.kt:HealthConnectAutoSync$/** The user turned auto sync on, with the permissions granted: on, and the last 30 days written at once. Returns the records written. */ + MaxLineLength:AutoStart.kt:AutoStart$(stepsPerMinute ?: 0) >= BRISK_STEPS || recent.takeLast(10).let { it.isNotEmpty() && it.map { r -> r.second }.average() >= threshold } + MaxLineLength:AutoStart.kt:AutoStart$readings.groupBy { it.first / 60 * 60 }.map { (t, r) -> t to Math.round(r.map { it.second }.average()).toInt() }.sortedBy { it.first } + MaxLineLength:Backtest.kt:Backtest.Companion$"strap_sleep_hrv" + MaxLineLength:Backtest.kt:Backtest.Companion$row.result.load.f1() + MaxLineLength:Backtest.kt:Backtest.Companion$val + MaxLineLength:BackupPage.kt:PageRow + MaxLineLength:BackupPage.kt:PageRow("Restore", "Any OpenStrap zip. Fills in what this phone is missing", onClick = onRestore, below = true, icon = AppIcons.ArrowCounterClockwise) + MaxLineLength:BackupPage.kt:PageRow("Save a copy", "A zip saved where you choose, never cleared", onClick = onSaveCopy, below = true, icon = AppIcons.DownloadSimple) + MaxLineLength:BackupPage.kt:PickBackup$. + MaxLineLength:BackupPage.kt:PickBackup$if + MaxLineLength:BodySignals.kt:BodySignals$if (d >= RR_FLAG_PER_MIN && d >= FLAG_SCALES * rrBase.scale) { flags += Flag.RrFlag; signals += "breathing rate up ${"%.1f".format(d)}/min" } + MaxLineLength:BodySignals.kt:BodySignals$if (d >= TEMP_FLAG_C && d >= FLAG_SCALES * tempBase.scale) { flags += Flag.TempFlag; signals += "skin temperature up from your usual" } + MaxLineLength:BodySignals.kt:BodySignals$if (hrvBase != null) hrvOf(n)?.let { if (hrvBase.z(it) <= HRV_SUPPORT_Z) { flags += Flag.HrvSupport; signals += "HRV well below your normal" } } + MaxLineLength:BodySignals.kt:BodySignals$val hrvBase = Recovery.baseline(pool.filter { it.valid }.mapNotNull { it.hrvLn }.takeLast(ALERT_BASELINE_NIGHTS), Recovery.HRV_LN_SD_FLOOR, MIN_ALERT_NIGHTS) + MaxLineLength:BodySignals.kt:BodySignals$val rhrBase = Recovery.baseline(pool.filter { it.valid }.mapNotNull { it.rhrMeanSleep }.takeLast(ALERT_BASELINE_NIGHTS), Recovery.RHR_SD_FLOOR, MIN_ALERT_NIGHTS) + MaxLineLength:BodySignals.kt:BodySignals$val rrBase = Recovery.baseline(pool.mapNotNull { it.breathRate }.takeLast(ALERT_BASELINE_NIGHTS), Recovery.BREATH_SD_FLOOR, MIN_ALERT_NIGHTS) + MaxLineLength:BodySignals.kt:BodySignals$val tempBase = Recovery.baseline(pool.mapNotNull { it.skinTemperature }.takeLast(ALERT_BASELINE_NIGHTS), Recovery.TEMP_SD_FLOOR, MIN_ALERT_NIGHTS) + MaxLineLength:BodySignals.kt:BodySignals$while (k > 0 && out[k].flags.none { it.core } && out[k].state == State.Alert && nightsBetween(sorted[k - 1].day, sorted[k].day) - 1 <= 1) { clearRun++; k-- } + MaxLineLength:CalorieRecords.kt:CalorieRecords$fun + MaxLineLength:CaloriesCopy.kt:CaloriesCopy$else + MaxLineLength:CaloriesCopy.kt:CaloriesCopy$fun + MaxLineLength:CaloriesCopy.kt:CaloriesCopy$fun aDay + MaxLineLength:CanvasDraw.kt:bars.forEachIndexed { i, bar -> bar?.let { drawPillBar(canvas, it, if (i == shown) color else Color.White.copy(alpha = WeekBarsLook.DIM_ALPHA)) } } + MaxLineLength:CanvasDraw.kt:canvas.drawLine(s.x0, height / 2, s.x1, height / 2, roundStroke(stageColor(s.stage), height).apply { strokeJoin = Paint.Join.ROUND }) + MaxLineLength:CanvasDraw.kt:canvas.drawRoundRect(0f, 0f, w, h, r, r, Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = paint.hairlineWidth; shader = gloss }) + MaxLineLength:CanvasDraw.kt:canvas.drawRoundRect(bar.topLeft.x, bar.topLeft.y, bar.topLeft.x + bar.size.width, bar.topLeft.y + bar.size.height, bar.corner, bar.corner, fill(color)) + MaxLineLength:CanvasDraw.kt:fun + MaxLineLength:CanvasDraw.kt:g.usual?.let { canvas.drawPath(androidPath(polylineOps(it)), roundStroke(Color.White.copy(alpha = GhostLineLook.USUAL_ALPHA), GhostLineLook.USUAL_DP * density)) } + MaxLineLength:CanvasDraw.kt:val gloss = LinearGradient(0f, 0f, if (g.across) w else 0f, if (g.across) 0f else h, g.stops.map { it.second.toArgb() }.toIntArray(), g.stops.map { it.first }.toFloatArray(), Shader.TileMode.CLAMP) + MaxLineLength:CardLooks.kt:Hue$enum + MaxLineLength:CardToDetail.kt:fadeIn(spring(Spring.DampingRatioNoBouncy, stiffness)) + MaxLineLength:CardToDetail.kt:if (motion == StackMotion.Jump || motion == StackMotion.Idle) return ContentTransform(EnterTransition.None, ExitTransition.None, sizeTransform = null) + MaxLineLength:Celebrations.kt:. + MaxLineLength:Celebrations.kt:Column + MaxLineLength:Celebrations.kt:PageSwitchRow("Weekly goal moment", "A full screen once a week, when you reach your goal", goalMoment, onGoalMoment, divider = false) + MaxLineLength:Celebrations.kt:RewardsCopy.whenSet(best.best.date, sportName) + MaxLineLength:Celebrations.kt:Text("From your workouts so far", color = Color.White, style = GlowType.pageTitle, modifier = Modifier.padding(start = 4.dp, top = 8.dp)) + MaxLineLength:Celebrations.kt:Text("Weekly goal met", color = Color.White, style = GlowType.pageTitle, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) + MaxLineLength:Celebrations.kt:Text(RewardsCopy.beatLine(best, now), color = Color.White, style = GlowType.sentence, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:Celebrations.kt:Text(RewardsCopy.bestTitle(best.best.kind), color = Color.White, style = GlowType.pageTitle, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:Celebrations.kt:Text(RewardsCopy.momentLine(inARow), color = Color.White, style = GlowType.sentence, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:Celebrations.kt:Text(title.orEmpty(), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.weight(1f).padding(start = 4.dp)) + MaxLineLength:Celebrations.kt:fun + MaxLineLength:Celebrations.kt:internal + MaxLineLength:Celebrations.kt:note?.let { Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.caption, textAlign = TextAlign.Center) } + MaxLineLength:Celebrations.kt:short: String + MaxLineLength:ChartSpec.kt:fun + MaxLineLength:ChartSpec.kt:ops += PathOp.Cubic(pts[i].x + dx, pts[i].y + m[i] * dx, pts[i + 1].x - dx, pts[i + 1].y - m[i + 1] * dx, pts[i + 1].x, pts[i + 1].y) + MaxLineLength:ChartSpec.kt:val hi = (yRange?.endInclusive ?: maxOf(known.max(), band?.endInclusive ?: -Float.MAX_VALUE)).let { if (it - lo < 1e-3f) lo + 1f else it } + MaxLineLength:Charts.kt:drawPath(fill, Brush.verticalGradient(listOf(color.copy(alpha = 0.30f), color.copy(alpha = 0.02f)), startY = 0f, endY = h)) + MaxLineLength:Charts.kt:private + MaxLineLength:Charts.kt:val fillBrush = Brush.verticalGradient(*stops.map { (f, c) -> f to c.copy(alpha = 0.16f) }.toTypedArray(), startY = y1, endY = y0) + MaxLineLength:ChooseScreen.kt:Column + MaxLineLength:ChooseScreen.kt:Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp), content = choose) + MaxLineLength:ChooseScreen.kt:EdgeCard + MaxLineLength:ChooseScreen.kt:PageRow(Workouts.sportName(type), icon = SportIcons.of(type), divider = i > 0, onClick = { keyboard?.hide(); onPick(type) }) + MaxLineLength:ChooseScreen.kt:Workouts.sportName(s.type) + MaxLineLength:ChooseScreen.kt:color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA) + MaxLineLength:ChooseScreen.kt:else + MaxLineLength:ChooseScreen.kt:if (query.isEmpty()) Text(hint, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.body, maxLines = 1) + MaxLineLength:Coaching.kt:WorkoutTarget.Companion$Zones(1, 1) + MaxLineLength:CompanionLink.kt:CompanionLink$(myAssociationId(address) ?: LEGACY_ID).let { if (settings.companionAssociation.first() != it) settings.setCompanionAssociation(it) } + MaxLineLength:CompanionLink.kt:CompanionLink$. + MaxLineLength:CompanionLink.kt:CompanionLink.<no name provided>$onError("Android couldn't find the strap (${error ?: "no reason given"}). Keep it on its charger near the phone and try again.") + MaxLineLength:ComponentGalleryActivity.kt:Edge("Load", "64", "Above your normal", MetricColors.load, EdgeStatus.Above, EdgeKind.Progress, Modifier.weight(1f), progress = 1f) + MaxLineLength:ComponentGalleryActivity.kt:Edge("Skin temperature", "34.6", "Above your normal", MetricColors.temperature, EdgeStatus.Above, EdgeKind.Value, Modifier.weight(1f)) + MaxLineLength:ComponentGalleryActivity.kt:Edge("Steps", "6,420", "80% of your goal", MetricColors.steps, EdgeStatus.Usual, EdgeKind.Progress, Modifier.weight(1f), progress = 0.8f) + MaxLineLength:ComponentGalleryActivity.kt:EdgeCard + MaxLineLength:ComponentGalleryActivity.kt:GlowCard + MaxLineLength:ComponentGalleryActivity.kt:GridCard(CardSize.Large) { m -> GlowCard(glow(MetricColors.readiness, LightAngle.LowerLeft), m) { GlowText("Large", "78", "The hero of the moment") } } + MaxLineLength:ComponentGalleryActivity.kt:GridCard(CardSize.Small) { m -> EdgeCard(MetricColors.habitAge, m, kind = EdgeKind.Earlier) { CardText("A lone small, now wide", "38", "Habit Age") } } + MaxLineLength:ComponentGalleryActivity.kt:GridCard(CardSize.Small) { m -> EdgeCard(MetricColors.heart, m, contentPadding = SmallPadding) { CardText("Small", "52", "Usual") } } + MaxLineLength:ComponentGalleryActivity.kt:GridCard(CardSize.Small) { m -> EdgeCard(MetricColors.hrv, m, status = EdgeStatus.Below, contentPadding = SmallPadding) { CardText("Small", "41", "Below your normal") } } + MaxLineLength:ComponentGalleryActivity.kt:GridCard(CardSize.Small) { m -> EdgeCard(MetricColors.spo2, m, contentPadding = SmallPadding) { CardText("Small", "97%", "Usual") } } + MaxLineLength:ComponentGalleryActivity.kt:GridCard(CardSize.Small) { m -> EdgeCard(MetricColors.stress, m, contentPadding = SmallPadding) { CardText("Small", "28", "Usual") } } + MaxLineLength:ComponentGalleryActivity.kt:GridCard(CardSize.Strip) { m -> EdgeCard(Color.White, m, contentPadding = PaddingValues(horizontal = 20.dp)) { StripText("Strip", "Start a workout") } } + MaxLineLength:ComponentGalleryActivity.kt:GridCard(CardSize.Tall) { m -> GlowCard(Charge.copy(lightLevel = 0.55f, second = true), m, contentPadding = SmallPadding) { GlowText("Tall", "55", "Charge") } } + MaxLineLength:ComponentGalleryActivity.kt:GridCard(CardSize.Wide) { m -> EdgeCard(MetricColors.sleepDeep, m, kind = EdgeKind.Sleep) { CardText("Wide", "7:40", "Last night") } } + MaxLineLength:ComponentGalleryActivity.kt:Text(status, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 6.dp)) + MaxLineLength:ComposeDraw.kt:drawPath(composePath(polylineOps(u)), Color.White.copy(alpha = GhostLineLook.USUAL_ALPHA), style = Stroke(GhostLineLook.USUAL_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round)) + MaxLineLength:ComposeDraw.kt:drawPath(fill, Brush.verticalGradient(listOf(color.copy(alpha = LineChartLook.FILL_ALPHA), Color.Transparent), startY = g.top, endY = g.plotBottom)) + MaxLineLength:ComposeDraw.kt:drawPath(line, color.copy(alpha = LineChartLook.HALO_ALPHA), style = Stroke(LineChartLook.HALO_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round)) + MaxLineLength:ComposeDraw.kt:if (rowColors == null) g.joins.forEach { j -> drawLine(Color.White.copy(alpha = HypnogramLook.JOIN_ALPHA), j.from, j.to, ChartInk.HAIRLINE_DP * density) } + MaxLineLength:CsvImporter.kt:CsvImporter$private suspend + MaxLineLength:CsvImporter.kt:CsvImporter$val + MaxLineLength:CsvImporter.kt:CsvImporter$val first = query("SELECT MIN(${key.column}) FROM ${key.table}") { c -> if (c.isNull(0)) null else c.getLong(0) }.single() + MaxLineLength:CsvImporter.kt:CsvImporter$val have = query("SELECT ${key.column} FROM ${key.table} WHERE ${key.column} BETWEEN ? AND ?", from, to) { it.getLong(0) }.toHashSet() + MaxLineLength:CsvTables.kt:"start,start_epoch,end_epoch,sport_type,source,pauses,end_reason,avg_hr,max_hr,ask_to_end,recorded_end_epoch,shown_start_epoch,target," + MaxLineLength:CsvTables.kt:"use_gps,keep_route,distance_m,distance_from_steps,rest,sets_target,laps,epoc,aerobic_te,recovery_minutes,vo2max,active_kcal,total_kcal,target_reached_epoch,structure,stretches" + MaxLineLength:CsvTables.kt:AppWorkoutRound + MaxLineLength:CsvTables.kt:AppWorkoutRoutePoint + MaxLineLength:CsvTables.kt:AppWorkoutSet + MaxLineLength:CsvTables.kt:CsvTable + MaxLineLength:CsvTables.kt:DailyEnergyRow + MaxLineLength:CsvTables.kt:aerobicTrainingEffect = it.floatOrNull("aerobic_te") + MaxLineLength:CsvTables.kt:distanceMeters = it.floatOrNull("distance_m") + MaxLineLength:CsvTables.kt:if (it.has("pauses")) it.string("pauses") else "" + MaxLineLength:CsvTables.kt:it.floatOrNull("speed_ms") + MaxLineLength:CsvTables.kt:it.rest + MaxLineLength:CsvTables.kt:targetReachedAt = it.longOrNull("target_reached_epoch") + MaxLineLength:CsvTables.kt:{ AppWorkoutHeartRate(it.long("workout_start_epoch"), it.long("epoch"), it.int("bpm"), it.has("from_history") && it.bool("from_history")) } + MaxLineLength:CsvTables.kt:{ DeletedWorkout(it.long("start_epoch"), it.longOrNull("effect_end_epoch"), it.longOrNull("deleted_epoch") ?: it.long("start_epoch")) } + MaxLineLength:CsvTables.kt:{ PaiSample(it.long("epoch"), it.float("daily"), it.float("total"), it.int("resting_hr"), it.int("max_hr"), it.floatOrNull("tomorrow_total")) } + MaxLineLength:CsvTables.kt:{ d, f, t -> d.appWorkoutHeartRateAll().filter { it.workoutStart in f..t } } + MaxLineLength:CsvTables.kt:{ d, f, t -> d.paiOnce(f, t) } + MaxLineLength:CsvTables.kt:{ d, f, t -> d.setsAll().filter { it.workoutStart in f..t } } + MaxLineLength:CsvTables.kt:{ listOf(iso(it.timestamp), it.timestamp, it.floors, it.run, it.fatBurn, it.stand, it.walk, it.sit, it.steps, it.calories, it.distanceCm) } + MaxLineLength:CsvTables.kt:{ listOf(it.workoutStart, it.timestamp, it.lat, it.lon, it.accuracy, it.speed, it.bearing, it.altitude, it.speedAccuracy, it.bearingAccuracy) } + MaxLineLength:DailyEnergyHistory.kt:DailyEnergyHistory$. + MaxLineLength:DailyEnergyHistory.kt:DailyEnergyHistory$private suspend + MaxLineLength:DailyEnergyHistory.kt:DailyEnergyHistory$suspend + MaxLineLength:Daos.kt:HealthDao$"AND (EXISTS (SELECT 1 FROM sleep_stage n WHERE n.sessionStart = sleep_stage.sessionStart AND n.start = sleep_stage.`end` + 60) " + MaxLineLength:Daos.kt:HealthDao$"WHERE NOT EXISTS (SELECT 1 FROM sleep_stage n WHERE n.sessionStart = sleep_stage.sessionStart AND n.start = sleep_stage.`end` AND n.start > sleep_stage.start) " + MaxLineLength:Daos.kt:HealthDao$@Query("DELETE FROM sleep_session WHERE nap = 1 AND EXISTS (SELECT 1 FROM sleep_session m WHERE m.nap = 0 AND sleep_session.start < m.`end` AND sleep_session.`end` > m.start)") + MaxLineLength:Daos.kt:HealthDao$@Query("DELETE FROM sleep_stage WHERE sessionStart IN (SELECT n.start FROM sleep_session n JOIN sleep_session m ON m.nap = 0 AND n.nap = 1 AND n.start < m.`end` AND n.`end` > m.start)") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT (SELECT COUNT(*) FROM activity_minute WHERE timestamp BETWEEN :from AND :to) + (SELECT COUNT(*) FROM heart_rate WHERE timestamp BETWEEN :from AND :to) * 7 + (SELECT COUNT(*) FROM sleep_session WHERE `end` BETWEEN :from AND :to) * 1009 + (SELECT COUNT(*) FROM workout WHERE start BETWEEN :from AND :to) * 100003 + (SELECT COUNT(*) + COALESCE(SUM(editedAt), 0) FROM sleep_edit) * 31") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(bpm) AS value, MIN(bpm) AS min, MAX(bpm) AS max, COUNT(*) AS count FROM heart_rate WHERE timestamp BETWEEN :from AND :to AND bpm BETWEEN 30 AND 230 GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(celsius) AS value, MIN(celsius) AS min, MAX(celsius) AS max, COUNT(*) AS count FROM skin_temperature WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(milliseconds) AS value, MIN(milliseconds) AS min, MAX(milliseconds) AS max, COUNT(*) AS count FROM hrv WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(milliseconds) AS value, MIN(milliseconds) AS min, MAX(milliseconds) AS max, COUNT(*) AS count FROM hrv_sdnn WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(perMinute) AS value, MIN(perMinute) AS min, MAX(perMinute) AS max, COUNT(*) AS count FROM breath_rate WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(percent) AS value, MIN(percent) AS min, MAX(percent) AS max, COUNT(*) AS count FROM sleep_spo2 WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(percent) AS value, MIN(percent) AS min, MAX(percent) AS max, COUNT(*) AS count FROM spo2 WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, CAST(strftime('%H', timestamp, 'unixepoch', 'localtime') AS INTEGER) AS hour, SUM(steps) AS steps FROM activity_minute WHERE timestamp BETWEEN :from AND :to AND steps BETWEEN 0 AND 250 GROUP BY day, hour ORDER BY day, hour") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, SUM(steps) AS value, MIN(steps) AS min, MAX(steps) AS max, COUNT(*) AS count FROM activity_minute WHERE timestamp BETWEEN :from AND :to AND steps BETWEEN 0 AND 250 GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, SUM(steps) AS value, MIN(steps) AS min, MAX(steps) AS max, COUNT(*) AS count FROM activity_minute WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, daily, total, MAX(timestamp) AS latest FROM pai WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT timestamp / 60 FROM heart_rate WHERE timestamp BETWEEN :from AND :to AND bpm BETWEEN 30 AND 230 UNION SELECT timestamp / 60 FROM activity_minute WHERE timestamp BETWEEN :from AND :to AND heartRate BETWEEN 30 AND 230") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT workoutStart, MAX(timestamp) AS lastSample, COUNT(*) AS samples, SUM(bpm) AS bpmSum FROM app_workout_hr GROUP BY workoutStart") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT workoutStart, MAX(timestamp) AS lastSample, COUNT(*) AS samples, SUM(bpm) AS bpmSum FROM workout_hr GROUP BY workoutStart") + MaxLineLength:Daos.kt:HealthDao$@Query("UPDATE app_workout SET epoc = :epoc, aerobicTrainingEffect = :aerobicTe, recoveryMinutes = :recoveryMinutes, vo2max = :vo2max, anaerobicTrainingEffect = :anaerobicTe, effectFromStrap = :fromStrap, ownRecoveryMinutes = :ownRecoveryMinutes WHERE start = :start") + MaxLineLength:Daos.kt:HealthDao$start: Long + MaxLineLength:DataStateParts.kt:drawCircle(Brush.radialGradient(listOf(color.copy(alpha = 0.55f), color.copy(alpha = 0f)), Offset(x, y), 9.dp.toPx()), 9.dp.toPx(), Offset(x, y)) + MaxLineLength:DataStates.kt:"When the strap was worn for under ${TodayCopy.duration(EnergyExpenditure.FULL_DAY_MINUTES * 60L)} of the day, or under that share of the day so far." + MaxLineLength:DataStates.kt:fun + MaxLineLength:DataStates.kt:readiness = readiness.copy(score = null, contributors = emptyList(), baselineNights = 0, phase = Recovery.Phase.Withheld, strainAlert = false, withheld = false) + MaxLineLength:DataStates.kt:readinessTrend = readinessTrend.map { if (it.first == date) it.first to null else it } + MaxLineLength:DayViewModel.kt:DayViewModel$SleepParts(session, st, naps, ns, before?.score, asDetected, main?.strap, if (main?.moved == true) whole else emptyList(), deleted, shown.mapNotNull { it.shown }) to spanSessions + MaxLineLength:DayViewModel.kt:DayViewModel$org.openstrap.app.data.UsualSteps.byHour(repo.hourlySteps(d.minusDays(org.openstrap.app.data.UsualSteps.DAYS.toLong()), d.minusDays(1)), d) + MaxLineLength:DayViewModel.kt:DayViewModel$return + MaxLineLength:DayViewModel.kt:DayViewModel$s?.let { w -> samples.filter { time(it) in w.start..w.end }.map(value).takeIf { it.isNotEmpty() }?.average()?.toFloat() } + MaxLineLength:DayViewModel.kt:DayViewModel$val + MaxLineLength:DayViewModel.kt:DayViewModel$val m = moreParts.copy(nights = n.nights, sleepHeartRate = n.heartRate, sleepHrv = n.hrv, sleepSkinTemperature = n.skinTemperature) + MaxLineLength:DayViewModel.kt:DayViewModel$val series = Summaries.heartRateSeries(hr.filter { it.timestamp in w.from..w.to }, act.filter { it.timestamp in w.from..w.to }) + MaxLineLength:DayViewModel.kt:DayViewModel$val sleepHrv = tonight?.let { w -> hrv.filter { it.timestamp in w.start..w.end }.map { ChartPoint(it.timestamp, it.milliseconds.toFloat()) } }.orEmpty() + MaxLineLength:DayViewModel.kt:DayViewModel$val sleepSkin = tonight?.let { w -> skin.filter { it.timestamp in w.start..w.end }.map { ChartPoint(it.timestamp, it.celsius) } }.orEmpty() + MaxLineLength:DeletedSleepPage.kt:SleepCopy.deletedLine(clock(r.start), clock(r.end), r.edit?.let { SleepEdits.daysLeft(it, now) } ?: SleepEdits.KEEP_DAYS) + MaxLineLength:DeletedSleepPage.kt:if + MaxLineLength:DeletedSleepPage.kt:item { Text(SleepCopy.DELETED_INTRO, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(horizontal = 4.dp)) } + MaxLineLength:DemoData.kt:DemoData$AppWorkoutHeartRate(start, start + s, (118 + 34 * (s / 300.0).coerceAtMost(1.0) + 6 * sin(s / 200.0) + rnd.nextInt(-2, 3)).toInt()) + MaxLineLength:DemoData.kt:DemoData$AppWorkoutRoutePoint + MaxLineLength:DemoData.kt:DemoData$averageHeartRate = hr.map { it.bpm }.average().toInt() + MaxLineLength:DemoData.kt:DemoData$path + MaxLineLength:DemoData.kt:DemoData$start + MaxLineLength:DemoData.kt:DemoData$useGps = true + MaxLineLength:DemoData.kt:DemoData$val cycle = listOf(SleepStage.LIGHT to 25, SleepStage.DEEP to 35, SleepStage.LIGHT to 20, SleepStage.REM to 18, SleepStage.AWAKE to 3) + MaxLineLength:DemoData.kt:DemoData$weeklyLoad = 380f + (20 - back) * 6 + MaxLineLength:DeviceScreen.kt:Column + MaxLineLength:DeviceScreen.kt:PageRow + MaxLineLength:DeviceScreen.kt:PageRow("Alarms", alarmsSummary(alarms) ?: waiting, onClick = onAlarms, divider = false, icon = AppIcons.Alarm, selected = selected == "Alarms") + MaxLineLength:DeviceScreen.kt:PageRow("Firmware", info?.firmware ?: (connection as? ConnectionState.Connected)?.firmware ?: "Check for updates", onClick = onFirmware, icon = AppIcons.Cpu, selected = selected == "Firmware") + MaxLineLength:DeviceScreen.kt:PageRow("Forget this strap", onClick = { confirmForget = true }, divider = !first, titleColor = DestructiveInk, icon = AppIcons.Trash) + MaxLineLength:DeviceScreen.kt:PageRow("Health alerts", healthAlertsSummary(alarms) ?: waiting, onClick = onHealthAlerts, icon = AppIcons.Heartbeat, selected = selected == "Health alerts") + MaxLineLength:DeviceScreen.kt:PageRow("Phone alerts", phoneAlertsSummary(calls, notifications), onClick = onPhoneAlerts, icon = AppIcons.Vibrate, selected = selected == "Phone alerts") + MaxLineLength:DeviceScreen.kt:PageRow("Zepp account", if (zeppSignedIn) "Signed in" else "Not signed in", onClick = onZeppAccount, icon = AppIcons.Key, selected = selected == "Zepp account") + MaxLineLength:DeviceScreen.kt:StepText("Force-stop or uninstall Zepp so it lets go of the strap. Don't unlink the strap in Zepp: that wipes its key.", muted = true) + MaxLineLength:DeviceScreen.kt:Text("% battery", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) + MaxLineLength:DeviceScreen.kt:confirm = "Disconnect" + MaxLineLength:DeviceScreen.kt:else -> PageButton("Connect", { onRequestBluetooth { vm.strap.connect() } }, Modifier.fillMaxWidth().height(56.dp), primary = true) + MaxLineLength:DeviceScreen.kt:if (!companion.onResult(result.resultCode)) vm.showMessage("Not linked. You can turn off battery optimization for OpenStrap instead.") + MaxLineLength:DeviceScreen.kt:led + MaxLineLength:DeviceViewModel.kt:DeviceViewModel$. + MaxLineLength:DeviceViewModel.kt:DeviceViewModel$container.app.contentResolver.openInputStream(uri)!!.use { org.openstrap.app.export.CsvImporter(container.database).importZip(it) } + MaxLineLength:DeviceViewModel.kt:DeviceViewModel$container.settings.healthConnectAuto.stateIn(viewModelScope, SharingStarted.Eagerly, org.openstrap.app.data.settings.HealthConnectAuto()) + MaxLineLength:DeviceViewModel.kt:DeviceViewModel$if (outcome == org.openstrap.app.export.AutoBackup.Outcome.Recording) org.openstrap.app.export.AutoBackup.afterWorkout(container.app) + MaxLineLength:DeviceViewModel.kt:DeviceViewModel$val name = withContext(Dispatchers.IO) { runCatching { androidx.documentfile.provider.DocumentFile.fromTreeUri(container.app, uri)?.name }.getOrNull() } + MaxLineLength:EnergyExpenditure.kt:EnergyExpenditure$b + net(if (steps >= RUNNING_CADENCE && neighbourAtLeast(minutes, k, RUNNING_CADENCE)) runningMet(steps, h) else cadenceMet(steps, a), w) + MaxLineLength:EnergyExpenditure.kt:EnergyExpenditure$val points = if (older) listOf(60f to 2f, 100f to 3f, 110f to 4f, 120f to 5f, 130f to 6f) else listOf(60f to 2f, 100f to 3f, 130f to 6f) + MaxLineLength:Entities.kt:AppWorkout$fun withPause + MaxLineLength:Entities.kt:AppWorkout$pauseRanges.sumOf { (minOf(it.last, now) - maxOf(it.first, from)).coerceAtLeast(0) } + (pausedAt?.let { (now - maxOf(it, from)).coerceAtLeast(0) } ?: 0) + MaxLineLength:Entities.kt:AppWorkoutRoutePoint$val point get() = org.openstrap.app.data.Route.Point(timestamp, lat, lon, accuracy, speed, bearing, altitude, speedAccuracy, bearingAccuracy) + MaxLineLength:Entities.kt:AppWorkoutRoutePoint.Companion$AppWorkoutRoutePoint(workoutStart, p.t, p.lat, p.lon, p.accuracy, p.speed, p.bearing, p.altitude, p.speedAccuracy, p.bearingAccuracy) + MaxLineLength:Entities.kt:Workout$/** The summary's Type.subtype: 1 auto-detected, 6 a phone workout mirrored on the strap, 0 normal (codemap 05 §8). Null before version 17. */ + MaxLineLength:FamilySummary.kt:FamilySummary$PeriodLine(name, WorkoutCopy.averageOver(readings, r.workStart, workEnd), Games.hardEfforts(inside, maxHr), workEnd - r.workStart) + MaxLineLength:FamilySummary.kt:FamilySummary$fun + MaxLineLength:FamilySummary.kt:FamilySummary$if (restSeconds != null) "You recovered $avg bpm in ${clockOrSeconds(restSeconds)} on average." else "You recovered $avg bpm in each rest on average." + MaxLineLength:FamilySummary.kt:FamilySummary$private + MaxLineLength:FamilySummary.kt:FamilySummary$return peaks.map { p -> p?.let { (if (top <= floor) 1.0 else (it - floor) / (top - floor)).toFloat().coerceIn(PEAK_MIN_SHARE, 1f) } } + MaxLineLength:FamilySummary.kt:FamilySummary$val low = r.restEnd?.takeIf { it > workEnd }?.let { end -> readings.filter { it.first in workEnd..end }.minOfOrNull { it.second } } + MaxLineLength:FamilySummary.kt:FamilySummary$val raw = org.openstrap.app.data.Summaries.bucketMeans(readings, points, { ((it.first - first) * (points - 1) / span).toInt().coerceIn(0, points - 1) }, { it.second.toFloat() }) + MaxLineLength:FirmwareCloud.kt:FirmwareCloud$401, 403 -> throw FirmwareCloudException("The server rejected the app token (HTTP $code). It may have expired.", rejected = true) + MaxLineLength:FirmwareCloud.kt:FirmwareCloud$else -> throw FirmwareCloudException("Update server returned HTTP $code${body.take(200).let { if (it.isBlank()) "" else ": $it" }}") + MaxLineLength:FirmwareCloud.kt:FirmwareCloud.Companion$fun + MaxLineLength:FirmwareCloud.kt:FirmwareCloud.Companion$val json = runCatching { JSONObject(body) }.getOrElse { throw FirmwareCloudException("Unexpected reply from the update server") } + MaxLineLength:FirmwareScreen.kt:Body("The strap is restarting. OpenStrap reconnects by itself. Check again afterwards in case there are more parts to install.") + MaxLineLength:FirmwareScreen.kt:InfoLine("Update service", when (info?.dfuAvailable) { true -> "Available"; false -> "Not found"; null -> "Connect to check" }) + MaxLineLength:FirmwareScreen.kt:PageButton(if (account == null) "Sign in with Zepp" else "Sign in again", { signIn = true }, Modifier.fillMaxWidth(), enabled = enabled, primary = true) + MaxLineLength:FirmwareScreen.kt:PageButton(if (next == null) "Unsupported" else "Download", { next?.let(vm::download) }, Modifier.weight(1f), enabled = next != null, primary = true) + MaxLineLength:FirmwareScreen.kt:PageRow("Install from a file", "For an image you have", onClick = { pickFile.launch(arrayOf("*/*")) }, enabled = !installing) + MaxLineLength:FirmwareScreen.kt:PageRow("Zepp account", if (account != null) "Signed in" else "Not signed in", onClick = onZeppAccount, divider = false, enabled = !installing) + MaxLineLength:FirmwareScreen.kt:Point(AppIcons.CheckCircle, "Safe install", "Downloads are checked against the server's checksum, and the strap verifies the signature before installing.") + MaxLineLength:FirmwareScreen.kt:Point(AppIcons.CloudArrowDown, "Update check", "Sends the update server your token, user ID and the strap's firmware, hardware and serial number. Nothing else.") + MaxLineLength:FirmwareScreen.kt:Point(AppIcons.Key, "Sign-in", "Happens on Zepp's own page. OpenStrap keeps only the app token and user ID, and fetches your device list once.") + MaxLineLength:FirmwareScreen.kt:confirm?.let { image -> ConfirmInstall(image, vm.blockers(), onDismiss = { confirm = null }, onInstall = { confirm = null; vm.install(image) }) } + MaxLineLength:FirmwareScreen.kt:else + MaxLineLength:FirmwareScreen.kt:if + MaxLineLength:FirmwareScreen.kt:if (image.warnings.isNotEmpty()) PageSwitchRow("I understand and want to install it anyway", null, accepted, { accepted = it }, divider = false) + MaxLineLength:FirmwareScreen.kt:item { AccountPanel(account, enabled = !installing, onSave = vm::saveAccount, onClear = vm::clearAccount, onSignIn = vm::saveSignIn) } + MaxLineLength:FirmwareScreen.kt:private + MaxLineLength:FirmwareViewModel.kt:FirmwareViewModel$ReadyImage(bytes, type, "${part.label} ${part.version}", part.version, fromCloud = true, trailer, trailerWarnings(bytes, trailer, type)) + MaxLineLength:FirmwareViewModel.kt:FirmwareViewModel$private val busy get() = _step.value.let { it is FirmwareStep.Checking || it is FirmwareStep.Downloading || it is FirmwareStep.Installing } + MaxLineLength:FirmwareViewModel.kt:FirmwareViewModel$val fresh = if (rejected) ZeppCloud.refreshAppToken(acc) else runCatching { ZeppCloud.refreshAppToken(acc) }.getOrNull() ?: return acc + MaxLineLength:Frame.kt:battery <= StrapChip.LOW_BATTERY -> StrapChip("$battery%", StrapChip.Dot.Low, "Strap connected, battery low, $battery percent") + MaxLineLength:Frame.kt:connection.charging && battery != null -> StrapChip("Charging $battery%", StrapChip.Dot.Charging, "Strap charging, battery $battery percent") + MaxLineLength:Frame.kt:fun + MaxLineLength:Frame.kt:if (connection !is ConnectionState.Connected && sinceStart < STARTUP_GRACE_S) return StrapChip("", StrapChip.Dot.Quiet, "Connecting to the strap") + MaxLineLength:Glass.kt:1.dp + MaxLineLength:Glass.kt:Box + MaxLineLength:Glass.kt:Box(Modifier.fillMaxWidth(), contentAlignment = if (icon != null) Alignment.Center else Alignment.CenterStart) { title() } + MaxLineLength:Glass.kt:Surface(shape = shape, color = GlassTint.copy(alpha = 0.72f), border = GlassEdge, contentColor = Color.White, modifier = modifier, content = content) + MaxLineLength:Glass.kt:fun + MaxLineLength:GlowActivityScreen.kt:TabCopy.AUTO_DETECTED + MaxLineLength:GlowActivityScreen.kt:if (shown.isEmpty()) ListRow("No workouts yet", MetricColors.heart, null, "Workouts you record show up here", dash = false, divider = divider()) + MaxLineLength:GlowActivityScreen.kt:modifier = Modifier.border(1.dp, Color.White.copy(alpha = 0.19f), RoundedCornerShape(6.dp)).padding(horizontal = 6.dp, vertical = 1.dp) + MaxLineLength:GlowActivityScreen.kt:private + MaxLineLength:GlowActivityScreen.kt:state: DayUiState + MaxLineLength:GlowCard.kt:GlowCache.peek(GlowCache.Key(still, IntSize(w.roundToInt(), h.roundToInt()), density.density)) ?: GlowCache.latest(still, density.density) + MaxLineLength:GlowCard.kt:it.nativeCanvas.drawBitmap(first.core, null, first.coreIn(w, h), Paint(FILTER).apply { alpha = (lit.coreOpacity * 255).roundToInt().coerceIn(0, 255) }) + MaxLineLength:GlowCharts.kt:drawRoundRect(color.copy(alpha = 0.35f), Offset(x - sw - 6.dp.toPx(), labelH / 2 - 4.dp.toPx()), Size(sw, 8.dp.toPx()), CornerRadius(3.dp.toPx())) + MaxLineLength:GlowCharts.kt:drawRoundRect(color.copy(alpha = NormalBandAlpha), Offset(0f, y0), Size(size.width, yOf(b.start) - y0), CornerRadius(12.dp.toPx())) + MaxLineLength:GlowCharts.kt:fun + MaxLineLength:GlowCharts.kt:if + MaxLineLength:GlowCharts.kt:if (stage != null && lastStage != null && stage != lastStage) haptics.perform(org.openstrap.app.ui.components.HapticEvent.ScrubStep) + MaxLineLength:GlowCharts.kt:text(m, v.roundToInt().toString(), valueText.copy(color = if (today) Ink else Ink.copy(alpha = 0.75f)), cx, y - 20.dp.toPx(), align = 0.5f) + MaxLineLength:GlowCharts.kt:val g = hypnogramGeometry(stages, rowNames.size, size.width, size.height, density, nameW, axis = labels.isNotEmpty()) ?: return@Canvas + MaxLineLength:GlowCharts.kt:val g = lineChartGeometry(values, size.width, size.height, density, inset, axis = labels.isNotEmpty(), yRange = yRange, band = band) ?: return@Canvas + MaxLineLength:GlowCharts.kt:val i = x?.takeIf { values.isNotEmpty() && width > 0f }?.let { tx -> (tx / (width / values.size)).toInt().coerceIn(0, values.size - 1) } + MaxLineLength:GlowHealthspan.kt:Box(Modifier.fillMaxWidth().height(16.dp)) { ScaleLabel(org.openstrap.app.ui.dashboard.TabCopy.ageRange(low, high), x((low + high) / 2f), w, label) } + MaxLineLength:GlowHealthspan.kt:Box(Modifier.fillMaxWidth().height(16.dp)) { ScaleLabel(org.openstrap.app.ui.dashboard.TabCopy.yourAge(age), x(age.toFloat()), w, label) } + MaxLineLength:GlowHealthspan.kt:Healthspan.Habit.Steps + MaxLineLength:GlowHealthspan.kt:Orchid + MaxLineLength:GlowHealthspan.kt:Text(text, color = color, style = MaterialTheme.typography.labelSmall, modifier = Modifier.align(androidx.compose.ui.BiasAlignment(bias, 0f))) + MaxLineLength:GlowHealthspan.kt:drawLine(Color.White.copy(alpha = 0.15f), Offset(px(from.toFloat()), y), Offset(px(to.toFloat()), y), 6.dp.toPx(), StrokeCap.Round) + MaxLineLength:GlowHealthspan.kt:drawLine(Orchid.copy(alpha = 0.5f), Offset(px(low.toFloat()), y), Offset(px(high.toFloat()), y), 12.dp.toPx(), StrokeCap.Round) + MaxLineLength:GlowHealthspan.kt:if (v == null) return if (t.habit == Healthspan.Habit.Vo2max) "No VO2 max from workouts yet, counted as typical" else "Not enough data yet, counted as typical" + MaxLineLength:GlowHealthspan.kt:org.openstrap.app.ui.dashboard.TabCopy.habitAgeUpdated(dateOf(it.asOf)) + MaxLineLength:GlowHealthspan.kt:private + MaxLineLength:GlowHealthspanScreen.kt:drawLine(Color.White.copy(alpha = 0.35f), Offset(mid, y - 5.dp.toPx()), Offset(mid, y + 5.dp.toPx()), 1.5.dp.toPx(), StrokeCap.Round) + MaxLineLength:GlowHealthspanScreen.kt:if (pace != null) PaceScale(marker = (pace + 1f) / 4f, color = MetricColors.habitAge, Modifier.padding(start = RowIcon + RowIconGap, bottom = GlowLayout.RowPadding)) + MaxLineLength:GlowLayout.kt:Layout + MaxLineLength:GlowLayout.kt:androidx.compose.runtime + MaxLineLength:GlowLayout.kt:val w = (t.width + g + c.width).let { if (cs.hasBoundedWidth) it.coerceIn(cs.minWidth, cs.maxWidth) else it.coerceAtLeast(cs.minWidth) } + MaxLineLength:GlowList.kt:* + MaxLineLength:GlowList.kt:. + MaxLineLength:GlowList.kt:Icon(icon, null, tint = iconTint.copy(alpha = iconTint.alpha * if (waiting) WAITING_ICON_ALPHA else 1f), modifier = Modifier.size(RowIcon)) + MaxLineLength:GlowMetricDetail.kt:/** A day's value of [metric]: the live or latest heart rate, the strap's resting heart rate, the day's steps, else the average of its readings. */ + MaxLineLength:GlowMetricDetail.kt:DetailEmpty(if (metric == Metric.Breathing) "Breathing rate is measured during sleep." else "No ${inSentence(metric.title)} for this day yet.") + MaxLineLength:GlowMetricDetail.kt:DetailEmpty(if (session == null) SleepCopy.noNight(state.date == LocalDate.now()) else "No ${inSentence(metric.title)} readings while asleep.") + MaxLineLength:GlowMetricDetail.kt:DetailHeadline(metric.format(v), listOf(metric.unit, label).filter { it.isNotEmpty() }.joinToString(", "), DetailCopy.statusWord(History.status(v, normal))) + MaxLineLength:GlowMetricDetail.kt:DetailSibling$internal + MaxLineLength:GlowMetricDetail.kt:Modifier.fillMaxWidth().padding(top = 6.dp).clip(shape).background(Color(0xFF121214)).border(1.dp, Color.White.copy(alpha = 0.07f), shape).padding(4.dp) + MaxLineLength:GlowMetricDetail.kt:Modifier.size(size).clip(CircleShape).background(Color.White.copy(alpha = 0.10f)).clickable(onClickLabel = label, role = Role.Button, onClick = onClick) + MaxLineLength:GlowMetricDetail.kt:Range.Month -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { dates[((dates.size - 1) * it).toInt()].format(DateTimeFormatter.ofPattern("d MMM")) } + MaxLineLength:GlowMetricDetail.kt:Text("Live", color = if (on) Color.Black else Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, maxLines = 1) + MaxLineLength:GlowMetricDetail.kt:Text(d.label, color = if (d.selected) Color.White else Color.White.copy(alpha = 0.5f), style = GlowType.secondary, maxLines = 1) + MaxLineLength:GlowMetricDetail.kt:Text(headline.label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:GlowMetricDetail.kt:band = nightNormal?.let { it.low..it.high } + MaxLineLength:GlowMetricDetail.kt:drawRect(Brush.radialGradient(listOf(color.copy(alpha = 0.4f), Color.Transparent), Offset(size.width / 2, -size.height * 0.1f), size.width * 0.7f)) + MaxLineLength:GlowMetricDetail.kt:else + MaxLineLength:GlowMetricDetail.kt:if + MaxLineLength:GlowMetricDetail.kt:if (heroValue == null && headline.word != null && headline.value != null) StatusWord(headline.word, color, Modifier.padding(top = 12.dp)) + MaxLineLength:GlowMetricDetail.kt:night && range == Range.Day -> SleepCopy.nightNoValue(state.sleep != null, isToday).let { (word, why) -> DetailHeadline.none(word, why) } + MaxLineLength:GlowMetricDetail.kt:onScrub = { h -> onScrub(h?.let { DetailHeadline("%,d".format(Math.round(state.hourlySteps[it])), "steps, %02d:00 to %02d:00".format(it, (it + 1) % 24), null) }) } + MaxLineLength:GlowMetricDetail.kt:onScrub(i?.let { values[it] }?.let { v -> DetailHeadline(metric.format(v), "${metric.unit} at ${clock(window.first + i * bucket)}".trim(), null) }) + MaxLineLength:GlowMetricDetail.kt:private + MaxLineLength:GlowMetricDetail.kt:v?.let { DetailHeadline(metric.format(it), "${metric.unit}, ${d.format(DateTimeFormatter.ofPattern("EEE d MMM"))}", DetailCopy.statusWord(History.status(it, nightNormal))) } + MaxLineLength:GlowMetricDetail.kt:val label = if (night) SleepCopy.nightLabel(metric, range) else DetailCopy.valueLabel(metric, range, live = live != null, today = isToday) + MaxLineLength:GlowMetricDetail.kt:values + MaxLineLength:GlowParts.kt:. + MaxLineLength:GlowParts.kt:fun + MaxLineLength:GlowQuiet.kt:Text(title, color = Color.White, style = GlowType.cardTitle, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + MaxLineLength:GlowRecoveryScreen.kt:HeroFigure(figure.toString(), if (score != null) TodayCopy.recoveryWord(score, alert) else shown?.word.orEmpty(), big = true) + MaxLineLength:GlowRecoveryScreen.kt:ListPanel + MaxLineLength:GlowRecoveryScreen.kt:if + MaxLineLength:GlowRecoveryScreen.kt:if (score != null) ready.let { TabCopy.learning(it.phase, it.baselineNights) }?.let { CaveatLine(it, MetricColors.readiness) } + MaxLineLength:GlowRecoveryScreen.kt:if (top.isEmpty()) ListRow("Waiting for a night", MetricColors.readiness, null, "What drove Recovery shows here each morning", dash = false, divider = false) + MaxLineLength:GlowRecoveryScreen.kt:noSleep = isToday && state.loaded && state.asleepSeconds == null + MaxLineLength:GlowRecoveryScreen.kt:r: RecoveryResult? + MaxLineLength:GlowRecoveryScreen.kt:r?.charge + MaxLineLength:GlowRecoveryScreen.kt:ready.phase == Recovery.Phase.Withheld -> TodayCopy.noScoreLine(TodayCopy.NoScore.Learning(ready.baselineNights, Recovery.SCORED_NIGHTS)) + MaxLineLength:GlowSleepScreen.kt:Box(Modifier.weight(seconds.toFloat()).fillMaxHeight().background(stageColor(stage), RoundedCornerShape(StageBarLook.CORNER_DP.dp))) + MaxLineLength:GlowSleepScreen.kt:SleepCard.Naps -> NapsCard(state, if (pair) m else Modifier.fillMaxWidth().height(GlowGrid.UnitHeight)) { onOpen(Detail.OfScore(Score.Naps)) } + MaxLineLength:GlowSleepScreen.kt:SmallCard(MetricColors.sleepLight, "Breathing disturbances", "%.1f".format(b.perHour), "an hour", TabCopy.breathingShort(b), modifier, onClick = onInfo) + MaxLineLength:GlowSleepScreen.kt:breathing != null -> if (pair) BreathingSmall(breathing, m) { breathingInfo = true } else BreathingCard(breathing) { breathingInfo = true } + MaxLineLength:GlowSleepScreen.kt:color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA) + MaxLineLength:GlowSleepScreen.kt:if (firstNights) TabCopy.FIRST_NIGHT_SLEEP else TabCopy.noNight(state.naps.isNotEmpty(), isToday, deleted = deleted != null) + MaxLineLength:GlowSleepScreen.kt:pair -> SmallCard(MetricColors.sleepLight, "Breathing disturbances", null, null, AFTER_FIRST_NIGHT, m, waiting = true) + MaxLineLength:GlowSleepScreen.kt:private + MaxLineLength:GlowSleepScreen.kt:return spans.map { GlowStage((it.start - start).toFloat() / (end - start), (it.end - start).toFloat() / (end - start), rows[it.stage] ?: 0) } + MaxLineLength:GlowSleepScreen.kt:val slots = sleepCards(SleepFacts(breathing = breathing != null || firstNights, naps = state.naps.isNotEmpty(), hasNight = state.sleep != null)) + MaxLineLength:GlowTabletop.kt:GlowLineChart(values, height = GlowLayout.ChartHeight, labels = listOf("00", "06", "12", "18", "24")) { _, v -> "${v.toInt()} bpm" } + MaxLineLength:GlowTodayScreen.kt:"Drained ${org.openstrap.app.ui.dashboard.TabCopy.drainedAmount(c.drainedExercise + c.drainedStress + c.drainedLiving)}" + MaxLineLength:GlowTodayScreen.kt:BodyRow("Breathing rate", MetricColors.sleepLight, s.breathRate.avg()?.toFloat(), normal(Metric.Breathing), "", Metric.Breathing) + MaxLineLength:GlowTodayScreen.kt:BodyRow("Resting heart rate", MetricColors.heart, s.restingHeartRate?.toFloat(), normal(Metric.RestingHr), "", Metric.RestingHr) + MaxLineLength:GlowTodayScreen.kt:ChargeWideCard(ctx.charge, status, slot.secondGlow, { openScore(org.openstrap.app.ui.dashboard.Score.Charge) }, soFar = soFar, upTo = ctx.upTo(ctx.newest), tag = ctx.partial) + MaxLineLength:GlowTodayScreen.kt:HeroKind.Waking -> WakingHero(ctx, lightAngle, { onOpenScore(org.openstrap.app.ui.dashboard.Score.Recovery) }, onRetryRecovery, m) + MaxLineLength:GlowTodayScreen.kt:TodayCard.Recovery -> RecoveryEarlierCard(ctx.recoveryShown, { openScore(org.openstrap.app.ui.dashboard.Score.Recovery) }, failure = ctx.recoveryFailure, onRetry = onRetryRecovery) + MaxLineLength:GlowTodayScreen.kt:else -> TodayCopy.charge(now, c?.let { org.openstrap.app.ui.dashboard.TabCopy.drainedAmount(it.drainedExercise + it.drainedStress + it.drainedLiving).toInt() }, c?.anchored == true, LocalTime.now().hour) + MaxLineLength:GlowTodayScreen.kt:habitAgeNew(Instant.ofEpochSecond(r.asOf).atZone(zone).toLocalDate(), habitAgeSeenAsOf == r.asOf, state.date, now.atZone(zone).toLocalDate()) + MaxLineLength:GlowTodayScreen.kt:if + MaxLineLength:GlowTodayScreen.kt:if (ctx.state.loaded) HeroSentence(TodayCopy.waking(ctx.state.asleepSeconds, score, ctx.alert, ctx.noScore, ctx.firstNights, ctx.isToday, deleted = ctx.state.deletedSleep != null)) + MaxLineLength:GlowTodayScreen.kt:if (slot.size == CardSize.Small) Modifier.fillMaxSize() else Modifier + MaxLineLength:GlowTodayScreen.kt:onLoad = { openScore(org.openstrap.app.ui.dashboard.Score.Load) } + MaxLineLength:GlowTodayScreen.kt:onOpenWorkout + MaxLineLength:GlowTodayScreen.kt:private + MaxLineLength:GlowTodayScreen.kt:val dim = if (pairing != null && slot.card != TodayCard.Hero && slot.card != TodayCard.EditToday) Modifier.alpha(0.45f) else Modifier + MaxLineLength:GlowTodayScreen.kt:when { ctx.recoveryFailure != null -> FAILED_SHORT; ctx.rec == null -> ctx.waiting; else -> TodayCopy.loadWord(ctx.loadStatus) } + MaxLineLength:GlowType.kt:GlowType$val + MaxLineLength:GlowWorkoutMode.kt:Modifier.fillMaxSize().then(if (scroll) Modifier.imePadding().verticalScroll(rememberScrollState()) else Modifier).statusBarsPadding() + MaxLineLength:GlowWorkoutMode.kt:Note("$day ${clock(s.start)}–${clock(s.end)}, mostly zone ${s.zone + 1}, ${s.averageHeartRate} bpm on average. Nobody pressed Start.") + MaxLineLength:GlowWorkoutMode.kt:Note("Your heart rate has been up since ${clock(o.start)}, ${o.averageHeartRate} bpm on average. Record a ${Workouts.sportName(o.sportType).lowercase()} from ${clock(o.start)}?") + MaxLineLength:GlowWorkoutMode.kt:Text("Trimmed to start at ${clock(a.shownStart)}. The rest is kept.", color = Color.White.copy(alpha = 0.8f), style = GlowType.secondary, modifier = Modifier.weight(1f)) + MaxLineLength:GlowWorkoutMode.kt:WorkoutPage + MaxLineLength:GlowWorkoutMode.kt:drawLine(color, Offset(x(a.first), y(a.second.toFloat())), Offset(x(b.first), y(b.second.toFloat())), strokeWidth = 2.dp.toPx(), cap = StrokeCap.Round) + MaxLineLength:GlowWorkoutMode.kt:drawRoundRect(Color.Black.copy(alpha = 0.55f), Offset(gx - grip / 2, h / 2 - 8.dp.toPx()), Size(grip, 16.dp.toPx()), CornerRadius(grip / 2)) + MaxLineLength:GlowWorkoutMode.kt:else + MaxLineLength:GlowWorkoutMode.kt:if + MaxLineLength:GlowWorkoutMode.kt:if (seconds >= 3600) "%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) else "%d:%02d".format(seconds / 60, seconds % 60) + MaxLineLength:GlowWorkoutMode.kt:internal + MaxLineLength:GlowWorkoutMode.kt:modifier = Modifier.clip(RoundedCornerShape(50)).pressClickable(onUndoTrim, RoundedCornerShape(50)).padding(horizontal = 12.dp, vertical = 8.dp) + MaxLineLength:GlowWorkoutMode.kt:private + MaxLineLength:GlowWorkoutMode.kt:return if (zone == 0) "under ${zones.startBpm(1, maxHr)} bpm" else "${zones.startBpm(zone, maxHr)} to ${zones.endBpm(zone, maxHr) ?: maxHr} bpm" + MaxLineLength:GlowWorkoutScreen.kt:"Dimmed: " + a.historyRanges.joinToString(", ") { "${clockAt(it.first)}–${clockAt(it.last + 1)}" } + " came from the strap's history." + MaxLineLength:GlowWorkoutScreen.kt:. + MaxLineLength:GlowWorkoutScreen.kt:CaloriesCopy.inYourDay(WorkoutDetailCopy.sport(w.sportType), k, dayActiveKcal?.roundToInt(), CaloriesCopy.dayWord(date, LocalDate.now()), true) + MaxLineLength:GlowWorkoutScreen.kt:CaloriesCopy.inYourDay(WorkoutDetailCopy.sport(w.sportType), k, dayActiveKcal?.roundToInt(), CaloriesCopy.dayWord(date, today), false) + MaxLineLength:GlowWorkoutScreen.kt:CompositionLocalProvider + MaxLineLength:GlowWorkoutScreen.kt:EdgeValue(at.second.toString(), "bpm", if (at.first > a.end) "At ${clockAt(at.first)}, after the end" else "At ${clockAt(at.first)}") + MaxLineLength:GlowWorkoutScreen.kt:ListRow("No workouts yet", MetricColors.heart, null, "Workouts you start in OpenStrap show up here, and the strap's own after a sync.", dash = false, divider = false) + MaxLineLength:GlowWorkoutScreen.kt:Text(text, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 8.dp)) + MaxLineLength:GlowWorkoutScreen.kt:a.sportName + MaxLineLength:GlowWorkoutScreen.kt:else + MaxLineLength:GlowWorkoutScreen.kt:from = Instant.ofEpochSecond(s.workout.start).atZone(ZoneId.systemDefault()).toLocalTime().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + MaxLineLength:GlowWorkoutScreen.kt:if (w.trainingLoad != null || (a.recoveryMinutes ?: 0) > 0 || drop != null) LoadCard(a, usual, best = null, drop = drop, onInfo = { info = true }) + MaxLineLength:GlowWorkoutScreen.kt:val zones = remember(a) { WorkoutRecorder.liveZoneSeconds(a.heartRate.filter { it.timestamp <= a.end }.map { it.timestamp to it.bpm }, a.maxHeartRate) } + MaxLineLength:GlowWorkoutSummary.kt:"Lap ${lap.index + 1}" + MaxLineLength:GlowWorkoutSummary.kt:"Zone ${z + 1}" + MaxLineLength:GlowWorkoutSummary.kt:CompositionLocalProvider + MaxLineLength:GlowWorkoutSummary.kt:FamilySummary.whenLine(a.shownStart, System.currentTimeMillis() / 1000) + MaxLineLength:GlowWorkoutSummary.kt:GoalMomentScreen(rewards.week.count, rewards.week.goal, rewards.inARow, starts, contentPadding, onSettings = { settings = true }, onContinue = done) + MaxLineLength:GlowWorkoutSummary.kt:Modifier.fillMaxWidth(best.toFloat() / seconds[i]).fillMaxHeight().clip(CircleShape).background(if (lit) Color.White else bar) + MaxLineLength:GlowWorkoutSummary.kt:Route.splits(route, summary.pauses) + MaxLineLength:GlowWorkoutSummary.kt:Text("bpm now", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) + MaxLineLength:GlowWorkoutSummary.kt:Text(hero.unit, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) + MaxLineLength:GlowWorkoutSummary.kt:Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 4.dp)) + MaxLineLength:GlowWorkoutSummary.kt:Text(r.time, color = Color.White, style = GlowType.secondary.copy(fontWeight = FontWeight.SemiBold), maxLines = 1, modifier = Modifier.padding(start = 14.dp)) + MaxLineLength:GlowWorkoutSummary.kt:celebrations.goalMoment + MaxLineLength:GlowWorkoutSummary.kt:drop?.let { Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 4.dp)) } + MaxLineLength:GlowWorkoutSummary.kt:else + MaxLineLength:GlowWorkoutSummary.kt:hero.line?.let { Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 6.dp)) } + MaxLineLength:GlowWorkoutSummary.kt:note = if (rows.isNotEmpty()) fastest?.let { "Fastest: ${rows[it].name.lowercase()}" } ?: summary.distanceMeters?.let { km(it) + if (it >= 1000) " km" else "" } else "${laps.size}" + MaxLineLength:GlowWorkoutSummary.kt:r.name + MaxLineLength:GlowWorkoutSummary.kt:trailingContent = { Spacer(Modifier.width(10.dp)); RoundButton(AppIcons.Info, "About the heart rate drop", onInfo, size = 32.dp) } + MaxLineLength:GlowWorkoutSummary.kt:val moment = rewards != null && celebrations != null && rewards.goalMet && celebrations.goalMoment && celebrations.momentWeek != rewards.weekStart + MaxLineLength:GoalsAndBests.kt:"Badges, ${view.earned.size} of ${Rewards.BADGES.size}" + MaxLineLength:GoalsAndBests.kt:"This week" + MaxLineLength:GoalsAndBests.kt:BadgeRow(earned.take(if (next != null) 3 else 4) + listOfNotNull(next), locked = { b -> view.earned.none { it.id == b.id } }, face = view::face) + MaxLineLength:GoalsAndBests.kt:RewardsCopy.bestTitle(b.kind) + MaxLineLength:GoalsAndBests.kt:Text("of $goal workouts", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) + MaxLineLength:GoalsAndBests.kt:b to (earned[b.id]?.let { shortDate(it.earnedAt) } ?: view.progress[b.id]?.let { (have, need) -> RewardsCopy.progressLine(have, need, b.unit) }) + MaxLineLength:GoalsAndBests.kt:color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA) + MaxLineLength:GoalsAndBests.kt:if (run > 0) "Weekly streak: $run ${if (run == 1) "week" else "weeks"} in a row" else "${(goal - count).coerceAtLeast(0)} short of your goal" + MaxLineLength:GoalsAndBests.kt:if (shown.isEmpty()) Text("Your bests show here after your first workouts.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(vertical = 10.dp)) + MaxLineLength:GoalsAndBests.kt:if (week?.met == true && run > 0) StoryChip("$run ${if (run == 1) "week" else "weeks"} in a row", best = false, met = true) else null + MaxLineLength:GoalsAndBests.kt:return (0L until 7L).map { first.plusDays(it).let { d -> d.dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.getDefault()) to (d in days) } } + MaxLineLength:GoalsAndBests.kt:val earned = view.earned.sortedByDescending { it.earnedAt }.take(5).mapNotNull { r -> Rewards.badge(r.id)?.let { it to shortDate(r.earnedAt) } } + MaxLineLength:GoalsAndBests.kt:val next = view.next?.let { b -> view.progress[b.id]?.let { (have, need) -> b to RewardsCopy.progressLine(have, need, b.unit) } } + MaxLineLength:GoalsSheet.kt:"Save" + MaxLineLength:GoalsSheet.kt:* + MaxLineLength:GoalsSheet.kt:. + MaxLineLength:GoalsSheet.kt:Box + MaxLineLength:GoalsSheet.kt:Modifier.fillMaxWidth().focusRequester(focus).onFocusChanged { if (it.isFocused) focused = true else if (focused) commit() } + MaxLineLength:GoalsSheet.kt:RoundButton(AppIcons.Minus, "Lower the calorie goal", { typing = false; refused = false; onChange(CalorieGoal.step(value, -Goals.CALORIES_STEP)) }, size = 44.dp) + MaxLineLength:GoalsSheet.kt:RoundButton(AppIcons.Plus, "Raise the calorie goal", { typing = false; refused = false; onChange(CalorieGoal.step(value, Goals.CALORIES_STEP)) }, size = 44.dp) + MaxLineLength:GrowFromButton.kt:Box(Modifier.fillMaxSize().graphicsLayer { alpha = if (origin == null) 1f else GrowMotion.contentAlpha(anim.value) }) { content(!visible) } + MaxLineLength:GrowFromButton.kt:GrowMotion$fun + MaxLineLength:HealthConnectExporter.kt:HealthConnectExporter$SleepSessionRecord.STAGE_TYPE_AWAKE, SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED, SleepSessionRecord.STAGE_TYPE_OUT_OF_BED -> SleepStage.AWAKE + MaxLineLength:HealthConnectExporter.kt:HealthConnectExporter$client.deleteRecords(SleepSessionRecord::class, TimeRangeFilter.between(Instant.ofEpochSecond(from - 86_400), Instant.ofEpochSecond(to))) + MaxLineLength:HealthConnectExporter.kt:HealthConnectExporter$fun minutes + MaxLineLength:HealthConnectExporter.kt:HealthConnectExporter$private fun meta + MaxLineLength:HealthConnectExporter.kt:HealthConnectExporter$val hr = read(HeartRateRecord::class).flatMap { r -> r.samples.map { HeartRateSample(it.time.epochSecond, it.beatsPerMinute.toInt()) } } + MaxLineLength:HealthConnectPage.kt:PageRow + MaxLineLength:HealthConnectPage.kt:PageRow("Copy now", "The last 30 days, to Android Health Connect", onClick = onCopyNow, below = true, divider = false, icon = AppIcons.Heartbeat) + MaxLineLength:HealthConnectPage.kt:PageRow("Restore", "Bring back what OpenStrap saved there", onClick = onRestore, below = true, icon = AppIcons.ArrowCounterClockwise) + MaxLineLength:HealthRepository.kt:HealthRepository$combine(dao.stress(w.from, w.to), stressExcluded(w.from, w.to)) { s, excluded -> s.filter { Recovery.stressCounts(it.timestamp, excluded) } } + MaxLineLength:HealthRepository.kt:HealthRepository$fun + MaxLineLength:HealthRepository.kt:HealthRepository$r.energy?.let { dao.insertDailyEnergy(listOf(DailyEnergyHistory.row(DayWindow.of(date).from, it, System.currentTimeMillis() / 1000))) } + MaxLineLength:Healthspan.kt:Healthspan$private + MaxLineLength:Healthspan.kt:Healthspan$return Value.of(VO2_WEIGHT * VO2_PER_MET * mets, VO2_WEIGHT * VO2_PER_MET_LOW * mets, VO2_WEIGHT * VO2_PER_MET_HIGH * mets, -VO2_LIMIT, VO2_LIMIT) + MaxLineLength:Healthspan.kt:Healthspan$val young = (interp(STEPS_UNDER_60, steps) - interp(STEPS_UNDER_60, STEPS_REF_UNDER_60)).coerceIn(-STEPS_LIMIT_YOUNG, STEPS_LIMIT_YOUNG) + MaxLineLength:Healthspan.kt:Healthspan.Result$val + MaxLineLength:Healthspan.kt:Healthspan.Terms$return Value(fitness.years + rest.sumOf { it.years.toDouble() }.toFloat(), fitness.low + rest.sumOf { it.low.toDouble() }.toFloat(), fitness.high + rest.sumOf { it.high.toDouble() }.toFloat()) + MaxLineLength:HealthspanEngine.kt:HealthspanEngine$result + MaxLineLength:HealthspanEngine.kt:HealthspanEngine$result?.takeIf { sunday == sundayOf(LocalDate.now(zone)) }?.let { dao.insertHabitAgeWeek(HabitAgeWeekRow(asOf, it.estimate, it.low, it.high, System.currentTimeMillis() / 1000)) } + MaxLineLength:HealthspanEngine.kt:HealthspanEngine$val + MaxLineLength:HeartZonesCopy.kt:HeartZonesCopy$MaxHeartRate.Source.Learned -> "Learned from your hardest efforts" + (m.learnedOn?.let { ", " + it.format(DateTimeFormatter.ofPattern("d MMM", locale)) } ?: "") + MaxLineLength:HeartZonesCopy.kt:HeartZonesCopy$fun + MaxLineLength:HeartZonesScreen.kt:0f to MetricColors.heart.copy(alpha = 0.55f) + MaxLineLength:HeartZonesScreen.kt:BpmStepper(HeartZonesCopy.ENDS_AT, end, { onZones(zones.withEnd(zone, end - 1, maxHr)) }, { onZones(zones.withEnd(zone, end + 1, maxHr)) }) + MaxLineLength:HeartZonesScreen.kt:BpmStepper(HeartZonesCopy.STARTS_AT, start, { onZones(zones.withStart(zone, start - 1, maxHr)) }, { onZones(zones.withStart(zone, start + 1, maxHr)) }) + MaxLineLength:HeartZonesScreen.kt:DotMatrixText(max?.bpm?.toString() ?: "0", dot = DotSize.Hero, color = if (max != null) Color.White else Color.White.copy(alpha = 0.2f)) + MaxLineLength:HeartZonesScreen.kt:PageButton(HeartZonesCopy.RESET, { onZones(HeartZones.STANDARD) }, Modifier.fillMaxWidth(), icon = AppIcons.ArrowCounterClockwise) + MaxLineLength:HeartZonesScreen.kt:PageButton(HeartZonesCopy.SAVE, { onMax(value) }, Modifier.weight(1f), enabled = value != null, primary = true, icon = AppIcons.Tick02) + MaxLineLength:HeartZonesScreen.kt:Text("$bpm bpm", color = Color.White, style = GlowType.sentence, modifier = Modifier.width(110.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center) + MaxLineLength:HeartZonesScreen.kt:ZoneRow(z, if (max != null) HeartZonesCopy.zoneBpm(zones, z, max.bpm) else "", divider = z > 0) { if (max != null) editing = z } + MaxLineLength:HeartZonesScreen.kt:if (max != null) Text(HeartZonesCopy.source(max), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:HeartZonesScreen.kt:if (max.source == MaxHeartRate.Source.Set) PageButton(HeartZonesCopy.LEARN_IT, { onMax(null) }, Modifier.weight(1f), icon = AppIcons.ArrowCounterClockwise) + MaxLineLength:HeartZonesScreen.kt:item { Text(HeartZonesCopy.NOTE, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(horizontal = 4.dp)) } + MaxLineLength:HeartZonesScreen.kt:text + MaxLineLength:History.kt:History$DayStat(monday, days.map { it.value }.average().toFloat(), days.minOf { it.value }, days.maxOf { it.value }, days.sumOf { it.count }) + MaxLineLength:HistorySync.kt:HistorySync$StrapReadinessRow(it.day, it.updatedAt, it.score, it.hrvScore, it.restingHrScore, it.skinTemperatureScore, it.breathingScore, it.sleepHrv, it.sleepRestingHr, it.algorithmVersion) + MaxLineLength:HistorySync.kt:HistorySync$else + MaxLineLength:HistorySync.kt:HistorySync$if + MaxLineLength:HistorySync.kt:HistorySync$if (!yours && Workouts.notifyAutoDetected(row, storedBefore, now)) runCatching { onAutoDetected(row) }.onFailure { log("auto-detect notification: $it") } + MaxLineLength:HistorySync.kt:HistorySync$if (only == null && session.services.has(Endpoints.READINESS)) runCatching { syncReadiness() }.onFailure { log("readiness: $it") } + MaxLineLength:HistorySync.kt:HistorySync$is OsaResult -> osaResults += OsaResultRow(r.timestamp, r.odiValidTime, r.odiRefined, r.odiScore, r.odiDispCode, r.odNumRefined, r.version) + MaxLineLength:HistorySync.kt:HistorySync$is RecoveryHeartRate -> if (r.samples.isNotEmpty()) recoveryHr += RecoveryHeartRateRow(r.trackId, r.samples.map { it.toByte() }.toByteArray()) + MaxLineLength:HistorySync.kt:HistorySync$is RestHrRealtime -> if (r.realtimeRestHr in 30..120) restingIfMissing += RestingHeartRate(dayStart(r.timestamp), r.realtimeRestHr) + MaxLineLength:HistorySync.kt:HistorySync$launch { gatt.notifications.filter { it.characteristic == HISTORY_CONTROL }.collect { control.send(FetchCodec.parse(it.value)) } } + MaxLineLength:HistorySync.kt:HistorySync$launch { session.messagesFor(Endpoints.HISTORY).collect { log("gdsp < ${it.toHex()}"); control.send(FetchCodec.parse(it)) } } + MaxLineLength:HistorySync.kt:HistorySync$log("history ${spec.type}: ${payload.size} bytes (${chunk.sizeField}×$unit) from ${Instant.ofEpochSecond(headerTime)}: ${payload.copyOf(minOf(32, payload.size)).toHex()}") + MaxLineLength:HistorySync.kt:HistorySync$log("history workout ${Instant.ofEpochSecond(w.summary.trackId)}: type ${w.summary.sportType}, subtype ${w.summary.subtype}, ${w.detail?.heartRate?.size ?: 0} HR samples") + MaxLineLength:HistorySync.kt:HistorySync$runCatching { org.openstrap.app.data.WorkoutEffectEngine(dao).takeStrapEffects(System.currentTimeMillis() / 1000 - WORKOUT_LOOKBACK) } + MaxLineLength:HistorySync.kt:HistorySync$val parsed = spec?.parser?.parse(payload, ParseContext(header, activityRecordSize = recordSize(code, payload, chunk.sizeField))) + MaxLineLength:HistorySync.kt:HistorySync$val reason = if (!gatt.connected.value) "The link to the strap dropped." else e.message?.takeUnless { e.cause != null && it == e.cause.toString() } + MaxLineLength:HistorySync.kt:HistorySync$val reply = session.request(Endpoints.READINESS, ReadinessCodec.scoreRequest(since), 8_000) { it.isNotEmpty() && it.u8(0) == ReadinessCodec.SCORE + 1 } + MaxLineLength:HistorySync.kt:HistorySync$when + MaxLineLength:InfoCopy.kt:InfoCopy$"${(Activity.HR_VIGOROUS_HRR * 100).toInt()}%. A vigorous minute counts double, as in the World Health Organization's 2020 guidelines." + MaxLineLength:InfoCopy.kt:InfoCopy$"Active kcal are what moving burned on top of your body's use at rest. The total adds that rest. Both are estimates, in kilocalories (kcal)." + MaxLineLength:InfoCopy.kt:InfoCopy$"Minutes of brisk movement or harder over the last 7 days, against the ${Activity.MVPA_WEEKLY_TARGET} a week health guidelines aim for." + MaxLineLength:InfoCopy.kt:InfoCopy$"Points for the minutes your heart rate is raised, harder minutes earning more, over the last 7 days. Aim for ${ActivityCopy.HEART_POINTS_TARGET}." + MaxLineLength:InfoCopy.kt:InfoCopy$e.sex?.let { add("That's typical of a ${if (it == org.openstrap.app.data.settings.UserProfile.Sex.Male) "man" else "woman"} aged about ${a.first} to ${a.last}.") } + MaxLineLength:InfoCopy.kt:InfoCopy$fun + MaxLineLength:InfoCopy.kt:InfoCopy$if (normal == null) return "Your normal shows after ${History.MIN_NORMAL_DAYS} $word with data. ${days.coerceAtMost(History.MIN_NORMAL_DAYS)} so far." + MaxLineLength:InfoCopy.kt:InfoCopy$ready.fallbackRestingHr?.let { add("Last night's own resting heart rate wasn't clean enough, so the strap's ${it.toInt()} bpm stands in.") } + MaxLineLength:InfoCopy.kt:InfoCopy$reg.midpointSdMinutes?.let { add("Meanwhile the middle of your sleep moved about ${TodayCopy.duration(Math.round(it) * 60L)} this week.") } + MaxLineLength:InfoSheet.kt:. + MaxLineLength:InfoSheet.kt:Modifier.padding(top = 24.dp).fillMaxWidth().background(Color(0xFF141416), GlowLayout.CardShape).padding(horizontal = 18.dp) + MaxLineLength:InfoSheet.kt:fun widest + MaxLineLength:Insights.kt:/* Black glass: what passes under is blurred and darkened until only its colour shows; a light catching the top edge, shade along the bottom. */ + MaxLineLength:LightSpec.kt:GlossSpec(false, listOf(0f to Color.White.copy(alpha = 0.04f), 0.75f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.35f))) + MaxLineLength:LightSpec.kt:GlossSpec(false, listOf(0f to Color.White.copy(alpha = 0.35f), 0.25f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.04f))) + MaxLineLength:LightSpec.kt:GlossSpec(true, listOf(0f to Color.White.copy(alpha = 0.35f), 0.25f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.04f))) + MaxLineLength:LightSpec.kt:rims += InsetShadow(-vx * g * 0.6f, -vy * g * 0.6f, blur = g * 1.2f, spread = -g * 0.5f, p.light.copy(alpha = k.coerceAtMost(1f))) + MaxLineLength:LiveDistance.kt:"${org.openstrap.app.ui.workout.StartCopy.structureName(structure)}, ${org.openstrap.app.ui.workout.StartCopy.restText(structure.rest)} rest" + MaxLineLength:LiveDistance.kt:Column + MaxLineLength:LiveDistance.kt:Modifier.clip(RoundedCornerShape(50)).background(org.openstrap.app.ui.glow.Raised).padding(horizontal = 14.dp, vertical = 9.dp) + MaxLineLength:LiveDistance.kt:Text("Rep ${round.index + 1}", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + MaxLineLength:LiveDistance.kt:Text(if (speed) "Speed" else "Pace", color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:LiveDistance.kt:Text(if (speed) "km/h" else "/km", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, maxLines = 1) + MaxLineLength:LiveDistance.kt:if (line != null) Text(line, color = Color.White, style = GlowType.sentence, textAlign = androidx.compose.ui.text.style.TextAlign.Center) + MaxLineLength:LiveDistance.kt:is org.openstrap.app.data.Phase.Work -> m.nextRest?.let { "Then rest ${org.openstrap.app.ui.workout.StartCopy.restText(it)}" } ?: "Last rep" + MaxLineLength:LiveDistance.kt:private + MaxLineLength:LiveDistance.kt:r + MaxLineLength:LiveDistance.kt:when { work -> "$repName rep"; rest -> "Rest"; m.phase == org.openstrap.app.data.Phase.WarmUp -> "Warming up"; else -> "Done" } + MaxLineLength:LiveDistance.kt:} + MaxLineLength:LiveFrame.kt:Icon(if (r.paused) AppIcons.PlayFill else AppIcons.PauseFill, label, tint = Color.White, modifier = Modifier.size(20.dp)) + MaxLineLength:LiveFrame.kt:Icon(if (r.paused) AppIcons.PlayFill else AppIcons.PauseFill, pauseLabel, tint = Color.Black, modifier = Modifier.size(30.dp)) + MaxLineLength:LiveFrame.kt:Row + MaxLineLength:LiveFrame.kt:WorkoutButton("Pause", Raised, Color.White, controls.onPause, Modifier.weight(1f), icon = AppIcons.PauseFill, height = 52.dp) + MaxLineLength:LiveFrame.kt:WorkoutButton("Resume", Color.White, Color.Black, controls.onResume, Modifier.weight(1f), icon = AppIcons.PlayFill, height = 64.dp) + MaxLineLength:LiveFrame.kt:else + MaxLineLength:LiveFrame.kt:if + MaxLineLength:LiveFrame.kt:if (!connected) Note("The strap isn't connected. Recording carries on, and the minutes missed are filled from its own history after it syncs.") + MaxLineLength:LiveFrame.kt:internal + MaxLineLength:LiveFrame.kt:internal val LiveFigure = GlowType.sentence.copy(fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.02).em, fontFeatureSettings = "tnum") + MaxLineLength:LiveFrame.kt:val side = org.openstrap.app.ui.glow.GlowLayout.pageSide(maxWidth, org.openstrap.app.ui.glow.LocalPageColumn.current, org.openstrap.app.ui.glow.GlowLayout.Gutter) + MaxLineLength:LiveGame.kt:Box(Modifier.weight(1f).height(8.dp).clip(RoundedCornerShape(50)).background(if (on) ZONE_COLORS[i] else Color.Black.copy(alpha = 0.35f))) + MaxLineLength:LiveGame.kt:if (bpm != null) DotMatrixText("$bpm", dot = DotSize.Detail) else Text(WorkoutCopy.noHeartRate(r.paused, connected), color = Color.White, style = GlowType.sentence) + MaxLineLength:LiveGame.kt:if (periods != null && phase is Phase.Rest) WorkoutButton("Start ${StartCopy.periodName(phase.round + 1, periods.rounds).lowercase()}", Color.White, Color.Black, onHalfTime, icon = AppIcons.PlayFill, height = 64.dp) + MaxLineLength:LiveGame.kt:if (periods != null && phase is Phase.Work) WorkoutButton(StartCopy.breakName(periods.rounds), Color.White, Color.Black, onHalfTime, icon = AppIcons.PauseFill, height = 64.dp) + MaxLineLength:LiveGame.kt:workoutClock((WorkoutRecorder.activeSeconds(r.workout, now()) - WorkoutRecorder.activeSeconds(r.workout, phase.start)).coerceAtLeast(0)) + MaxLineLength:LiveGlance.kt:Glance$action = if (m.everyMinute) GlanceAction.Skip else GlanceAction.Pause + MaxLineLength:LiveGlance.kt:Glance$figure = "${i.activeSeconds / 60}" + MaxLineLength:LiveGlance.kt:Glance$figure = figure + MaxLineLength:LiveGlance.kt:Glance$goal = listOfNotNull(heart?.let { "$it." }, s.restBelow?.let { "Ends sooner once you stay under $it." }).joinToString(" ").ifEmpty { null } + MaxLineLength:LiveGlance.kt:Glance$line1 = "All ${m.rounds} ${word.lowercase()}s done" + MaxLineLength:LiveGlance.kt:Glance$line1 = "Warming up, ${base.clock}" + MaxLineLength:LiveGlance.kt:Glance$line2 + MaxLineLength:LiveGlance.kt:Glance$progress = s.restTotal?.takeIf { it > 0 }?.let { GlanceProgress((s.restLeft.toFloat() / it).coerceIn(0f, 1f), drain = true) } + MaxLineLength:LiveGlance.kt:Glance$tint = GlanceTint.Asking + MaxLineLength:LiveGlance.kt:Glance$val done = if (distance && i.distanceMeters != null) "$sport, ${kmWithUnit(i.distanceMeters)} so far" else "$sport, $clock so far" + MaxLineLength:LiveGlance.kt:internal + MaxLineLength:LiveGlance.kt:val ran = route.filter { it.t >= p.start }.takeIf { it.size > 1 }?.let { Route.distance(it, r.workout.pauseRanges).toFloat() } ?: 0f + MaxLineLength:LiveHeart.kt:LiveDistanceScreen(r, liveHeartRate, recent, stats, connection, contentPadding, controls, route, history, onLap, liveView, onLiveView, kcal, onSkip, split, compact) + MaxLineLength:LiveHeart.kt:LiveGameScreen(r, liveHeartRate, WorkoutCopy.readings(history, recent), stats, zoneSeconds, connection, contentPadding, controls, kcal, onSkip, split, compact) + MaxLineLength:LiveHeart.kt:LiveIntervalsScreen(r, liveHeartRate, WorkoutCopy.readings(history, recent), connection, contentPadding, controls, onSkip, onAddRest, split, compact) + MaxLineLength:LiveHeart.kt:WorkoutHeartCard(r, liveHeartRate, recent, zoneSeconds, connected, activeSeconds(r), Modifier.fillMaxSize(), fill = true, kcal = kcal?.active) + MaxLineLength:LiveHeart.kt:drawArc(ZONE_COLORS[z].copy(alpha = if (on) 1f else 0.22f), start, seg, false, topLeft, box, style = Stroke(stroke, cap = StrokeCap.Round)) + MaxLineLength:LiveHeart.kt:else -> LiveIndoorScreen(r, liveHeartRate, recent, stats, zoneSeconds, connection, contentPadding, controls, onLap, kcal, split, compact) + MaxLineLength:LiveHeart.kt:family == Workouts.Family.MindBody -> LiveMindScreen(r, liveHeartRate, recent, connection, contentPadding, controls, split, compact) + MaxLineLength:LiveHeart.kt:if (on) drawArc(ZONE_COLORS[z].copy(alpha = 0.3f), start, seg, false, topLeft, box, style = Stroke(stroke * 2.6f, cap = StrokeCap.Round)) + MaxLineLength:LiveHeart.kt:return org.openstrap.app.data.Summaries.bucketMeans(recent, (WINDOW_S / BUCKET_S).toInt(), { ((it.first - from) / BUCKET_S).toInt() }, { it.second.toFloat() }) + MaxLineLength:LiveIndoor.kt:else + MaxLineLength:LiveIndoor.kt:if (r.structure is org.openstrap.app.data.Structure.Reps) RepCard(r, emptyList(), recent, liveHeartRate.takeIf { connected && !r.paused }) + MaxLineLength:LiveIntervals.kt:Column + MaxLineLength:LiveIntervals.kt:Icon(if (label == "Done") AppIcons.Tick02 else AppIcons.SkipFill, label, tint = Color.White, modifier = Modifier.size(20.dp)) + MaxLineLength:LiveIntervals.kt:Modifier.fillMaxWidth().height(56.dp).semantics { contentDescription = "Peak heart rate per round: " + peaks.filterNotNull().joinToString(", ") } + MaxLineLength:LiveIntervals.kt:Phase.WarmUp -> PhaseFigure("Warm up", "${m.rounds} rounds after it", "Warming up", null, "Start the rounds when you're ready", compact) + MaxLineLength:LiveIntervals.kt:Row + MaxLineLength:LiveIntervals.kt:Text(peaks.filterNotNull().joinToString(", ") + " bpm", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:LiveIntervals.kt:if (line.isNotEmpty()) Text(line, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, textAlign = TextAlign.Center) + MaxLineLength:LiveIntervals.kt:is Phase.Rest -> if (!compact) WorkoutButton("+10 s", OverGlow, Color.White, onAddRest, Modifier.align(Alignment.CenterHorizontally).width(140.dp), height = 52.dp) + MaxLineLength:LiveIntervals.kt:is Phase.Work -> if (m.everyMinute) WorkoutButton("Done", Color.White, Color.Black, onSkip, icon = AppIcons.Tick02, height = tall) + MaxLineLength:LiveIntervals.kt:val h = if (p == null) 4.dp.toPx() else ((p - low).toFloat() / (top - low).coerceAtLeast(1)).coerceIn(0.15f, 1f) * size.height + MaxLineLength:LiveIntervals.kt:where: String + MaxLineLength:LiveIntervals.kt:zone?.let { Spacer(Modifier.width(16.dp)); Text(WorkoutCopy.zoneLine(it), color = Color.White, style = GlowType.secondary, maxLines = 1) } + MaxLineLength:LiveMap.kt:Box(Modifier.size(6.dp).clip(CircleShape).background(if (acc != null && acc <= Route.MAX_ACCURACY_M) TrendGood else TrendBad)) + MaxLineLength:LiveMap.kt:MapButton(AppIcons.MapFold, if (routeOnly) "Show the map" else "Show route only, no map", lit = routeOnly) { routeOnly = !routeOnly } + MaxLineLength:LiveMap.kt:RoundIcon(icon, label, onClick, surface = if (lit) Color.White else OverMap, ink = if (lit) Color.Black else Color.White, iconSize = 20.dp) + MaxLineLength:LiveMap.kt:Text(line, color = Color.White, style = GlowType.body.copy(fontFeatureSettings = "tnum"), maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:LiveMind.kt:LiveFrame + MaxLineLength:LivePaused.kt:internal + MaxLineLength:LiveSets.kt:Box(Modifier.size(14.dp).clip(CircleShape).background(if (i < done || now) Color.White else Color.Black.copy(alpha = 0.35f))) + MaxLineLength:LiveSets.kt:Box(Modifier.weight(1f)) { WorkoutButton("Start set ${moment.setNumber}", Color.White, Color.Black, onStartSet, icon = AppIcons.PlayFill, height = tall) } + MaxLineLength:LiveSets.kt:Column + MaxLineLength:LiveSets.kt:LogStepper("Reps", reps.toString(), "Fewer reps", "More reps", Modifier.weight(1f), { reps = Strength.stepReps(reps, -1) }, { reps = Strength.stepReps(reps, 1) }) + MaxLineLength:LiveSets.kt:LogStepper("kg", Strength.kgText(kg), "Less weight", "More weight", Modifier.weight(1f), { kg = Strength.stepKg(kg, -1) }, { kg = Strength.stepKg(kg, 1) }) + MaxLineLength:LiveSets.kt:Modifier.fillMaxWidth().clip(GlowLayout.CardShape).border(1.5.dp, Color.White.copy(alpha = 0.2f), GlowLayout.CardShape).padding(20.dp) + MaxLineLength:LiveSets.kt:Text("All $target sets done. Hold the square to end, or keep going.", color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + MaxLineLength:LiveSets.kt:Text("Filled from your last set. Change only what's different.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:LiveSets.kt:Text("after " + WorkoutCopy.setOf(done, r.workout.setsTarget).lowercase(), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:LiveSets.kt:Text(WorkoutCopy.restRule(rule.belowBpm), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:LiveSets.kt:Text(WorkoutCopy.setLine(s, Strength.peak(history, s.start, s.end!!)), color = Color.White.copy(alpha = 0.72f), style = GlowType.body, maxLines = 1, overflow = TextOverflow.Ellipsis) + MaxLineLength:LiveSets.kt:Text(WorkoutCopy.setOf(number, r.workout.setsTarget) + " next. Tap Start when you pick up the weight.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:LiveSets.kt:Text(WorkoutCopy.setOf(number, r.workout.setsTarget), color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:LiveSets.kt:Text(if (number == 1) "Ready for set 1" else "Rest over", color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:LiveSets.kt:drawArc(RestGreen, -90f, 360f * (left.toFloat() / total).coerceIn(0f, 1f), false, inset, arc, style = Stroke(w, cap = StrokeCap.Round)) + MaxLineLength:LiveSets.kt:if (bpm != null) Text("$bpm bpm" + (drop?.takeIf { it > 0 }?.let { ", down $it" } ?: ""), color = Color.White, style = GlowType.sentence) + MaxLineLength:LiveSets.kt:if (naming) ExerciseSheet(r.exercise, exercises.map { it.name }, onPick = { onExercise(it); naming = false }, onDismiss = { naming = false }) + MaxLineLength:LiveSets.kt:if (typed.isEmpty()) Text("Type a name", color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.body) + MaxLineLength:LiveSets.kt:if (typed.isNotBlank()) Icon(AppIcons.Tick02, "Use this name", tint = Color.White, modifier = Modifier.size(28.dp).clip(CircleShape).pressClickable({ onPick(typed) }, CircleShape).padding(4.dp)) + MaxLineLength:LiveSets.kt:names.forEachIndexed { i, name -> PageRow(name, value = if (name.equals(current, ignoreCase = true)) "Now" else null, divider = i > 0, onClick = { onPick(name) }) } + MaxLineLength:LiveSets.kt:private + MaxLineLength:LiveSets.kt:r.inSet -> WorkoutButton("Set done", Color.White, Color.Black, { haptics.perform(HapticEvent.SetDone); onSetDone() }, icon = AppIcons.Tick02, height = tall) + MaxLineLength:LiveSets.kt:r.resting -> "Rest after " + WorkoutCopy.setOf(moment.done, r.workout.setsTarget).lowercase() to workoutClock((moment.restLeft ?: 0).coerceAtLeast(0)) + MaxLineLength:MainActivity.kt:) + MaxLineLength:MainActivity.kt:*(if (page == tab) emptyArray() else arrayOf(androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner provides idleBack)) + MaxLineLength:MainActivity.kt:CompositionLocalProvider + MaxLineLength:MainActivity.kt:LaunchedEffect(org.openstrap.app.update.Releases.newer(updates.releases, updater.installed).firstOrNull()?.tag) { updater.markSeen() } + MaxLineLength:MainActivity.kt:MainActivity$((application as OpenStrapApp).container.strap as? org.openstrap.app.device.StrapManager)?.simulate(script, intent.getIntExtra("simulate_steps", 0), intent.getIntExtra("simulate_battery", 80)) + MaxLineLength:MainActivity.kt:MainActivity$// Debug: `--es simulate_hr 140x120,80x600 --ei simulate_steps 0 --ei simulate_battery 80` plays heart rate as a connected strap (emulator). + MaxLineLength:MainActivity.kt:MainActivity$container.settings.savePairedDevice(org.openstrap.app.data.settings.PairedDevice("00:00:00:00:00:00", "00", "Helio Strap")) + MaxLineLength:MainActivity.kt:MainActivity$runCatching { strap.buzz(pattern) }.onFailure { Toast.makeText(this@MainActivity, "Buzz failed: ${it.message}", Toast.LENGTH_SHORT).show() } + MaxLineLength:MainActivity.kt:MainActivity$setContent { OpenStrapTheme { AppRoot(container, metricRequests, todayRequests, strapRequests, forcedMoment, strapPreview, dataPreview) } } + MaxLineLength:MainActivity.kt:MainActivity$startService(Intent(this, StrapService::class.java).setAction(StrapService.ACTION_SAMPLE).putExtra(StrapService.EXTRA_SAMPLE, sample)) + MaxLineLength:MainActivity.kt:MainActivity$val script = simulate.split(',').mapNotNull { p -> p.split('x').takeIf { it.size == 2 }?.let { (b, s) -> b.toIntOrNull()?.let { bpm -> s.toIntOrNull()?.let { bpm to it } } } } + MaxLineLength:MainActivity.kt:Page$You + MaxLineLength:MainActivity.kt:Page.PhoneAlerts -> org.openstrap.app.ui.alarms.PhoneAlertsScreen(container.phoneAlerts, container.settings, padding, onBack = close) + MaxLineLength:MainActivity.kt:Tab.Today -> todayStack = stack + MaxLineLength:MainActivity.kt:Toast.makeText(context, "Health Connect isn't available on this phone.", Toast.LENGTH_LONG).show() + MaxLineLength:MainActivity.kt:activity?.let { androidx.window.layout.WindowInfoTracker.getOrCreate(it).windowLayoutInfo(it) } ?: kotlinx.coroutines.flow.emptyFlow() + MaxLineLength:MainActivity.kt:androidx.compose.foundation.layout + MaxLineLength:MainActivity.kt:androidx.compose.foundation.layout.Spacer(androidx.compose.ui.Modifier.width(with(density) { bookHinge.bounds.width().toDp() })) + MaxLineLength:MainActivity.kt:androidx.compose.runtime.SideEffect { if (freshClockFacts != null && freshClockFacts != keptClockFacts) keptClockFacts = freshClockFacts } + MaxLineLength:MainActivity.kt:androidx.compose.ui.Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).hazeSource(navGlass, zIndex = 1f) + MaxLineLength:MainActivity.kt:androidx.compose.ui.Modifier.fillMaxSize().padding(start = if (rail) org.openstrap.app.ui.components.NavRailWidth else 0.dp) + MaxLineLength:MainActivity.kt:androidx.lifecycle.compose.LifecycleResumeEffect(Unit) { deviceVm.checkAutoHealthConnect(); onPauseOrDispose { } } + MaxLineLength:MainActivity.kt:container + MaxLineLength:MainActivity.kt:day + MaxLineLength:MainActivity.kt:earned.mapNotNull { org.openstrap.app.data.Rewards.badge(it.id) } + MaxLineLength:MainActivity.kt:glance.value?.let { g -> org.openstrap.app.ui.glow.WorkoutCapsule(g, onClick = { session.minimized.value = false }, onAction = onGlanceAction, modifier = place) } + MaxLineLength:MainActivity.kt:header = { org.openstrap.app.ui.glow.PageHeader("Recovery", pop, subtitle = org.openstrap.app.ui.glow.dayName(day.date)) } + MaxLineLength:MainActivity.kt:healthspan = healthspan + MaxLineLength:MainActivity.kt:if (pages.lastOrNull() != Page.WeeklyRecap && (pages.isEmpty() || closePages())) pages = listOf(Page.You, Page.GoalsAndBests, Page.WeeklyRecap) + MaxLineLength:MainActivity.kt:if (plan.useGps && !org.openstrap.app.device.WorkoutLocation.granted(container.app)) requestLocation(withBluetooth) else withBluetooth() + MaxLineLength:MainActivity.kt:if (result.values.none { it }) Toast.makeText(context, "Without location, distance comes from the strap's steps.", Toast.LENGTH_LONG).show() + MaxLineLength:MainActivity.kt:if (workouts.isNotEmpty()) value = runCatching { container.rewards.view(workouts, goals.workoutsPerWeek, bests, earned) }.getOrNull() + MaxLineLength:MainActivity.kt:initializer { org.openstrap.app.ui.workout.WorkoutsViewModel(container.database.healthDao(), container.settings.profile, container.maxHeartRates) } + MaxLineLength:MainActivity.kt:launch { pager.interactionSource.interactions.collect { if (it is androidx.compose.foundation.interaction.DragInteraction.Start) swiped.set(true) } } + MaxLineLength:MainActivity.kt:liveHero = org.openstrap.app.ui.glow.LiveHeroHooks({ liveGlance.value }, onGlanceAction, { liveHeroShown = it }) + MaxLineLength:MainActivity.kt:onEditsChange = { e -> scope.launch { container.settings.saveTodayEdits(e.hidden.map { it.name }, e.pinned.map { it.name }) } } + MaxLineLength:MainActivity.kt:onOpenScore = { push(Detail.OfScore(it)) } + MaxLineLength:MainActivity.kt:org.openstrap.app.data.MomentResolver.resolve(clock, zone, clockDate, recording, lastWorkout?.end, sleepEnd, bedtime, workoutStarts) + MaxLineLength:MainActivity.kt:org.openstrap.app.data.Workouts.recentSports(workouts.filter { it.startedByYou }.sortedByDescending { it.workout.start }.map { it.workout.sportType }) + MaxLineLength:MainActivity.kt:org.openstrap.app.device.WidgetUpdater.changed(container.app, setOf(org.openstrap.app.widget.WidgetKind.Steps, org.openstrap.app.widget.WidgetKind.Moment)) + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.components.PullToSync(realSync, realConnection is ConnectionState.Connected, container.strap::syncNow) { screen() } + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.device.DeletedSleepPage(deleted, padding, onRestore = dayVm::restoreSleep, onBack = onBack) + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.glow.ChooseSport(type, last?.let { org.openstrap.app.ui.workout.StartCopy.lastTime(it, today, zone) } ?: "") + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.glow.LocalGlowMood provides (todayMoment?.let { org.openstrap.app.ui.dashboard.momentMood(it) } ?: org.openstrap.app.ui.glow.GlowMood.Day) + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.glow.LocalGlowPagePaused provides remember(pager, page) { { pager.isScrollInProgress || pager.settledPage != page } } + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.glow.metricIndex(day, recovery, healthspan, workouts, strapScore = strapReadiness?.takeIf { it.day == day.dayStart }?.score) + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.workout.GlanceAction.AddRest -> container.workout.let { if ((it.state.value as? org.openstrap.app.device.WorkoutState.Recording)?.strength == true) it.extendRest() else it.addRest() } + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.workout.GlanceAction.End, org.openstrap.app.ui.workout.GlanceAction.Open -> container.workout.minimized.value = false + MaxLineLength:MainActivity.kt:pages.ifEmpty { keptPages[0] } + MaxLineLength:MainActivity.kt:paired = pairedKnown + MaxLineLength:MainActivity.kt:private + MaxLineLength:MainActivity.kt:profile + MaxLineLength:MainActivity.kt:sleepEditing = org.openstrap.app.ui.glow.SleepEditing(dayVm::setSleepTimes, dayVm::deleteSleep, dayVm::restoreSleep) + MaxLineLength:MainActivity.kt:startPlan(org.openstrap.app.data.WorkoutSetup.plan(type, settings, sides, org.openstrap.app.ui.glow.locationAvailable(context)), null) + MaxLineLength:MainActivity.kt:updates + MaxLineLength:MainActivity.kt:val + MaxLineLength:MainActivity.kt:val dayVm: DayViewModel = viewModel(factory = viewModelFactory { initializer { DayViewModel(container.health, container.strap.todaySteps, container.settings.profile, container.autoHealthConnect::sleepEdited) } }) + MaxLineLength:MainActivity.kt:val energyHistory: org.openstrap.app.ui.glow.EnergyHistory = remember(container) { { from, to -> container.health.dailyEnergy(from, to) } } + MaxLineLength:MainActivity.kt:val firmwareInstalling: () -> Boolean = { Page.Firmware in pages && firmwareVm.step.value is org.openstrap.app.ui.firmware.FirmwareStep.Installing } + MaxLineLength:MainActivity.kt:val first = view?.thisWeek?.minusWeeks(1) ?: org.openstrap.app.data.Rewards.weekStart(java.time.LocalDate.now(), container.rewards.firstDay).minusWeeks(1) + MaxLineLength:MainActivity.kt:val goal = earned.firstOrNull { it.id == b.id }?.let { org.openstrap.app.data.Rewards.goalAt(it, weeks, container.rewards.firstDay, java.time.ZoneId.systemDefault()) } + MaxLineLength:MainActivity.kt:val granted = container.app.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED + MaxLineLength:MainActivity.kt:val ok = granted.containsAll(container.healthConnect.permissions) || runCatching { container.healthConnect.hasPermissions() }.getOrDefault(false) + MaxLineLength:MainActivity.kt:val realStep = if (!realPairedKnown || firstConnect) pairStep(realPairedKnown, scan.scanning, scan.found.isNotEmpty(), realConnection) else null + MaxLineLength:MainActivity.kt:val rewardsKey = remember(workouts, recordingStart) { workouts.filter { it.workout.start != recordingStart }.map { Triple(it.workout.start, it.shownStart, it.end) } } + MaxLineLength:MainActivity.kt:val series by androidx.compose.runtime.produceState<List<Pair<Long, Int>>?>(null, date) { value = workoutsVm.dayMinuteHeartRate(date) } + MaxLineLength:MainActivity.kt:val sinceStart = if (preview != null) Long.MAX_VALUE else (android.os.SystemClock.uptimeMillis() - android.os.Process.getStartUptimeMillis()) / 1000 + MaxLineLength:MainActivity.kt:val sport = if (m == org.openstrap.app.data.Moment.During) (workoutState as? org.openstrap.app.device.WorkoutState.Recording)?.workout?.sportType else lastWorkout?.workout?.sportType + MaxLineLength:MainActivity.kt:val sports = remember(mine) { org.openstrap.app.data.WorkoutSetup.choose(mine.map { it.workout.sportType to it.workout.start }, System.currentTimeMillis() / 1000) } + MaxLineLength:MainActivity.kt:val strapReadiness by remember(day.dayStart) { container.database.healthDao().strapReadiness(day.dayStart) }.collectAsStateWithLifecycle(null) + MaxLineLength:MainActivity.kt:val suggestedCalories by produceState<Int?>(null) { value = runCatching { container.health.suggestedCalorieGoal() }.getOrNull() } + MaxLineLength:MainActivity.kt:val summary by remember(state) { workoutsVm.phoneSummary(state.start, backfill = !state.preview) }.collectAsStateWithLifecycle(null) + MaxLineLength:MainActivity.kt:workouts = workouts + MaxLineLength:MaxHeartRates.kt:MaxHeartRates$private suspend fun work: MaxHeartRate + MaxLineLength:MomentHeader.kt:RoundButton(AppIcons.User, if (updateWaiting) "You, a new version is ready" else "You", "Open You", g.onClick, g.modifier, dot = updateWaiting) + MaxLineLength:MomentHeader.kt:icon: androidx.compose.ui.graphics.vector.ImageVector + MaxLineLength:MomentWidget.kt:Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_MOMENT, MainActivity.MOMENT_NOW).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + MaxLineLength:MomentWidget.kt:MomentChartKind.Line -> ghostLineGeometry(chart.shares, null, 1f, w, h, f.density)?.let { drawGhostLine(c, it, Color.White, f.density) } + MaxLineLength:MomentWidget.kt:MomentWidget$override fun sample: MomentWidgetModel + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$. + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$SleepWidgetNight.of(today, main, org.openstrap.app.data.SleepSource(dao).stagesOnce(main), SleepHistory.mainSleeps(sessions, zone)) + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$internal suspend + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$val + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$val ended = strap.map { MomentWorkout(it.sportType, it.end) } + phone.mapNotNull { w -> w.end?.let { MomentWorkout(w.sportType, it) } } + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$val recording = (container.workout.state.value as? WorkoutState.Recording)?.let { MomentRecordingInput(it.workout.sportType, it.workout.start) } + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$val span = DayWindow(DayWindow.nightEndingOn(today.minusDays(SLEEP_NIGHTS - 1L), zone).from, DayWindow.nightEndingOn(today, zone).to) + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$val usual = usualSport(yours) ?: usualSport(all.filter { it.first != Workouts.GENERIC_ACTIVITY }) ?: Workouts.DEFAULT_SPORTS.first() + MaxLineLength:MomentWidget.kt:fit.sentence?.let { Gap(); Text(it.text, style = WidgetType.hero.copy(fontSize = it.sp.sp), maxLines = MomentLayout.SENTENCE_LINES) } + MaxLineLength:MomentWidgetModel.kt:MomentLayout$else + MaxLineLength:MomentWidgetModel.kt:MomentLayout$fun + MaxLineLength:MomentWidgetModel.kt:MomentLayout$m: MomentWidgetModel + MaxLineLength:MomentWidgetModel.kt:MomentLayout$return Fit(name, dot = dot, row = row, sentence = sentence, chart = chart, waiting = waiting, content = blocks.sum(), inner = blocks.size - 1, top = statusTop) + MaxLineLength:MomentWidgetModel.kt:MomentLayout$return Fit(name, waiting = waiting, play = play, content = content, inner = (if (waiting != null) 1 else 0) + (if (play) 1 else 0), top = statusTop) + MaxLineLength:MomentWidgetModel.kt:MomentLayout$val + MaxLineLength:MomentWidgetModel.kt:MomentLayout$val name = statusTop?.let { Line(m.name, it.titleSp) } ?: (line(listOf(m.name), nameSizes, width, fontScale) ?: Line(m.name, nameSizes.last())) + MaxLineLength:MomentWidgetModel.kt:MomentLayout$val parts = listOfNotNull(name.height(fontScale), tile.takeIf { it > 0f }, sport?.height(fontScale), since?.height(fontScale)) + MaxLineLength:MomentWidgetModel.kt:MomentLayout$val waiting = if (m.number == null && sentence == null) m.waiting?.let { line(listOf(it), FIGURE_SIZES, width, fontScale) } else null + MaxLineLength:MomentWidgetModel.kt:MomentLight$data + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$e.points.forEach { p -> if (p.t in wake..t) { val i = ((p.t - wake) / step).toInt().coerceAtMost(n); sums[i] += p.value; counts[i]++ } } + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$figures = if (e != null && left != null && drained != null) listOf("Drained $drained, recharged ${e.recharged}", "Drained $drained") else emptyList() + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$return MomentChart(MomentChartKind.Line, values = values, low = low, high = high, from = clock(start), to = clock(points.last().x), description = description) + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$return MomentChart(MomentChartKind.Stages, stages = n.stages, from = from, to = to, description = "Last night's stages, $from to $to") + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$sentences = shorter(TodayCopy.waking(input.night?.asleepSeconds, input.score, input.alert, input.noScore, input.firstNights, isToday = true, deleted = input.nightDeleted)) + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$val wake = input.inputs.sleepEnd ?: today.atTime(org.openstrap.app.data.MomentResolver.WAKE_FALLBACK).atZone(zone).toEpochSecond() + MaxLineLength:Motion.kt:fun + MaxLineLength:MovingBorder.kt:drawRoundRect(brush, Offset(inset, inset), Size(size.width - thin, size.height - thin), CornerRadius(corner - inset), style = Stroke(thin)) + MaxLineLength:MovingBorder.kt:drawRoundRect(brush, Offset(inset, inset), Size(size.width - thin, size.height - thin), CornerRadius(corner), alpha = 0.28f, style = Stroke(halo)) + MaxLineLength:Narrative.kt:Narrative$/** Under 6 h asleep: the bottom of Zepp's duration bands (codemap 06 §4.2), and 56 on [org.openstrap.app.data.Recovery.durationScore]. */ + MaxLineLength:Narrative.kt:Narrative$add(Contributor("Continuity", ratingFor(Math.round(c)), c / 100f, "${formatDuration(wasoMinutes * 60L)} awake after falling asleep")) + MaxLineLength:Narrative.kt:Narrative$add(Contributor("Total sleep", totalSleepRating(asleepMin, duration), asleepMin / needMinutes.toFloat(), formatDuration(asleepSeconds) + " of ${formatDuration(needMinutes * 60L)}")) + MaxLineLength:Narrative.kt:Narrative$calm != null -> "Stress counts from ${t.value}: ${org.openstrap.app.data.Recovery.STRESS_MARGIN} above your calm level of $calm over the last ${t.days} days." + MaxLineLength:Narrative.kt:Narrative$left > 0 -> "Stress counts from ${t.value} for now. After ${if (left == 1) "1 more day" else "$left more days"} of wear it adjusts to your own calm level." + MaxLineLength:NotificationCopy.kt:NotificationCopy$gps -> distanceMeters?.takeIf { it >= 10 }?.let { (if (distanceFromSteps) "≈ " else "") + km(it) + if (it >= 1000) " km" else "" } + MaxLineLength:OpenStrapApp.kt:AppContainer$app + MaxLineLength:OpenStrapApp.kt:AppContainer$val updater = org.openstrap.app.update.AppUpdater(app, settings, recording = { workout.state.value is org.openstrap.app.device.WorkoutState.Recording }) + MaxLineLength:OpenStrapApp.kt:OpenStrapApp$if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) container.strap.backgroundConnect() + MaxLineLength:PageParts.kt:Text(placeholder, color = Color.White.copy(alpha = 0.3f), style = GlowType.body, maxLines = 1, overflow = TextOverflow.Ellipsis) + MaxLineLength:PageParts.kt:Text(title, color = Color.White, style = GlowType.body, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + MaxLineLength:PageParts.kt:Text(title, color = Color.White, style = GlowType.cardTitle, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + MaxLineLength:PageParts.kt:drawRoundRect(Color.White, topLeft = Offset(-w + (size.width + w) * slide.value, 0f), size = Size(w, size.height), cornerRadius = r) + MaxLineLength:PageParts.kt:fun + MaxLineLength:PageParts.kt:if (note != null) Text(note, color = RowValueColor, style = GlowType.secondary, modifier = Modifier.padding(bottom = GlowLayout.RowPadding)) + MaxLineLength:PageParts.kt:if (subtitle != null) Text(subtitle, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + MaxLineLength:PageParts.kt:if (value != null) Text(value, color = RowValueColor, style = GlowType.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis) + MaxLineLength:PageParts.kt:modifier.fillMaxWidth().clip(PanelShape).background(PanelSurface, PanelShape).border(1.dp, Color.White.copy(alpha = 0.06f), PanelShape) + MaxLineLength:PageParts.kt:tonalElevation = 0.dp + MaxLineLength:PageParts.kt:trailingIcon = if (on) ({ Icon(AppIcons.Tick02, null, tint = Color.White, modifier = Modifier.size(18.dp)) }) else null + MaxLineLength:Phases.kt:Phases$fun + MaxLineLength:Phases.kt:Phases$if (p.start == 0L || rounds.last().workEnd != null) return Step(rounds + AppWorkoutRound(workoutStart, p.round, now), Switch.WorkStarts) + MaxLineLength:Phases.kt:Phases$private + MaxLineLength:Phases.kt:Phases$return if (restToo) Step(done + AppWorkoutRound(r.workoutStart, r.index + 1, now), Switch.WorkStarts) else Step(done, Switch.RestStarts) + MaxLineLength:Phases.kt:Structure.Companion$EVERY.matchEntire(body)?.destructured?.let { (p, n) -> return EveryMinute(p.toLong(), n.toInt(), warm).takeIf { it.period > 0 && it.rounds > 0 } } + MaxLineLength:Phases.kt:Structure.Companion$val reps = if (unit == "m") Reps(v.toInt(), null, r.toLong(), n.toInt(), warm) else Reps(null, v.toLong(), r.toLong(), n.toInt(), warm) + MaxLineLength:Phases.kt:Structure.Reps$data + MaxLineLength:PhoneAlerts.kt:PhoneAlerts$if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && m is AudioManager.OnModeChangedListener) audio.removeOnModeChangedListener(m) + MaxLineLength:PhoneAlerts.kt:PhoneAlerts$log("call notification ${sbn.packageName}: type $callType, ongoing ${sbn.isOngoing}, full screen ${n.fullScreenIntent != null}, update $update, filter ${ranking?.matchesInterruptionFilter()} -> ${if (incoming) "ringing" else "not ringing"}") + MaxLineLength:PhoneAlerts.kt:PhoneAlerts$runCatching { strap.startCallBuzz(callStyle == CallBuzz.PULSED) }.onFailure { log("call buzz failed: ${it.message}"); return@launch } + MaxLineLength:PhoneAlertsScreen.kt:. + MaxLineLength:PhoneAlertsScreen.kt:callPreviewError = runCatching { alerts.previewCall(style) }.exceptionOrNull()?.let { it.message ?: "Couldn't buzz the strap" } + MaxLineLength:PhoneAlertsScreen.kt:previewError = runCatching { alerts.preview(p) }.exceptionOrNull()?.let { it.message ?: "Couldn't buzz the strap" } + MaxLineLength:ProfileScreen.kt:NumberField(feet, { feet = it.take(1) }, "Feet", "ft", !heightBlank && heightValue == null, Modifier.weight(1f)) + MaxLineLength:ProfileScreen.kt:NumberField(inches, { inches = it.take(2) }, "Inches", "in", !heightBlank && heightValue == null, Modifier.weight(1f)) + MaxLineLength:ProfileScreen.kt:NumberField(sleepNeed, { sleepNeed = it }, "Sleep need (7–9, blank for your age's default)", "h", sleepNeed.isNotBlank() && sleepNeedValue == null, decimal = true) + MaxLineLength:ProfileScreen.kt:PageNote(if (activity in 0..4) org.openstrap.app.data.Vo2max.ACTIVITY_LEVELS[activity] else "Pick the level that best matches a usual week.") + MaxLineLength:ProfileScreen.kt:ScreenList + MaxLineLength:ProfileScreen.kt:fun + MaxLineLength:ProfileScreen.kt:org.openstrap.app.ui.glow.GlowSegments((1..5).map { "$it" }, activity, { i -> activity = if (activity == i) -1 else i }) + MaxLineLength:ProfileScreen.kt:org.openstrap.app.ui.glow.GlowSegments(listOf("Female", "Male"), if (sex in 0..1) sex else -1, { i -> sex = if (sex == i) 2 else i }) + MaxLineLength:ProfileScreen.kt:org.openstrap.app.ui.glow.GlowSegments(listOf("Metric", "Imperial"), if (imperial) 1 else 0, { switchUnits(it == 1) }) + MaxLineLength:ProfileScreen.kt:org.openstrap.app.ui.workout.HeartZonesCopy.MAX_ROW + MaxLineLength:ProfileScreen.kt:val dirty = listOf(age, sex, imperial, weight, heightCm, feet, inches, activity, betaBlocker, pregnant, sleepNeed).joinToString("\u0000") != initial + MaxLineLength:ProfileScreen.kt:val initial = rememberSaveable { listOf(age, sex, imperial, weight, heightCm, feet, inches, activity, betaBlocker, pregnant, sleepNeed).joinToString("\u0000") } + MaxLineLength:ProfileScreen.kt:var sex by rememberSaveable { mutableIntStateOf(when (profile.sex) { UserProfile.Sex.Female -> 0; UserProfile.Sex.Male -> 1; null -> 2 }) } + MaxLineLength:ProfileScreen.kt:var weight by rememberSaveable { mutableStateOf(profile.weightKg?.let { formatNumber(if (imperial) Units.kgToLb(it) else it) }.orEmpty()) } + MaxLineLength:PullToSync.kt:* + MaxLineLength:PullToSync.kt:label + MaxLineLength:ReadyCardio.kt:. + MaxLineLength:ReadyCardio.kt:Box + MaxLineLength:ReadyCardio.kt:Column + MaxLineLength:ReadyCardio.kt:Modifier.clip(RoundedCornerShape(12.dp)).clickable(onClickLabel = StartCopy.TAP_TO_TYPE, onClick = onClick).padding(horizontal = 8.dp) + MaxLineLength:ReadyCardio.kt:Modifier.fillMaxWidth().bringIntoViewRequester(inView).clip(GlowLayout.CardShape).background(ListSurface).border(1.dp, RowHairline, GlowLayout.CardShape).padding(16.dp) + MaxLineLength:ReadyCardio.kt:Modifier.focusRequester(focus[i]).onFocusChanged { if (it.isFocused) { focused = i; everFocused = true } else if (focused == i) focused = null } + MaxLineLength:ReadyCardio.kt:Modifier.focusRequester(minutesFocus).tracked(1).onFocusChanged { if (!it.isFocused) hoursDone = false } + MaxLineLength:ReadyCardio.kt:Modifier.weight(1f).height(34.dp).clip(CircleShape).border(1.dp, if (chosen) Color.White else Color.White.copy(alpha = 0.15f), CircleShape) + MaxLineLength:ReadyCardio.kt:PageButton(StartCopy.SAVE_INTERVALS, { onSave(name.trim()) }, Modifier.fillMaxWidth(), enabled = name.isNotBlank(), primary = true, icon = AppIcons.Plus) + MaxLineLength:ReadyCardio.kt:PageButton(StartCopy.TURN_ON_LOCATION, onTurnOnLocation, Modifier.fillMaxWidth().padding(bottom = 14.dp), icon = SportIconsLocation) + MaxLineLength:ReadyCardio.kt:Presets(StartCopy.INTERVAL_PRESETS.map { it to StartCopy.structureName(it) }, reps.copy(warmUp = false)) { onReps(it.warm(reps.warmUp)) } + MaxLineLength:ReadyCardio.kt:ReadyPlanCard(family, choices.map { it.second }, at?.let { choices[it].second } ?: other(), at, { onChoose(choices[it].first) }, typing, onTyping, content) + MaxLineLength:ReadyCardio.kt:Row + MaxLineLength:ReadyCardio.kt:Text("×", color = Color.White.copy(alpha = 0.5f), style = GlowType.goalValueMinor, modifier = Modifier.alignByBaseline().padding(horizontal = 4.dp)) + MaxLineLength:ReadyCardio.kt:Text(StartCopy.planTitle(family), color = Color.White.copy(alpha = 0.6f), style = GlowType.body, modifier = Modifier.padding(horizontal = 4.dp)) + MaxLineLength:ReadyCardio.kt:Text(label, color = if (on) Color.Black else Color.White.copy(alpha = 0.85f), style = GlowType.secondary.copy(fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal)) + MaxLineLength:ReadyCardio.kt:Text(text, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:ReadyCardio.kt:TypedValueScope$/** What a [TypedValue]'s lines have: its numbers ([Number]), what's typed so far ([typed], null at rest), the number being typed ([focused]). */ + MaxLineLength:ReadyCardio.kt:ValueStepper + MaxLineLength:ReadyCardio.kt:fun Modifier.tracked + MaxLineLength:ReadyCardio.kt:hours = selected("${seconds / 3600}"); minutes = selected("${seconds / 60 % 60}"); everFocused = false; hoursDone = false; onTyping(true) + MaxLineLength:ReadyCardio.kt:hours = v.copy(text = v.text.filter { it.isDigit() }.takeLast(1), selection = TextRange(1).takeIf { v.text.length > 1 } ?: v.selection) + MaxLineLength:ReadyCardio.kt:if + MaxLineLength:ReadyCardio.kt:if (hint != null) Text(hint, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.caption, modifier = Modifier.padding(top = 4.dp)) + MaxLineLength:ReadyCardio.kt:if (maxHr != null) Text("${zones.startBpm(z, maxHr)}", color = ink.copy(alpha = ink.alpha * 0.75f), style = GlowType.caption) + MaxLineLength:ReadyCardio.kt:if (maxHr != null) Text(StartCopy.zoneRangeLine(goal, zones, maxHr), color = Color.White.copy(alpha = 0.7f), style = GlowType.body, textAlign = TextAlign.Center) + MaxLineLength:ReadyCardio.kt:private + MaxLineLength:ReadyCardio.kt:texts[i] + MaxLineLength:ReadyCardio.kt:} + MaxLineLength:ReadyPlans.kt:Modifier.fillMaxWidth().height(10.dp).semantics { contentDescription = "${s.rounds} rounds of ${s.work} seconds work and ${s.rest} seconds rest" } + MaxLineLength:ReadyPlans.kt:PlanChoice + MaxLineLength:ReadyPlans.kt:Workouts.Family.Intervals + MaxLineLength:ReadyPlans.kt:if (i < s.rounds - 1 && s.rest > 0) Box(Modifier.weight(s.rest.toFloat()).height(10.dp).clip(CircleShape).background(MetricColors.sleepDeep)) + MaxLineLength:ReadyPlans.kt:internal + MaxLineLength:ReadyPlans.kt:listOf(NumberField("${rounds.period}", "s a round"), NumberField("${rounds.rounds}", "rounds", length = 2, style = GlowType.goalValueMinor)) + MaxLineLength:ReadyScreen.kt:PageSwitchRow(StartCopy.WARM_UP, StartCopy.WARM_UP_NOTE, rounds.warmUp, { structureCode = rounds.warm(it).code }, divider = false) + MaxLineLength:ReadyScreen.kt:Row(Modifier.weight(1f), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, content = value) + MaxLineLength:ReadyScreen.kt:SportPicker(type, recentSports, contentPadding, onPick = { onSport(it); picking = false }, onBack = { if (pickSport) onBack() else picking = false }) + MaxLineLength:ReadyScreen.kt:Text(StartCopy.strapLine(connection, liveHeartRate), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:ReadyScreen.kt:Text(text, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.caption, modifier = Modifier.padding(start = 4.dp)) + MaxLineLength:ReadyScreen.kt:pinned = if (typing) null else ({ WorkoutButton(StartCopy.startLabel(type), Color.White, Color.Black, start, icon = AppIcons.PlayFill, height = 64.dp) }) + MaxLineLength:ReadyScreen.kt:type + MaxLineLength:ReadyScreen.kt:{ draft = if (it == null) draft.copy(kind = WorkoutSetup.GoalKind.None) else draft.copy(kind = WorkoutSetup.GoalKind.Time, seconds = it) } + MaxLineLength:Recovery.kt:Recovery$fun + MaxLineLength:Recovery.kt:Recovery$if (rhr != null && strapRhr != null && abs(rhr - strapRhr) > RHR_QA_BPM) add("Resting HR: ours ${rhr.toInt()} bpm, the strap's ${strapRhr.toInt()} bpm") + MaxLineLength:Recovery.kt:Recovery$out += Contributor(Factor.Sleep, tonight.strapScore ?: sleepScore(tonight, sleepNeed), formatMinutes(tonight.asleepMinutes) + " asleep") + MaxLineLength:Recovery.kt:Recovery$return Readiness(null, emptyList(), max(valid.count { it.hrvLn != null }, valid.count { it.restingHr != null }), false, emptyList(), false) + MaxLineLength:Recovery.kt:Recovery$return if (SOFT_MIN_FUSION) extras(soft, alert && soft == ALERT_CAP, false, soft) else extras(score.coerceIn(0, 100), capped, false, soft) + MaxLineLength:Recovery.kt:Recovery$stressRun = if (m.sleepStage == null && m.workout == null && m.stress != null && m.stress >= params.stressFrom) stressRun + 1 else 0 + MaxLineLength:Recovery.kt:Recovery$val seconds = stages.filter { it.third == SleepStage.AWAKE }.sumOf { (max(it.first, onset) until min(it.second, wake)).let { r -> max(0L, r.last - r.first + 1) } } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$Recovery.maxHeartRate(profile.age(), strapMax, Recovery.observedPeak(dailyMaxima(hrAll, from, to, zone).values.toList()), profile.maxHeartRate) + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$d to (if (back == 0) readiness.score else Recovery.readiness(nights.lastOrNull { it.day == m }, nights, loadsBefore(loadDays, m), causes, sleepNeed).score) + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$fun + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$if (range.isEmpty()) null else range to Recovery.workoutMinutes(range, dao.workoutHeartRateOnce(w.start).map { it.timestamp to it.bpm }) + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$loadTrend + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$phoneWorkoutKcal(w, profile, age, restingHr, maxHr, vo2max, now)?.forEach { if (it.t in day.from..day.to) phone[it.t] = it.kcal } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$private suspend + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$suspend + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val calm = Recovery.calmStress(dao.stressOnce(calmFrom, to).map { it.timestamp to it.value }, hr.toMap(), calmSteps, restingHr, maxHr, stressExcluded, asleep) + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val dailyLoad = { series: List<Pair<Long, Int>> -> Recovery.dailyLoad(series, restingHr, maxHr, female, workoutRanges, asleep) { stepsByMinute[it] } } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val gridStart = date.minusDays(SleepRegularity.SRI_DAYS.toLong() + 1).atTime(SleepRegularity.DAY_START_HOUR, 0).atZone(zone).toEpochSecond() + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val midpoints = mains.filterKeys { it > midnight - 7 * 86_400 + 3600 && it <= midnight }.values.map { minutesAfterNoon((it.start + it.end) / 2).toFloat() } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val minutes = Recovery.minutes(simFrom, simTo, simHr, steps, stress, stages, workouts = workoutRanges, offWrist = offWrist, wear = wear) + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val noon = date.minusDays(SleepRegularity.SRI_DAYS.toLong() + 1 - k).atTime(SleepRegularity.DAY_START_HOUR, 0).atZone(zone).toEpochSecond() + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val period = stagesTonight.takeIf { it.isNotEmpty() }?.let { ((it.maxOf { st -> st.end } - it.minOf { st -> st.start }) / 60).toInt() } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val rmssd = hrv.filter { inSleep(it.timestamp) && it.milliseconds in Recovery.HRV_MIN_MS..Recovery.HRV_MAX_MS }.map { it.milliseconds.toFloat() } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val times = (minuteSteps.keys.filter { it in day.from..day.to } + minuteHr.keys.filter { it in day.from..day.to } + sleep + phone.keys).toSortedSet() + MaxLineLength:RecoveryEngine.kt:RecoveryEngine.Companion$fun + MaxLineLength:RecoveryEngine.kt:RecoveryEngine.Companion$fun afterNoon + MaxLineLength:RecoveryEngine.kt:RecoveryEngine.Companion$val end = recent.map { (afterNoon(it.start) + (it.end - it.start).coerceIn(0L, 86_399L)).toFloat() }.median()?.toLong() ?: (18 * 3600L) + MaxLineLength:RecoveryWidget.kt:else -> LargeContent(model, RecoveryLayout.large(model, frame.contentWidth, frame.contentHeight, frame.fontScale, frame.status), frame) + MaxLineLength:RecoveryWidget.kt:frame.bucket == WidgetSizes.Small -> SmallContent(model, RecoveryLayout.small(model, frame.contentWidth, frame.contentHeight, frame.fontScale, frame.status), frame) + MaxLineLength:RecoveryWidget.kt:frame.bucket == WidgetSizes.Wide -> WideContent(model, RecoveryLayout.wide(model, frame.contentWidth, frame.contentHeight, frame.fontScale, frame.status), frame) + MaxLineLength:RecoveryWidget.kt:val bitmap = renderToBitmap(width * density, WeekBarsLook.HEIGHT_DP * density) { drawWeekBars(it, bars, week.indexOfFirst { d -> d.shown }, Ink) } + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$WidgetHero.ladder(WidgetSizes.Small, height).map { at(it, true) }.firstOrNull { it.spread.fits(height, MIN_GAP_DP) }?.let { return it } + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$WidgetHero.ladder(WidgetSizes.Wide, height).map { at(it, true) }.firstOrNull { it.spread.fits(height, MIN_GAP_DP) }?.let { return it } + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$fun + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$org.openstrap.app.ui.glow.draw.WeekBarsLook.column(days, content - dots - WORD_GAP_DP - textWidth(word, WORD_SP, fontScale) - WEEK_GAP_DP) + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$return Fit(top, d, m.word != null, Spread.of(top.height, row, if (figures) figuresHeight(fontScale) else null), figures = figures) + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$return Fit(top, d, w, Spread.of(top.height, WidgetDots.height(d), if (w) WidgetText.line(wordSp, fontScale) else null), wordSp = wordSp ?: WORD_SP) + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$rowWidth(m, d, fontScale) + WEEK_GAP_DP + org.openstrap.app.ui.glow.draw.WeekBarsLook.width(m.week.size, column) <= width + 0.01f + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$val lines = if (sentence) m.sentence?.let { WidgetText.wrappedLines(it, sp, width, fontScale) }?.takeIf { it <= SENTENCE_LINES } ?: return null else 0 + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$val spread = Spread.of(top.height, lines.takeIf { it > 0 }?.let { it * WidgetText.line(sp, fontScale) }, row, if (figures) figuresHeight(fontScale) else null) + MaxLineLength:Rewards.kt:Rewards$fun + MaxLineLength:Rewards.kt:Rewards$if (structure is Structure.Timed && structure.work == Structure.TABATA.work && structure.rest == Structure.TABATA.rest) earn("first_tabata", e) + MaxLineLength:Rewards.kt:Rewards$ordered.flatMap { w -> w.sets.filter { it.end != null && it.weightKg != null && (it.reps ?: 0) > 0 && !it.exercise.isNullOrBlank() }.map { w to it } } + MaxLineLength:Rewards.kt:Rewards$private + MaxLineLength:Rewards.kt:Rewards$runs.maxByOrNull { it.gpsMeters!! }?.let { out += PersonalBest(Kind.LONGEST_RUN, it.gpsMeters!!.toDouble(), it.shownStart, it.start) } + MaxLineLength:Rewards.kt:Rewards.Stretch.Companion$Stretch(m.toIntOrNull() ?: return@mapNotNull null, s.toLongOrNull() ?: return@mapNotNull null, t.toLongOrNull() ?: return@mapNotNull null) + MaxLineLength:RewardsCopy.kt:RewardsCopy$"${week.count} of ${week.goal} workouts" + if (week.met) ", goal met." + (if (inARow > 1) " $inARow weeks in a row." else "") else "." + MaxLineLength:RewardsCopy.kt:RewardsCopy$* + MaxLineLength:RewardsCopy.kt:RewardsCopy$EarnedLine(EarnedLine.Kind.Best, bestTitle(n.best.kind), bestValue(n.best.kind, n.best.value), "New best, ${beatBy(n.best, n.before)}") + MaxLineLength:RewardsCopy.kt:RewardsCopy$return "${beatBy(n.best, n.before).replaceFirstChar { it.uppercase() }} than your best from ${d.format(DateTimeFormatter.ofPattern(pattern, locale))}." + MaxLineLength:RewardsCopy.kt:RewardsCopy$return EarnedLine(EarnedLine.Kind.Goal, if (week.metByThis) "Weekly goal met" else "Weekly goal", "${week.count} of ${week.goal}", note, met = week.count >= week.goal) + MaxLineLength:RewardsCopy.kt:suspend + MaxLineLength:RewardsCopy.kt:val progress = Rewards.BADGES.mapNotNull { b -> Rewards.progress(b, f.workouts, f.days, weeks, thisWeek)?.let { b.id to it } }.toMap() + MaxLineLength:RewardsKeeper.kt:RewardsKeeper$if (!settings.rewardsStarted.first() && dao.appWorkoutsAll().isEmpty() && dao.workouts().first().isEmpty()) settings.startRewards(historyBadges = false) + MaxLineLength:RewardsKeeper.kt:RewardsKeeper$stretches = Rewards.Stretch.parse(p?.stretches) + MaxLineLength:SampleData.kt:SampleData$if (!asleep && m % 30 == 0) stress += StressSample(ts, (25 + 20 * sin((hour - 8) / 10.0 * PI) + rnd.nextInt(-8, 8)).toInt().coerceIn(5, 95)) + MaxLineLength:SampleData.kt:SampleData$minutes += ActivityMinute(ts, steps, if (workout) 90 else if (walking) 45 else 5, if (workout) 2 else if (walking) 1 else 0, bpm) + MaxLineLength:ScoreDetail.kt:DetailSentence(DetailCopy.charge(now, atWake, TabCopy.drainedAmount(c.drainedExercise + c.drainedStress + c.drainedLiving).toInt(), c.anchored, isToday, LocalTime.now().hour)) + MaxLineLength:ScoreDetail.kt:Score.HeartPoints + MaxLineLength:ScoreDetail.kt:m.title + MaxLineLength:ScoreDetail.kt:onOpen: (Detail) -> Unit + MaxLineLength:ScoreDetail.kt:range == Range.Week && known.isNotEmpty() -> DetailHeadline(Math.round(known.average()).toString(), "Average of the last 7 days", null) + MaxLineLength:ScoreDetail.kt:ready.phase == Recovery.Phase.Withheld -> TodayCopy.noScoreLine(TodayCopy.NoScore.Learning(ready.baselineNights, Recovery.SCORED_NIGHTS)) + MaxLineLength:ScoreDetail.kt:score + MaxLineLength:ScoreDetail.kt:score != null -> DetailHeadline(score.toString(), if (isToday) "this morning" else "that morning", TodayCopy.recoveryWord(score, alert)) + MaxLineLength:ScoreDetail.kt:scrub = i?.let { values[it] }?.let { v -> DetailHeadline(chargeShown(v).toString(), "at ${clockAt(day.first + i * step)}", Narrative.chargeHeadline(chargeShown(v))) } + MaxLineLength:ScoreDetail.kt:scrub = i?.let { week[it] }?.let { (d, v) -> v?.let { DetailHeadline(it.toString(), d.format(DateTimeFormatter.ofPattern("EEE d MMM")), TodayCopy.recoveryWord(it, false)) } } + MaxLineLength:ScoreDetail.kt:val stats by remember(metric, from, to) { if (from < to) history(metric, from.plusDays(1), to) else flowOf(emptyList()) }.collectAsState(emptyList()) + MaxLineLength:ScoreDetail.kt:values + MaxLineLength:SettingsStore.kt:SettingsStore$val + MaxLineLength:SettingsStore.kt:SettingsStore$val keep = setOf(birthYear, sex, weight, height, imperial, sleepNeed, maxHr, activityLevel, activityAt, bodyAt, betaBlocker, pregnant, stepGoal, sleepGoal, activeGoal, calorieGoal, todayHidden, todayPinned, hcAuto, hcSynced, hcStopped, buzzCalls, buzzNotifications, buzzPattern, callBuzz, quietOn, quietFrom, quietTo, quietCalls, backupFolder, backupFolderName, backupDone, backupFailed, backupFailedAt) + MaxLineLength:SettingsStore.kt:SettingsStore$val todayPinnedNames: Flow<List<String>?> = context.dataStore.data.map { prefs -> prefs[todayPinned]?.split(',')?.filter { it.isNotEmpty() } } + MaxLineLength:ShareCards.kt:0f to color.copy(alpha = strength) + MaxLineLength:ShareCards.kt:?: + MaxLineLength:ShareCards.kt:Box(Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 10.dp), contentAlignment = Alignment.Center) { StoryNumber(card.count, DotSize.Live, 310.dp) } + MaxLineLength:ShareCards.kt:Box(Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 10.dp), contentAlignment = Alignment.Center) { StoryNumber(card.time, DotSize.Detail, 310.dp) } + MaxLineLength:ShareCards.kt:Brush.verticalGradient(0f to Color.Transparent, 0.35f to Color.Black.copy(alpha = 0.55f), 1f to Color.Black.copy(alpha = 0.85f)) + MaxLineLength:ShareCards.kt:Canvas(Modifier.size(14.dp)) { drawCircle(Color.White.copy(alpha = 0.8f), radius = size.minDimension * 0.34f, style = Stroke(2.4.dp.toPx())) } + MaxLineLength:ShareCards.kt:Modifier.fillMaxWidth().fillMaxHeight(0.3f).background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent))) + MaxLineLength:ShareCards.kt:StoryCard.Calm$class + MaxLineLength:ShareCards.kt:StoryCard.Indoor$class + MaxLineLength:ShareCards.kt:StoryCard.Lift$class + MaxLineLength:ShareCards.kt:StoryCard.Rounds$class + MaxLineLength:ShareCards.kt:StoryCard.Week$class + MaxLineLength:ShareCards.kt:StoryRouteShape$return points.indices.map { i -> Offset((left + (xs[i] - xs.min()) * scale).toFloat(), (top + (ys[i] - ys.min()) * scale).toFloat()) } + MaxLineLength:ShareCards.kt:Text("My week", color = Color.White, style = GlowType.cardTitle, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:ShareCards.kt:Text("Workout time", color = Color.White.copy(alpha = StoryDim), style = GlowType.secondary, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:ShareCards.kt:Text(card.footer, color = Color.White.copy(alpha = StoryDim), style = GlowType.secondary, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:ShareCards.kt:Text(card.goalLine, color = Color.White.copy(alpha = StoryDim), style = GlowType.secondary, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:ShareCards.kt:Text(card.sport, color = Color.White, style = GlowType.cardTitle, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:ShareCards.kt:Text(card.whenLine, color = Color.White.copy(alpha = 0.55f), style = GlowType.secondary, maxLines = 1, modifier = Modifier.padding(top = 2.dp)) + MaxLineLength:ShareCards.kt:Text(name, color = Color.White, style = GlowType.body, maxLines = 1, modifier = Modifier.weight(1f).padding(end = 12.dp)) + MaxLineLength:ShareCards.kt:WeekFigure$internal + MaxLineLength:ShareCards.kt:f.unit?.let { Text(" $it", color = Color.White, style = GlowType.body, maxLines = 1, modifier = Modifier.padding(bottom = 3.dp)) } + MaxLineLength:ShareCards.kt:if + MaxLineLength:ShareCards.kt:internal + MaxLineLength:ShareCards.kt:s.unit?.let { Text(" $it", color = Color.White, style = GlowType.body, maxLines = 1, modifier = Modifier.padding(bottom = 3.dp)) } + MaxLineLength:ShareCards.kt:val (icon, tint) = if (chip.best) AppIcons.FlagCheckered to MetricColors.calories else AppIcons.Target to if (chip.met) MetricColors.good else Color.White + MaxLineLength:ShareScreen.kt:Row + MaxLineLength:ShareScreen.kt:StoryCardView(card, photo, shown, Modifier.drawWithContent { layer.record { this@drawWithContent.drawContent() }; recorded.complete(Unit) }) + MaxLineLength:ShareScreen.kt:StoryFiles$/** Writes [bitmap] as a PNG named [name] to Pictures/OpenStrap; true once it's there. Below Android 10 it needs the storage permission. */ + MaxLineLength:ShareScreen.kt:Text("Route", color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1, modifier = Modifier.width(84.dp)) + MaxLineLength:ShareScreen.kt:WorkoutButton("Share", Color.White, Color.Black, { if (export == null) export = Export.Share }, Modifier.weight(1f), icon = AppIcons.UploadSimple) + MaxLineLength:ShareScreen.kt:WorkoutButton(saveLabel, Raised, Color.White, { if (export == null) save() }, Modifier.weight(1f), icon = if (saved == true) AppIcons.Tick02 else AppIcons.DownloadSimple) + MaxLineLength:ShareScreen.kt:modifier.height(44.dp).clip(CircleShape).background(Raised).pressClickable(onClick, CircleShape).padding(start = 16.dp, end = 20.dp) + MaxLineLength:ShareScreen.kt:storyCard(FamilySummary.familyOf(a.workout.sportType, summary.structure), summary, hero, earned, usualLoad, System.currentTimeMillis() / 1000) + MaxLineLength:ShareScreen.kt:style = if (on) GlowType.secondary.copy(fontWeight = FontWeight.SemiBold) else GlowType.secondary + MaxLineLength:ShareScreen.kt:val + MaxLineLength:SleepCopy.kt:SleepCopy$const val DELETED_INTRO = "Sleep and naps you deleted in the last 7 days. Restoring puts it back in your totals, scores and Health Connect." + MaxLineLength:SleepCopy.kt:SleepCopy$if (count == 1) "One nap, ${TodayCopy.duration(totalSeconds)}." else "${DetailCopy.count(count, "nap").replaceFirstChar { it.uppercase() }}, ${TodayCopy.duration(totalSeconds)} in all." + MaxLineLength:SleepCopy.kt:SleepCopy$return History.normal(previous(state).mapNotNull { n -> p(n)?.let { DayStat(n.date, it, it, it, 1) } }, state.date.minusDays(1), SLEEP_NIGHTS - 1) + MaxLineLength:SleepCopy.kt:SleepCopy$sd != null -> "The middle of your sleep moved about ${TodayCopy.duration(sd.roundToInt() * 60L)} this week. The full measure needs more wear." + MaxLineLength:SleepDetail.kt:) + MaxLineLength:SleepDetail.kt:Caption(SleepCopy.napHeart(hr.minOf { it.y }.toInt(), hr.map { it.y }.average().toInt()), Modifier.padding(top = 6.dp, bottom = 8.dp)) + MaxLineLength:SleepDetail.kt:ChartTitle("Heart rate", "Lowest ${hr.minOf { it.y }.toInt()}, average ${hr.map { it.y }.average().toInt()} bpm", scrubbed = hrAt) + MaxLineLength:SleepDetail.kt:ContributorRow(f.name, color, f.word, f.fraction, offNormal = f.off, divider = i > 0, detail = f.detail, onClick = { info = true }) + MaxLineLength:SleepDetail.kt:DetailCopy.span(Range.Week, DetailCopy.levels(values, normal), nightly = true, normalKnown = normal != null, days = before.size) + MaxLineLength:SleepDetail.kt:Modifier.align(Alignment.BottomCenter).padding(bottom = contentPadding.calculateBottomPadding() + 16.dp, start = 16.dp, end = 16.dp) + MaxLineLength:SleepDetail.kt:Score.Naps.title + MaxLineLength:SleepDetail.kt:Score.Sleep.title + MaxLineLength:SleepDetail.kt:ScrubLine(hr, window, 100.dp, listOf(clock(nap.start), clock(nap.end)), MetricColors.heart, "bpm") { scrub = it } + MaxLineLength:SleepDetail.kt:Text(SLEEP_NAMES[st].orEmpty(), color = Color.White.copy(alpha = 0.85f), style = GlowType.secondary, modifier = Modifier.width(56.dp)) + MaxLineLength:SleepDetail.kt:UndoHost + MaxLineLength:SleepDetail.kt:band = normal?.let { it.low..it.high } + MaxLineLength:SleepDetail.kt:if + MaxLineLength:SleepDetail.kt:item { DeletedNight(SleepCopy.deletedNight(clock(deleted.start), clock(deleted.end), isToday)) { editing.restore(deleted.strap) } } + MaxLineLength:SleepDetail.kt:listOf(clock(session.start) to SleepCopy.FELL_ASLEEP, clock(session.end) to SleepCopy.WOKE, clockDuration(asleep) to SleepCopy.ASLEEP) + MaxLineLength:SleepDetail.kt:notes = listOfNotNull(SleepCopy.SCORE_FROM_YOUR_TIMES.takeIf { ownScore }) + InfoCopy.sleepNotes(r?.sleepNeed ?: Recovery.SLEEP_NEED_MINUTES) + MaxLineLength:SleepDetail.kt:onAdjust = { adjusting = nap.start } + MaxLineLength:SleepDetail.kt:onOpen: (Detail) -> Unit + MaxLineLength:SleepDetail.kt:onScrub = { i -> onScrub(i?.let { values[it] }?.let { v -> DetailHeadline("%.${decimals}f".format(v), "$unit at ${clock(window.first + i * step)}", null) }) } + MaxLineLength:SleepDetail.kt:private + MaxLineLength:SleepDetail.kt:private fun minutesOf: Int + MaxLineLength:SleepDetail.kt:range == Range.Week && known.isNotEmpty() -> DetailHeadline(Math.round(known.average()).toString(), "Average of the last 7 nights", null) + MaxLineLength:SleepDetail.kt:scrub = i?.let { week[it] }?.let { (d, v) -> v?.let { DetailHeadline(Math.round(it).toString(), d.format(DAY), TabCopy.sleepScoreWord(Math.round(it))) } } + MaxLineLength:SleepDetail.kt:val before = state.nights.filter { it.date < state.date }.mapNotNull { n -> n.session?.score?.let { DayStat(n.date, it.toFloat(), it.toFloat(), it.toFloat(), 1) } } + MaxLineLength:SleepDetail.kt:val others = state.nights.takeLast(SleepHistory.WEEK_NIGHTS).filter { it.date != state.date }.mapNotNull { it.asleepSeconds } + MaxLineLength:SleepDetail.kt:val week = state.nights.takeLast(SleepHistory.WEEK_NIGHTS).map { n -> n.date to if (n.date == state.date) score?.toFloat() else n.session?.score?.toFloat() } + MaxLineLength:SleepEdits.kt:SleepEdits$fun + MaxLineLength:SleepEdits.kt:SleepEdits$overlap != null -> "That runs into your ${if (overlap.nap) "nap" else "sleep"} at ${CLOCK.format(Instant.ofEpochSecond(overlap.start).atZone(zone))}." + MaxLineLength:SleepEdits.kt:SleepEdits$return s.copy(score = score(strap, s, Part(((end - start) / 60).toInt(), null), Part(((strap.end - strap.start) / 60).toInt(), null))) + MaxLineLength:SleepEdits.kt:SleepEdits$start < strap.start - MAX_SHIFT_SECONDS || end > strap.end + MAX_SHIFT_SECONDS -> "Keep it within 3 hours of what the strap saw." + MaxLineLength:SleepEdits.kt:SleepEdits$val s = strap.copy(start = start, end = end, deepMinutes = null, lightMinutes = null, remMinutes = null, awakeMinutes = null, wakeCount = null) + MaxLineLength:SleepPanels.kt:NightVital(Metric.HeartRate, "Average heart rate", MetricColors.heart, average?.toString(), if (average != null) "While asleep" else missing) + MaxLineLength:SleepPanels.kt:val spans = week.map { n -> n.session?.let { sinceNoon(it.start, false) to sinceNoon(it.end, true).coerceAtLeast(sinceNoon(it.start, false) + 1) } } + MaxLineLength:SleepRegularity.kt:SleepRegularity$fun + MaxLineLength:SleepRegularity.kt:SleepRegularity.Band$* + MaxLineLength:SleepSource.kt:SleepSource$if (strap == null) dao.sleepStages(session.start) else dao.sleepStages(strap).map { SleepEdits.clip(it, session.start, session.end) } + MaxLineLength:SleepSource.kt:SleepSource$private suspend + MaxLineLength:SleepSource.kt:SleepSource$return edits.filter { it.deleted && !SleepEdits.expired(it, now) }.sortedByDescending { it.editedAt }.mapNotNull { e -> strapOf(e, edits) } + MaxLineLength:SleepSource.kt:SleepSource$val e = edits.firstOrNull { !it.deleted && it.nap == session.nap && it.start == session.start && it.end == session.end } ?: return null + MaxLineLength:SleepWidget.kt:SleepWidget.Companion$suspend + MaxLineLength:SleepWidget.kt:SleepWidget.Companion$val deleted = tonight == null && DayWindow.nightEndingOn(today, zone).let { w -> org.openstrap.app.data.SleepSource(dao).nightDeleted(w.from, w.to) } + MaxLineLength:SleepWidget.kt:SleepWidget.Companion$val span = DayWindow(DayWindow.nightEndingOn(today.minusDays(SLEEP_NIGHTS - 1L), zone).from, DayWindow.nightEndingOn(today, zone).to) + MaxLineLength:SleepWidgetModel.kt:SleepLayout$listOfNotNull(note, asleep) to hasBar + MaxLineLength:SleepWidgetModel.kt:SleepWidgetModel.Companion$fun + MaxLineLength:SleepWidgetModel.kt:SleepWidgetModel.Companion$return SleepWidgetModel(null, TITLE, null, false, null, null, null, null, null, null, emptyList(), emptyList(), emptyList(), line) + MaxLineLength:SleepWidgetModel.kt:SleepWidgetModel.Companion$return totals.map { (stage, seconds) -> SleepStageRow(stage, SLEEP_NAMES[stage].orEmpty(), TodayCopy.duration(seconds), seconds.toFloat() / all) } + MaxLineLength:SleepWidgetModel.kt:SleepWidgetNight.Companion$fun + MaxLineLength:StartCopy.kt:StartCopy$"Ends near " + now.plusSeconds(seconds).format(DateTimeFormatter.ofPattern(if (is24Hour) "HH:mm" else "h:mm a", Locale.getDefault())) + MaxLineLength:StartCopy.kt:StartCopy$fun + MaxLineLength:StartCopy.kt:StartCopy$val names = if (goal.low == goal.high) ZONE_NAMES[goal.low].lowercase() else "${ZONE_NAMES[goal.low].lowercase()} to ${ZONE_NAMES[goal.high].lowercase()}" + MaxLineLength:StartWidget.kt:* + MaxLineLength:StartWidget.kt:frame.bucket == WidgetSizes.Small -> Column(GlanceModifier.fillMaxWidth().defaultWeight().then(Inset)) { SmallContent(m, frame) } + MaxLineLength:StartWidget.kt:rec != null && frame.bucket == WidgetSizes.Small -> Column(GlanceModifier.fillMaxWidth().defaultWeight().then(Inset)) { SmallRecording(m, rec, frame) } + MaxLineLength:StartWidget.kt:return renderToBitmap(s, s) { c -> c.drawCircle(s / 2, s / 2, s / 2, Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color.toArgb() }) } + MaxLineLength:StartWidgetModel.kt:StartLayout$WidgetSizes.Wide -> wide(m, width, height, fontScale, top.height).let { it.content + (it.gaps - 2) * MIN_GAP_DP <= height + 0.01f } + MaxLineLength:StartWidgetModel.kt:StartLayout$WidgetText.width(label, WidgetText.CAPTION_SP, fontScale) + FIGURE_GAP_DP + WidgetText.width(value, WidgetText.BODY_SP, fontScale) <= width + MaxLineLength:StartWidgetModel.kt:StartLayout$fun + MaxLineLength:StartWidgetModel.kt:StartLayout$if (grown.isNotEmpty() && grown.first().spread.fits(height, MIN_GAP_DP)) return Spread.grow(grown, height, MIN_GAP_DP) { it.spread } + MaxLineLength:StartWidgetModel.kt:StartLayout$if (tile <= room && COLUMNS * tile + (COLUMNS - 1) * CAPTION_SPACE_DP <= width) Tiles(next.take(COLUMNS - 1), tile, 0f, tile) else null + MaxLineLength:StartWidgetModel.kt:StartLayout$return Recording(tile, open, if (r.paused) r.status else name, false, TIMER_MIN_SP, SincePlace.None, null, top + timerLine(TIMER_MIN_SP, fontScale) + words, 3) + MaxLineLength:StartWidgetModel.kt:StartLayout$return Recording(tile, open, name, false, sp, if (since) SincePlace.Under else SincePlace.None, null, base + if (since) sinceH else 0f, 3) + MaxLineLength:StartWidgetModel.kt:StartLayout$val title = listOf(StartWidgetModel.TITLE, SHORT_TITLE).firstOrNull { WidgetText.fitWidth(it, TopRow.TITLE_SIZES.last(), fontScale) + beside <= width } ?: SHORT_TITLE + MaxLineLength:StartWidgetModel.kt:StartWidgetModel.Companion$val + MaxLineLength:StepsWidget.kt:WeekChart(ImageProvider(weekBitmap(m, week.column, density)), "Steps each day this week", m.week.map { it.letter to (it.state == StepsDay.Today) }, week.column) + MaxLineLength:StepsWidgetModel.kt:StepsLayout$height - top.height - r.height(fontScale) - (week?.height ?: 0f) - inner * MIN_GAP_DP >= (if (m.showLine) MIN_LINE_DP else BAR_DP) + MaxLineLength:StepsWidgetModel.kt:StepsLayout$return WidgetHero.ladder(WidgetSizes.Small, height).map { at(it, word, withShare) }.firstOrNull { it.spread.fits(height, gap) } ?: base + MaxLineLength:StepsWidgetModel.kt:StepsLayout$val rows = WidgetHero.ladder(if (large) WidgetSizes.Large else WidgetSizes.Wide, height).map { numberRow(m.stepsText, m.toGo, width, fontScale, it) } + MaxLineLength:StepsWidgetModel.kt:StepsLayout$val title = WidgetText.width(StepsWidgetModel.THIS_WEEK, sp, fontScale) + WEEK_GAP_DP + WidgetText.width(weekLine, sp, fontScale) <= width + MaxLineLength:StrapController.kt:StrapController$suspend + MaxLineLength:StrapController.kt:SyncRecord$is SyncState.Failed -> copy(lastAttempt = if (previous is SyncState.Running) lastAttempt ?: now else now, failingSince = failingSince ?: now) + MaxLineLength:StrapDrawing.kt:0f to white.copy(alpha = 0f) + MaxLineLength:StrapDrawing.kt:for ((x, y) in listOf(43f to 36f, 77f to 36f, 43f to 110f, 77f to 110f)) drawLine(white.copy(alpha = 0.5f), o(x, y), o(x, y + 4f), 1.2f * k, StrokeCap.Round) + MaxLineLength:StrapDrawing.kt:for (x in listOf(44f, 52f, 60f, 68f)) drawLine(white.copy(alpha = 0.09f), o(x, y + 1.5f), o(x + 3f, y + 1.5f), k, StrokeCap.Round) + MaxLineLength:StrapDrawing.kt:for (x in listOf(48f, 56f, 64f, 72f)) drawLine(white.copy(alpha = 0.09f), o(x, y + 4.5f), o(x + 3f, y + 4.5f), k, StrokeCap.Round) + MaxLineLength:StrapManager.kt:StrapManager$(sync.value as? SyncState.Done)?.let { d -> runCatching { settings.setLastSyncDone(d.atEpochSeconds) }.onFailure { log("sync time not kept: $it") } } + MaxLineLength:StrapManager.kt:StrapManager$TimeSync.parseVersion(s.request(Endpoints.TIME, TimeSync.versionRequest, 5_000) { it.isNotEmpty() && it.u8(0) == TimeSync.VERSION + 1 }) + MaxLineLength:StrapManager.kt:StrapManager$androidx.core.content.ContextCompat.registerReceiver(context, timeChanged, filter, androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED) + MaxLineLength:StrapManager.kt:StrapManager$buzzLock.withLock { if (callHold) runCatching { playMotor(CALL_PULSE) }.onFailure { if (callHold) log("call pulse failed: $it") } } + MaxLineLength:StrapManager.kt:StrapManager$else + MaxLineLength:StrapManager.kt:StrapManager$if (!AlarmCodec.isSuccess(request, command(Endpoints.ALARMS, request))) throw StrapCommandException("The strap didn't delete the alarm") + MaxLineLength:StrapManager.kt:StrapManager$if (!AlarmCodec.isSuccess(request, command(Endpoints.ALARMS, request))) throw StrapCommandException("The strap didn't delete the alarms") + MaxLineLength:StrapManager.kt:StrapManager$if (!AlarmCodec.isSuccess(request, command(Endpoints.ALARMS, request))) throw StrapCommandException("The strap didn't save the alarm") + MaxLineLength:StrapManager.kt:StrapManager$if (!MotorVibrate.isOk(command(Endpoints.MOTOR_VIBRATE, MotorVibrate.play(pattern)))) throw StrapCommandException("The strap didn't buzz") + MaxLineLength:StrapManager.kt:StrapManager$if (!SettingsCodec.isWriteSuccess(reply)) throw StrapCommandException("The strap didn't accept the restart (code ${reply.getOrNull(1)})") + MaxLineLength:StrapManager.kt:StrapManager$if (!SettingsCodec.isWriteSuccess(reply)) throw StrapCommandException("The strap didn't accept the setting (code ${reply.getOrNull(1)})") + MaxLineLength:StrapManager.kt:StrapManager$if (!firmwareUpdating.value) runCatching { s.send(Endpoints.REALTIME_ACTIVITY, RealtimeSteps.QUERY) }.onFailure { log("steps query failed: $it") } + MaxLineLength:StrapManager.kt:StrapManager$if (session?.services?.has(Endpoints.SETTINGS) == true && fastRechargeOn() == true) throw DfuException(-1, "Turn off fast-charge mode on the strap first") + MaxLineLength:StrapManager.kt:StrapManager$launch { s.messagesFor(Endpoints.OPEN_APP).collect { p -> if (opens++ == 0 || !p.contentEquals(KEEP_ALIVE_REPLY)) log("live hr 001d < ${p.toHex()}") } } + MaxLineLength:StrapManager.kt:StrapManager$log("device features ${r.toHex()} -> ${s.features?.let { "mask %02x g2 %s alg %s".format(it.mask, it.group2?.toHex(), it.algorithm) } ?: "unparsed"}") + MaxLineLength:StrapManager.kt:StrapManager$log("live hr subscribe: ${s.request(Endpoints.HEART_RATE, LiveHeartRate.SUBSCRIBE, 5_000) { it.u8(0) == 0x04 }.toHex()}") + MaxLineLength:StrapManager.kt:StrapManager$log("steps today=${it.steps} dist=${it.distanceMeters} kcal=${it.calories}" + if (!it.isPlausible) " (total doesn't match distance: counting from synced history)" else "") + MaxLineLength:StrapManager.kt:StrapManager$override val sportSessionPushes = MutableSharedFlow<SportSession.Push>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) + MaxLineLength:StrapManager.kt:StrapManager$return SettingsCodec.parse(reply, protocol?.version) ?: throw StrapCommandException("The strap couldn't read its settings (code ${SettingsCodec.replyCode(reply)})") + MaxLineLength:StrapManager.kt:StrapManager$runCatching { WorkoutGapFill(dao) { settings.profile.first() }.run()?.let { log(it) } }.onFailure { log("workout gap fill: $it") } + MaxLineLength:StrapManager.kt:StrapManager$scope.launch { HistorySync(s, client, dao, settings, sync, ::log).dumpRaw(System.currentTimeMillis() / 1000 - days * 86_400L, java.io.File(context.filesDir, "raw"), types) } + MaxLineLength:StrapManager.kt:StrapManager$settingsQuery(SettingsCodec.GROUP_HEALTH, listOf(SettingsCodec.KEY_CALORIE_TARGET), withOptions = false) + MaxLineLength:StrapManager.kt:StrapManager$val + MaxLineLength:StrapManager.kt:StrapManager$val ack = command(Endpoints.SETTINGS, SettingsCodec.write(SettingsCodec.GROUP_GENERAL, group.version, listOf(SettingsCodec.boolSetting(key, true)))) + MaxLineLength:StrapManager.kt:StrapManager$val acks = link?.let { l -> launch { l.controlNotifications.collect { log("probe %04x ack < %s".format(endpoint, it.toHex())) } } } + MaxLineLength:StrapManager.kt:StrapManager$val keys = SettingsCodec.parse(r, settingsProtocol?.version)?.settings?.values?.joinToString(" ") { "${it.key}=${it.text ?: it.number ?: it.raw.toHex()}" } + MaxLineLength:StrapManager.kt:StrapManager$val r = s.request(DeviceFeatures.ENDPOINT, DeviceFeatures.request, 5_000) { it.isNotEmpty() && it.u8(0) == DeviceFeatures.REPLY } + MaxLineLength:StrapManager.kt:StrapManager$val r = s.request(Endpoints.SETTINGS, SettingsCodec.query(group, listOf(SettingsCodec.KEY_RESTART), withOptions = false), 5_000) { SettingsCodec.isReplyTo(group, it) } + MaxLineLength:StrapManager.kt:StrapManager$val r = s.request(Endpoints.SETTINGS, SettingsCodec.query(group, withOptions = false), 5_000) { SettingsCodec.isReplyTo(group, it) } + MaxLineLength:StrapManager.kt:StrapManager$val r = s.request(Endpoints.SETTINGS, SettingsCodec.versionRequest, 5_000) { it.isNotEmpty() && it.u8(0) == SettingsCodec.VERSION + 1 } + MaxLineLength:StrapManager.kt:StrapManager$val reply = settingsQuery(SettingsCodec.GROUP_DEVICE_INFORMATION, listOf(SettingsCodec.KEY_WEAR_STATUS), withOptions = false) + MaxLineLength:StrapManager.kt:StrapManager$val request = if (isNew) AlarmCodec.add(alarm, caps.titles, caps.workdaysSupported) else AlarmCodec.modify(alarm, caps.titles, caps.workdaysSupported) + MaxLineLength:StrapManager.kt:StrapManager$val written = org.openstrap.app.data.DailyEnergyHistory(dao).update(profile, profileChanged = settings.lastSync(DAILY_ENERGY_PROFILE) != key) + MaxLineLength:StrapService.kt:StrapService$* + MaxLineLength:StrapService.kt:StrapService$. + MaxLineLength:StrapService.kt:StrapService$PendingIntent.getService(this, action.hashCode(), Intent(this, StrapService::class.java).setAction(action), PendingIntent.FLAG_IMMUTABLE) + MaxLineLength:StrapService.kt:StrapService$Status + MaxLineLength:StrapService.kt:StrapService$else -> listOfNotNull(if (connected.charging) "Strap charging" else "Strap connected", battery?.let { "battery $it%" }).joinToString(", ") + MaxLineLength:StrapService.kt:StrapService$n = workoutNotification(w, hr.takeIf { c is ConnectionState.Connected }, c is ConnectionState.Connected, confirming, workout) + MaxLineLength:StrapService.kt:StrapService$private + MaxLineLength:StrapService.kt:StrapService$return Figures(r.readiness.score, r.charge.latest?.let(::chargeShown), sport, (container.strap.sync.value as? SyncState.Done)?.atEpochSeconds ?: figures.value?.syncedAt) + MaxLineLength:StrapService.kt:StrapService$setContentText(NotificationCopy.END_QUESTION).apply { WorkoutNotification.actions(paused = false, confirming = true, ::service).forEach(::addAction) } + MaxLineLength:StrapService.kt:StrapService$style.setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(target.kcal.coerceAtLeast(1)).setColor(color))) + MaxLineLength:StrapService.kt:StrapService$style.setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(target.seconds.toInt().coerceAtLeast(1)).setColor(color))) + MaxLineLength:StrapService.kt:StrapService$val + MaxLineLength:StrapService.kt:StrapService$val auto = w.endReason == org.openstrap.app.data.db.AppWorkout.END_AUTO || w.endReason == org.openstrap.app.data.db.AppWorkout.END_CAR + MaxLineLength:StrapService.kt:StrapService$val time = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + MaxLineLength:StrapService.kt:StrapService$val time = java.time.Instant.ofEpochSecond(end).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + MaxLineLength:StrapService.kt:StrapService$val time = java.time.Instant.ofEpochSecond(o.start).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + MaxLineLength:StrapService.kt:StrapService$val withGps = if (gps && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) base or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else base + MaxLineLength:StrapService.kt:StrapService$w.distanceMeters?.takeIf { it >= 10 }?.let { (if (w.distanceFromSteps) "≈ " else "") + org.openstrap.app.ui.glow.km(it) + if (it >= 1000) " km" else "" } + MaxLineLength:StrapService.kt:StrapService.Companion$if (zone == 0) "under ${60 * maxHr / 100} bpm" else "${(50 + zone * 10) * maxHr / 100}–${if (zone == 4) maxHr else (60 + zone * 10) * maxHr / 100 - 1} bpm" + MaxLineLength:StrapStatus.kt:fun + MaxLineLength:Summaries.kt:Summaries$bucketMeans(items, ((range.last - range.first + 1) / seconds).toInt().coerceAtLeast(2), { ((time(it) - range.first) / seconds).toInt() }, value) + MaxLineLength:SummaryCopy.kt:SummaryCopy$SplitRow(if (whole) "Km ${i + 1}" else "Last ${km(last)}", workoutClock(sp.seconds), WorkoutCopy.averageOver(readings, from, sp.endT)) + MaxLineLength:SummarySections.kt:"${p.hardEfforts} hard ${if (p.hardEfforts == 1) "effort" else "efforts"}" + MaxLineLength:SummarySections.kt:) + MaxLineLength:SummarySections.kt:. + MaxLineLength:SummarySections.kt:Box(Modifier.fillMaxWidth(share).fillMaxHeight().clip(CircleShape).background(if (share >= 1f) color else color.copy(alpha = 0.6f))) + MaxLineLength:SummarySections.kt:Column + MaxLineLength:SummarySections.kt:Modifier.weight(1f).height(RoundColumnHeight * share).clip(shape).background(if (i == best) color else color.copy(alpha = 0.45f)) + MaxLineLength:SummarySections.kt:Row + MaxLineLength:SummarySections.kt:SummaryHero$internal + MaxLineLength:SummarySections.kt:Text("$it", color = Color.White, style = GlowType.rowValue, maxLines = 1, modifier = Modifier.padding(start = 12.dp)) + MaxLineLength:SummarySections.kt:Text("Earned", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(start = 4.dp)) + MaxLineLength:SummarySections.kt:Text("Zone ${z + 1}, ${ZONE_NAMES[z].lowercase()}", color = Color.White, style = style, maxLines = 1, modifier = Modifier.weight(1f).padding(start = 10.dp)) + MaxLineLength:SummarySections.kt:Text(p.name, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:SummarySections.kt:WeekDaysPanel(weekStarts, note = week?.let { FamilySummary.weekGoalLine(it.count, it.goal) }, day = day, color = MetricColors.evening) + MaxLineLength:SummarySections.kt:days: List<Pair<String, Boolean>> + MaxLineLength:SummarySections.kt:fun at + MaxLineLength:SummarySections.kt:if (peak) drawLine(Color.White.copy(alpha = 0.12f), Offset(0f, size.height - 0.5f), Offset(size.width, size.height - 0.5f), 1.dp.toPx()) + MaxLineLength:SummarySections.kt:if (share != null) Box(Modifier.fillMaxWidth(share).fillMaxHeight().clip(CircleShape).background(zoneColor(s.peak, maxHr))) + MaxLineLength:SummarySections.kt:letter + MaxLineLength:SummarySections.kt:lifted != null -> SummaryHero("%,d".format(Math.round(lifted)), "kg lifted", listOf(time to "Time", "$sets" to "Sets", rest to "Avg rest")) + MaxLineLength:SummarySections.kt:line = listOfNotNull(a.workout.averageHeartRate?.let { "Average $it bpm" }, a.activeKcal?.let { "${Math.round(it)} active kcal" }).joinToString(", ").ifEmpty { null } + MaxLineLength:SummarySections.kt:listOf(load?.toString() to (load?.let { FamilySummary.loadLabel(it, usualLoad) } ?: "Load"), kcal to "Active kcal", peak to "Peak bpm") + MaxLineLength:SummarySections.kt:private + MaxLineLength:SummarySections.kt:s.peak?.let { "peak $it" }.orEmpty() + MaxLineLength:SummarySections.kt:sets > 0 -> SummaryHero("$sets", if (sets == 1) "set" else "sets", listOf(time to "Time", rest to "Avg rest", peak to "Peak")) + MaxLineLength:SummarySections.kt:val text = note ?: days.count { it.second }.let { n -> "$n ${if (n == 1) "day" else "days"} with a workout ${if (thisWeek) "this" else "that"} week." } + MaxLineLength:SummarySections.kt:val zones = remember(a) { WorkoutRecorder.liveZoneSeconds(a.heartRate.filter { it.timestamp <= a.end }.map { it.timestamp to it.bpm }, a.maxHeartRate) } + MaxLineLength:SummarySections.kt:workoutClock(t) + MaxLineLength:SyncNowAction.kt:SyncNowAction$context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED -> strap.backgroundConnect() + MaxLineLength:SyncStates.kt:StrapPreview.Charging -> copy(paired = true, connection = connected.copy(charging = true), sync = SyncState.Idle, record = SyncRecord()) + MaxLineLength:SyncStates.kt:StrapPreview.Disconnected -> copy(paired = true, connection = ConnectionState.Disconnected, sync = SyncState.Idle, record = SyncRecord(), lastSeen = now - 18 * 60) + MaxLineLength:SyncStates.kt:StrapPreview.NotPaired -> copy(paired = false, connection = ConnectionState.NotPaired, sync = SyncState.Idle, record = SyncRecord(), lastSeen = null) + MaxLineLength:SyncStates.kt:record = SyncRecord(lastSuccess = now - STUCK_AFTER_S - 3600, lastAttempt = now - 10 * 60, failingSince = now - STUCK_AFTER_S - 60) + MaxLineLength:TabCopy.kt:TabCopy$fun + MaxLineLength:TabParts.kt:Text(name, color = Color.White, style = GlowType.body, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) + MaxLineLength:TabParts.kt:Text(word, color = if (offNormal) color else Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + MaxLineLength:TabParts.kt:internal + MaxLineLength:TabPlans.kt:(if (pair) small(SleepCard.Breathing, Hue.Sleep, Edge.Bottom, Pane.Left) else TabSlot(SleepCard.Breathing, edge(Hue.Sleep, Edge.Bottom), Pane.Right)) + MaxLineLength:TabPlans.kt:(if (pair) small(SleepCard.Naps, Hue.Sleep, Edge.Left, Pane.Left) else TabSlot(SleepCard.Naps, edge(Hue.Sleep, Edge.Left), Pane.Left)) + MaxLineLength:TabPlans.kt:lookClashes(slots.map { it.look }, tabPanes(slots, smallsAcross && wide), wide, fullTouchesColumns = true, sizes = slots.map { it.size }) + MaxLineLength:Theme.kt:style.copy(fontFamily = FunnelDisplay, fontWeight = weight, fontSize = size.sp, letterSpacing = tracking.sp, fontFeatureSettings = "tnum") + MaxLineLength:TodayCards.kt:/** The workout just done, after it: how long it was, the average heart rate and the zone it mostly sat in. Lit from the left, about earlier. */ + MaxLineLength:TodayCards.kt:BodyRow$val name: String + MaxLineLength:TodayCards.kt:Column(Modifier.weight(1f)) { EdgeValue(now?.toString(), null, status, Color.White.copy(alpha = 0.8f), dot = DotSize.Second) } + MaxLineLength:TodayCards.kt:ListRow("Load today", MetricColors.load, load?.roundToInt()?.toString(), loadLine, divider = false, onClick = onLoad, icon = Score.Load.rowIcon()) + MaxLineLength:TodayCards.kt:Text("$name, your usual", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + MaxLineLength:TodayCards.kt:color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA) + MaxLineLength:TodayCards.kt:contentPadding = StripPadding + MaxLineLength:TodayCards.kt:else + MaxLineLength:TodayCards.kt:if + MaxLineLength:TodayCards.kt:if (feeling != null) Text(CheckIn.label(feeling), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:TodayCards.kt:if (status != null) Text(status, color = statusColor, style = GlowType.secondary, modifier = Modifier.padding(top = 6.dp), maxLines = 2, overflow = TextOverflow.Ellipsis) + MaxLineLength:TodayCards.kt:if (unit != null && value != null) Text(unit, color = Color.White.copy(alpha = 0.7f), style = GlowType.secondary, modifier = Modifier.padding(start = 6.dp)) + MaxLineLength:TodayCards.kt:if (unit != null && value != null) Text(unit, color = Color.White.copy(alpha = 0.7f), style = GlowType.secondary, modifier = Modifier.padding(start = 8.dp)) + MaxLineLength:TodayCards.kt:internal + MaxLineLength:TodayCards.kt:kind: EdgeKind = EdgeKind.Value + MaxLineLength:TodayCards.kt:line + MaxLineLength:TodayCards.kt:title: String + MaxLineLength:TodayCards.kt:val line = remember(state.hourlySteps, isToday, hour) { org.openstrap.app.data.Summaries.runningSteps(state.hourlySteps, if (isToday) hour else null) } + MaxLineLength:TodayCopy.kt:TodayCopy$estimate != null -> RecoveryShown(estimate, "Strap's estimate", noScore?.let(::noScoreLine) ?: "OpenStrap's own score isn't ready yet.", estimate = true) + MaxLineLength:TodayCopy.kt:TodayCopy$fun + MaxLineLength:TodayCopy.kt:TodayCopy$workouts: List<String?> + MaxLineLength:TodayLooks.kt:fun + MaxLineLength:TodayLooks.kt:private fun opposite + MaxLineLength:TodayLooks.kt:val + MaxLineLength:TodayLooks.kt:val pick = options(out[at]).firstOrNull { o -> settled(out.toMutableList().also { it[at] = o }, i, wide, across) } ?: return false + MaxLineLength:TodayLooks.kt:val places = (top until end).filter { it != from && it in starts }.sortedWith(compareBy({ kotlin.math.abs(it - from) }, { it < from })) + MaxLineLength:UpdateCopy.kt:UpdateCopy$fun + MaxLineLength:UpdateCopy.kt:UpdateCopy$return if (install.total > 0) "Downloading, ${install.done * 100 / install.total}%" else "Downloading, ${megabytes(install.done)}" + MaxLineLength:UpdateNotifier.kt:UpdateNotifier$fun available + MaxLineLength:UpdatesPage.kt:PageRow + MaxLineLength:UpdatesPage.kt:Text(release.versionName, color = Color.White, style = GlowType.body.copy(fontWeight = FontWeight.Medium), modifier = Modifier.weight(1f)) + MaxLineLength:UpdatesPage.kt:Text(spans(block.spans), color = Color.White.copy(alpha = 0.75f), style = GlowType.secondary, modifier = Modifier.padding(top = 1.dp)) + MaxLineLength:UpdatesPage.kt:if (blocks.isEmpty()) Text("No notes for this release.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:UpdatesPage.kt:spans(block.spans) + MaxLineLength:UsualSteps.kt:UsualSteps$/** Within this share of your usual counts as about usual: the band Today uses for Load ([org.openstrap.app.ui.dashboard.TodayCopy.LOAD_BAND]). */ + MaxLineLength:WidgetGalleryActivity.kt:WidgetGalleryActivity$* + MaxLineLength:WidgetGalleryActivity.kt:WidgetGalleryActivity$else -> placed.split(',').mapNotNull { n -> WidgetKind.entries.firstOrNull { it.name.equals(n.trim(), ignoreCase = true) } }.toSet() + MaxLineLength:WidgetGalleryActivity.kt:WidgetGalleryActivity$if + MaxLineLength:WidgetGalleryActivity.kt:WidgetGalleryActivity$layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply { bottomMargin = dp(16) } + MaxLineLength:WidgetGalleryActivity.kt:WidgetGalleryActivity$private + MaxLineLength:WidgetGalleryActivity.kt:WidgetGalleryActivity$val info: AppWidgetProviderInfo? = manager.getInstalledProvidersForPackage(packageName, null).firstOrNull { it.provider == provider } + MaxLineLength:WidgetMoment.kt:val + MaxLineLength:WidgetMoment.kt:val sleepEnd = org.openstrap.app.data.SleepSource(dao).sessionsOnce(night.from, night.to).filter { !it.nap }.maxByOrNull { it.end - it.start }?.end + MaxLineLength:WidgetParts.kt:) + MaxLineLength:WidgetParts.kt:CardWidget(open, bitmapExtra, WidgetSizes.PADDING_DP, showStatus, { w, h, corner, density -> glowBackground(style, w, h, corner, density) }, content) + MaxLineLength:WidgetParts.kt:GlanceModifier.padding(top = (WidgetSizes.PADDING_DP + top - reach).coerceAtLeast(0f).dp, end = (WidgetSizes.PADDING_DP - reach).dp) + MaxLineLength:WidgetParts.kt:Text(letter, style = (if (lit) WidgetType.captionShown else WidgetType.caption).copy(textAlign = TextAlign.Center), maxLines = 1) + MaxLineLength:WidgetParts.kt:TopRowView(title, null, TopRow.of(title, null, f.status, f.contentWidth, f.fontScale, withWords = f.bucket != WidgetSizes.Small), f, above = 0f) + MaxLineLength:WidgetParts.kt:WidgetType$val caption = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.50f)), fontSize = WidgetText.CAPTION_SP.sp, fontWeight = FontWeight.Medium) + MaxLineLength:WidgetParts.kt:c.drawCircle(s / 2, s / 2, s / 2, android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { this.color = ControlSurface.toArgb() }) + MaxLineLength:WidgetParts.kt:internal + MaxLineLength:WidgetParts.kt:internal suspend + MaxLineLength:WidgetParts.kt:runCatching { context.resources.getDimension(android.R.dimen.system_app_widget_background_radius) / context.resources.displayMetrics.density }.getOrNull() + MaxLineLength:WidgetParts.kt:val allPixels = WidgetSizes.all.sumOf { (it.width.value * it.height.value).toDouble() } * bitmapExtra * metrics.density * metrics.density + MaxLineLength:WidgetParts.kt:val density = metrics.density * WidgetSizes.bitmapScale(allPixels.toLong(), WidgetSizes.bitmapBudget(metrics.widthPixels, metrics.heightPixels)) + MaxLineLength:WidgetSamples.kt:WidgetSamples$fun sleep: SleepWidgetModel + MaxLineLength:WidgetSamples.kt:WidgetSamples$val session = SleepSession(start, at(today, 6, 56), score = 82, wakeCount = 3, deepMinutes = 92, remMinutes = 101, lightMinutes = 267, awakeMinutes = 18) + MaxLineLength:WidgetSizes.kt:WidgetDots$dot * ((1 + org.openstrap.app.ui.glow.DotSize.DOT_GAP) * org.openstrap.app.ui.glow.draw.DotFont.ROWS - org.openstrap.app.ui.glow.DotSize.DOT_GAP) + MaxLineLength:WidgetSizes.kt:WidgetDots$dot * ((1 + org.openstrap.app.ui.glow.DotSize.DOT_GAP) * org.openstrap.app.ui.glow.draw.DotFont.columns(text) - org.openstrap.app.ui.glow.DotSize.DOT_GAP) + MaxLineLength:WidgetStatusLine.kt:TopRow.Companion$WidgetText.fitWidth(title, sp, fontScale) + StatusButton.TITLE_GAP_DP + WidgetText.fitWidth(note, WidgetText.SECONDARY_SP, fontScale) <= width + MaxLineLength:WidgetStatusLine.kt:TopRow.Companion$fun room + MaxLineLength:WidgetStatusLine.kt:TopRow.Companion$val h = maxOf(WidgetText.line(sp, fontScale), StatusButton.SIZE_DP, if (words != null) WidgetText.line(StatusButton.WORDS_SP, fontScale) else 0f) + MaxLineLength:WidgetUpdater.kt:WidgetUpdater$fun + MaxLineLength:WidgetUpdater.kt:WidgetUpdater$if (at == null) cancel(context, ACTION_MOMENT) else setAlarm(context, ACTION_MOMENT, at.toEpochMilli(), wake = momentWakes(next, tick)) + MaxLineLength:WidgetWork.kt:WidgetWork$if (workerClassName == SessionWorker::class.java.name) SessionWorker(appContext, workerParameters, coroutineContext = dispatcher) else null + MaxLineLength:WorkoutCapsule.kt:Text(Workouts.sportName(sportType), color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:WorkoutCapsule.kt:Text(glance.line1, color = Color.White, style = MediumBody.copy(fontFeatureSettings = "tnum"), maxLines = 1, overflow = TextOverflow.Ellipsis) + MaxLineLength:WorkoutCapsule.kt:Text(it, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + MaxLineLength:WorkoutCapsule.kt:Text(label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + MaxLineLength:WorkoutCapsule.kt:if + MaxLineLength:WorkoutCapsule.kt:if (glance.figure != null) DotMatrixText(glance.figure, dot = DotSize.Hero) else WaitingDash(Modifier.padding(vertical = 12.dp)) + MaxLineLength:WorkoutCapsule.kt:infiniteRepeatable(keyframes { durationMillis = period; 1.5f at period * 15 / 100; 1f at period * 40 / 100 }, RepeatMode.Restart) + MaxLineLength:WorkoutCapsule.kt:internal + MaxLineLength:WorkoutCapsule.kt:modifier.fillMaxWidth().height(if (quiet) 56.dp else 64.dp).then(if (quiet) Modifier else Modifier.shadow(24.dp, shape, ambientColor = color, spotColor = color)) + MaxLineLength:WorkoutCapsule.kt:private + MaxLineLength:WorkoutCapsule.kt:val move = androidx.compose.animation.core.tween<androidx.compose.ui.unit.IntOffset>(swift, easing = org.openstrap.app.ui.components.Motion.easing) + MaxLineLength:WorkoutCopy.kt:WorkoutCopy$if (belowBpm == null) "Rest ends when the time is up." else "Rest ends when the time is up, or sooner once you stay under $belowBpm." + MaxLineLength:WorkoutCopy.kt:WorkoutCopy$return "Km ${i + 1} in ${workoutClock(sp.seconds)}" + (avg?.let { ", avg $it" } ?: "") to if (ago < 1) "just now" else "$ago min ago" + MaxLineLength:WorkoutCopy.kt:WorkoutCopy$return if (target.meters < 1000) "${m.toInt()} of ${target.label}" else String.format(java.util.Locale.ROOT, "%.2f of %s", m / 1000f, target.label) + MaxLineLength:WorkoutDetailCopy.kt:WorkoutDetailCopy$WorkoutFact("Distance", if (m >= 1000) km(m) else m.toInt().toString(), if (fromSteps) "$unit, estimated from your steps" else unit) + MaxLineLength:WorkoutDetailCopy.kt:WorkoutDetailCopy$if + MaxLineLength:WorkoutDetailCopy.kt:WorkoutDetailCopy$vo2max?.let { "VO2 max" to "The most oxygen your body can use, in ml per kg per minute, estimated from this workout: ${Math.round(it)}." } + MaxLineLength:WorkoutEffect.kt:WorkoutEffect$fun + MaxLineLength:WorkoutEffect.kt:WorkoutEffect$if (start.isNotEmpty() && end.isNotEmpty() && kotlin.math.abs(end.average() - start.average()) > MAX_GRADE * v * WINDOW_S) return Double.NaN + MaxLineLength:WorkoutEffect.kt:WorkoutEffect$private + MaxLineLength:WorkoutEffect.kt:WorkoutEffect.Result$data + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$dao.updateAppWorkoutEffect(w.start, e.epoc, e.aerobicTrainingEffect, e.recoveryMinutes, e.vo2max, e.anaerobicTrainingEffect, e.effectFromStrap, e.ownRecoveryMinutes) + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$dao.updateAppWorkoutEffect(w.start, s.epoc, s.aerobicTrainingEffect, s.recoveryMinutes, s.vo2max, s.anaerobicTrainingEffect, fromStrap = true, ownRecoveryMinutes = null) + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$return WorkoutRecorder.withStrapRecord(w, s, Workouts.effectOf(s, dao.sportEffectsOnce(s.start, s.start + Workouts.EFFECT_WINDOW_S))) + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$return phone.mapNotNull { p -> p.epoc?.let { p.from to it } } + strap.mapNotNull { s -> s.trainingLoad?.let { s.start to it.toFloat() } } + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$s.start < w.from && !Workouts.sameWorkout(s.start..s.end, w.from..w.end!!) && phone.none { p -> Workouts.sameWorkout(s.start..s.end, p.from..p.end!!) } + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$val + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$w.copy(activeKcal = null, totalKcal = null) + MaxLineLength:WorkoutMap.kt:val px = with(density) { listOf(fitPadding.calculateLeftPadding(LayoutDirection.Ltr), fitPadding.calculateTopPadding(), fitPadding.calculateRightPadding(LayoutDirection.Ltr), fitPadding.calculateBottomPadding()).map { it.roundToPx() } } + MaxLineLength:WorkoutNotification.kt:WorkoutNotification$Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_SHOW_WORKOUT, true).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + MaxLineLength:WorkoutNotification.kt:WorkoutNotification$fun + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$/** The state that closes the strap's session: STOP for a workout that is kept, ABANDON for one that isn't, so the strap drops its record. */ + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$fun + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$return + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$shownStart = w.from + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$val bpm = hr.filter { it.first in a until b }.map { it.second }.takeIf { it.isNotEmpty() }?.average()?.let { Math.round(it).toInt() } + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$val hr = (history.filter { it.fromHistory } + live.map { AppWorkoutHeartRate(w.start, it.first, it.second) }).sortedBy { it.timestamp } + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$val minutes = kcalMinutes(w, hr, minuteSteps(stepTotals) + syncedSteps, restingHr, maxHr, profile, age, vo2max, now) ?: return previous + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$val z = liveZoneSeconds(a.heartRate.filter { it.timestamp in a.shownStart..a.end }.map { it.timestamp to it.bpm }, a.maxHeartRate) + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder.Usual$data + MaxLineLength:WorkoutSession.kt:WorkoutSession$(w.pausedAt?.let { w.withPause(it, now()) } ?: w).also { if (it != w) strapState(org.openstrap.protocol.services.SportSession.State.RESUME, it, send = toStrap) } + MaxLineLength:WorkoutSession.kt:WorkoutSession$_state.value = it.copy(sets = sets, restUntil = restUntil, exercise = sets.lastOrNull()?.exercise, logSets = log, rounds = rounds) + MaxLineLength:WorkoutSession.kt:WorkoutSession$_stats.value = WorkoutRecorder.Stats(Math.round(sum.toDouble() / count).toInt(), maxOf(_stats.value?.max ?: 0, bpm), minOf(_stats.value?.min ?: bpm, bpm)) + MaxLineLength:WorkoutSession.kt:WorkoutSession$dao.activityOnce(r.workout.from - 60, t + 60).filter { it.steps in 0..250 }.associate { it.timestamp / 60 * 60 to it.steps } + MaxLineLength:WorkoutSession.kt:WorkoutSession$dao.upsertSavedWorkout(org.openstrap.app.data.db.SavedWorkout(name = name, sport = sport, structure = structure, createdAt = now())) + MaxLineLength:WorkoutSession.kt:WorkoutSession$if + MaxLineLength:WorkoutSession.kt:WorkoutSession$if (filter == android.app.NotificationManager.INTERRUPTION_FILTER_NONE || filter == android.app.NotificationManager.INTERRUPTION_FILTER_ALARMS) return + MaxLineLength:WorkoutSession.kt:WorkoutSession$return runCatching { org.openstrap.app.data.WorkoutEffectEngine(dao).withEffect(stats, hr, settings.profile.first(), route) }.getOrDefault(stats) + MaxLineLength:WorkoutSession.kt:WorkoutSession$scope.launch { runCatching { strap.buzz(c.pattern) }.onFailure { (strap as? StrapManager)?.log("coach $c not played: ${it.message}") } } + MaxLineLength:WorkoutSession.kt:WorkoutSession$scope.launch { runCatching { strap.buzz(pattern) }.onFailure { (strap as? StrapManager)?.log("buzz $what not played: ${it.message}") } } + MaxLineLength:WorkoutSession.kt:WorkoutSession$step.switch?.let { buzz(org.openstrap.app.data.WorkoutBuzz.of(it, game = s is org.openstrap.app.data.Structure.Periods), it.name) } + MaxLineLength:WorkoutSession.kt:WorkoutSession$val log = runCatching { settings.sportSettings.first()[org.openstrap.app.data.Workouts.tileType(w.sportType)]?.log }.getOrNull() == true + MaxLineLength:WorkoutSession.kt:WorkoutSession$val peak = { a: Long, b: Long -> org.openstrap.app.data.Strength.peak(synchronized(allReadings) { allReadings.toList() }, a, b) } + MaxLineLength:WorkoutSession.kt:WorkoutSession$val restUntil = sets.lastOrNull()?.takeIf { it.end != null && it.restEnd == null }?.let { it.end!! + restRule(w).seconds } + MaxLineLength:WorkoutSession.kt:WorkoutSession$val resting = runCatching { dao.restingHrOnce(now() - RESTING_LOOKBACK_S, now()).lastOrNull()?.bpm }.getOrNull() ?: DEFAULT_RESTING_HR + MaxLineLength:WorkoutSession.kt:WorkoutSession$val resting = runCatching { org.openstrap.app.data.WorkoutEffectEngine(dao).restingHr(w.from, zone) }.getOrDefault(DEFAULT_RESTING_HR.toFloat()) + MaxLineLength:WorkoutSession.kt:WorkoutSession$val stretches = org.openstrap.app.data.Rewards.Stretch.format(org.openstrap.app.data.Rewards.stretches(_route.value, ended.pauseRanges)) + MaxLineLength:WorkoutSession.kt:WorkoutSession$val w = AppWorkout(start = from, end = to, sportType = sportType, source = AppWorkout.SOURCE_SUGGESTED, endReason = AppWorkout.END_USER) + MaxLineLength:WorkoutSession.kt:WorkoutSession$val w = r.workout.let { w -> distanceNow(w)?.let { (m, steps) -> w.copy(distanceMeters = m, distanceFromSteps = steps) } ?: w } + MaxLineLength:WorkoutSession.kt:WorkoutSession.<no name provided>$if (cls != android.bluetooth.BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO && cls != android.bluetooth.BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE) return + MaxLineLength:WorkoutSession.kt:WorkoutSession.<no name provided>$val device = androidx.core.content.IntentCompat.getParcelableExtra(intent, android.bluetooth.BluetoothDevice.EXTRA_DEVICE, android.bluetooth.BluetoothDevice::class.java) + MaxLineLength:WorkoutSetup.kt:WorkoutSetup$current != null && current.low == current.high && current.low != tapped -> WorkoutTarget.Zones(minOf(current.low, tapped), maxOf(current.low, tapped)) + MaxLineLength:WorkoutSetup.kt:WorkoutSetup$saved.filter { Structure.parse(it.structure) != null }.take(SAVED_CHIPS).map { RecentChip.Saved(it.name, it.sport, it.structure) } + MaxLineLength:WorkoutSuggestions.kt:WorkoutSuggestions$out += Suggestion(run.first().first, run.last().first + 60, Math.round(run.map { it.second }.average()).toInt(), zones.maxBy { it.value }.key) + MaxLineLength:WorkoutTileService.kt:WorkoutTileService$val pending = PendingIntent.getActivity(this, REQUEST_TILE, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + MaxLineLength:Workouts.kt:Workouts$fun + MaxLineLength:Workouts.kt:Workouts$return (strap + phone.map { WorkoutRecorder.analyse(it, emptyList(), maxHr).copy(sets = sets[it.start]) }).sortedByDescending { it.workout.start } + MaxLineLength:Workouts.kt:Workouts$val strap = ws.filter { phoneTwin(it, phone, now) == null }.map { w -> analyse(w, listOfNotNull(lastByWorkout[w.start]), effects, maxHr) } + MaxLineLength:Workouts.kt:Workouts.Sides$data + MaxLineLength:WorkoutsMigration.kt:WorkoutsMigration$"`workStart` INTEGER NOT NULL, `workEnd` INTEGER, `restEnd` INTEGER, `peakBpm` INTEGER, PRIMARY KEY(`workoutStart`, `index`))" + MaxLineLength:WorkoutsMigration.kt:WorkoutsMigration$* + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$. + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$WorkoutRecorder.laps(w.from, w.end ?: a.end, marks, hr.filter { !it.fromHistory }.map { it.timestamp to it.bpm }, route, w.pauseRanges) + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$combine + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$combine(dao.appWorkouts(), dao.setCounts()) { phone, sets -> phone to sets.associate { it.workoutStart to it.sets } } + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$org.openstrap.app.data.Summaries.bucketAverage(org.openstrap.app.data.Summaries.heartRateSeries(dao.heartRateOnce(w.from, w.to), dao.activityOnce(w.from, w.to)), 60) + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$val + MaxLineLength:YouPage.kt:"Deleted sleep" + MaxLineLength:YouPage.kt:"Goals and bests" + MaxLineLength:YouPage.kt:"Health Connect" + MaxLineLength:YouPage.kt:PageRow("Backup", BackupCopy.summary(backup, now, zone), onClick = onBackup, below = true, icon = AppIcons.ShieldCheck, selected = selected == "Backup") + MaxLineLength:YouPage.kt:PageRow("Diagnostics", "$logLines lines, for bug reports", onClick = onDiagnostics, below = true, icon = AppIcons.Bug, selected = selected == "Diagnostics") + MaxLineLength:YouPage.kt:PageRow("Profile", profile.summary(), onClick = onProfile, below = true, divider = false, icon = AppIcons.User, selected = selected == "Profile") + MaxLineLength:YouPage.kt:fun + MaxLineLength:YouPage.kt:if (onSampleData != null) PageRow("Load sample data", "Debug builds only", onClick = onSampleData, below = true, icon = AppIcons.Flask) + MaxLineLength:YouPage.kt:org.openstrap.app.ui.workout.HeartZonesCopy.TITLE + MaxLineLength:ZeppCloud.kt:BoundDevice$override fun toString + MaxLineLength:ZeppCloud.kt:ZeppCloud$. + MaxLineLength:ZeppCloud.kt:ZeppCloud$if (result.isNotEmpty() && result != "ok") throw ZeppCloudException("Zepp refused to renew the sign-in ($result). Sign in again.", rejected = true) + MaxLineLength:ZeppCloud.kt:ZeppCloud$val bases = (listOfNotNull(preferredBase?.trim()?.takeIf { it.startsWith("https://") }?.let { if (it.endsWith("/")) it else "$it/" }) + API_HOSTS).distinct() + MaxLineLength:ZeppCloud.kt:ZeppCloud$val login = account.loginToken?.takeIf { it.isNotBlank() } ?: throw ZeppCloudException("No login token to renew the sign-in with. Sign in again.", rejected = true) + MaxLineLength:ZeppCloud.kt:ZeppCloud$val root = runCatching { JSONObject(body) }.getOrElse { throw ZeppCloudException("Unexpected reply when renewing the Zepp sign-in") } + MaxLineLength:ZeppSignIn.kt:"$provider sign-in doesn't work inside OpenStrap, because $provider blocks sign-in in embedded pages.\n\n" + MaxLineLength:ZeppSignIn.kt:Column + MaxLineLength:ZeppSignIn.kt:Modifier.size(44.dp).clip(CircleShape).background(ControlSurface).clickable(onClickLabel = "Close", onClick = onDismiss) + MaxLineLength:ZeppSignIn.kt:Row + MaxLineLength:ZeppSignIn.kt:Text("Auth key", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.weight(1f)) + MaxLineLength:ZeppSignIn.kt:Text(if (s.thirdParty) "Use email sign-in" else "Couldn't get your devices", color = Color.White, style = GlowType.cardTitle, maxLines = 1) + MaxLineLength:ZeppSignIn.kt:Text(label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, maxLines = 1, modifier = Modifier.padding(end = 12.dp)) + MaxLineLength:ZeppSignIn.kt:value + MemberNameEqualsClassName:Pai.kt:Pai$fun pai: Double + MissingNonRestartableComposable:MainActivity.kt:@androidx.compose.runtime.Composable private fun MetricDetail + MissingReadOnlyComposable:Charts.kt:@Composable private fun axisStyle + MissingReadOnlyComposable:Charts.kt:@Composable private fun readoutStyle + ModifierComposed:Motion.kt:fun Modifier.pressClickable: Modifier + ModifierMissing:Adaptive.kt:@Composable fun <S> ListDetail + ModifierMissing:AllMetricsScreen.kt:@Composable fun AllMetricsSheet + ModifierMissing:Celebrations.kt:@Composable fun BestCardScreen + ModifierMissing:Celebrations.kt:@Composable fun CelebrationsSheet + ModifierMissing:Celebrations.kt:@Composable fun GoalMomentScreen + ModifierMissing:Celebrations.kt:@Composable fun HistoryBadgesScreen + ModifierMissing:ChooseScreen.kt:@Composable fun GlowChooseScreen + ModifierMissing:DayCalendar.kt:@Composable fun DayCalendarSheet + ModifierMissing:DeletedSleepPage.kt:@Composable fun DeletedSleepPage + ModifierMissing:DeviceScreen.kt:@Composable fun DeviceScreen + ModifierMissing:DeviceScreen.kt:@Composable fun DiagnosticsScreen + ModifierMissing:FirmwareScreen.kt:@Composable fun FirmwareScreen + ModifierMissing:Glass.kt:@Composable fun GlassAlertDialog + ModifierMissing:Glass.kt:@Composable fun GlassOverlay + ModifierMissing:Glass.kt:@Composable fun GlassSheet + ModifierMissing:GlowLayout.kt:@Composable fun GlowHeader + ModifierMissing:GlowLayout.kt:@Composable fun GlowScreenList + ModifierMissing:GlowMetricDetail.kt:@Composable fun GlowMetricDetailScreen + ModifierMissing:GlowQuiet.kt:@Composable fun PanelTitle + ModifierMissing:GlowSleepScreen.kt:@Composable fun GlowSleepScreen + ModifierMissing:GlowTodayScreen.kt:@Composable fun GlowTodayScreen + ModifierMissing:GlowWorkoutMode.kt:@Composable fun GlowAddWorkoutScreen + ModifierMissing:GlowWorkoutSummary.kt:@Composable fun GlowWorkoutSummaryScreen + ModifierMissing:GoalsAndBests.kt:@Composable fun AllBadgesPage + ModifierMissing:GoalsAndBests.kt:@Composable fun GoalsAndBestsPage + ModifierMissing:GoalsAndBests.kt:@Composable fun WeeklyGoalSheet + ModifierMissing:GoalsAndBests.kt:@Composable fun WeeklyRecapPage + ModifierMissing:GoalsSheet.kt:@Composable fun GoalsSheet + ModifierMissing:HeartZonesScreen.kt:@Composable fun HeartZonesScreen + ModifierMissing:InfoSheet.kt:@Composable fun InfoSheet + ModifierMissing:LiveHeart.kt:@Composable fun GlowWorkoutLiveScreen + ModifierMissing:MomentHeader.kt:@Composable fun MomentHeader + ModifierMissing:PageParts.kt:@Composable fun <T> PageMenuRow + ModifierMissing:PageParts.kt:@Composable fun BackButton + ModifierMissing:PageParts.kt:@Composable fun PageConfirm + ModifierMissing:PageParts.kt:@Composable fun PageHeader + ModifierMissing:PageParts.kt:@Composable fun PageNote + ModifierMissing:PageParts.kt:@Composable fun PageRow + ModifierMissing:PageParts.kt:@Composable fun PageSheet + ModifierMissing:PageParts.kt:@Composable fun PageSwitchRow + ModifierMissing:PhoneAlertsScreen.kt:@Composable fun PhoneAlertsScreen + ModifierMissing:ProfileScreen.kt:@Composable fun ProfileScreen + ModifierMissing:PullToSync.kt:@Composable fun PullSyncReveal + ModifierMissing:ReadyScreen.kt:@Composable fun GlowWorkoutReadyScreen + ModifierMissing:ScreenParts.kt:@Composable fun ScreenList + ModifierMissing:UpdatesPage.kt:@Composable fun UpdatesPage + ModifierMissing:WorkoutCapsule.kt:@Composable fun PulseDot + ModifierMissing:WorkoutCapsule.kt:@Composable fun WorkoutMorph + ModifierMissing:YouPage.kt:@Composable fun YouPage + ModifierNaming:ReadyCardio.kt:field: Modifier + ModifierNotUsedAtRoot:GlowTodayScreen.kt:modifier + ModifierWithoutDefault:ChooseScreen.kt:modifier: Modifier + ModifierWithoutDefault:ComponentGalleryActivity.kt:modifier: Modifier + ModifierWithoutDefault:DataStateParts.kt:modifier: Modifier + ModifierWithoutDefault:DeviceScreen.kt:modifier: Modifier + ModifierWithoutDefault:GlowActivityScreen.kt:modifier: Modifier + ModifierWithoutDefault:GlowRecoveryScreen.kt:modifier: Modifier + ModifierWithoutDefault:GlowSleepScreen.kt:modifier: Modifier + ModifierWithoutDefault:GlowTodayScreen.kt:modifier: Modifier + ModifierWithoutDefault:Insights.kt:modifier: Modifier + ModifierWithoutDefault:LiveDistance.kt:modifier: Modifier + ModifierWithoutDefault:LiveFrame.kt:modifier: Modifier + ModifierWithoutDefault:LiveHeart.kt:modifier: Modifier + ModifierWithoutDefault:LiveSets.kt:modifier: Modifier + ModifierWithoutDefault:MomentHeader.kt:modifier: Modifier + ModifierWithoutDefault:ReadyCardio.kt:field: Modifier + ModifierWithoutDefault:ShareCards.kt:modifier: Modifier + ModifierWithoutDefault:ShareScreen.kt:modifier: Modifier + ModifierWithoutDefault:StartWidget.kt:modifier: GlanceModifier + ModifierWithoutDefault:StrapDrawing.kt:modifier: Modifier + ModifierWithoutDefault:WorkoutCapsule.kt:modifier: Modifier + ModifierWithoutDefault:ZeppSignIn.kt:modifier: Modifier + MultipleEmitters:FirmwareScreen.kt:@Composable private fun CheckResult + MultipleEmitters:GlowWorkoutMode.kt:@Composable internal fun TrimSection + MultipleEmitters:ReadyCardio.kt:@Composable private fun IntervalsGoal + MultipleEmitters:ReadyCardio.kt:@Composable private fun ReadyPlanCard + MultipleEmitters:ReadyCardio.kt:@Composable private fun ZoneGoal + MultipleEmitters:ShareCards.kt:@Composable private fun StoryHeading + MultipleEmitters:TodayCards.kt:@Composable internal fun EdgeValue + MultipleEmitters:TodayCards.kt:@Composable internal fun RecoveryFailed + MultipleEmitters:WidgetParts.kt:@Composable internal fun WaitingContent + MutableStateAutoboxing:ActivityDetail.kt:mutableStateOf(0f) + MutableStateAutoboxing:GlowCharts.kt:mutableStateOf(0f) + MutableStateAutoboxing:GlowWorkoutMode.kt:mutableStateOf(from) + MutableStateAutoboxing:GlowWorkoutMode.kt:mutableStateOf(to) + MutableStateAutoboxing:GlowWorkoutScreen.kt:mutableStateOf(0f) + MutableStateAutoboxing:ReadyCardio.kt:mutableStateOf(0) + NestedBlockDepth:BodySignals.kt:BodySignals$fun evaluate: List<Day> + NestedBlockDepth:Recovery.kt:Recovery$fun charge: ChargeDay + NestedBlockDepth:StartWidgetModel.kt:StartLayout$fun tiles: Tiles? + NoNameShadowing:DemoData.kt:DemoData${ it + it.first() } + NoNameShadowing:HistorySync.kt:HistorySync${ if (kept.isEmpty()) it else it.coerceAtMost(kept.maxOf { r -> r.timestamp } + 60) } + NoNameShadowing:HistorySync.kt:HistorySync${ it.start == w.summary.trackId } + NoNameShadowing:HistorySync.kt:HistorySync${ it.timestamp in MIN_VALID_EPOCH..now + FUTURE_SLACK } + NoNameShadowing:HistorySync.kt:HistorySync${ log("auto-detect notification: $it") } + ParameterNaming:ChooseScreen.kt:onSaved: (RecentChip.Saved) -> Unit = {} + ParameterNaming:GlowLayout.kt:onFocused: () -> Unit = {} + ParameterNaming:GlowRecoveryScreen.kt:onChargeShown: () -> Unit = {} + ParameterNaming:LiveHeart.kt:onSetDone: () -> Unit = {} + ParameterNaming:LiveSets.kt:onSetDone: () -> Unit + ParameterNaming:MainActivity.kt:onOpenSaved: (sport: Int, structure: String) -> Unit = { s, _ -> onReadySport(s) } + ParameterNaming:SleepDetail.kt:onGone: () -> Unit + ParameterNaming:WorkoutMap.kt:onPanned: () -> Unit = {} + RedundantSuspendModifier:CompanionLink.kt:CompanionLink$suspend + RememberMissing:MainActivity.kt:mutableStateOf(null) + ReturnCount:AlarmsScreen.kt:fun alarmsSummary: String? + ReturnCount:AppUpdater.kt:AppUpdater$suspend fun dailyCheck + ReturnCount:AutoHealthConnect.kt:HealthConnectAutoSync$suspend fun check: Boolean + ReturnCount:AutoStart.kt:AutoStart$fun offer: Offer? + ReturnCount:BackupCopy.kt:BackupCopy$fun status: String + ReturnCount:BackupCopy.kt:BackupCopy$fun summary: String + ReturnCount:BackupFiles.kt:BackupFiles$fun classify: Failure + ReturnCount:CardMotion.kt:CardMotion$fun detailTakesPart: Boolean + ReturnCount:CardToDetail.kt:@Composable fun Modifier.cardNumber: Modifier + ReturnCount:CardToDetail.kt:@Composable fun Modifier.detailNumber: Modifier + ReturnCount:CardToDetail.kt:@Composable fun Modifier.detailSurface: Modifier + ReturnCount:ChartSpec.kt:fun smoothOps: List<PathOp> + ReturnCount:Coaching.kt:Coach$private fun zoneCue: Cue? + ReturnCount:DailyEnergyHistory.kt:DailyEnergyHistory$suspend fun update: Int + ReturnCount:DataStates.kt:fun wear: Wear? + ReturnCount:DayViewModel.kt:DayViewModel$private suspend fun yesterdayScore: Int? + ReturnCount:DetailCopy.kt:DetailCopy$fun span: String? + ReturnCount:EnergyExpenditure.kt:EnergyExpenditure$fun bmrPerMinute: Float? + ReturnCount:EnergyExpenditure.kt:EnergyExpenditure$fun cadenceMet: Float + ReturnCount:EnergyExpenditure.kt:EnergyExpenditure$fun day: Day? + ReturnCount:EnergyExpenditure.kt:EnergyExpenditure$fun workoutMinutes: List<WorkoutMinute>? + ReturnCount:FamilySummary.kt:FamilySummary$fun gameRead: String? + ReturnCount:FamilySummary.kt:FamilySummary$fun settled: Pair<Int, Int>? + ReturnCount:FamilySummary.kt:FamilySummary$fun versusLast: String? + ReturnCount:FirmwareViewModel.kt:FirmwareViewModel$private suspend fun renewed: ZeppAccount + ReturnCount:Frame.kt:fun strapChip: StrapChip + ReturnCount:GlowCharts.kt:private fun upToLength: List<Offset> + ReturnCount:GlowWorkoutSummary.kt:@Composable fun GlowWorkoutSummaryScreen + ReturnCount:Haptics.kt:ScrubTicks$fun next: HapticEvent? + ReturnCount:HealthConnectExporter.kt:HealthConnectExporter$suspend fun exportWorkout: Int + ReturnCount:HealthRepository.kt:HealthRepository$fun daily: Flow<List<DayStat>> + ReturnCount:Healthspan.kt:Healthspan$fun blockOffset: Float? + ReturnCount:Healthspan.kt:Healthspan$fun estimate: Pair<Result?, Withheld?> + ReturnCount:Healthspan.kt:Healthspan$fun pace: Float? + ReturnCount:Healthspan.kt:Healthspan$internal fun interp: Float + ReturnCount:HeartZones.kt:HeartZones.Companion$fun parse: HeartZones? + ReturnCount:HistorySync.kt:HistorySync$private fun holdBackPartialMinute: Pair<List<HistoryRecord>, Long?> + ReturnCount:HistorySync.kt:HistorySync$private suspend fun syncType + ReturnCount:HistorySync.kt:HistorySync$private suspend fun syncWorkouts + ReturnCount:LiveGlance.kt:Glance$fun goalLine: Pair<String?, String?> + ReturnCount:LiveGlance.kt:Glance$fun of: LiveGlance + ReturnCount:LiveGlance.kt:internal fun intervalMoment: IntervalMoment? + ReturnCount:LiveSteps.kt:LiveSteps$fun today: Int + ReturnCount:MomentResolver.kt:MomentResolver$fun resolve: Moment + ReturnCount:MomentWidgetModel.kt:MomentLayout$fun ladder: List<Fit> + ReturnCount:MomentWidgetModel.kt:MomentLayout$fun numberRow: NumberRow? + ReturnCount:MomentWidgetModel.kt:MomentLayout$fun small: Fit + ReturnCount:MomentWidgetModel.kt:MomentWidgetModel.Companion$fun lead: MomentLead + ReturnCount:MomentWidgetModel.kt:MomentWidgetModel.Content$fun energyChart: MomentChart? + ReturnCount:MomentWidgetModel.kt:MomentWidgetModel.Content$fun heartChart: MomentChart? + ReturnCount:MomentWidgetModel.kt:MomentWidgetModel.Content$fun stagesChart: MomentChart? + ReturnCount:Phases.kt:Phases$fun tick: Step + ReturnCount:Phases.kt:Structure.Companion$fun parse: Structure? + ReturnCount:PhoneAlerts.kt:PhoneAlerts$fun onNotification + ReturnCount:PhoneAlerts.kt:PhoneAlerts$private fun shouldBuzz: Boolean + ReturnCount:Recovery.kt:Recovery$fun daytimeRestingHr: Float? + ReturnCount:Recovery.kt:Recovery$fun readiness: Readiness + ReturnCount:Recovery.kt:Recovery$fun withWorkoutMinutes: List<Pair<Long, Int>> + ReturnCount:RecoveryEngine.kt:RecoveryEngine$suspend fun phoneWorkoutKcal: List<EnergyExpenditure.WorkoutMinute>? + ReturnCount:RecoveryWidgetModel.kt:RecoveryWidgetModel.Companion$fun from: RecoveryWidgetModel + ReturnCount:Releases.kt:Releases$fun version: List<Int>? + ReturnCount:Rewards.kt:Rewards$fun progress: Pair<Long, Long>? + ReturnCount:RewardsCopy.kt:RewardsCopy$fun bestShort: String + ReturnCount:RewardsCopy.kt:RewardsCopy$fun bestTitle: String + ReturnCount:Route.kt:Route$fun zoneAt: Int + ReturnCount:Route.kt:Route$private fun velocity: Velocity? + ReturnCount:ShareCards.kt:StoryRouteShape$fun fit: List<Offset> + ReturnCount:ShareCards.kt:StoryRouteShape$fun trimEnds: List<Route.Point> + ReturnCount:SleepCopy.kt:SleepCopy$fun nightNormal: History.Normal? + ReturnCount:SleepEdits.kt:SleepEdits$fun stillHeartRate: Int? + ReturnCount:SleepWidgetModel.kt:SleepLayout$fun legendRows: Int + ReturnCount:SleepWidgetModel.kt:SleepLayout$fun scoreRow: ScoreRow + ReturnCount:SleepWidgetModel.kt:SleepLayout$private fun smallRow: ScoreRow + ReturnCount:SleepWidgetModel.kt:SleepWidgetModel.Companion$fun from: SleepWidgetModel + ReturnCount:StartCopy.kt:StartCopy$fun parseTime: Long? + ReturnCount:StartWidgetModel.kt:StartLayout$fun figures: Figures + ReturnCount:StartWidgetModel.kt:StartLayout$fun small: Small + ReturnCount:StartWidgetModel.kt:StartLayout$fun statusSmall: StatusSmall + ReturnCount:StartWidgetModel.kt:StartLayout$fun tiles: Tiles? + ReturnCount:StartWidgetModel.kt:StartLayout$private fun block: Block + ReturnCount:StartWidgetModel.kt:StartWidgetModel.Companion$fun fact: String + ReturnCount:StrapManager.kt:StrapManager$override suspend fun buzz + ReturnCount:StrapManager.kt:StrapManager$override suspend fun startCallBuzz + ReturnCount:StrapManager.kt:StrapManager$private fun startSync + ReturnCount:StrapManager.kt:StrapManager$private suspend fun notifyBodySignals + ReturnCount:StrapService.kt:StrapService$override fun onStartCommand: Int + ReturnCount:StrapService.kt:StrapService$private fun workoutNotification: Notification + ReturnCount:Strength.kt:Strength$fun restOver: Boolean + ReturnCount:Strength.kt:Strength.Rest.Companion$fun parse: Rest? + ReturnCount:SummaryCopy.kt:SummaryCopy$fun carriedOver: String? + ReturnCount:SummaryCopy.kt:SummaryCopy$fun dropLine: String + ReturnCount:SummarySections.kt:@Composable internal fun FamilySection: Boolean + ReturnCount:SyncStates.kt:fun syncStuck: Boolean + ReturnCount:TabCopy.kt:TabCopy$fun loadBalance: String? + ReturnCount:TodayCopy.kt:TodayCopy$fun waking: String + ReturnCount:TodayLooks.kt:fun varyLooks: List<TodaySlot> + ReturnCount:TodayLooks.kt:private fun options: List<TodaySlot> + ReturnCount:UpdateCopy.kt:UpdateCopy$fun checkStatus: String + ReturnCount:UpdateCopy.kt:UpdateCopy$fun install: String + ReturnCount:UpdateCopy.kt:UpdateCopy$fun summary: String + ReturnCount:Vo2max.kt:Vo2max$fun estimate: Pair<Estimate?, Withheld?> + ReturnCount:WeeklyRecap.kt:WeeklyRecap$suspend fun check + ReturnCount:WidgetGalleryActivity.kt:WidgetGalleryActivity$private fun bindAll + ReturnCount:WidgetUpdater.kt:UpdateThrottle$@Synchronized fun request: Long? + ReturnCount:WorkoutCopy.kt:WorkoutCopy$fun splitLine: SplitLine? + ReturnCount:WorkoutEffect.kt:WorkoutEffect$fun aerobicTe: Float + ReturnCount:WorkoutEffect.kt:WorkoutEffect$fun distAt: Double? + ReturnCount:WorkoutEffect.kt:WorkoutEffect$fun speed: Double? + ReturnCount:WorkoutEffect.kt:WorkoutEffect$fun vo2max: Float? + ReturnCount:WorkoutEffect.kt:WorkoutEffect$private fun window: Double? + ReturnCount:WorkoutEffectEngine.kt:WorkoutEffectEngine$suspend fun backfill + ReturnCount:WorkoutEffectEngine.kt:WorkoutEffectEngine$suspend fun backfillKcal + ReturnCount:WorkoutEffectEngine.kt:WorkoutEffectEngine$suspend fun strapEffect: AppWorkout? + ReturnCount:WorkoutGapFill.kt:WorkoutGapFill$suspend fun fill: Int + ReturnCount:WorkoutLocation.kt:WorkoutLocation$@SuppressLint("MissingPermission") fun start: Boolean + ReturnCount:WorkoutRecorder.kt:WorkoutRecorder$fun stoppedSince: Long? + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun cadence: Int? + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun checkRest + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun checkStopped + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun cue + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun onStrapPush + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun record + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun stepsDistance: Float? + ReturnCount:WorkoutSession.kt:WorkoutSession$private suspend fun countKcal + ReturnCount:WorkoutSession.kt:WorkoutSession$private suspend fun saveDistance + ReturnCount:WorkoutSession.kt:WorkoutSession.<no name provided>$override fun onReceive + ReturnCount:Workouts.kt:Workouts$fun activeKcal: Int? + ReturnCount:Workouts.kt:Workouts$fun indoorSide: Boolean? + ReturnCount:Workouts.kt:Workouts$private fun recovery: Int? + ReturnCount:YouPage.kt:fun healthConnectLine: String + ReturnCount:ZeppCloud.kt:ZeppCloud$fun needsAppTokenRefresh: Boolean + ReturnCount:ZeppCloud.kt:ZeppCloud$fun sessionFromCookies: ZeppSession? + ReturnCount:ZeppCloud.kt:ZeppCloud$private fun decodeJson: JSONObject? + SpreadOperator:Charts.kt:(*stops, startY = y1, endY = y0) + SpreadOperator:Charts.kt:(*stops.map { (f, c) -> f to c.copy(alpha = 0.16f) }.toTypedArray(), startY = y1, endY = y0) + SpreadOperator:ComposeDraw.kt:(*colorStops(), start = Offset(x0, y0), end = Offset(x1, y1)) + SpreadOperator:ComposeDraw.kt:(*stops.toTypedArray()) + SpreadOperator:Insights.kt:(*PinnedFade.stops(-ramp, 0f, size.height), startY = -ramp, endY = size.height) + SpreadOperator:Insights.kt:TabBarFade$(*stops, startY = top, endY = bottom) + SpreadOperator:MainActivity.kt:( // Remembered: a new lambda each time would be a new static local, recomposing the whole tab under it. org.openstrap.app.ui.glow.LocalGlowPagePaused provides remember(pager, page) { { pager.isScrollInProgress || pager.settledPage != page } }, org.openstrap.app.ui.dashboard.LocalScrollToTop provides scrollToTop[page], *(if (page == tab) emptyArray() else arrayOf(androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner provides idleBack)), ) + SpreadOperator:OpenStrapDatabase.kt:OpenStrapDatabase.Companion$(*MIGRATIONS) + SpreadOperator:WorkoutMap.kt:( *listOfNotNull( PropertyFactory.visibility(if (hereColor != null) Property.VISIBLE else Property.NONE), hereColor?.let { PropertyFactory.circleColor(it.toArgb()) }, ).toTypedArray(), ) + SuspendFunSwallowedCancellation:AlarmsViewModel.kt:AlarmsViewModel$runCatching + SuspendFunSwallowedCancellation:DailyEnergyHistory.kt:DailyEnergyHistory$runCatching + SuspendFunSwallowedCancellation:HistorySync.kt:HistorySync$catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e log("history failed: $e") // A wrapped system error's message is just its class name (android.os.DeadObjectException), not for the card. // Whatever a call on a dropped link threw (a missing characteristic, a closed op), the cause is the link. val reason = if (!gatt.connected.value) "The link to the strap dropped." else e.message?.takeUnless { e.cause != null && it == e.cause.toString() } state.value = SyncState.Failed(reason ?: "Sync failed") } + SuspendFunSwallowedCancellation:HistorySync.kt:HistorySync$runCatching + SuspendFunSwallowedCancellation:MainActivity.kt:MainActivity$runCatching + SuspendFunSwallowedCancellation:MainActivity.kt:runCatching + SuspendFunSwallowedCancellation:PhoneAlerts.kt:PhoneAlerts$runCatching + SuspendFunSwallowedCancellation:PhoneAlertsScreen.kt:runCatching + SuspendFunSwallowedCancellation:ShareScreen.kt:runCatching + SuspendFunSwallowedCancellation:StrapManager.kt:StrapManager$catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e log("connect failed: $e") connection.value = ConnectionState.Failed(e.message ?: "Connection failed") close() scheduleReconnect() } + SuspendFunSwallowedCancellation:StrapManager.kt:StrapManager$catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e log("live hr failed: $e") liveHeartRate.value = null } + SuspendFunSwallowedCancellation:StrapManager.kt:StrapManager$catch (e: Exception) { log("dfu failed: ${e.message}") throw e } + SuspendFunSwallowedCancellation:StrapManager.kt:StrapManager$runCatching + SuspendFunSwallowedCancellation:StrapService.kt:StrapService$runCatching + SuspendFunSwallowedCancellation:WidgetGalleryActivity.kt:WidgetGalleryActivity$catch (e: Exception) { Log.w(TAG, "widget gallery: update failed: $e") } + SuspendFunSwallowedCancellation:WorkoutGapFill.kt:WorkoutGapFill$runCatching + SuspendFunSwallowedCancellation:WorkoutSession.kt:WorkoutSession$runCatching + SuspendFunSwallowedCancellation:WorkoutSession.kt:WorkoutSession.<no name provided>$runCatching + SuspendFunSwallowedCancellation:WorkoutsViewModel.kt:WorkoutsViewModel$runCatching + SwallowedException:DayViewModel.kt:DayViewModel$e: Exception + SwallowedException:HistorySync.kt:HistorySync$e: FastRechargeModeException + SwallowedException:StrapService.kt:StrapService$e: SecurityException + ThrowsCount:AppUpdater.kt:AppUpdater$fun install + ThrowsCount:AutoBackup.kt:AutoBackup$private suspend fun write: String + ThrowsCount:StrapManager.kt:StrapManager$override suspend fun installFirmware + ThrowsCount:StrapManager.kt:StrapManager$override suspend fun restartStrap + ThrowsCount:StrapManager.kt:StrapManager$override suspend fun sportSession + ThrowsCount:StrapManager.kt:StrapManager$private suspend fun command: ByteArray + ThrowsCount:StrapManager.kt:StrapManager$private suspend fun sendFindDevice + ThrowsCount:ZeppCloud.kt:ZeppCloud$fun parseAppTokens: AppTokenRefresh + TooGenericExceptionCaught:DayViewModel.kt:DayViewModel$e: Exception + TooGenericExceptionCaught:GattClient.kt:GattClient$e: RuntimeException + TooGenericExceptionCaught:HistorySync.kt:HistorySync$e: Exception + TooGenericExceptionCaught:MomentWidget.kt:MomentWidget.Companion$e: Exception + TooGenericExceptionCaught:RecoveryWidget.kt:RecoveryWidget.Companion$e: Exception + TooGenericExceptionCaught:ShareScreen.kt:StoryFiles$e: Exception + TooGenericExceptionCaught:SleepWidget.kt:SleepWidget.Companion$e: Exception + TooGenericExceptionCaught:StrapManager.kt:StrapManager$e: Exception + TooGenericExceptionCaught:SyncNowAction.kt:SyncNowAction$e: Exception + TooGenericExceptionCaught:WearReminder.kt:WearEventLog$e: Exception + TooGenericExceptionCaught:WidgetGalleryActivity.kt:WidgetGalleryActivity$e: Exception + TooGenericExceptionCaught:WidgetParts.kt:e: Exception + TooManyFunctions:ActivityCopy.kt:ActivityCopy + TooManyFunctions:ActivityDetail.kt:org.openstrap.app.ui.glow.ActivityDetail.kt + TooManyFunctions:AppUpdater.kt:AppUpdater + TooManyFunctions:CaloriesCopy.kt:CaloriesCopy + TooManyFunctions:CanvasDraw.kt:org.openstrap.app.ui.glow.draw.CanvasDraw.kt + TooManyFunctions:CardMotion.kt:CardMotion + TooManyFunctions:ChartSpec.kt:org.openstrap.app.ui.glow.draw.ChartSpec.kt + TooManyFunctions:ChooseScreen.kt:org.openstrap.app.ui.glow.ChooseScreen.kt + TooManyFunctions:ComposeDraw.kt:org.openstrap.app.ui.glow.draw.ComposeDraw.kt + TooManyFunctions:CsvTables.kt:CsvRow + TooManyFunctions:Daos.kt:HealthDao + TooManyFunctions:DataStates.kt:org.openstrap.app.ui.dashboard.DataStates.kt + TooManyFunctions:DetailCopy.kt:DetailCopy + TooManyFunctions:DeviceScreen.kt:org.openstrap.app.ui.device.DeviceScreen.kt + TooManyFunctions:DeviceViewModel.kt:DeviceViewModel : ViewModel + TooManyFunctions:FamilySummary.kt:FamilySummary + TooManyFunctions:FirmwareScreen.kt:org.openstrap.app.ui.firmware.FirmwareScreen.kt + TooManyFunctions:FirmwareViewModel.kt:FirmwareViewModel : ViewModel + TooManyFunctions:GattClient.kt:GattClient + TooManyFunctions:GlowActivityScreen.kt:org.openstrap.app.ui.glow.GlowActivityScreen.kt + TooManyFunctions:GlowCharts.kt:org.openstrap.app.ui.glow.GlowCharts.kt + TooManyFunctions:GlowLayout.kt:org.openstrap.app.ui.glow.GlowLayout.kt + TooManyFunctions:GlowMetricDetail.kt:org.openstrap.app.ui.glow.GlowMetricDetail.kt + TooManyFunctions:GlowRecoveryScreen.kt:org.openstrap.app.ui.glow.GlowRecoveryScreen.kt + TooManyFunctions:GlowTodayScreen.kt:org.openstrap.app.ui.glow.GlowTodayScreen.kt + TooManyFunctions:GlowWorkoutMode.kt:org.openstrap.app.ui.glow.GlowWorkoutMode.kt + TooManyFunctions:HealthConnectExporter.kt:HealthConnectExporter + TooManyFunctions:HealthRepository.kt:HealthRepository + TooManyFunctions:Healthspan.kt:Healthspan + TooManyFunctions:HistorySync.kt:HistorySync + TooManyFunctions:InfoCopy.kt:InfoCopy + TooManyFunctions:LiveFrame.kt:org.openstrap.app.ui.glow.live.LiveFrame.kt + TooManyFunctions:LiveGlance.kt:Glance + TooManyFunctions:LiveSets.kt:org.openstrap.app.ui.glow.live.LiveSets.kt + TooManyFunctions:MomentWidget.kt:org.openstrap.app.widget.MomentWidget.kt + TooManyFunctions:MomentWidgetModel.kt:MomentLayout + TooManyFunctions:MomentWidgetModel.kt:MomentWidgetModel$Content + TooManyFunctions:PageParts.kt:org.openstrap.app.ui.glow.PageParts.kt + TooManyFunctions:PhoneAlerts.kt:PhoneAlerts + TooManyFunctions:ReadyCardio.kt:org.openstrap.app.ui.glow.ReadyCardio.kt + TooManyFunctions:Recovery.kt:Recovery + TooManyFunctions:RecoveryEngine.kt:RecoveryEngine + TooManyFunctions:Rewards.kt:Rewards + TooManyFunctions:RewardsCopy.kt:RewardsCopy + TooManyFunctions:Route.kt:Route + TooManyFunctions:SettingsStore.kt:SettingsStore + TooManyFunctions:ShareCards.kt:org.openstrap.app.ui.glow.ShareCards.kt + TooManyFunctions:SleepCopy.kt:SleepCopy + TooManyFunctions:SleepDetail.kt:org.openstrap.app.ui.glow.SleepDetail.kt + TooManyFunctions:SleepEdits.kt:SleepEdits + TooManyFunctions:SleepSource.kt:SleepSource + TooManyFunctions:SleepWidget.kt:org.openstrap.app.widget.SleepWidget.kt + TooManyFunctions:SleepWidgetModel.kt:SleepLayout + TooManyFunctions:StartCopy.kt:StartCopy + TooManyFunctions:StartWidget.kt:org.openstrap.app.widget.StartWidget.kt + TooManyFunctions:StartWidgetModel.kt:StartLayout + TooManyFunctions:StepsWidget.kt:org.openstrap.app.widget.StepsWidget.kt + TooManyFunctions:StepsWidgetModel.kt:StepsLayout + TooManyFunctions:StrapController.kt:StrapController + TooManyFunctions:StrapManager.kt:StrapManager : StrapController + TooManyFunctions:StrapService.kt:StrapService : LifecycleService + TooManyFunctions:SummaryCopy.kt:SummaryCopy + TooManyFunctions:SummarySections.kt:org.openstrap.app.ui.glow.SummarySections.kt + TooManyFunctions:TabCopy.kt:TabCopy + TooManyFunctions:TodayCards.kt:org.openstrap.app.ui.glow.TodayCards.kt + TooManyFunctions:TodayCopy.kt:TodayCopy + TooManyFunctions:TodayLooks.kt:org.openstrap.app.ui.dashboard.TodayLooks.kt + TooManyFunctions:WidgetGalleryActivity.kt:WidgetGalleryActivity : ComponentActivity + TooManyFunctions:WidgetParts.kt:org.openstrap.app.widget.WidgetParts.kt + TooManyFunctions:WidgetUpdater.kt:WidgetUpdater + TooManyFunctions:WorkoutCopy.kt:WorkoutCopy + TooManyFunctions:WorkoutDetailCopy.kt:WorkoutDetailCopy + TooManyFunctions:WorkoutRecorder.kt:WorkoutRecorder + TooManyFunctions:WorkoutSession.kt:WorkoutSession + TooManyFunctions:Workouts.kt:Workouts + TooManyFunctions:ZeppCloud.kt:ZeppCloud + TopLevelPropertyNaming:GlowCharts.kt:/** Your normal behind a chart: a band of the ink at 12% (spec 2.7). */ private const val NormalBandAlpha = ChartInk.NORMAL_BAND_ALPHA + TopLevelPropertyNaming:GlowWorkoutScreen.kt:/** The heart rate line is drawn in ten-second steps: readable on a phone, and the card still says the time. */ private const val HeartStep = 10L + TopLevelPropertyNaming:GlowWorkoutSummary.kt:/** How much higher the hero's light reaches at the top of a personal best's swell. */ private const val SwellLevel = 0.25f + TopLevelPropertyNaming:GlowWorkoutSummary.kt:/** The drop line shows the last minute of the workout before the end. */ private const val BeforeS = 60L + TopLevelPropertyNaming:ShareCards.kt:/** White for a card's dimmer lines, as on the boards. */ private const val StoryDim = 0.7f + TopLevelPropertyNaming:SummarySections.kt:/** How many points a heart line is drawn through. */ private const val HeartPoints = 60 + TopLevelPropertyNaming:SummarySections.kt:/** Rounds shown with their figures; past this many there's no room, and the columns stand alone. */ private const val LabelledRounds = 10 + UnnecessaryLaunchedEffect:ChooseScreen.kt:LaunchedEffect + UnnecessaryLaunchedEffect:DeviceScreen.kt:LaunchedEffect + UnnecessaryLaunchedEffect:GlowTodayScreen.kt:LaunchedEffect + UnnecessaryLaunchedEffect:GlowWorkoutMode.kt:LaunchedEffect + UnnecessaryLaunchedEffect:GoalsSheet.kt:LaunchedEffect + UnnecessaryLaunchedEffect:LiveDistance.kt:LaunchedEffect + UnnecessaryLaunchedEffect:LiveHeart.kt:LaunchedEffect + UnnecessaryLaunchedEffect:MainActivity.kt:LaunchedEffect + UnnecessaryLaunchedEffect:ReadyCardio.kt:LaunchedEffect + UnnecessaryLaunchedEffect:WorkoutMap.kt:LaunchedEffect + UnsafeCallOnNullableType:Activity.kt:Activity$minutes[k].hrr!! + UnsafeCallOnNullableType:ActivityDetail.kt:shown.first!! + UnsafeCallOnNullableType:ChartSpec.kt:values[lastIndex]!! + UnsafeCallOnNullableType:ComponentGalleryActivity.kt:GlowGrid.height(CardSize.Tall)!! + UnsafeCallOnNullableType:CsvTables.kt:CsvRow$cell(name)!! + UnsafeCallOnNullableType:EnergyExpenditure.kt:EnergyExpenditure$hrSum[m.t]!! + UnsafeCallOnNullableType:EnergyExpenditure.kt:EnergyExpenditure$m.hrr!! + UnsafeCallOnNullableType:FamilySummary.kt:FamilySummary$last.workout.averageHeartRate!! + UnsafeCallOnNullableType:FamilySummary.kt:FamilySummary$lines[it].peak!! + UnsafeCallOnNullableType:FamilySummary.kt:FamilySummary$r.workEnd!! + UnsafeCallOnNullableType:FamilySummary.kt:FamilySummary$s.end!! + UnsafeCallOnNullableType:GattClient.kt:GattClient$gatt!! + UnsafeCallOnNullableType:GlowCharts.kt:values[i]!! + UnsafeCallOnNullableType:GlowWorkoutMode.kt:WorkoutRecorder.liveZone(a.second, maxHr)!! + UnsafeCallOnNullableType:GlowWorkoutSummary.kt:set.end!! + UnsafeCallOnNullableType:GoalsSheet.kt:activeValue!! + UnsafeCallOnNullableType:GoalsSheet.kt:sleepValue!! + UnsafeCallOnNullableType:GoalsSheet.kt:stepsValue!! + UnsafeCallOnNullableType:HistorySync.kt:HistorySync$next!! + UnsafeCallOnNullableType:HistorySync.kt:HistorySync$spec.parser!! + UnsafeCallOnNullableType:LiveDistance.kt:round.workEnd!! + UnsafeCallOnNullableType:LiveIntervals.kt:it.workEnd!! + UnsafeCallOnNullableType:LiveSets.kt:s.end!! + UnsafeCallOnNullableType:MomentWidgetModel.kt:MomentWidgetModel.Content$input.lastWorkout!! + UnsafeCallOnNullableType:MomentWidgetModel.kt:MomentWidgetModel.Content$input.night!! + UnsafeCallOnNullableType:MomentWidgetModel.kt:MomentWidgetModel.Content$input.recording!! + UnsafeCallOnNullableType:MomentWidgetModel.kt:MomentWidgetModel.Content$n.session.score!! + UnsafeCallOnNullableType:Phases.kt:Phases$r.workEnd!! + UnsafeCallOnNullableType:ReadyPlans.kt:periods!! + UnsafeCallOnNullableType:ReadyPlans.kt:rounds!! + UnsafeCallOnNullableType:ReadyScreen.kt:pair!! + UnsafeCallOnNullableType:Recovery.kt:Recovery$percentile(values, 0.5f)!! + UnsafeCallOnNullableType:Recovery.kt:Recovery$percentile(values.map { abs(it - centre) }, 0.5f)!! + UnsafeCallOnNullableType:Recovery.kt:Recovery$steps!! + UnsafeCallOnNullableType:Recovery.kt:Recovery$tonight.hrv!! + UnsafeCallOnNullableType:ReleaseNotes.kt:ReleaseNotes$bullet.find(line)!! + UnsafeCallOnNullableType:ReleaseNotes.kt:ReleaseNotes$heading.find(line)!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$badge(id)!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$badge(id)!!.needs!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$it.gpsMeters!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$it.second.exercise!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$it.second.weightKg!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$s.exercise!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$s.weightKg!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$sets.maxByOrNull { it.second.weightKg!! }!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$w.gpsMeters!! + UnsafeCallOnNullableType:RewardsCopy.kt:RewardsView$progress[b.id]!! + UnsafeCallOnNullableType:ShareCards.kt:it.exercise!! + UnsafeCallOnNullableType:ShareCards.kt:mine.first().exercise!! + UnsafeCallOnNullableType:SleepEdits.kt:SleepEdits.Resolved$edit.end!! + UnsafeCallOnNullableType:SleepEdits.kt:SleepEdits.Resolved$edit.start!! + UnsafeCallOnNullableType:StrapStatus.kt:lastSync!! + UnsafeCallOnNullableType:Strength.kt:Strength$it.reps!! + UnsafeCallOnNullableType:Strength.kt:Strength$it.weightKg!! + UnsafeCallOnNullableType:SummaryCopy.kt:SummaryCopy$usual!! + UnsafeCallOnNullableType:SummarySections.kt:it.value!! + UnsafeCallOnNullableType:TabCopy.kt:TabCopy$reg.band!! + UnsafeCallOnNullableType:TabParts.kt:GlowGrid.height(CardSize.Tall)!! + UnsafeCallOnNullableType:TodayLooks.kt:out[a].look!! + UnsafeCallOnNullableType:TodayLooks.kt:slots[a].look!! + UnsafeCallOnNullableType:Vo2max.kt:Vo2max$Recovery.percentile(recent.takeLast(RHR_NIGHTS_USED), 0.5f)!! + UnsafeCallOnNullableType:WeekStory.kt:WeekStory$it.steps!! + UnsafeCallOnNullableType:WorkoutEffectEngine.kt:WorkoutEffectEngine$p.end!! + UnsafeCallOnNullableType:WorkoutEffectEngine.kt:WorkoutEffectEngine$w.end!! + UnsafeCallOnNullableType:WorkoutRecorder.kt:WorkoutRecorder$liveZone(a.second, maxHr)!! + UnsafeCallOnNullableType:WorkoutSession.kt:WorkoutSession$WorkoutRecorder.liveZone(pbpm, r.maxHeartRate)!! + UnsafeCallOnNullableType:WorkoutSession.kt:WorkoutSession$it.end!! + UnsafeCallOnNullableType:WorkoutSuggestions.kt:WorkoutSuggestions$WorkoutRecorder.liveZone(it.second, maxHr)!! + UnstableCollections:ActivityDetail.kt:List<Color> + UnstableCollections:ActivityDetail.kt:List<Float> + UnstableCollections:ActivityDetail.kt:List<List<Float>> + UnstableCollections:ActivityDetail.kt:List<Pair<String, Color>> + UnstableCollections:ActivityDetail.kt:List<String> + UnstableCollections:ActivityDetail.kt:Map<LocalDate, CaloriesCopy.DayKcal> + UnstableCollections:AllMetricsScreen.kt:List<IndexGroup> + UnstableCollections:Celebrations.kt:List<Long> + UnstableCollections:Celebrations.kt:List<Pair<Rewards.Badge, String?>> + UnstableCollections:Celebrations.kt:List<Rewards.Badge> + UnstableCollections:Charts.kt:List<ChartPoint> + UnstableCollections:Charts.kt:List<ChartSegment> + UnstableCollections:Charts.kt:List<Pair<Float, Color>>? + UnstableCollections:ChooseScreen.kt:List<Int> + UnstableCollections:ChooseScreen.kt:List<RecentChip> + UnstableCollections:ChooseScreen.kt:Map<Int, Boolean> + UnstableCollections:DayCalendar.kt:Set<LocalDate> + UnstableCollections:DeletedSleepPage.kt:List<SleepEdits.Resolved> + UnstableCollections:DeviceScreen.kt:List<String> + UnstableCollections:FirmwareScreen.kt:List<String> + UnstableCollections:GlowActivityScreen.kt:List<Float> + UnstableCollections:GlowActivityScreen.kt:List<Float>? + UnstableCollections:GlowActivityScreen.kt:List<Int> + UnstableCollections:GlowActivityScreen.kt:List<WorkoutAnalysis> + UnstableCollections:GlowCharts.kt:List<Color> + UnstableCollections:GlowCharts.kt:List<Color>? + UnstableCollections:GlowCharts.kt:List<Float> + UnstableCollections:GlowCharts.kt:List<Float>? + UnstableCollections:GlowCharts.kt:List<Float?> + UnstableCollections:GlowCharts.kt:List<GlowDay> + UnstableCollections:GlowCharts.kt:List<GlowStage> + UnstableCollections:GlowCharts.kt:List<Int> + UnstableCollections:GlowCharts.kt:List<Int?> + UnstableCollections:GlowCharts.kt:List<String> + UnstableCollections:GlowCharts.kt:List<org.openstrap.app.ui.components.StageSpan> + UnstableCollections:GlowCharts.kt:List<org.openstrap.app.ui.dashboard.DetailCopy.Level?> + UnstableCollections:GlowGrid.kt:List<GridCard> + UnstableCollections:GlowLayout.kt:List<@Composable () -> Unit> + UnstableCollections:GlowMetricDetail.kt:List<DayStat> + UnstableCollections:GlowMetricDetail.kt:List<DetailCopy.StripDay> + UnstableCollections:GlowMetricDetail.kt:List<DetailSibling> + UnstableCollections:GlowMetricDetail.kt:List<Metric> + UnstableCollections:GlowMetricDetail.kt:List<Pair<String?, String>> + UnstableCollections:GlowMetricDetail.kt:List<Range> + UnstableCollections:GlowMetricDetail.kt:List<String> + UnstableCollections:GlowRecoveryScreen.kt:List<BodySignal> + UnstableCollections:GlowSleepScreen.kt:List<Pair<Int, Long>> + UnstableCollections:GlowSleepScreen.kt:List<StageSpan> + UnstableCollections:GlowTodayScreen.kt:List<Float>? + UnstableCollections:GlowTodayScreen.kt:List<WorkoutAnalysis> + UnstableCollections:GlowWorkoutMode.kt:List<Int> + UnstableCollections:GlowWorkoutMode.kt:List<Pair<Long, Int>> + UnstableCollections:GlowWorkoutMode.kt:List<Pair<Long, Int>>? + UnstableCollections:GlowWorkoutMode.kt:Map<Int, Boolean> + UnstableCollections:GlowWorkoutScreen.kt:List<Float?> + UnstableCollections:GlowWorkoutScreen.kt:List<Int> + UnstableCollections:GlowWorkoutScreen.kt:List<LongRange> + UnstableCollections:GlowWorkoutScreen.kt:List<WorkoutAnalysis> + UnstableCollections:GlowWorkoutScreen.kt:List<WorkoutFact> + UnstableCollections:GlowWorkoutSummary.kt:List<Pair<Long, Int>> + UnstableCollections:GlowWorkoutSummary.kt:List<Route.Point> + UnstableCollections:GlowWorkoutSummary.kt:List<SplitRow> + UnstableCollections:GlowWorkoutSummary.kt:List<WorkoutAnalysis> + UnstableCollections:GoalsAndBests.kt:List<Pair<String, Boolean>> + UnstableCollections:InfoSheet.kt:List<Pair<String, String>>? + UnstableCollections:InfoSheet.kt:List<String> + UnstableCollections:Insights.kt:List<NavItem> + UnstableCollections:LiveDistance.kt:List<Pair<Long, Int>> + UnstableCollections:LiveDistance.kt:List<Route.Point> + UnstableCollections:LiveDistance.kt:List<Route.Split> + UnstableCollections:LiveFrame.kt:List<Pair<String, Color>> + UnstableCollections:LiveGame.kt:List<Pair<Long, Int>> + UnstableCollections:LiveHeart.kt:List<LongRange> + UnstableCollections:LiveHeart.kt:List<Pair<Long, Int>> + UnstableCollections:LiveHeart.kt:List<Route.Point> + UnstableCollections:LiveHeart.kt:List<org.openstrap.app.data.db.ExerciseRow> + UnstableCollections:LiveIndoor.kt:List<Pair<Long, Int>> + UnstableCollections:LiveIntervals.kt:List<AppWorkoutRound> + UnstableCollections:LiveIntervals.kt:List<Pair<Long, Int>> + UnstableCollections:LiveIntervals.kt:List<Route.Point> + UnstableCollections:LiveMap.kt:List<LongRange> + UnstableCollections:LiveMap.kt:List<Pair<Long, Int>> + UnstableCollections:LiveMap.kt:List<Pair<String?, String>> + UnstableCollections:LiveMap.kt:List<Route.Point> + UnstableCollections:LiveMind.kt:List<Float?> + UnstableCollections:LiveMind.kt:List<Pair<Long, Int>> + UnstableCollections:LivePaused.kt:List<Pair<Long, Int>> + UnstableCollections:LivePaused.kt:List<Pair<String?, String>> + UnstableCollections:LiveSets.kt:List<AppWorkoutSet> + UnstableCollections:LiveSets.kt:List<ExerciseRow> + UnstableCollections:LiveSets.kt:List<Pair<Long, Int>> + UnstableCollections:LiveSets.kt:List<String> + UnstableCollections:MainActivity.kt:List<Page> + UnstableCollections:PageParts.kt:List<T> + UnstableCollections:ReadyCardio.kt:List<GoalKind> + UnstableCollections:ReadyCardio.kt:List<NumberField> + UnstableCollections:ReadyCardio.kt:List<Pair<Long, String>> + UnstableCollections:ReadyCardio.kt:List<Pair<String, String>> + UnstableCollections:ReadyCardio.kt:List<Pair<T, PlanOption>> + UnstableCollections:ReadyCardio.kt:List<Pair<T, String>> + UnstableCollections:ReadyCardio.kt:List<PlanOption> + UnstableCollections:ReadyCardio.kt:List<SavedWorkout> + UnstableCollections:ReadyScreen.kt:List<Int> + UnstableCollections:ReadyScreen.kt:List<org.openstrap.app.data.db.SavedWorkout> + UnstableCollections:ReadyScreen.kt:Map<Int, Boolean> + UnstableCollections:RecoveryWidget.kt:List<RecoveryFigure> + UnstableCollections:RecoveryWidget.kt:List<RecoveryWeekDay> + UnstableCollections:ScoreDetail.kt:List<org.openstrap.app.data.WorkoutAnalysis> + UnstableCollections:ShareCards.kt:List<Float?> + UnstableCollections:ShareCards.kt:List<Int> + UnstableCollections:ShareCards.kt:List<Int?> + UnstableCollections:ShareCards.kt:List<Route.Point> + UnstableCollections:ShareCards.kt:List<WeekFigure> + UnstableCollections:ShareScreen.kt:List<EarnedLine> + UnstableCollections:ShareScreen.kt:List<Route.Point> + UnstableCollections:ShareScreen.kt:List<WorkoutAnalysis> + UnstableCollections:SleepDetail.kt:List<ChartPoint> + UnstableCollections:SleepDetail.kt:List<SleepSession> + UnstableCollections:SleepDetail.kt:List<String> + UnstableCollections:SleepPanels.kt:List<SleepNight> + UnstableCollections:SleepWidget.kt:List<SleepLegendItem> + UnstableCollections:SleepWidget.kt:List<SleepStageRow> + UnstableCollections:SummarySections.kt:List<EarnedLine> + UnstableCollections:SummarySections.kt:List<ExerciseSets> + UnstableCollections:SummarySections.kt:List<Float?> + UnstableCollections:SummarySections.kt:List<Long> + UnstableCollections:SummarySections.kt:List<Pair<Long, Int>> + UnstableCollections:SummarySections.kt:List<Pair<String, Boolean>> + UnstableCollections:SummarySections.kt:List<Pair<String?, String>> + UnstableCollections:SummarySections.kt:List<PeriodLine> + UnstableCollections:SummarySections.kt:List<RoundLine> + UnstableCollections:SummarySections.kt:List<Route.Point> + UnstableCollections:SummarySections.kt:List<WorkoutAnalysis> + UnstableCollections:TabParts.kt:List<Pair<String?, String>> + UnstableCollections:TodayCards.kt:List<BodyRow> + UnstableCollections:TodayCards.kt:List<Float>? + UnstableCollections:TodayCards.kt:List<Float?>? + UnstableCollections:TodayCards.kt:List<HealthAlertRow> + UnstableCollections:TodayCards.kt:List<String> + UnstableCollections:TodayCards.kt:List<org.openstrap.app.data.WorkoutAnalysis> + UnstableCollections:WidgetParts.kt:List<Pair<String, Boolean>> + UnstableCollections:WorkoutMap.kt:List<Pair<List<Route.Point>, Int>> + UnusedParameter:Narrative.kt:Narrative$session: SleepSession + UnusedParameter:Rewards.kt:Rewards$days: List<Day> + UnusedPrivateFunction:StrapService.kt:StrapService.Companion$private fun zoneRange + UnusedPrivateFunction:SummaryCopy.kt:SummaryCopy$private fun month: String + UnusedPrivateProperty:SummaryCopy.kt:SummaryCopy$/** Shorter than these and a best means little. */ private const val BEST_MIN_M = 1000f + UnusedPrivateProperty:SummaryCopy.kt:SummaryCopy$private const val BEST_MIN_S = 10 * 60L + UnusedVariable:LiveDistance.kt:val density = LocalDensity.current + UnusedVariable:WorkoutsViewModel.kt:WorkoutsViewModel$all + UseIsNullOrEmpty:WeeklyRecap.kt:WeeklyRecap$week == null || week.count == 0 + UseOrEmpty:ActivityDetail.kt:e?.let { mapOf(date to CaloriesCopy.DayKcal(date, it.activeKcal, it.totalKcal, it.workoutKcal, it.partial)) } ?: emptyMap() + UseOrEmpty:ActivityDetail.kt:it.second ?: emptyList() + UseOrEmpty:Backtest.kt:Backtest.Companion$it?.toString() ?: "" + UseOrEmpty:CsvExporter.kt:CsvExporter.Companion$v?.toString()?.let(::escape) ?: "" + UseOrEmpty:GlowSleepScreen.kt:SLEEP_NAMES[it.key] ?: "" + UseOrEmpty:GlowSleepScreen.kt:byRow[it] ?: "" + UseOrEmpty:GlowWorkoutSummary.kt:r.averageBpm?.let { ", average $it bpm" } ?: "" + UseOrEmpty:HeartZonesCopy.kt:HeartZonesCopy$m.learnedOn?.let { ", " + it.format(DateTimeFormatter.ofPattern("d MMM", locale)) } ?: "" + UseOrEmpty:HistorySync.kt:HistorySync$only?.let { ", syncing $it" } ?: "" + UseOrEmpty:LiveGlance.kt:Glance$(i.target as? WorkoutTarget.Time)?.let { " of ${it.seconds / 60} min" } ?: "" + UseOrEmpty:LiveGlance.kt:Glance$m.drop?.takeIf { it > 0 }?.let { ", down $it" } ?: "" + UseOrEmpty:LiveGlance.kt:Glance$s.drop?.takeIf { it > 0 }?.let { ", down $it" } ?: "" + UseOrEmpty:LiveGlance.kt:Glance$s.setsTarget?.let { " of $it" } ?: "" + UseOrEmpty:LiveIntervals.kt:m.drop?.takeIf { it > 0 }?.let { ", down $it" } ?: "" + UseOrEmpty:LiveMind.kt:first?.minus(b)?.takeIf { it > 0 }?.let { ", down $it since you started" } ?: "" + UseOrEmpty:LivePaused.kt:drop?.takeIf { it > 0 }?.let { ", down $it since you stopped" } ?: "" + UseOrEmpty:LiveSets.kt:drop?.takeIf { it > 0 }?.let { ", down $it" } ?: "" + UseOrEmpty:LiveSets.kt:target?.let { ", ${(it - done).coerceAtLeast(0)} to go" } ?: "" + UseOrEmpty:MainActivity.kt:last?.let { org.openstrap.app.ui.workout.StartCopy.lastTime(it, today, zone) } ?: "" + UseOrEmpty:MomentWidgetModel.kt:MomentWidgetModel.Content$nowValue?.let { ", $it now" } ?: "" + UseOrEmpty:MomentWidgetModel.kt:MomentWidgetModel.Content$word?.let { ", $it" } ?: "" + UseOrEmpty:ShareCards.kt:top?.let { ", top ${Strength.kgText(it)} kg" } ?: "" + UseOrEmpty:TodayCopy.kt:TodayCopy$score?.let { s -> ", score $s" } ?: "" + UseOrEmpty:WorkoutCapsule.kt:glance.line2?.let { ". $it" } ?: "" + UseOrEmpty:WorkoutCapsule.kt:glance.secondLabel ?: "" + UseOrEmpty:WorkoutCopy.kt:WorkoutCopy$avg?.let { ", avg $it" } ?: "" + UseOrEmpty:WorkoutCopy.kt:WorkoutCopy$target?.let { " of $it" } ?: "" + UseOrEmpty:WorkoutDetailCopy.kt:WorkoutDetailCopy$drop120?.let { ", $it in two" } ?: "" + UseOrEmpty:WorkoutDetailCopy.kt:WorkoutDetailCopy$mostlyZone(zoneSeconds)?.let { ", mostly in zone ${it + 1}" } ?: "" + UseOrEmpty:WorkoutDetailCopy.kt:WorkoutDetailCopy$usualLoad?.let { " Your usual is ${Math.round(it)}." } ?: "" + UseRequire:CsvImporter.kt:CsvImporter$throw IllegalArgumentException("This isn't an OpenStrap export.") + VarCouldBeVal:ReadyScreen.kt:var belowLevel by rememberSaveable(key, settings) { mutableStateOf(lastRest.belowBpm ?: StartCopy.DEFAULT_BELOW) } + VarCouldBeVal:Route.kt:Route$var i = times.binarySearch(t).let { if (it < 0) -it - 1 else it } + VarsWithoutStateBacking:ActivityDetail.kt:var first = true + VarsWithoutStateBacking:AlarmsScreen.kt:var first = true + VarsWithoutStateBacking:DeviceScreen.kt:var first = true + VarsWithoutStateBacking:GlowActivityScreen.kt:var day: LocalDate? = null + VarsWithoutStateBacking:GlowActivityScreen.kt:var first = true + VarsWithoutStateBacking:GlowWorkoutSummary.kt:var first = true + VarsWithoutStateBacking:ReadyCardio.kt:var rows = 0 + VarsWithoutStateBacking:ReadyScreen.kt:var divider = false + VarsWithoutStateBacking:StartWidget.kt:var x = -StartLayout.CAPTION_BLEED_DP + VarsWithoutStateBacking:SummarySections.kt:var first = true + VarsWithoutStateBacking:SummarySections.kt:var i = 0 + ViewModelForwarding:AlarmsScreen.kt:HeartAlertsPanel(state, vm) + ViewModelForwarding:DeviceScreen.kt:FindStrapButton(vm, Modifier.weight(1f).height(56.dp)) + ViewModelForwarding:DeviceScreen.kt:KeepConnectedRow(vm, keepConnected, device.address) + ViewModelForwarding:DeviceScreen.kt:ManualPairSheet(vm, scan, onRequestBluetooth, onDismiss = { vm.stopScan(); manual = false }) + ViewModelForwarding:DeviceScreen.kt:PairPanel(vm, scan, onRequestBluetooth) + ViewModelForwarding:FirmwareScreen.kt:CheckResult(step.result, vm) + ViewModelForwarding:FirmwareScreen.kt:StepPanel(step, vm, onInstall = { confirm = it }) + ViewModelForwarding:MainActivity.kt:DeviceScreen( deviceVm, padding, requestBluetooth, container.settings, alarmsState, account != null, lastSeen, onAlarms = { rowOpens(Page.Alarms) }, onPhoneAlerts = { rowOpens(Page.PhoneAlerts) }, onHealthAlerts = { rowOpens(Page.HealthAlerts) }, onFirmware = { rowOpens(Page.Firmware) }, onZeppAccount = { rowOpens(Page.ZeppAccount) }, onBack = back, preview = preview, selected = selected?.row, ) + ViewModelForwarding:MainActivity.kt:FirmwareScreen(firmwareVm, padding, onBack = close, onZeppAccount = { open(Page.ZeppAccount) }) + ViewModelForwarding:MainActivity.kt:ZeppAccountScreen(firmwareVm, padding, onBack = close) + ViewModelInjection:MainActivity.kt:val dayVm: DayViewModel = viewModel(factory = viewModelFactory { initializer { DayViewModel(container.health, container.strap.todaySteps, container.settings.profile, container.autoHealthConnect::sleepEdited) } }) + ViewModelInjection:MainActivity.kt:val deviceVm: DeviceViewModel = viewModel(factory = viewModelFactory { initializer { DeviceViewModel(container) } }) + ViewModelInjection:MainActivity.kt:val firmwareVm: FirmwareViewModel = viewModel(factory = viewModelFactory { initializer { FirmwareViewModel(container) } }) + ViewModelInjection:MainActivity.kt:val workoutsVm: org.openstrap.app.ui.workout.WorkoutsViewModel = viewModel(factory = viewModelFactory { initializer { org.openstrap.app.ui.workout.WorkoutsViewModel(container.database.healthDao(), container.settings.profile, container.maxHeartRates) } }) + WildcardImport:LiveFrame.kt:import org.openstrap.app.ui.glow.* + WildcardImport:LiveHeart.kt:import org.openstrap.app.ui.glow.* + WildcardImport:LiveMap.kt:import org.openstrap.app.ui.glow.* + + diff --git a/app/detekt-baseline-release.xml b/app/detekt-baseline-release.xml new file mode 100644 index 00000000..42581ba3 --- /dev/null +++ b/app/detekt-baseline-release.xml @@ -0,0 +1,3086 @@ + + + + + ArrayPrimitive:WidgetStatusLine.kt:Spread.Companion$Float? + ComplexCondition:BodySignals.kt:BodySignals$k > 0 && out[k].flags.none { it.core } && out[k].state == State.Alert && nightsBetween(sorted[k - 1].day, sorted[k].day) - 1 <= 1 + ComplexCondition:GlowMetricDetail.kt:!night && metric == Metric.HeartRate && isToday && canUseLive && liveEnabled + ComplexCondition:GlowMetricDetail.kt:!night && metric == Metric.HeartRate && isToday && canUseLive && range == Range.Day + ComplexCondition:GlowTodayScreen.kt:it == HeroKind.Waking && ctx.state.loaded && ctx.recoveryFailure == null && wakingLead(ctx.firstNights, ctx.state.asleepSeconds, ctx.isToday) == WakingLead.Charge + ComplexCondition:HistorySync.kt:HistorySync$received.any { it is WorkoutSummary || it is WorkoutDetail || it is SportEffect || it is RecoveryHeartRate } + ComplexCondition:LiveIntervals.kt:m.phase is Phase.Work && m.left != null && workLength != null && workLength > 0 + ComplexCondition:LiveSets.kt:!compact && target != null && moment.done >= target && !r.inSet + ComplexCondition:MainActivity.kt:historyBadges && workoutState is org.openstrap.app.device.WorkoutState.Idle && readySport == null && !choosing + ComplexCondition:Recovery.kt:Recovery$m.sleepStage == null && m.workout == null && m.stress != null && m.stress >= params.stressFrom + ComplexCondition:Recovery.kt:Recovery$own != null && strap != null && strap > 0 && abs(own - strap) / strap > HRV_QA_FRACTION + ComplexCondition:SyncStates.kt:!isToday || !stopped || newest == null || now - newest < staleAfter + ComplexCondition:Vo2max.kt:Vo2max$w == null || h == null || measured == null || day - measured > BODY_MAX_AGE_DAYS * dayS + ComplexCondition:WorkoutsViewModel.kt:WorkoutsViewModel$backfill && w.end != null && w.epoc == null && hr.isNotEmpty() + ComposableNaming:GlowWorkoutSummary.kt:@Composable internal fun SplitsList: Boolean + ComposableNaming:SummarySections.kt:@Composable internal fun FamilySection: Boolean + ComposableNaming:SummarySections.kt:@Composable private fun IndoorHeartCard: Boolean + ComposableParamOrder:Adaptive.kt:@Composable fun <S> ListDetail + ComposableParamOrder:GlowLayout.kt:@Composable internal fun TitleRow + ComposableParamOrder:GlowParts.kt:@Composable fun DotMatrixText + ComposableParamOrder:Insights.kt:@Composable fun FloatingNavBar + ComposableParamOrder:Insights.kt:@Composable fun FloatingNavRail + ComposableParamOrder:PageParts.kt:@Composable fun PageProgress + ComposableParamOrder:ReadyCardio.kt:@Composable private fun TypedNumber + ComposableParamOrder:SummarySections.kt:@Composable internal fun WeekDots + ComposableParamOrder:SummarySections.kt:@Composable private fun SmoothLine + ComposableParamOrder:TodayCards.kt:@Composable internal fun HeroFigure + ComposableParamOrder:ZeppSignIn.kt:@Composable fun ZeppSignInDialog + CompositionLocalAllowlist:CardToDetail.kt:/** * False where a card's tap opens a detail without growing: the rows inside a detail (Related rows), which crossfade * to the next detail instead (spec 3.5). */ val LocalCardsGrow = compositionLocalOf { true } + CompositionLocalAllowlist:CardToDetail.kt:/** The screen's share in the card to detail motion; null outside a tab's stack or with animations off. */ val LocalCardShare = compositionLocalOf<CardShare?> { null } + CompositionLocalAllowlist:CardToDetail.kt:val LocalCardNumbers = compositionLocalOf<CardNumbers?> { null } + CompositionLocalAllowlist:EdgeCard.kt:val LocalEdgeOverride = staticCompositionLocalOf { EdgeOverride() } + CompositionLocalAllowlist:Glass.kt:/** The overlay host, provided at the app's root. */ val LocalGlassHost = staticCompositionLocalOf<GlassHost> { error("No GlassHost provided") } + CompositionLocalAllowlist:GlowCard.kt:/** * Whether glow cards should hold still right now; a scrolling screen provides its scroll state, so * the cores pause while the list moves and the scroll gets every frame to itself. */ val LocalGlowPaused = staticCompositionLocalOf<() -> Boolean> { { false } } + CompositionLocalAllowlist:GlowCard.kt:/** * Whether the page holding these cards is off screen or the tab pager is moving; provided by the pager, apart from * [LocalGlowPaused] so a screen's own scroll state doesn't override it. Cores hold still then, so a swipe between * tabs gets every frame to itself and the pages kept ready either side don't animate unseen. */ val LocalGlowPagePaused = staticCompositionLocalOf<() -> Boolean> { { false } } + CompositionLocalAllowlist:GlowLayout.kt:/** * The widest a page's column gets ([GlowLayout.PageMaxWidth] over the app); [ScreenList] and the workout pages centre * their content in it. A dashboard lays out its own width, so [GlowScreenList] clears it. */ val LocalPageColumn = compositionLocalOf { GlowLayout.PageMaxWidth } + CompositionLocalAllowlist:GlowLayout.kt:/** * Whether the card around this content opens something when tapped. [GlowCard] and [EdgeCard] provide it from their * `onClick`, so every card title ([CardTitle]) shows the tap caret on its own (spec 2.5, Tappable says so). */ val LocalCardOpens = compositionLocalOf { false } + CompositionLocalAllowlist:GlowStyle.kt:/** * The mood for the cards on this screen; every [GlowCard] and [EdgeCard] under it follows. Only Today (by its moment) * and the Sleep tab provide one; everywhere else it's [GlowMood.Day]. */ val LocalGlowMood = staticCompositionLocalOf { GlowMood.Day } + CompositionLocalAllowlist:GrowFromButton.kt:val LocalGrowOrigin = staticCompositionLocalOf<GrowOrigin?> { null } + CompositionLocalAllowlist:PullToSync.kt:/** Set by [PullToSync]; [PullSyncReveal] reads it to open a gap under the wordmark. */ val LocalPullSync = staticCompositionLocalOf<PullSyncState?> { null } + CompositionLocalAllowlist:Routes.kt:/** Your daily goals, provided once at the top of the app so every screen measures against the same ones. */ val LocalGoals = compositionLocalOf { Goals() } + CompositionLocalAllowlist:WidgetParts.kt:/** * How many times the launcher has resized this kind of widget ([WidgetRefresh.resized]). The card reads it so a new size * lays the widget out again, since the session's own options can stay at the empty ones it started with. */ internal val LocalResized = staticCompositionLocalOf { 0 } + CompositionLocalAllowlist:WidgetParts.kt:/** The strap status line every widget's frame reads ([WidgetFrame.status]), null while all is well. */ internal val LocalStatusLine = staticCompositionLocalOf<StatusLine?> { null } + ContentSlotReused:Adaptive.kt:detail: @Composable (S) -> Unit + ContentSlotReused:Adaptive.kt:list: @Composable () -> Unit + ContentSlotReused:GlowLayout.kt:start: @Composable () -> Unit + ContentSlotReused:TabParts.kt:draw: @Composable (T, Modifier) -> Unit + CyclomaticComplexMethod:ActivityDetail.kt:@Composable private fun CaloriesDetail + CyclomaticComplexMethod:ActivityDetail.kt:@Composable private fun HeartPointsDetail + CyclomaticComplexMethod:ActivityDetail.kt:@Composable private fun LoadDetail + CyclomaticComplexMethod:BodySignals.kt:BodySignals$fun evaluate: List<Day> + CyclomaticComplexMethod:CardLooks.kt:fun neighbours: Set<kotlin.Pair<Int, Int>> + CyclomaticComplexMethod:Charts.kt:@Composable fun LineChart + CyclomaticComplexMethod:DayViewModel.kt:DayViewModel$private fun stateFor: Flow<DayUiState> + CyclomaticComplexMethod:DeviceScreen.kt:@Composable fun DeviceScreen + CyclomaticComplexMethod:EnergyExpenditure.kt:EnergyExpenditure$fun day: Day? + CyclomaticComplexMethod:GlowActivityScreen.kt:@Composable private fun FactsList + CyclomaticComplexMethod:GlowCard.kt:@Composable fun GlowCard + CyclomaticComplexMethod:GlowCharts.kt:@Composable fun GlowHypnogram + CyclomaticComplexMethod:GlowCharts.kt:@Composable internal fun NormalTrend + CyclomaticComplexMethod:GlowHealthspan.kt:@Composable fun HabitAgeEdgeCard + CyclomaticComplexMethod:GlowMetricDetail.kt:@Composable fun GlowMetricDetailScreen + CyclomaticComplexMethod:GlowMetricDetail.kt:@Composable private fun DayChart + CyclomaticComplexMethod:GlowTodayScreen.kt:@Composable fun GlowTodayScreen + CyclomaticComplexMethod:GlowTodayScreen.kt:@Composable private fun Hero + CyclomaticComplexMethod:GlowTodayScreen.kt:@Composable private fun TodayRow + CyclomaticComplexMethod:GlowWorkoutScreen.kt:@Composable fun GlowWorkoutDetailScreen + CyclomaticComplexMethod:GlowWorkoutScreen.kt:@Composable private fun PastSummary + CyclomaticComplexMethod:GlowWorkoutSummary.kt:@Composable fun GlowWorkoutSummaryScreen + CyclomaticComplexMethod:GlowWorkoutSummary.kt:@Composable internal fun SplitsList: Boolean + CyclomaticComplexMethod:Healthspan.kt:Healthspan$fun estimate: Pair<Result?, Withheld?> + CyclomaticComplexMethod:HealthspanEngine.kt:HealthspanEngine$private suspend fun daily: Healthspan.Daily + CyclomaticComplexMethod:HistorySync.kt:HistorySync$private suspend fun store + CyclomaticComplexMethod:HistorySync.kt:HistorySync$suspend fun run + CyclomaticComplexMethod:LightSpec.kt:fun glowPaint: GlowPaint + CyclomaticComplexMethod:LiveDistance.kt:@Composable internal fun LiveDistanceScreen + CyclomaticComplexMethod:LiveDistance.kt:@Composable private fun RepPage + CyclomaticComplexMethod:LiveFrame.kt:@Composable internal fun LiveFrame + CyclomaticComplexMethod:LiveGlance.kt:Glance$fun of: LiveGlance + CyclomaticComplexMethod:LiveGlance.kt:Glance$private fun interval: LiveGlance + CyclomaticComplexMethod:LiveIntervals.kt:@Composable internal fun LiveIntervalsScreen + CyclomaticComplexMethod:LiveSets.kt:@Composable internal fun LiveStrengthScreen + CyclomaticComplexMethod:MainActivity.kt:@Composable private fun AppRoot + CyclomaticComplexMethod:MainActivity.kt:@Composable private fun OverPages + CyclomaticComplexMethod:MainActivity.kt:@Composable private fun androidx.compose.foundation.layout.BoxScope.WorkoutLayer + CyclomaticComplexMethod:MainActivity.kt:MainActivity$private fun handleDebugIntent + CyclomaticComplexMethod:MomentWidget.kt:MomentWidget.Companion$internal suspend fun load: MomentWidgetModel + CyclomaticComplexMethod:MomentWidgetModel.kt:MomentLayout$private fun ladders: List<List<Fit>> + CyclomaticComplexMethod:NotificationCopy.kt:NotificationCopy$fun card: WorkoutCard + CyclomaticComplexMethod:PhoneAlertsScreen.kt:@Composable fun PhoneAlertsScreen + CyclomaticComplexMethod:ProfileScreen.kt:@Composable fun ProfileScreen + CyclomaticComplexMethod:ReadyScreen.kt:@Composable fun GlowWorkoutReadyScreen + CyclomaticComplexMethod:Recovery.kt:Recovery$fun charge: ChargeDay + CyclomaticComplexMethod:Recovery.kt:Recovery$fun readiness: Readiness + CyclomaticComplexMethod:RecoveryEngine.kt:RecoveryEngine$suspend fun compute: RecoveryResult + CyclomaticComplexMethod:Rewards.kt:Rewards$fun badges: List<BadgeRow> + CyclomaticComplexMethod:ScoreDetail.kt:@Composable private fun ChargeDetail + CyclomaticComplexMethod:ScoreDetail.kt:@Composable private fun RecoveryDetail + CyclomaticComplexMethod:ShareCards.kt:internal fun storyCard: StoryCard + CyclomaticComplexMethod:ShareScreen.kt:@Composable private fun ShareScreen + CyclomaticComplexMethod:SleepDetail.kt:@Composable internal fun NapsDetail + CyclomaticComplexMethod:SleepDetail.kt:@Composable internal fun SleepScoreDetail + CyclomaticComplexMethod:SportIcons.kt:SportIcons$fun of: ImageVector + CyclomaticComplexMethod:StrapManager.kt:StrapManager$private suspend fun afterAuth + CyclomaticComplexMethod:StrapService.kt:StrapService$override fun onStartCommand: Int + CyclomaticComplexMethod:StrapService.kt:StrapService$private fun idleContent: IdleContent + CyclomaticComplexMethod:SummarySections.kt:@Composable internal fun FamilySection: Boolean + CyclomaticComplexMethod:SummarySections.kt:internal fun summaryHero: SummaryHero + CyclomaticComplexMethod:TodayLooks.kt:private fun ownLook: CardLook + CyclomaticComplexMethod:WorkoutCapsule.kt:@Composable internal fun LiveHero + CyclomaticComplexMethod:WorkoutDetailCopy.kt:WorkoutDetailCopy$fun effectSheet: EffectSheet + CyclomaticComplexMethod:WorkoutEffect.kt:WorkoutEffect$fun vo2max: Float? + CyclomaticComplexMethod:WorkoutSession.kt:WorkoutSession$private suspend fun watchEffort + DestructuringDeclarationWithTooManyEntries:DayViewModel.kt:DayViewModel$val (session, stages, naps, napStages) = sleepParts + DestructuringDeclarationWithTooManyEntries:Phases.kt:Structure.Companion$(v, unit, r, n) + DestructuringDeclarationWithTooManyEntries:StrapService.kt:StrapService$(c, hr, w, restored, m) + DestructuringDeclarationWithTooManyEntries:WorkoutsViewModel.kt:WorkoutsViewModel$val (w, hr, maxHr, all) = q + DoubleMutabilityForCollection:ReleaseNotes.kt:ReleaseNotes$var lines = mutableListOf<String>() + DoubleMutabilityForCollection:WorkoutSuggestions.kt:WorkoutSuggestions$var run = mutableListOf<Pair<Long, Int>>() + EmptyElseBlock:GlowCharts.kt:if (l > 0f) out += lerp(points[i], points[i + 1], left / l) + EmptyElseBlock:MainActivity.kt:if (Page.Firmware in pages) firmwareVm.reset() + EmptyElseBlock:Recovery.kt:Recovery$if (wakesSeen > 0) wakes += c + ForEachOnRange:HeartZonesScreen.kt:0 until Workouts.ZONES + ForEachOnRange:ReadyCardio.kt:0 until Workouts.ZONES + ForEachOnRange:TodayCards.kt:CheckIn.MIN..CheckIn.MAX + ForbiddenComment:Recovery.kt:Recovery$* Not-worn drift per minute: the mean of Zepp's default no-wear slopes for its two pools (native 03 §4.7), physical * −0.04596 with mental −0.0505 at or above 50 and −0.02386 below: about −2.9 an hour above 50, −2.1 below. * Zepp learns per-user slopes from rest minutes; OpenStrap keeps the defaults. * * TODO: learn the user's own drift rate from their history, as Zepp does (native 03 §4.7): each day, measure the * mean per-minute change of Charge over quiet, awake, worn minutes; once about 30 days and enough minutes have * built up, replace these defaults with the minute-weighted mean of those days, keeping the defaults until then. + ForbiddenComment:TabCopy.kt:TabCopy$* A workout row's second line: its fact, then when it started ("3.4 km, 17:35"), or the time alone. * TODO: a personal best ("Fastest 5 km since March") goes after the fact in the mock's warm colour (0xFFFFD08A) * once the app detects them; it doesn't yet. + ForbiddenMethodCall/collectAsState:ActivityDetail.kt:collectAsState(emptyMap()) + ForbiddenMethodCall/collectAsState:ActivityDetail.kt:collectAsState(null) + ForbiddenMethodCall/collectAsState:DeviceScreen.kt:collectAsState() + ForbiddenMethodCall/collectAsState:DeviceScreen.kt:collectAsState(false) + ForbiddenMethodCall/collectAsState:DeviceScreen.kt:collectAsState(true) + ForbiddenMethodCall/collectAsState:FirmwareScreen.kt:collectAsState() + ForbiddenMethodCall/collectAsState:GlowActivityScreen.kt:collectAsState(emptyMap()) + ForbiddenMethodCall/collectAsState:GlowMetricDetail.kt:collectAsState(null) + ForbiddenMethodCall/collectAsState:GlowRecoveryScreen.kt:collectAsState(emptyList()) + ForbiddenMethodCall/collectAsState:GlowWorkoutMode.kt:collectAsState() + ForbiddenMethodCall/collectAsState:PhoneAlertsScreen.kt:collectAsState() + ForbiddenMethodCall/collectAsState:PhoneAlertsScreen.kt:collectAsState(BuzzPattern.TWO_SHORT) + ForbiddenMethodCall/collectAsState:PhoneAlertsScreen.kt:collectAsState(CallBuzz.STEADY) + ForbiddenMethodCall/collectAsState:PhoneAlertsScreen.kt:collectAsState(QuietHours()) + ForbiddenMethodCall/collectAsState:PhoneAlertsScreen.kt:collectAsState(false) + ForbiddenMethodCall/collectAsState:ReadyScreen.kt:collectAsState() + ForbiddenMethodCall/collectAsState:ScoreDetail.kt:collectAsState(emptyList()) + ForbiddenMethodCall/collectAsState:TabParts.kt:collectAsState(emptyList()) + ForbiddenMethodCall/collectAsState:TodayCards.kt:collectAsState(emptyList()) + ForbiddenMethodCall/uiClock:ActivityDetail.kt:ActivityDetailContext$now() + ForbiddenMethodCall/uiClock:ActivityDetail.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:AllMetricsScreen.kt:now() + ForbiddenMethodCall/uiClock:BackupPage.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:Celebrations.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:DayCalendar.kt:now() + ForbiddenMethodCall/uiClock:DayViewModel.kt:DayUiState$now() + ForbiddenMethodCall/uiClock:DayViewModel.kt:DayViewModel$now() + ForbiddenMethodCall/uiClock:DeletedSleepPage.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:DeviceScreen.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:DeviceViewModel.kt:DeviceViewModel$currentTimeMillis() + ForbiddenMethodCall/uiClock:FirmwareViewModel.kt:FirmwareViewModel$currentTimeMillis() + ForbiddenMethodCall/uiClock:Frame.kt:now() + ForbiddenMethodCall/uiClock:GlowActivityScreen.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:GlowActivityScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowHealthspanScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowMetricDetail.kt:now() + ForbiddenMethodCall/uiClock:GlowRecoveryScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowSleepScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowTodayScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowWorkoutMode.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:GlowWorkoutScreen.kt:now() + ForbiddenMethodCall/uiClock:GlowWorkoutSummary.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:GoalsAndBests.kt:now() + ForbiddenMethodCall/uiClock:HealthConnectPage.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:LiveFrame.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:LiveGlance.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:LiveHeart.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:LiveMind.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:MomentHeader.kt:now() + ForbiddenMethodCall/uiClock:ProfileScreen.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:ProfileScreen.kt:now() + ForbiddenMethodCall/uiClock:RewardsCopy.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:RewardsCopy.kt:now(f.zone) + ForbiddenMethodCall/uiClock:ScoreDetail.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:ScoreDetail.kt:now() + ForbiddenMethodCall/uiClock:ShareScreen.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:SleepDetail.kt:now() + ForbiddenMethodCall/uiClock:SummarySections.kt:now() + ForbiddenMethodCall/uiClock:TodayCards.kt:now() + ForbiddenMethodCall/uiClock:UpdatesPage.kt:currentTimeMillis() + ForbiddenMethodCall/uiClock:WorkoutsViewModel.kt:WorkoutsViewModel$currentTimeMillis() + ForbiddenMethodCall/uiClock:WorkoutsViewModel.kt:WorkoutsViewModel$now() + ForbiddenMethodCall/uiClock:YouPage.kt:currentTimeMillis() + ImplicitDefaultLocale:ActivityCopy.kt:ActivityCopy$"%,d".format(atRest) + ImplicitDefaultLocale:ActivityCopy.kt:ActivityCopy$"%,d".format(steps) + ImplicitDefaultLocale:ActivityCopy.kt:ActivityCopy$"steps, %02d:00 to %02d:00".format(hour, (hour + 1) % 24) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(Math.round(e.hourly.total[it])) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(Math.round(e.totalKcal - e.activeKcal)) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(Math.round(e.totalKcal)) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(Math.round(it.sum())) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(active) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(goal) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(it) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(s.kcal) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(summary.activeADay) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(summary.activeTotal) + ImplicitDefaultLocale:ActivityDetail.kt:"%,d".format(totals.stepsADay) + ImplicitDefaultLocale:ActivityDetail.kt:"%.1f".format(km) + ImplicitDefaultLocale:ActivityDetail.kt:"%.1f".format(r.strain) + ImplicitDefaultLocale:ActivityDetail.kt:"%.2f".format(it) + ImplicitDefaultLocale:AlarmsScreen.kt:"%02d:%02d %s".format(first.hour, first.minute, daysLabel(first.days).lowercase()) + ImplicitDefaultLocale:AlarmsScreen.kt:"%02d:%02d".format(a.hour, a.minute) + ImplicitDefaultLocale:AppUpdater.kt:AppUpdater.Companion$"%02x".format(it) + ImplicitDefaultLocale:BackupCopy.kt:BackupCopy$"$what %,d rows couldn't be read.".format(skipped) + ImplicitDefaultLocale:BackupCopy.kt:BackupCopy$"Restored %,d records and your settings.".format(records) + ImplicitDefaultLocale:BackupCopy.kt:BackupCopy$"Restored %,d records.".format(records) + ImplicitDefaultLocale:BodySignals.kt:BodySignals$"%.1f".format(d) + ImplicitDefaultLocale:CaloriesCopy.kt:CaloriesCopy$"%d h %02d".format(m / 60, m % 60) + ImplicitDefaultLocale:CaloriesCopy.kt:CaloriesCopy$"active kcal, %02d:00 to %02d:00".format(hour, (hour + 1) % 24) + ImplicitDefaultLocale:CsvTables.kt:"%02x".format(it) + ImplicitDefaultLocale:DeviceViewModel.kt:DeviceViewModel$"Restored %,d records from Health Connect.".format(n) + ImplicitDefaultLocale:FirmwareCloud.kt:FirmwareCloud.Companion$"%02x".format(it) + ImplicitDefaultLocale:FirmwareScreen.kt:"%,d bytes".format(image.bytes.size) + ImplicitDefaultLocale:FirmwareScreen.kt:"%,d".format(it.length / 1024) + ImplicitDefaultLocale:GattStrapLink.kt:StrapUuids$"0000%04x-0000-1000-8000-00805f9b34fb".format(id) + ImplicitDefaultLocale:GattStrapLink.kt:StrapUuids$"0000%04x-0000-3512-2118-0009af100700".format(id) + ImplicitDefaultLocale:GlowActivityScreen.kt:"%,d".format(if (h != null && picked != null) Math.round(picked) else state.steps) + ImplicitDefaultLocale:GlowActivityScreen.kt:"%,d".format(it) + ImplicitDefaultLocale:GlowActivityScreen.kt:"%.1f".format(it / 1000f) + ImplicitDefaultLocale:GlowActivityScreen.kt:"Goal %,d".format(goal) + ImplicitDefaultLocale:GlowHealthspan.kt:"%,d".format(((ref ?: 0f) / 100).roundToInt() * 100) + ImplicitDefaultLocale:GlowHealthspan.kt:"%,d".format(v.roundToInt()) + ImplicitDefaultLocale:GlowMetricDetail.kt:"%,d".format(Math.round(state.hourlySteps[it])) + ImplicitDefaultLocale:GlowMetricDetail.kt:"steps, %02d:00 to %02d:00".format(it, (it + 1) % 24) + ImplicitDefaultLocale:GlowSleepScreen.kt:"%.1f".format(b.perHour) + ImplicitDefaultLocale:GlowTodayScreen.kt:"%,d".format(ctx.state.steps) + ImplicitDefaultLocale:GlowWorkoutMode.kt:"%d:%02d".format(seconds / 60, seconds % 60) + ImplicitDefaultLocale:GlowWorkoutMode.kt:"%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) + ImplicitDefaultLocale:Goals.kt:Goals$"%,d steps, %,d active kcal, %s sleep, %d active hours".format(steps, calories, sleep, activeHours) + ImplicitDefaultLocale:GoalsAndBests.kt:"%,d".format(Math.round(kcal)) + ImplicitDefaultLocale:GoalsAndBests.kt:"%,d".format(it) + ImplicitDefaultLocale:GoalsAndBests.kt:"%d:%02d".format(seconds / 3600, seconds / 60 % 60) + ImplicitDefaultLocale:GoalsSheet.kt:"%,d active kcal".format(value) + ImplicitDefaultLocale:GoalsSheet.kt:"Suggested %,d, from your last 4 weeks".format(suggested) + ImplicitDefaultLocale:InfoCopy.kt:InfoCopy$"$night Your usual is %.1f.".format(usual) + ImplicitDefaultLocale:InfoCopy.kt:InfoCopy$"${if (today) "Last night" else "That night"} %.1f an hour.".format(b.perHour) + ImplicitDefaultLocale:InfoCopy.kt:InfoCopy$"%.1f".format(this) + ImplicitDefaultLocale:InfoCopy.kt:InfoCopy$"Your last 7 days against the last 4 weeks come to %.2f, the acute to chronic ratio. It's shown for ".format(it) + ImplicitDefaultLocale:LiveGlance.kt:Glance$"%d:%02d".format(s / 60, s % 60) + ImplicitDefaultLocale:LiveIntervals.kt:"%02d".format(it) + ImplicitDefaultLocale:MomentWidgetModel.kt:MomentWidgetModel.Content$"Bedtime %02d:%02d".format(it.hour, it.minute) + ImplicitDefaultLocale:PhoneAlertsScreen.kt:"%02d:%02d".format(minutes / 60, minutes % 60) + ImplicitDefaultLocale:Recovery.kt:Recovery$"%.${decimals}f".format(abs(v)) + ImplicitDefaultLocale:Recovery.kt:Recovery$"%.1f".format(o) + ImplicitDefaultLocale:Recovery.kt:Recovery$"%02d".format(m % 60) + ImplicitDefaultLocale:RewardsCopy.kt:RewardsCopy$"%,d of %,d %s".format(have, need, unit) + ImplicitDefaultLocale:RewardsCopy.kt:RewardsCopy$"%,d".format(Math.round(diff)) + ImplicitDefaultLocale:RewardsCopy.kt:RewardsCopy$"%,d".format(Math.round(value)) + ImplicitDefaultLocale:Routes.kt:Metric$"%,d".format(Math.round(v)) + ImplicitDefaultLocale:Routes.kt:Metric$"%.${decimals}f".format(v) + ImplicitDefaultLocale:ShareCards.kt:"%,d".format(Math.round(it)) + ImplicitDefaultLocale:SleepCopy.kt:SleepCopy$"%02d:%02d".format(it / 60, it % 60) + ImplicitDefaultLocale:SleepDetail.kt:"%.${decimals}f".format(v) + ImplicitDefaultLocale:SleepDetail.kt:"Average %.1f br/min".format(breath.map { it.y }.average()) + ImplicitDefaultLocale:SleepPanels.kt:"%02d:%02d".format(it / 60, it % 60) + ImplicitDefaultLocale:SleepPanels.kt:"%02d:%02d".format(it.hour, it.minute) + ImplicitDefaultLocale:SleepRegularity.kt:SleepRegularity$"%02d:%02d".format(minutes / 60, abs(minutes % 60)) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"%02x".format(type) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"%08x".format(crc32(file)) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"%tT %s".format(System.currentTimeMillis(), message) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"cmd %04x > %s < %s".format(endpoint, request.toHex(), reply.toHex()) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"cmd %04x > %s".format(Endpoints.FIND_DEVICE, request.toHex()) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"mask %02x g2 %s alg %s".format(it.mask, it.group2?.toHex(), it.algorithm) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"probe %04x < %s".format(endpoint, it.toHex()) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"probe %04x > %s".format(endpoint, payload.toHex()) + ImplicitDefaultLocale:StrapManager.kt:StrapManager$"probe %04x ack < %s".format(endpoint, it.toHex()) + ImplicitDefaultLocale:StrapService.kt:StrapService.Companion$"%d:%02d".format(seconds / 60, seconds % 60) + ImplicitDefaultLocale:StrapService.kt:StrapService.Companion$"%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) + ImplicitDefaultLocale:SummarySections.kt:"%,d".format(Math.round(it)) + ImplicitDefaultLocale:SummarySections.kt:"%,d".format(Math.round(lifted)) + ImplicitDefaultLocale:TabCopy.kt:TabCopy$"%.1f km".format(meters / 1000f) + ImplicitDefaultLocale:TabCopy.kt:TabCopy$"%02d:%02d".format(it / 60, it % 60) + ImplicitDefaultLocale:TabCopy.kt:TabCopy$"%d:%02d a km".format(s / 60, s % 60) + ImplicitDefaultLocale:TabCopy.kt:TabCopy$"Usual %.1f, early estimate".format(usual) + ImplicitDefaultLocale:TabCopy.kt:TabCopy$"Your usual is %.1f an hour".format(usual) + ImplicitDefaultLocale:TodayCards.kt:"%,d".format(it.roundToInt()) + ImplicitDefaultLocale:TodayCards.kt:"%,d".format(state.steps) + ImplicitDefaultLocale:TodayCards.kt:"%d:%02d".format(seconds / 3600, (seconds % 3600) / 60) + ImplicitDefaultLocale:TodayCards.kt:"Goal %,d".format(goal) + ImplicitDefaultLocale:TodayCopy.kt:TodayCopy$"$h h %02d".format(m) + ImplicitDefaultLocale:YouPage.kt:"%,d steps, %,d active kcal, %d active hours".format(goals.steps, goals.calories, goals.activeHours) + ImplicitDefaultLocale:YouPage.kt:"%02d".format(minutes % 60) + InjectDispatcher:AppUpdater.kt:AppUpdater$IO + InjectDispatcher:AutoBackup.kt:AutoBackup$IO + InjectDispatcher:DayViewModel.kt:DayViewModel$Default + InjectDispatcher:DayViewModel.kt:DayViewModel$IO + InjectDispatcher:DeviceViewModel.kt:DeviceViewModel$IO + InjectDispatcher:FirmwareCloud.kt:FirmwareCloud$IO + InjectDispatcher:FirmwareViewModel.kt:FirmwareViewModel$IO + InjectDispatcher:GlowCard.kt:Default + InjectDispatcher:MaxHeartRates.kt:MaxHeartRates$Default + InjectDispatcher:OpenStrapApp.kt:OpenStrapApp$Default + InjectDispatcher:PhoneAlerts.kt:PhoneAlerts$Default + InjectDispatcher:RewardsCopy.kt:Default + InjectDispatcher:RewardsKeeper.kt:RewardsKeeper$Default + InjectDispatcher:RewardsKeeper.kt:RewardsKeeper$IO + InjectDispatcher:ShareScreen.kt:IO + InjectDispatcher:StrapManager.kt:StrapManager$Default + InjectDispatcher:StrapManager.kt:StrapManager$IO + InjectDispatcher:StrapService.kt:StrapService$Default + InjectDispatcher:WidgetUpdater.kt:WidgetUpdater$Default + InjectDispatcher:WorkoutSession.kt:WorkoutSession$Default + InjectDispatcher:WorkoutsViewModel.kt:WorkoutsViewModel$Default + InjectDispatcher:ZeppCloud.kt:ZeppCloud$IO + InstanceOfCheckForException:HistorySync.kt:HistorySync$e is FastRechargeModeException + LambdaParameterEventTrailing:ActivityDetail.kt:onPick: (Int?) -> Unit + LambdaParameterEventTrailing:ChooseScreen.kt:onChange: (String) -> Unit + LambdaParameterEventTrailing:ChooseScreen.kt:onClick: () -> Unit + LambdaParameterEventTrailing:GlowActivityScreen.kt:onClick: () -> Unit + LambdaParameterEventTrailing:GlowRecoveryScreen.kt:onOpen: () -> Unit + LambdaParameterEventTrailing:GlowSleepScreen.kt:onOpen: () -> Unit + LambdaParameterEventTrailing:LiveSets.kt:onMore: () -> Unit + LambdaParameterEventTrailing:ShareScreen.kt:onClick: () -> Unit + LambdaParameterEventTrailing:ZeppSignIn.kt:onClick: () -> Unit + LambdaParameterEventTrailing:ZeppSignIn.kt:onThirdParty: (provider: String) -> Unit + LambdaParameterInRestartableEffect:GlowLayout.kt:onFocused: () -> Unit = {} + LambdaParameterInRestartableEffect:ReadyCardio.kt:onDone: () -> Unit + LambdaParameterInRestartableEffect:SleepDetail.kt:onGone: () -> Unit + LargeClass:Recovery.kt:Recovery + LargeClass:StrapManager.kt:StrapManager : StrapController + LargeClass:WorkoutSession.kt:WorkoutSession + LongMethod:DayViewModel.kt:DayViewModel$private fun stateFor: Flow<DayUiState> + LongMethod:HistorySync.kt:HistorySync$private suspend fun store + LongMethod:InfoCopy.kt:InfoCopy$fun of: Info + LongMethod:LightSpec.kt:fun glowPaint: GlowPaint + LongMethod:MainActivity.kt:MainActivity$private fun handleDebugIntent + LongMethod:Recovery.kt:Recovery$fun charge: ChargeDay + LongMethod:Recovery.kt:Recovery$fun readiness: Readiness + LongMethod:RecoveryEngine.kt:RecoveryEngine$suspend fun compute: RecoveryResult + LongMethod:StrapManager.kt:StrapManager$private suspend fun afterAuth + LongMethod:StrapService.kt:StrapService$override fun onStartCommand: Int + LongParameterList:ActivityDetail.kt:ActivityDetailContext + LongParameterList:ChartSpec.kt:LineChartGeometry + LongParameterList:GlowStyle.kt:GlowLight + LongParameterList:GlowTodayScreen.kt:TodayContext + LongParameterList:NotificationCopy.kt:NotificationCopy$fun card: WorkoutCard + LongParameterList:Recovery.kt:Recovery$fun minutes: List<Minute> + LongParameterList:RewardsCopy.kt:RewardsCopy$fun summary: SummaryRewards + LongParameterList:Route.kt:Route$private fun smoothPiece + LongParameterList:WidgetParts.kt:WidgetFrame + LongParameterList:WorkoutDetailCopy.kt:WorkoutDetailCopy$fun effectSheet: EffectSheet + LongParameterList:WorkoutEffect.kt:WorkoutEffect$fun compute: Result + LongParameterList:WorkoutRecorder.kt:WorkoutRecorder$fun kcalMinutes: List<EnergyExpenditure.WorkoutMinute>? + LongParameterList:WorkoutRecorder.kt:WorkoutRecorder$fun liveKcalTick: Kcal? + LongParameterList:WorkoutSession.kt:WorkoutSession$fun start + LoopWithTooManyJumpStatements:CsvImporter.kt:CsvImporter$while + LoopWithTooManyJumpStatements:GlowWorkoutMode.kt:for + LoopWithTooManyJumpStatements:GlowWorkoutScreen.kt:for + LoopWithTooManyJumpStatements:HealthConnectExporter.kt:HealthConnectExporter$for + LoopWithTooManyJumpStatements:HistorySync.kt:HistorySync$for + LoopWithTooManyJumpStatements:Recovery.kt:Recovery$for + LoopWithTooManyJumpStatements:Rewards.kt:Rewards$for + LoopWithTooManyJumpStatements:StartWidgetModel.kt:StartLayout$for + LoopWithTooManyJumpStatements:StepsWidgetModel.kt:StepsLayout$for + LoopWithTooManyJumpStatements:WorkoutEffectEngine.kt:WorkoutEffectEngine$for + LoopWithTooManyJumpStatements:WorkoutSuggestions.kt:WorkoutSuggestions$for + MagicNumber:Activity.kt:Activity$60 + MagicNumber:Activity.kt:Activity$60L + MagicNumber:Activity.kt:Activity$7_000 + MagicNumber:Activity.kt:Activity$8_000 + MagicNumber:ActivityCopy.kt:ActivityCopy$100 + MagicNumber:ActivityCopy.kt:ActivityCopy$23 + MagicNumber:ActivityCopy.kt:ActivityCopy$24 + MagicNumber:ActivityCopy.kt:ActivityCopy$50 + MagicNumber:ActivityDetail.kt:3f + MagicNumber:ActivityDetail.kt:5 + MagicNumber:ActivityDetail.kt:6 + MagicNumber:AlarmsScreen.kt:7 + MagicNumber:AppUpdater.kt:AppUpdater$15_000 + MagicNumber:AppUpdater.kt:AppUpdater$200 + MagicNumber:AppUpdater.kt:AppUpdater$20_000 + MagicNumber:AppUpdater.kt:AppUpdater$299 + MagicNumber:AppUpdater.kt:AppUpdater$30_000 + MagicNumber:AutoBackup.kt:AutoBackup$10_000 + MagicNumber:AutoHealthConnect.kt:AutoHealthConnect$3600 + MagicNumber:AutoHealthConnect.kt:HealthConnectAutoSync$1000 + MagicNumber:AutoStart.kt:AutoStart$10 + MagicNumber:AutoStart.kt:AutoStart$3 + MagicNumber:AutoStart.kt:AutoStart$5 + MagicNumber:AutoStart.kt:AutoStart$60 + MagicNumber:CalorieRecords.kt:CalorieRecords$23 + MagicNumber:CaloriesCopy.kt:CaloriesCopy$100 + MagicNumber:CaloriesCopy.kt:CaloriesCopy$24 + MagicNumber:CaloriesCopy.kt:CaloriesCopy$60 + MagicNumber:CanvasDraw.kt:0.57735f + MagicNumber:CanvasDraw.kt:0.5f + MagicNumber:CanvasDraw.kt:255 + MagicNumber:CardMotion.kt:CardMotion$3 + MagicNumber:CardMotion.kt:CardMotion$4 + MagicNumber:CardMotion.kt:CardMotion$5 + MagicNumber:Cards.kt:60 + MagicNumber:Celebrations.kt:0.35f + MagicNumber:Celebrations.kt:3 + MagicNumber:Celebrations.kt:4 + MagicNumber:ChartSpec.kt:0.5f + MagicNumber:ChartSpec.kt:9f + MagicNumber:Charts.kt:0.001f + MagicNumber:Charts.kt:15 + MagicNumber:Charts.kt:4 + MagicNumber:Charts.kt:60 + MagicNumber:ChooseScreen.kt:4 + MagicNumber:Coaching.kt:Cue.AboveTarget$120 + MagicNumber:Coaching.kt:Cue.BackInTarget$200 + MagicNumber:Coaching.kt:Cue.BelowTarget$700 + MagicNumber:CsvExporter.kt:CsvExporter.Companion$3 + MagicNumber:CsvImporter.kt:CsvImporter$2_000 + MagicNumber:CsvTables.kt:CsvRow$16 + MagicNumber:DailyEnergyHistory.kt:DailyEnergyHistory$1000 + MagicNumber:DataStateParts.kt:0.001f + MagicNumber:DataStateParts.kt:0.999f + MagicNumber:DataStateParts.kt:650 + MagicNumber:DataStateParts.kt:700 + MagicNumber:DataStates.kt:100 + MagicNumber:DataStates.kt:55 + MagicNumber:DataStates.kt:60L + MagicNumber:DayCalendar.kt:160 + MagicNumber:DayCalendar.kt:220 + MagicNumber:DayCalendar.kt:4 + MagicNumber:DayCalendar.kt:6 + MagicNumber:DayCalendar.kt:7 + MagicNumber:DayViewModel.kt:DayUiState$24 + MagicNumber:DetailCopy.kt:DetailCopy$6 + MagicNumber:DeviceScreen.kt:3 + MagicNumber:DeviceViewModel.kt:DeviceViewModel.Companion$32 + MagicNumber:EdgeCard.kt:EdgeGlow$1.702f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$0.2f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$10f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$1440f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$200f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$3.5f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$3600 + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$5f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$6.25f + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$60 + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$60L + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure$86_400L + MagicNumber:EnergyExpenditure.kt:EnergyExpenditure.Hourly$24 + MagicNumber:FamilySummary.kt:FamilySummary$100 + MagicNumber:FamilySummary.kt:FamilySummary$1000.0 + MagicNumber:FamilySummary.kt:FamilySummary$100f + MagicNumber:FamilySummary.kt:FamilySummary$11 + MagicNumber:FamilySummary.kt:FamilySummary$17 + MagicNumber:FamilySummary.kt:FamilySummary$23 + MagicNumber:FamilySummary.kt:FamilySummary$3 + MagicNumber:FamilySummary.kt:FamilySummary$4 + MagicNumber:FamilySummary.kt:FamilySummary$60 + MagicNumber:FamilySummary.kt:FamilySummary$60.0 + MagicNumber:FamilySummary.kt:FamilySummary$7L + MagicNumber:FirmwareCloud.kt:FirmwareCloud$15_000 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$200 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$20_000 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$299 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$30_000 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$401 + MagicNumber:FirmwareCloud.kt:FirmwareCloud$403 + MagicNumber:FirmwareScreen.kt:100 + MagicNumber:FirmwareScreen.kt:1024 + MagicNumber:FirmwareViewModel.kt:FirmwareViewModel$1024 + MagicNumber:FirmwareViewModel.kt:FirmwareViewModel$16 + MagicNumber:Frame.kt:11 + MagicNumber:Frame.kt:143 + MagicNumber:Frame.kt:15 + MagicNumber:Frame.kt:215 + MagicNumber:Frame.kt:224 + MagicNumber:Frame.kt:23 + MagicNumber:Frame.kt:24 + MagicNumber:Frame.kt:3 + MagicNumber:Frame.kt:4 + MagicNumber:Frame.kt:40 + MagicNumber:Frame.kt:48 + MagicNumber:Frame.kt:6 + MagicNumber:Frame.kt:60 + MagicNumber:Frame.kt:7 + MagicNumber:Frame.kt:8 + MagicNumber:GattClient.kt:GattClient$37 + MagicNumber:GattClient.kt:GattClient$500 + MagicNumber:Glass.kt:0xFF101012 + MagicNumber:Glass.kt:0xFF1A1A1D + MagicNumber:Glass.kt:220 + MagicNumber:GlowActivityScreen.kt:1000f + MagicNumber:GlowCard.kt:0.035f + MagicNumber:GlowCard.kt:0.03f + MagicNumber:GlowCard.kt:0.08f + MagicNumber:GlowCard.kt:0.09f + MagicNumber:GlowCard.kt:0.30f + MagicNumber:GlowCard.kt:0.55f + MagicNumber:GlowCard.kt:13 + MagicNumber:GlowCard.kt:15 + MagicNumber:GlowCard.kt:24 + MagicNumber:GlowCard.kt:255 + MagicNumber:GlowCard.kt:60f + MagicNumber:GlowCard.kt:8 + MagicNumber:GlowCharts.kt:0.6f + MagicNumber:GlowCharts.kt:180f + MagicNumber:GlowCharts.kt:48 + MagicNumber:GlowGrid.kt:3 + MagicNumber:GlowHealthspan.kt:0.05f + MagicNumber:GlowHealthspan.kt:0.375f + MagicNumber:GlowHealthspan.kt:0.75f + MagicNumber:GlowHealthspan.kt:100 + MagicNumber:GlowHealthspan.kt:60 + MagicNumber:GlowHealthspan.kt:8000f + MagicNumber:GlowHealthspanScreen.kt:0.05f + MagicNumber:GlowHealthspanScreen.kt:7 + MagicNumber:GlowList.kt:360f + MagicNumber:GlowList.kt:90f + MagicNumber:GlowMetricDetail.kt:0.1f + MagicNumber:GlowMetricDetail.kt:0.25f + MagicNumber:GlowMetricDetail.kt:0.2f + MagicNumber:GlowMetricDetail.kt:0.47f + MagicNumber:GlowMetricDetail.kt:0.5f + MagicNumber:GlowMetricDetail.kt:0.7f + MagicNumber:GlowMetricDetail.kt:0xFF121214 + MagicNumber:GlowMetricDetail.kt:24 + MagicNumber:GlowMetricDetail.kt:50 + MagicNumber:GlowParts.kt:0.4f + MagicNumber:GlowRecoveryScreen.kt:100f + MagicNumber:GlowStyle.kt:0.5f + MagicNumber:GlowStyle.kt:1e-6f + MagicNumber:GlowStyle.kt:GlowPalette$0.01f + MagicNumber:GlowStyle.kt:GlowPalette$0.03f + MagicNumber:GlowStyle.kt:GlowPalette$0.04f + MagicNumber:GlowStyle.kt:GlowPalette$0.08f + MagicNumber:GlowStyle.kt:GlowPalette$0.45f + MagicNumber:GlowStyle.kt:GlowPalette$0.6f + MagicNumber:GlowStyle.kt:GlowPalette$0.7f + MagicNumber:GlowStyle.kt:GlowPalette$0.85f + MagicNumber:GlowStyle.kt:GlowPalette$0.8f + MagicNumber:GlowStyle.kt:GlowPalette$0.9f + MagicNumber:GlowStyle.kt:GlowPalette$360f + MagicNumber:GlowStyle.kt:GlowPalette$4f + MagicNumber:GlowStyle.kt:GlowStyle$40f + MagicNumber:GlowTodayScreen.kt:0xFF4E8F86 + MagicNumber:GlowTodayScreen.kt:300 + MagicNumber:GlowWorkoutMode.kt:1000 + MagicNumber:GlowWorkoutMode.kt:300 + MagicNumber:GlowWorkoutMode.kt:3600 + MagicNumber:GlowWorkoutMode.kt:4 + MagicNumber:GlowWorkoutMode.kt:50 + MagicNumber:GlowWorkoutMode.kt:60 + MagicNumber:GlowWorkoutSummary.kt:1000 + MagicNumber:GlowWorkoutSummary.kt:450 + MagicNumber:GlowWorkoutSummary.kt:5 + MagicNumber:GlowWorkoutSummary.kt:900 + MagicNumber:Goals.kt:CalorieGoal$1.1f + MagicNumber:Goals.kt:CalorieGoal$10 + MagicNumber:Goals.kt:CalorieGoal$10f + MagicNumber:GoalsAndBests.kt:100_000 + MagicNumber:GoalsAndBests.kt:100_001 + MagicNumber:GoalsAndBests.kt:100_002 + MagicNumber:GoalsAndBests.kt:100_003 + MagicNumber:GoalsAndBests.kt:3 + MagicNumber:GoalsAndBests.kt:3600 + MagicNumber:GoalsAndBests.kt:4 + MagicNumber:GoalsAndBests.kt:6 + MagicNumber:GoalsAndBests.kt:60 + MagicNumber:GoalsAndBests.kt:7L + MagicNumber:GoalsSheet.kt:4 + MagicNumber:GrowFromButton.kt:0xFF1F1F22 + MagicNumber:GrowFromButton.kt:GrowMotion$0.01f + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$100 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$1000 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$11 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$143 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$15 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$200 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$215 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$222 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$224 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$225 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$226 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$23 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$24 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$3 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$3600 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$4 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$40 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$49 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$50 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$500 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$52 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$6 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$60 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$61 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$7 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$70 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$8 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$86_400 + MagicNumber:HealthConnectExporter.kt:HealthConnectExporter$9 + MagicNumber:HealthRepository.kt:DayWindow.Companion$24 + MagicNumber:HealthRepository.kt:DayWindow.Companion$3600 + MagicNumber:HealthRepository.kt:HealthRepository$1000 + MagicNumber:HealthRepository.kt:HealthRepository$23 + MagicNumber:HealthRepository.kt:HealthRepository$3600 + MagicNumber:Healthspan.kt:Healthspan$0.5f + MagicNumber:Healthspan.kt:Healthspan$4 + MagicNumber:Healthspan.kt:Healthspan$8 + MagicNumber:Healthspan.kt:Healthspan.Value.Companion$0.5f + MagicNumber:Healthspan.kt:Healthspan.Value.Companion$1.5f + MagicNumber:HealthspanEngine.kt:HealthspanEngine$1000 + MagicNumber:HealthspanEngine.kt:HealthspanEngine$60 + MagicNumber:HeartZones.kt:HeartZones$100f + MagicNumber:HeartZones.kt:HeartZones.Companion$100f + MagicNumber:HeartZonesScreen.kt:0.5f + MagicNumber:HeartZonesScreen.kt:3 + MagicNumber:HistorySync.kt:HistorySync$0xFF + MagicNumber:HistorySync.kt:HistorySync$100 + MagicNumber:HistorySync.kt:HistorySync$1000 + MagicNumber:HistorySync.kt:HistorySync$120 + MagicNumber:HistorySync.kt:HistorySync$20 + MagicNumber:HistorySync.kt:HistorySync$30 + MagicNumber:HistorySync.kt:HistorySync$32 + MagicNumber:HistorySync.kt:HistorySync$3600 + MagicNumber:HistorySync.kt:HistorySync$4 + MagicNumber:HistorySync.kt:HistorySync$40 + MagicNumber:HistorySync.kt:HistorySync$400.0 + MagicNumber:HistorySync.kt:HistorySync$60 + MagicNumber:HistorySync.kt:HistorySync$70 + MagicNumber:InfoCopy.kt:InfoCopy$25 + MagicNumber:InfoCopy.kt:InfoCopy$3 + MagicNumber:InfoCopy.kt:InfoCopy$60 + MagicNumber:InfoCopy.kt:InfoCopy$60L + MagicNumber:InfoCopy.kt:InfoCopy$70 + MagicNumber:InfoCopy.kt:InfoCopy$80 + MagicNumber:InfoCopy.kt:InfoCopy$85 + MagicNumber:InfoCopy.kt:InfoCopy$90 + MagicNumber:InfoSheet.kt:0xFF141416 + MagicNumber:LightSpec.kt:0.6f + MagicNumber:LiveDistance.kt:3600 + MagicNumber:LiveDistance.kt:50 + MagicNumber:LiveFrame.kt:1000 + MagicNumber:LiveFrame.kt:200 + MagicNumber:LiveFrame.kt:360f + MagicNumber:LiveFrame.kt:50 + MagicNumber:LiveFrame.kt:90f + MagicNumber:LiveGame.kt:5 + MagicNumber:LiveGame.kt:50 + MagicNumber:LiveGlance.kt:1000.0 + MagicNumber:LiveGlance.kt:50 + MagicNumber:LiveGlance.kt:Glance$1000 + MagicNumber:LiveGlance.kt:Glance$1000f + MagicNumber:LiveGlance.kt:Glance$3600 + MagicNumber:LiveGlance.kt:Glance$60 + MagicNumber:LiveHeart.kt:0.45f + MagicNumber:LiveHeart.kt:100 + MagicNumber:LiveHeart.kt:2.6f + MagicNumber:LiveIndoor.kt:0.04f + MagicNumber:LiveIndoor.kt:50 + MagicNumber:LiveIntervals.kt:6 + MagicNumber:LiveIntervals.kt:<no name provided>$0.08f + MagicNumber:LiveIntervals.kt:<no name provided>$0.3f + MagicNumber:LiveIntervals.kt:<no name provided>$0.62f + MagicNumber:LiveIntervals.kt:<no name provided>$0.6f + MagicNumber:LiveIntervals.kt:<no name provided>$0.7f + MagicNumber:LiveIntervals.kt:<no name provided>$0.88f + MagicNumber:LiveIntervals.kt:<no name provided>$1.08f + MagicNumber:LiveMap.kt:1.3f + MagicNumber:LiveMap.kt:50 + MagicNumber:LiveMind.kt:0.5f + MagicNumber:LiveMind.kt:0.6f + MagicNumber:LiveMind.kt:360f + MagicNumber:LiveMind.kt:60 + MagicNumber:LiveMind.kt:90f + MagicNumber:LiveSets.kt:10 + MagicNumber:LiveSets.kt:360f + MagicNumber:LiveSets.kt:4 + MagicNumber:LiveSets.kt:90f + MagicNumber:MainActivity.kt:1000 + MagicNumber:MainActivity.kt:190 + MagicNumber:MainActivity.kt:2.5f + MagicNumber:MainActivity.kt:30_000 + MagicNumber:MainActivity.kt:3f + MagicNumber:MainActivity.kt:MainActivity$100 + MagicNumber:MainActivity.kt:MainActivity$80 + MagicNumber:MetricInfo.kt:Range.Month$30 + MagicNumber:MetricInfo.kt:Range.Week$7 + MagicNumber:MetricInfo.kt:Range.Year$365 + MagicNumber:MomentHeader.kt:0.4f + MagicNumber:MomentHeader.kt:3 + MagicNumber:MomentResolver.kt:MomentResolver$1440 + MagicNumber:MomentResolver.kt:MomentResolver$60 + MagicNumber:MomentResolver.kt:MomentResolver$60L + MagicNumber:MomentResolver.kt:MomentResolver$720 + MagicNumber:MomentWidgetModel.kt:MomentLayout.Fit$0.01f + MagicNumber:MomentWidgetModel.kt:MomentWidgetModel.Companion$1440 + MagicNumber:MomentWidgetModel.kt:MomentWidgetModel.Companion$60 + MagicNumber:MomentWidgetModel.kt:MomentWidgetModel.Companion$720 + MagicNumber:Motion.kt:4 + MagicNumber:Motion.kt:8 + MagicNumber:MovingBorder.kt:1e6f + MagicNumber:MovingBorder.kt:360f + MagicNumber:MovingBorder.kt:90f + MagicNumber:Narrative.kt:Narrative$100f + MagicNumber:Narrative.kt:Narrative$60L + MagicNumber:Narrative.kt:Narrative$70 + MagicNumber:Narrative.kt:Narrative$80 + MagicNumber:Narrative.kt:Narrative$85 + MagicNumber:Narrative.kt:Narrative$90 + MagicNumber:NotificationCopy.kt:NotificationCopy$3 + MagicNumber:NotificationCopy.kt:NotificationCopy$4 + MagicNumber:PageParts.kt:0.45f + MagicNumber:PageParts.kt:0.4f + MagicNumber:Pai.kt:Pai$1e-9 + MagicNumber:Pai.kt:Pai$7 + MagicNumber:PhoneAlerts.kt:PhoneAlerts$1000 + MagicNumber:PhoneAlerts.kt:PhoneAlerts$60 + MagicNumber:PhoneAlertsScreen.kt:60 + MagicNumber:ProfileScreen.kt:4 + MagicNumber:ProfileScreen.kt:5 + MagicNumber:PullToSync.kt:180f + MagicNumber:ReadyCardio.kt:0.5f + MagicNumber:ReadyCardio.kt:1.2f + MagicNumber:ReadyCardio.kt:120 + MagicNumber:ReadyCardio.kt:3 + MagicNumber:ReadyCardio.kt:3600 + MagicNumber:ReadyCardio.kt:40 + MagicNumber:ReadyCardio.kt:6 + MagicNumber:ReadyCardio.kt:60 + MagicNumber:ReadyCardio.kt:80 + MagicNumber:ReadyPlans.kt:60 + MagicNumber:Recovery.kt:Recovery$0.5f + MagicNumber:Recovery.kt:Recovery$0.64f + MagicNumber:Recovery.kt:Recovery$0.86f + MagicNumber:Recovery.kt:Recovery$0.8f + MagicNumber:Recovery.kt:Recovery$0x7E + MagicNumber:Recovery.kt:Recovery$1.2f + MagicNumber:Recovery.kt:Recovery$1.67f + MagicNumber:Recovery.kt:Recovery$1.92f + MagicNumber:Recovery.kt:Recovery$10 + MagicNumber:Recovery.kt:Recovery$100 + MagicNumber:Recovery.kt:Recovery$100.0 + MagicNumber:Recovery.kt:Recovery$100f + MagicNumber:Recovery.kt:Recovery$140 + MagicNumber:Recovery.kt:Recovery$225 + MagicNumber:Recovery.kt:Recovery$3 + MagicNumber:Recovery.kt:Recovery$30 + MagicNumber:Recovery.kt:Recovery$50f + MagicNumber:Recovery.kt:Recovery$6 + MagicNumber:Recovery.kt:Recovery$60 + MagicNumber:Recovery.kt:Recovery$60L + MagicNumber:Recovery.kt:Recovery$65 + MagicNumber:Recovery.kt:Recovery$7 + MagicNumber:Recovery.kt:Recovery.Factor.BloodOxygen$0.03f + MagicNumber:Recovery.kt:Recovery.Factor.Breathing$0.04f + MagicNumber:Recovery.kt:Recovery.Factor.HrvBalance$0.30f + MagicNumber:Recovery.kt:Recovery.Factor.PreviousDayLoad$0.04f + MagicNumber:Recovery.kt:Recovery.Factor.RecoveryIndex$0.03f + MagicNumber:Recovery.kt:Recovery.Factor.RestingHr$0.22f + MagicNumber:Recovery.kt:Recovery.Factor.Sleep$0.16f + MagicNumber:Recovery.kt:Recovery.Factor.SleepBalance$0.12f + MagicNumber:Recovery.kt:Recovery.Factor.Temperature$0.06f + MagicNumber:Recovery.kt:Recovery.MinuteRanges$60 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$0.10f + MagicNumber:RecoveryEngine.kt:RecoveryEngine$10 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$1000 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$1440 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$30 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$60 + MagicNumber:RecoveryEngine.kt:RecoveryEngine$60f + MagicNumber:RecoveryEngine.kt:RecoveryEngine$720 + MagicNumber:RecoveryEngine.kt:RecoveryEngine.Companion$43_200 + MagicNumber:RecoveryEngine.kt:RecoveryEngine.Companion$60 + MagicNumber:RecoveryEngine.kt:RecoveryEngine.Companion$86_400 + MagicNumber:RecoveryWidgetModel.kt:RecoveryLayout$100 + MagicNumber:RecoveryWidgetModel.kt:RecoveryLayout$100f + MagicNumber:RecoveryWidgetModel.kt:RecoveryLayout$4f + MagicNumber:Releases.kt:Releases$3 + MagicNumber:Rewards.kt:Rewards$1000.0 + MagicNumber:Rewards.kt:Rewards$7 + MagicNumber:Rewards.kt:Rewards$7L + MagicNumber:Rewards.kt:Rewards.Stretch.Companion$3 + MagicNumber:RewardsCopy.kt:1000 + MagicNumber:RewardsCopy.kt:RewardsCopy$1000 + MagicNumber:RewardsCopy.kt:RewardsCopy$1000.0 + MagicNumber:RewardsCopy.kt:RewardsCopy$21097 + MagicNumber:RewardsCopy.kt:RewardsCopy$3 + MagicNumber:RewardsCopy.kt:RewardsCopy$6 + MagicNumber:RewardsCopy.kt:RewardsCopy$60 + MagicNumber:Route.kt:Route$10 + MagicNumber:Route.kt:Route$1000 + MagicNumber:Route.kt:Route$3 + MagicNumber:ScoreDetail.kt:100f + MagicNumber:SettingsStore.kt:BuzzPattern.ONE_LONG$600 + MagicNumber:SettingsStore.kt:BuzzPattern.ONE_SHORT$200 + MagicNumber:SettingsStore.kt:BuzzPattern.THREE_SHORT$120 + MagicNumber:SettingsStore.kt:BuzzPattern.TWO_SHORT$150 + MagicNumber:SettingsStore.kt:QuietHours$22 + MagicNumber:SettingsStore.kt:QuietHours$60 + MagicNumber:SettingsStore.kt:QuietHours$7 + MagicNumber:SettingsStore.kt:SettingsStore$24 + MagicNumber:SettingsStore.kt:SettingsStore$60 + MagicNumber:ShareCards.kt:0.32f + MagicNumber:ShareCards.kt:0.35f + MagicNumber:ShareCards.kt:0.38f + MagicNumber:ShareCards.kt:0.3f + MagicNumber:ShareCards.kt:0.62f + MagicNumber:ShareCards.kt:0.6f + MagicNumber:ShareCards.kt:0.7f + MagicNumber:ShareCards.kt:0.95f + MagicNumber:ShareCards.kt:10 + MagicNumber:ShareCards.kt:40 + MagicNumber:ShareCards.kt:60 + MagicNumber:ShareScreen.kt:StoryFiles$100 + MagicNumber:SleepCopy.kt:SleepCopy$1440 + MagicNumber:SleepCopy.kt:SleepCopy$60 + MagicNumber:SleepCopy.kt:SleepCopy$60L + MagicNumber:SleepDetail.kt:100f + MagicNumber:SleepDetail.kt:60 + MagicNumber:SleepEditMigration.kt:SleepEditMigration$1000 + MagicNumber:SleepEdits.kt:SleepEdits$60 + MagicNumber:SleepEdits.kt:SleepEdits$86_399 + MagicNumber:SleepEdits.kt:SleepEdits$86_400 + MagicNumber:SleepEdits.kt:SleepEdits$86_400L + MagicNumber:SleepHistory.kt:SleepHistory$12 + MagicNumber:SleepHistory.kt:SleepHistory$24 + MagicNumber:SleepHistory.kt:SleepHistory$3600 + MagicNumber:SleepHistory.kt:SleepHistory$6 + MagicNumber:SleepHistory.kt:SleepHistory$60 + MagicNumber:SleepHistory.kt:SleepHistory$60L + MagicNumber:SleepPanels.kt:12 + MagicNumber:SleepPanels.kt:24 + MagicNumber:SleepPanels.kt:60 + MagicNumber:SleepRegularity.kt:SleepRegularity$60 + MagicNumber:SleepRegularity.kt:SleepRegularity$72f + MagicNumber:SleepRegularity.kt:SleepRegularity$79f + MagicNumber:SleepRegularity.kt:SleepRegularity$87f + MagicNumber:SleepWidget.kt:255 + MagicNumber:SleepWidget.kt:26 + MagicNumber:SleepWidgetModel.kt:SleepLayout$0.01f + MagicNumber:SleepWidgetModel.kt:SleepLayout$3 + MagicNumber:SportIcons.kt:SportIcons$10 + MagicNumber:SportIcons.kt:SportIcons$108 + MagicNumber:SportIcons.kt:SportIcons$109 + MagicNumber:SportIcons.kt:SportIcons$11 + MagicNumber:SportIcons.kt:SportIcons$12 + MagicNumber:SportIcons.kt:SportIcons$130 + MagicNumber:SportIcons.kt:SportIcons$143 + MagicNumber:SportIcons.kt:SportIcons$15 + MagicNumber:SportIcons.kt:SportIcons$17 + MagicNumber:SportIcons.kt:SportIcons$18 + MagicNumber:SportIcons.kt:SportIcons$204 + MagicNumber:SportIcons.kt:SportIcons$205 + MagicNumber:SportIcons.kt:SportIcons$215 + MagicNumber:SportIcons.kt:SportIcons$222 + MagicNumber:SportIcons.kt:SportIcons$224 + MagicNumber:SportIcons.kt:SportIcons$225 + MagicNumber:SportIcons.kt:SportIcons$23 + MagicNumber:SportIcons.kt:SportIcons$4 + MagicNumber:SportIcons.kt:SportIcons$49 + MagicNumber:SportIcons.kt:SportIcons$50 + MagicNumber:SportIcons.kt:SportIcons$51 + MagicNumber:SportIcons.kt:SportIcons$53 + MagicNumber:SportIcons.kt:SportIcons$55 + MagicNumber:SportIcons.kt:SportIcons$57 + MagicNumber:SportIcons.kt:SportIcons$58 + MagicNumber:SportIcons.kt:SportIcons$6 + MagicNumber:SportIcons.kt:SportIcons$60 + MagicNumber:SportIcons.kt:SportIcons$61 + MagicNumber:SportIcons.kt:SportIcons$7 + MagicNumber:SportIcons.kt:SportIcons$70 + MagicNumber:SportIcons.kt:SportIcons$76 + MagicNumber:SportIcons.kt:SportIcons$8 + MagicNumber:SportIcons.kt:SportIcons$83 + MagicNumber:SportIcons.kt:SportIcons$85 + MagicNumber:SportIcons.kt:SportIcons$88 + MagicNumber:SportIcons.kt:SportIcons$89 + MagicNumber:SportIcons.kt:SportIcons$9 + MagicNumber:SportIcons.kt:SportIcons$92 + MagicNumber:SportIcons.kt:SportIcons$97 + MagicNumber:StartCopy.kt:StartCopy$10 + MagicNumber:StartCopy.kt:StartCopy$100 + MagicNumber:StartCopy.kt:StartCopy$1000 + MagicNumber:StartCopy.kt:StartCopy$1000.0 + MagicNumber:StartCopy.kt:StartCopy$10L + MagicNumber:StartCopy.kt:StartCopy$120 + MagicNumber:StartCopy.kt:StartCopy$30L + MagicNumber:StartCopy.kt:StartCopy$3600 + MagicNumber:StartCopy.kt:StartCopy$3600L + MagicNumber:StartCopy.kt:StartCopy$4 + MagicNumber:StartCopy.kt:StartCopy$50 + MagicNumber:StartCopy.kt:StartCopy$50_000 + MagicNumber:StartCopy.kt:StartCopy$5L + MagicNumber:StartCopy.kt:StartCopy$60 + MagicNumber:StartCopy.kt:StartCopy$600L + MagicNumber:StartCopy.kt:StartCopy$60L + MagicNumber:StartCopy.kt:StartCopy$7 + MagicNumber:StartCopy.kt:StartCopy$8L + MagicNumber:StartCopy.kt:StartCopy$900 + MagicNumber:StartCopy.kt:StartCopy$90L + MagicNumber:StartWidget.kt:0.5f + MagicNumber:StartWidgetModel.kt:StartLayout$3 + MagicNumber:StartWidgetModel.kt:StartWidgetModel.Companion$1000L + MagicNumber:StartWidgetModel.kt:StartWidgetModel.Companion$10f + MagicNumber:StepsWidgetModel.kt:StepsLayout$3 + MagicNumber:StepsWidgetModel.kt:StepsWidgetModel.Companion$7L + MagicNumber:StrapDrawing.kt:0.45f + MagicNumber:StrapDrawing.kt:0xFF0C0F0E + MagicNumber:StrapDrawing.kt:1.2f + MagicNumber:StrapDrawing.kt:1.4f + MagicNumber:StrapDrawing.kt:1.5f + MagicNumber:StrapDrawing.kt:110f + MagicNumber:StrapDrawing.kt:14f + MagicNumber:StrapDrawing.kt:150f + MagicNumber:StrapDrawing.kt:22f + MagicNumber:StrapDrawing.kt:25 + MagicNumber:StrapDrawing.kt:3.2f + MagicNumber:StrapDrawing.kt:34f + MagicNumber:StrapDrawing.kt:36f + MagicNumber:StrapDrawing.kt:39f + MagicNumber:StrapDrawing.kt:3f + MagicNumber:StrapDrawing.kt:4.5f + MagicNumber:StrapDrawing.kt:40f + MagicNumber:StrapDrawing.kt:42f + MagicNumber:StrapDrawing.kt:43f + MagicNumber:StrapDrawing.kt:44f + MagicNumber:StrapDrawing.kt:45f + MagicNumber:StrapDrawing.kt:48f + MagicNumber:StrapDrawing.kt:49f + MagicNumber:StrapDrawing.kt:4f + MagicNumber:StrapDrawing.kt:52f + MagicNumber:StrapDrawing.kt:56f + MagicNumber:StrapDrawing.kt:5f + MagicNumber:StrapDrawing.kt:60f + MagicNumber:StrapDrawing.kt:64f + MagicNumber:StrapDrawing.kt:68f + MagicNumber:StrapDrawing.kt:70f + MagicNumber:StrapDrawing.kt:72f + MagicNumber:StrapDrawing.kt:75f + MagicNumber:StrapDrawing.kt:77f + MagicNumber:StrapDrawing.kt:90f + MagicNumber:StrapDrawing.kt:9f + MagicNumber:StrapManager.kt:StrapManager$0x04 + MagicNumber:StrapManager.kt:StrapManager$0x05 + MagicNumber:StrapManager.kt:StrapManager$1000 + MagicNumber:StrapManager.kt:StrapManager$1000L + MagicNumber:StrapManager.kt:StrapManager$1_000 + MagicNumber:StrapManager.kt:StrapManager$23 + MagicNumber:StrapManager.kt:StrapManager$247 + MagicNumber:StrapManager.kt:StrapManager$3 + MagicNumber:StrapManager.kt:StrapManager$400 + MagicNumber:StrapManager.kt:StrapManager$5_000 + MagicNumber:StrapManager.kt:StrapManager$60 + MagicNumber:StrapManager.kt:StrapManager$60_000 + MagicNumber:StrapManager.kt:StrapManager$86_400L + MagicNumber:StrapService.kt:StrapService$100 + MagicNumber:StrapService.kt:StrapService$1000 + MagicNumber:StrapService.kt:StrapService$36 + MagicNumber:StrapService.kt:StrapService$5 + MagicNumber:StrapService.kt:StrapService$77 + MagicNumber:StrapService.kt:StrapService.Companion$10 + MagicNumber:StrapService.kt:StrapService.Companion$100 + MagicNumber:StrapService.kt:StrapService.Companion$3600 + MagicNumber:StrapService.kt:StrapService.Companion$4 + MagicNumber:StrapService.kt:StrapService.Companion$50 + MagicNumber:StrapService.kt:StrapService.Companion$60 + MagicNumber:StrapStatus.kt:7 + MagicNumber:Summaries.kt:Summaries$23 + MagicNumber:Summaries.kt:Summaries$230 + MagicNumber:Summaries.kt:Summaries$30 + MagicNumber:Summaries.kt:Summaries$5 + MagicNumber:Summaries.kt:Summaries$60 + MagicNumber:SummaryCopy.kt:SummaryCopy$100 + MagicNumber:SummaryCopy.kt:SummaryCopy$1000 + MagicNumber:SummaryCopy.kt:SummaryCopy$60.0 + MagicNumber:SummaryCopy.kt:SummaryCopy$90 + MagicNumber:SummarySections.kt:0.5f + MagicNumber:SummarySections.kt:10 + MagicNumber:SummarySections.kt:1000 + MagicNumber:SummarySections.kt:4 + MagicNumber:SummarySections.kt:60 + MagicNumber:TabCopy.kt:TabCopy$1000f + MagicNumber:TabCopy.kt:TabCopy$1440 + MagicNumber:TabCopy.kt:TabCopy$3600f + MagicNumber:TabCopy.kt:TabCopy$60 + MagicNumber:TabCopy.kt:TabCopy$70 + MagicNumber:TabCopy.kt:TabCopy$80 + MagicNumber:TabCopy.kt:TabCopy$85 + MagicNumber:TabCopy.kt:TabCopy$90 + MagicNumber:Theme.kt:MetricColors$40 + MagicNumber:TodayCards.kt:100f + MagicNumber:TodayCards.kt:3 + MagicNumber:TodayCards.kt:3600 + MagicNumber:TodayCards.kt:4 + MagicNumber:TodayCards.kt:5 + MagicNumber:TodayCards.kt:50 + MagicNumber:TodayCards.kt:60 + MagicNumber:TodayCopy.kt:TodayCopy$0.8 + MagicNumber:TodayCopy.kt:TodayCopy$1440 + MagicNumber:TodayCopy.kt:TodayCopy$6 + MagicNumber:TodayCopy.kt:TodayCopy$60 + MagicNumber:TodayCopy.kt:TodayCopy$60L + MagicNumber:TodayCopy.kt:TodayCopy$70 + MagicNumber:TodayCopy.kt:TodayCopy$720 + MagicNumber:TodayCopy.kt:TodayCopy$85 + MagicNumber:Units.kt:Units$12 + MagicNumber:UpdateCopy.kt:UpdateCopy$100 + MagicNumber:UpdateCopy.kt:UpdateCopy$1_000_000.0 + MagicNumber:UsualSteps.kt:UsualSteps$24 + MagicNumber:UsualSteps.kt:UsualSteps$59 + MagicNumber:UsualSteps.kt:UsualSteps$60f + MagicNumber:WidgetRefresh.kt:WidgetRefresh$12 + MagicNumber:WidgetRefresh.kt:WidgetRefresh$3 + MagicNumber:WidgetRefresh.kt:WidgetRefresh$3600 + MagicNumber:WidgetRefresh.kt:WidgetRefresh$4 + MagicNumber:WidgetRefresh.kt:WidgetRefresh$86_400 + MagicNumber:WidgetSamples.kt:WidgetSamples$10_000 + MagicNumber:WidgetSamples.kt:WidgetSamples$14 + MagicNumber:WidgetSamples.kt:WidgetSamples$190 + MagicNumber:WidgetSamples.kt:WidgetSamples$8_432 + MagicNumber:WidgetSizes.kt:WidgetDots$0.5f + MagicNumber:WidgetSizes.kt:WidgetSizes$3 + MagicNumber:WidgetSizes.kt:WidgetSizes$4 + MagicNumber:WidgetSizes.kt:WidgetText$0.27 + MagicNumber:WidgetSizes.kt:WidgetText$0.37 + MagicNumber:WidgetSizes.kt:WidgetText$0.57 + MagicNumber:WidgetSizes.kt:WidgetText$0.58 + MagicNumber:WidgetSizes.kt:WidgetText$0.68 + MagicNumber:WidgetSizes.kt:WidgetText$0.88 + MagicNumber:WidgetStatusLine.kt:Spread$0.01f + MagicNumber:WidgetUpdater.kt:WidgetUpdater$1000 + MagicNumber:WidgetUpdater.kt:WidgetUpdater$3 + MagicNumber:WorkoutCapsule.kt:4 + MagicNumber:WorkoutCapsule.kt:50 + MagicNumber:WorkoutCopy.kt:WorkoutCopy$0.35f + MagicNumber:WorkoutCopy.kt:WorkoutCopy$0.65f + MagicNumber:WorkoutCopy.kt:WorkoutCopy$1000 + MagicNumber:WorkoutCopy.kt:WorkoutCopy$1000f + MagicNumber:WorkoutCopy.kt:WorkoutCopy$60 + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$0.5f + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$0.875f + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$1000 + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$10f + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$3 + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$3f + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$4 + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$4f + MagicNumber:WorkoutDetailCopy.kt:WorkoutDetailCopy$5f + MagicNumber:WorkoutEffect.kt:WorkoutEffect$10 + MagicNumber:WorkoutEffect.kt:WorkoutEffect$100 + MagicNumber:WorkoutEffect.kt:WorkoutEffect$3.5 + MagicNumber:WorkoutEffect.kt:WorkoutEffect$50 + MagicNumber:WorkoutEffect.kt:WorkoutEffect$5f + MagicNumber:WorkoutEffect.kt:WorkoutEffect$60 + MagicNumber:WorkoutEffectEngine.kt:WorkoutEffectEngine$60 + MagicNumber:WorkoutGapFill.kt:WorkoutGapFill$1000 + MagicNumber:WorkoutLocation.kt:WorkoutLocation.Companion$1000 + MagicNumber:WorkoutMap.kt:0.25f + MagicNumber:WorkoutMap.kt:18f + MagicNumber:WorkoutMap.kt:3 + MagicNumber:WorkoutMap.kt:3f + MagicNumber:WorkoutMap.kt:5f + MagicNumber:WorkoutMap.kt:6f + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$1000 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$120 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$240 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$250 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$30 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$59 + MagicNumber:WorkoutRecorder.kt:WorkoutRecorder$60 + MagicNumber:WorkoutSession.kt:WorkoutSession$1000 + MagicNumber:WorkoutSession.kt:WorkoutSession$100L + MagicNumber:WorkoutSession.kt:WorkoutSession$1_000 + MagicNumber:WorkoutSession.kt:WorkoutSession$250 + MagicNumber:WorkoutSession.kt:WorkoutSession$5f + MagicNumber:WorkoutSession.kt:WorkoutSession$60 + MagicNumber:WorkoutSetup.kt:WorkoutSetup$3 + MagicNumber:WorkoutSetup.kt:WorkoutSetup$400 + MagicNumber:WorkoutSetup.kt:WorkoutSetup$6 + MagicNumber:WorkoutSetup.kt:WorkoutSetup$90 + MagicNumber:WorkoutStyle.kt:3 + MagicNumber:WorkoutSuggestions.kt:WorkoutSuggestions$59 + MagicNumber:WorkoutSuggestions.kt:WorkoutSuggestions$60 + MagicNumber:WorkoutSuggestions.kt:WorkoutSuggestions$60L + MagicNumber:WorkoutTileService.kt:WorkoutTileService$34 + MagicNumber:Workouts.kt:Workouts$1000 + MagicNumber:Workouts.kt:Workouts$120 + MagicNumber:Workouts.kt:Workouts$5 + MagicNumber:Workouts.kt:Workouts$60 + MagicNumber:Workouts.kt:Workouts$60L + MagicNumber:WorkoutsMigration.kt:WorkoutsMigration$25 + MagicNumber:WorkoutsMigration.kt:WorkoutsMigration$26 + MagicNumber:WorkoutsViewModel.kt:WorkoutsViewModel$1000 + MagicNumber:WorkoutsViewModel.kt:WorkoutsViewModel$60 + MagicNumber:YouPage.kt:60 + MagicNumber:ZeppCloud.kt:ZeppCloud$1000 + MagicNumber:ZeppCloud.kt:ZeppCloud$15_000 + MagicNumber:ZeppCloud.kt:ZeppCloud$200 + MagicNumber:ZeppCloud.kt:ZeppCloud$20_000 + MagicNumber:ZeppCloud.kt:ZeppCloud$299 + MagicNumber:ZeppCloud.kt:ZeppCloud$3 + MagicNumber:ZeppCloud.kt:ZeppCloud$32 + MagicNumber:ZeppCloud.kt:ZeppCloud$401 + MagicNumber:ZeppCloud.kt:ZeppCloud$403 + MagicNumber:ZeppSignIn.kt:1000 + MagicNumber:ZeppSignIn.kt:4 + MagicNumber:ZeppSignIn.kt:8 + MapGetWithNotNullAssertionOperator:EnergyExpenditure.kt:EnergyExpenditure$hrSum[m.t]!! + MapGetWithNotNullAssertionOperator:RewardsCopy.kt:RewardsView$progress[b.id]!! + MatchingDeclarationName:ActivityDetail.kt:ActivityDetailContext + MatchingDeclarationName:ChooseScreen.kt:ChooseSport + MatchingDeclarationName:Daos.kt:HealthDao + MatchingDeclarationName:DayCalendar.kt:DayPicker + MatchingDeclarationName:PullToSync.kt:PullSyncState + MatchingDeclarationName:SummarySections.kt:SummaryHero + MatchingDeclarationName:Theme.kt:MetricColors + MaxLineLength:Activity.kt:Activity.Mvpa$operator fun plus + MaxLineLength:ActivityCopy.kt:ActivityCopy$if (fromWorkout) "From your latest workout the strap could read it from." else "Estimated from your profile and resting heart rate, updated weekly." + MaxLineLength:ActivityCopy.kt:ActivityCopy$m.moderateMinutes == 0 && m.vigorousMinutes == 0 -> if (today) "No active minutes so far today." else "No active minutes that day." + MaxLineLength:ActivityDetail.kt:(e.pal?.takeIf { !ctx.isToday }?.let { "%.2f".format(it) to "Activity level" } ?: ("%,d".format(goal) to "Goal")) + MaxLineLength:ActivityDetail.kt:KcalBars(stacks, listOf(color, WalkingKcal, EverydayKcal), goal = null, labels = listOf("00", "06", "12", "18", "24"), spread = true, Modifier.padding(top = 16.dp), height = 120.dp, onPick = onPick) + MaxLineLength:ActivityDetail.kt:Range.Month -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { bars[((bars.size - 1) * it).toInt()].first.format(DateTimeFormatter.ofPattern("d MMM")) } + MaxLineLength:ActivityDetail.kt:Score.ActiveMinutes.title + MaxLineLength:ActivityDetail.kt:Score.HeartPoints.title + MaxLineLength:ActivityDetail.kt:Text(text, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 8.dp)) + MaxLineLength:ActivityDetail.kt:androidx.compose.foundation.layout + MaxLineLength:ActivityDetail.kt:bars.map { it.second ?: emptyList() } + MaxLineLength:ActivityDetail.kt:contentPadding = ctx.contentPadding + MaxLineLength:ActivityDetail.kt:divider = i > 0 + MaxLineLength:ActivityDetail.kt:drawLine(Color.White.copy(alpha = 0.08f), Offset(0f, size.height - 0.5.dp.toPx()), Offset(size.width, size.height - 0.5.dp.toPx()), 1.dp.toPx()) + MaxLineLength:ActivityDetail.kt:else + MaxLineLength:ActivityDetail.kt:fun avg + MaxLineLength:ActivityDetail.kt:horizontalArrangement = if (spread) androidx.compose.foundation.layout.Arrangement.SpaceBetween else androidx.compose.foundation.layout.Arrangement.Start + MaxLineLength:ActivityDetail.kt:if (latest != null && est != null) ListRow("From your profile", color, "${est.low}-${est.high}", "Estimated weekly", divider = !first) + MaxLineLength:ActivityDetail.kt:if (oldest > 0f) drawRoundRect(Color.Black.copy(alpha = 0.35f * a), Offset(x, yOf(oldest)), Size(w, size.height - yOf(oldest)), r) + MaxLineLength:ActivityDetail.kt:if (range == Range.Day) flowOf(null) else ctx.energyHistory?.invoke(start.minusDays(range.days.toLong()), date) ?: flowOf(emptyMap()) + MaxLineLength:ActivityDetail.kt:item { HourlyKcal(e, color) { h -> scrub = h?.let { DetailHeadline("%,d".format(Math.round(e.hourly.total[it])), CaloriesCopy.hourLabel(it), null) } } } + MaxLineLength:ActivityDetail.kt:labels = listOf(0f, 1 / 3f, 2 / 3f, 1f).map { month[((month.size - 1) * it).toInt()].format(DateTimeFormatter.ofPattern("d MMM")) } + MaxLineLength:ActivityDetail.kt:onClick = { ctx.onOpen(Detail.Workout(w.workout.start)) } + MaxLineLength:ActivityDetail.kt:onScrub = { i -> scrub = i?.let { DetailHeadline(Math.round(values[it]).toString(), "minutes, ${week[it].format(DAY)}", null) } } + MaxLineLength:ActivityDetail.kt:onScrub = { i -> scrub = i?.let { DetailHeadline(Math.round(values[it]).toString(), "steps, ${days[it].date.format(DAY)}", null) } } + MaxLineLength:ActivityDetail.kt:onScrub = { i -> scrub = i?.let { totals[it] }?.let { v -> DetailHeadline(v.toString(), "7-day total, ${month[i].format(DAY)}", ActivityCopy.heartPointsWord(v)) } } + MaxLineLength:ActivityDetail.kt:onScrub = { i -> scrub = i?.let { values[it] }?.let { v -> DetailHeadline(Math.round(v).toString(), days[i].format(DAY), loadWordOf(v, usual)) } } + MaxLineLength:ActivityDetail.kt:range == Range.Day -> DetailHeadline(Math.round(r.load).toString(), if (ctx.isToday) "today" else "that day", status?.takeIf { it != TodayCopy.LoadStatus.SoFar }?.text) + MaxLineLength:ActivityDetail.kt:range == Range.Week -> DetailHeadline(a.weekEquivalent.toString(), "minutes, last 7 days", ActivityCopy.activeWord(a.weekEquivalent)) + MaxLineLength:ActivityDetail.kt:scrub = i?.let { DetailHeadline(Math.round(daily[it] ?: 0f).toString(), ActivityCopy.earnedLabel(week[it].format(DAY)), null) } + MaxLineLength:ActivityDetail.kt:summary.activeADay != null -> DetailHeadline("%,d".format(summary.activeADay), CaloriesCopy.rangeLabel(range), CaloriesCopy.goalDays(summary)) + MaxLineLength:ActivityDetail.kt:val ended = source?.let { w -> Instant.ofEpochSecond(w.end).atZone(zone).let { ActivityCopy.ended(it.toLocalDate(), it.format(CLOCK), ctx.today) } } + MaxLineLength:ActivityDetail.kt:val label = if (range == Range.Year) "active kcal a day, week of ${d.format(DateTimeFormatter.ofPattern("d MMM"))}" else "active kcal, ${d.format(DAY)}" + MaxLineLength:ActivityDetail.kt:values + MaxLineLength:AlarmsScreen.kt:PageButton("Save", { onSave(alarm.copy(hour = time.hour, minute = time.minute, days = days, enabled = true)) }, Modifier.weight(1f), primary = true) + MaxLineLength:AlarmsScreen.kt:PageTextButton("Delete all", onDeleteAll, Modifier.align(Alignment.CenterHorizontally), enabled = !state.busy, color = DestructiveInk) + MaxLineLength:AlarmsScreen.kt:Row + MaxLineLength:AlarmsScreen.kt:Text(daysLabel(a.days), style = GlowType.secondary, color = Color.White.copy(alpha = 0.6f), modifier = Modifier.padding(top = 6.dp)) + MaxLineLength:AlarmsViewModel.kt:AlarmsUiState$val + MaxLineLength:AlarmsViewModel.kt:AlarmsViewModel$if (value == SettingsCodec.HR_CONTINUOUS && g[SettingsCodec.KEY_ACTIVITY_HR] != null) add(SettingsCodec.boolSetting(SettingsCodec.KEY_ACTIVITY_HR, true)) + MaxLineLength:AlarmsViewModel.kt:AlarmsViewModel${ (caps, alarms, health) -> s.copy(loading = false, busy = false, error = error, caps = caps, alarms = alarms, health = health) } + MaxLineLength:AllMetricsScreen.kt:Column + MaxLineLength:AllMetricsScreen.kt:ListRow(r.name, r.color, r.value, r.status, onClick = { onOpen(r.target) }, divider = i > 0, icon = r.target.rowIcon()) + MaxLineLength:AllMetricsScreen.kt:Text("All metrics", color = Color.White, style = GlowType.cardTitle.copy(fontSize = 20.sp, lineHeight = 26.sp), maxLines = 1) + MaxLineLength:AllMetricsScreen.kt:listOf(row("Sleep", MetricColors.sleepDeep, state.sleep?.score?.toString(), "Score for the night", IndexTarget.Home(HomeTab.Sleep))) + MaxLineLength:AllMetricsScreen.kt:metric(Metric.SkinTemperature, "Skin temperature", MetricColors.temperature, state.skinTemperature.takeIf { it.isNotEmpty() }?.map { it.y }?.average()?.toFloat(), "Day average") + MaxLineLength:AllMetricsScreen.kt:row("Habit Age", MetricColors.habitAge, habitValue, habitStatus, IndexTarget.Open(Detail.Healthspan), waiting = habitStatus) + MaxLineLength:AllMetricsScreen.kt:shown.word.takeIf { shown.estimate } ?: status ?: "Score for the day" + MaxLineLength:AppIcons.kt:AppIcons$val + MaxLineLength:AppIcons.kt:AppIcons$val ArrowCircleUp: ImageVector by lazy { line("ArrowCircleUp", "M2 12A10 10 0 1 0 22 12A10 10 0 1 0 2 12Z", "M16 13.5C16 13.5 13.054 10.5 12 10.5C10.9459 10.5 8 13.5 8 13.5") } + MaxLineLength:AppIcons.kt:AppIcons$val ArrowCounterClockwise: ImageVector by lazy { line("ArrowCounterClockwise", "M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C8.66873 3 5.76018 4.80989 4.20404 7.5", "M3 3V4.27816C3 6.47004 3 7.56599 3.70725 8.16512C4.4145 8.76425 5.49553 8.58408 7.6576 8.22373L9 8") } + MaxLineLength:AppIcons.kt:AppIcons$val ArrowLeft: ImageVector by lazy { line("ArrowLeft", "M5.5 12.002H19", "M10.9999 18.002C10.9999 18.002 4.99998 13.583 4.99997 12.0019C4.99996 10.4208 11 6.00195 11 6.00195", autoMirror = true) } + MaxLineLength:AppIcons.kt:AppIcons$val Backpack: ImageVector by lazy { line("Backpack", "M5 12C5 8.13401 8.13401 5 12 5C15.866 5 19 8.13401 19 12V16.3333C19 17.8847 19 18.6604 18.7877 19.2858C18.388 20.4633 17.4633 21.388 16.2858 21.7877C15.6604 22 14.8847 22 13.3333 22H10.6667C9.11529 22 8.3396 22 7.71424 21.7877C6.53668 21.388 5.61201 20.4633 5.21228 19.2858C5 18.6604 5 17.8847 5 16.3333V12Z", "M5 17C6.64996 15.17 9.17273 14 12 14C14.8273 14 17.35 15.17 19 17", "M11 10H13", "M9 6V5C9 4.06812 9 3.60218 9.15224 3.23463C9.35523 2.74458 9.74458 2.35523 10.2346 2.15224C10.6022 2 11.0681 2 12 2C12.9319 2 13.3978 2 13.7654 2.15224C14.2554 2.35523 14.6448 2.74458 14.8478 3.23463C15 3.60218 15 4.06812 15 5V6", "M5 19H3.71429C2.76751 19 2 18.2325 2 17.2857L2 16C2 14.3431 3.34315 13 5 13", "M19 19H20.2857C21.2325 19 22 18.2325 22 17.2857L22 16C22 14.3431 20.6569 13 19 13") } + MaxLineLength:AppIcons.kt:AppIcons$val Barbell: ImageVector by lazy { line("Barbell", "M7.73438 13.7323C7.73438 13.7323 8.63984 12.5102 9.23438 12.2319C11.0292 11.3915 11.3943 11.0263 12.2344 9.23091C12.5127 8.63618 13.7344 7.73044 13.7344 7.73044M10.2344 16.2331C10.2344 16.2331 11.4561 15.3273 11.7344 14.7326C12.5745 12.9373 12.9396 12.5721 14.7344 11.7317C15.3289 11.4533 16.2344 10.2312 16.2344 10.2312", "M14.4311 2.89207C14.938 2.38343 15.7611 2.38212 16.2696 2.88916L21.0814 7.68726C21.5899 8.1943 21.5912 9.01767 21.0843 9.52632L19.5557 11.0603C19.0488 11.5689 18.2257 11.5702 17.7172 11.0632L12.9054 6.26507C12.397 5.75803 12.3957 4.93466 12.9025 4.42601L14.4311 2.89207Z", "M4.41377 12.9022C4.92065 12.3936 5.74376 12.3923 6.25225 12.8993L11.0641 17.6974C11.5725 18.2045 11.5738 19.0278 11.067 19.5365L9.53836 21.0704C9.03148 21.5791 8.20837 21.5804 7.69988 21.0733L2.88808 16.2752C2.37959 15.7682 2.37829 14.9448 2.88517 14.4362L4.41377 12.9022Z", "M17.9377 3.45254C19.8201 0.985467 23.14 3.8401 20.5431 6.02872M3.37811 17.9773C0.998473 19.9687 3.99782 23.1586 6.06742 20.4657") } + MaxLineLength:AppIcons.kt:AppIcons$val Basketball: ImageVector by lazy { line("Basketball", "M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z", "M2 12.9506C8.14512 13.5607 13.5577 8.11477 12.9506 2", "M11.0507 22.0012C10.4406 15.856 15.8866 10.4434 22.0013 11.0505", "M17 20C17 12.8203 11.1797 7 4 7") } + MaxLineLength:AppIcons.kt:AppIcons$val BatteryCharging: ImageVector by lazy { line("BatteryCharging", "M2 12C2 9.17157 2 7.75736 2.87868 6.87868C3.75736 6 5.17157 6 8 6H13C15.8284 6 17.2426 6 18.1213 6.87868C19 7.75736 19 9.17157 19 12C19 14.8284 19 16.2426 18.1213 17.1213C17.2426 18 15.8284 18 13 18H8C5.17157 18 3.75736 18 2.87868 17.1213C2 16.2426 2 14.8284 2 12Z", "M10.8282 9L9.08572 11.1749C8.89899 11.4079 9.03283 11.7433 9.33733 11.8053L11.1627 12.1773C11.4873 12.2434 11.6111 12.6147 11.3842 12.8413L9.22216 15", "M19 9.5L20.0272 9.6712C20.7085 9.78475 21.0491 9.84152 21.3076 10.0067C21.5618 10.1691 21.7612 10.4044 21.8796 10.6819C22 10.964 22 11.3093 22 12C22 12.6907 22 13.036 21.8796 13.3181C21.7612 13.5956 21.5618 13.8309 21.3076 13.9933C21.0491 14.1585 20.7085 14.2153 20.0272 14.3288L19 14.5") } + MaxLineLength:AppIcons.kt:AppIcons$val Bicycle: ImageVector by lazy { line("Bicycle", "M6 20.0026C8.20914 20.0026 10 18.2118 10 16.0026C10 13.7935 8.20914 12.0026 6 12.0026C3.79086 12.0026 2 13.7935 2 16.0026C2 18.2118 3.79086 20.0026 6 20.0026Z", "M18 20.0026C20.2091 20.0026 22 18.2118 22 16.0026C22 13.7935 20.2091 12.0026 18 12.0026C15.7909 12.0026 14 13.7935 14 16.0026C14 18.2118 15.7909 20.0026 18 20.0026Z", "M6 16.0026H10.3706C10.7302 16.0026 11.0622 15.8095 11.2399 15.4968L15.5 8.00262", "M12 13.0026L7 7.00262M7 7.00262H5M7 7.00262H9", "M20.0039 6.21868C19.7999 5.64268 19.4399 4.74268 18.2399 4.32268C17.4599 4.02268 15.5399 3.90268 15.2999 4.08268C14.9527 4.16949 14.9399 4.56268 15.1079 5.10268C15.2444 5.68163 15.4559 6.42824 15.6479 7.14268C16.1399 8.97348 17.2199 12.9387 18.0239 15.9987") } + MaxLineLength:AppIcons.kt:AppIcons$val Bluetooth: ImageVector by lazy { line("Bluetooth", "M12.4742 12L16.2428 9.05534C17.3189 8.21451 17.857 7.79409 17.9716 7.24865C18.0144 7.04517 18.0154 6.83493 17.9748 6.63101C17.8657 6.08438 17.332 5.65832 16.2645 4.8062C14.6552 3.52156 13.8505 2.87924 13.1738 3.01878C12.9267 3.06975 12.6962 3.18351 12.504 3.34942C11.9779 3.80362 11.9779 4.84315 11.9779 6.92221V11.6122M12.4742 12L11.9779 12.3877M12.4742 12L16.2428 14.9446C17.319 15.7855 17.857 16.2059 17.9716 16.7513C18.0144 16.9548 18.0155 17.165 17.9748 17.369C17.8658 17.9156 17.332 18.3417 16.2645 19.1938C14.6552 20.4784 13.8505 21.1208 13.1738 20.9812C12.9266 20.9302 12.6962 20.8165 12.504 20.6506C11.9779 20.1964 11.9779 19.1568 11.9779 17.0778V12.3877M12.4742 12L11.9779 11.6122M11.9779 12.3877L6.00452 17.055M11.9779 12.3877V11.6122M11.9779 11.6122L6.00452 6.94494", "M5.37952 12H5.25452M18.8795 12H18.7545M5.50452 12C5.50452 12.1381 5.39259 12.25 5.25452 12.25C5.11645 12.25 5.00452 12.1381 5.00452 12C5.00452 11.8619 5.11645 11.75 5.25452 11.75C5.39259 11.75 5.50452 11.8619 5.50452 12ZM19.0045 12C19.0045 12.1381 18.8926 12.25 18.7545 12.25C18.6164 12.25 18.5045 12.1381 18.5045 12C18.5045 11.8619 18.6164 11.75 18.7545 11.75C18.8926 11.75 19.0045 11.8619 19.0045 12Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Boat: ImageVector by lazy { line("Boat", "M20.8198 3.1806C10.9252 1.49952 2.07124 11.6154 3.03263 20.9246C12.3645 21.8836 22.505 13.0512 20.8198 3.1806Z", "M6.95898 16.9961H6.96796", "M16.959 6.99612H16.968", "M9.4958 14.4781C8.80348 13.7875 8.80348 12.6678 9.4958 11.9772L11.9649 9.51407C12.6572 8.82344 13.7796 8.82344 14.4719 9.51407C15.1643 10.2047 15.1643 11.3244 14.4719 12.015L12.0028 14.4781C11.3105 15.1688 10.1881 15.1688 9.4958 14.4781Z", "M5.7582 5.68399C5.88051 3.29259 5.22238 2.32989 4.60371 2.05808C4.24623 1.90102 3.85728 2.09995 3.54202 2.33003C2.94876 2.76299 2.62277 3.08516 2.23608 3.68002C2.03886 3.98341 1.91293 4.36064 2.06947 4.68678C2.59441 5.78048 4.68496 5.93975 5.7582 5.68399ZM5.7582 5.68399L18.24 18.3176M18.24 18.3176C18.1192 20.7091 18.778 21.6714 19.3968 21.9428C19.7544 22.0996 20.1432 21.9005 20.4583 21.6702C21.0513 21.2368 21.3771 20.9145 21.7634 20.3194C21.9604 20.0158 22.0861 19.6385 21.9294 19.3125C21.4037 18.2191 19.3131 18.0612 18.24 18.3176Z") } + MaxLineLength:AppIcons.kt:AppIcons$val BoxingGlove: ImageVector by lazy { line("BoxingGlove", "M5.98148 11.2453C5.89446 13.9678 9.10256 17.3235 12.1105 18.3784M5.98148 11.2453C5.67692 11.768 2.56612 14.2618 2.51173 15.3182C2.24816 17.416 5.81222 21.357 8.56025 21.4915C9.58647 21.6371 11.0099 19.9102 12.4528 18.6272C13.2475 17.9205 14.3928 17.7571 18.9772 16.6194C20.6408 16.3303 22.6394 13.4607 19.6766 11.0283M5.98148 11.2453C6.9097 9.04414 8.51438 4.78884 11.6614 3.10843C12.9394 2.3823 16.2588 1.71372 19.5114 4.80592C20.6304 5.74702 22.5701 7.80848 20.7647 10.1836C19.561 11.5864 17.741 11.1803 17.5642 13.6064") } + MaxLineLength:AppIcons.kt:AppIcons$val Brain02: ImageVector by lazy { line("Brain02", "M4.22222 21.9948V18.4451C4.22222 17.1737 3.88927 16.5128 3.23482 15.4078C2.4503 14.0833 2 12.5375 2 10.8866C2 5.97866 5.97969 2 10.8889 2C15.7981 2 19.7778 5.97866 19.7778 10.8866C19.7778 11.4663 19.7778 11.7562 19.802 11.9187C19.8598 12.3072 20.0411 12.6414 20.2194 12.9873L22 16.4407L20.6006 17.1402C20.195 17.3429 19.9923 17.4443 19.851 17.6314C19.7097 17.8184 19.67 18.0296 19.5904 18.4519L19.5826 18.4931C19.4004 19.4606 19.1993 20.5286 18.6329 21.2024C18.4329 21.4403 18.1853 21.6336 17.9059 21.7699C17.4447 21.9948 16.8777 21.9948 15.7437 21.9948C15.219 21.9948 14.6928 22.0069 14.1682 21.9942C12.9247 21.9639 12 20.9184 12 19.7044", "M14.388 10.5315C13.9617 10.5315 13.5729 10.3702 13.2784 10.1048M14.388 10.5315C14.388 11.6774 13.7241 12.7658 12.4461 12.7658C11.1681 12.7658 10.5043 13.8541 10.5043 15M14.388 10.5315C16.5373 10.5315 16.5373 7.18017 14.388 7.18017C14.1927 7.18017 14.0053 7.21403 13.8312 7.27624C13.9362 4.77819 10.3349 4.1 9.51923 6.44018M10.5043 8.29729C10.5043 7.52323 10.1133 6.8411 9.51923 6.44018M9.51923 6.44018C7.66742 5.19034 5.19883 7.4331 6.37324 9.43277C4.40226 9.72827 4.61299 12.7658 6.6205 12.7658C7.18344 12.7658 7.68111 12.4844 7.98234 12.0538") } + MaxLineLength:AppIcons.kt:AppIcons$val Brain: ImageVector by lazy { line("Brain", "M16.998 7.12652C17.3182 7.04393 17.654 7 18 7C20.2091 7 22 8.79086 22 11C22 13.2091 20.2091 15 18 15C17.6451 15 17.3009 14.9538 16.9733 14.867M16.998 7.12652C16.9993 7.08451 17 7.04233 17 7C17 4.79086 15.2091 3 13 3C11.0824 3 9.47994 4.34939 9.09041 6.15043M16.998 7.12652C16.9769 7.80763 16.7854 8.44584 16.4649 9M16.9733 14.867C16.9909 14.7472 17 14.6247 17 14.5C17 13.2905 16.1411 12.2816 15 12.05M16.9733 14.867C16.7957 16.0737 15.756 17 14.5 17H14C11.7909 17 10 18.7909 10 21M9.09041 6.15043C8.74377 6.05243 8.37801 6 8 6C5.79086 6 4 7.79086 4 10C4 10.3886 4.05542 10.7643 4.15878 11.1195M9.09041 6.15043C10.1015 6.43625 10.9498 7.10965 11.4649 8M4.15878 11.1195C2.9114 11.4832 2 12.6352 2 14C2 15.6569 3.34315 17 5 17C6.30622 17 7.41746 16.1652 7.82929 15M4.15878 11.1195C4.24921 11.4303 4.37632 11.7255 4.53513 12", "M11.8361 11.7435C11.3257 12.2353 10.453 12.3202 9.70713 11.9008C8.9612 11.4814 8.58031 10.6917 8.73535 10") } + MaxLineLength:AppIcons.kt:AppIcons$val Bug: ImageVector by lazy { line("Bug", "M3.01309 4.99084C2.89323 6.05084 3.55249 8.42285 6.48923 8.42285", "M17.5951 8.38081C18.8357 8.57881 21.1132 7.49881 20.9957 5.00281", "M20.9928 20.9989C21.0528 19.9429 20.1777 17.5549 17.599 17.4229", "M6.45163 17.4708C5.65013 17.2308 3.01306 18.3348 3.01306 20.9988", "M9.3299 6.11884C9.35388 5.09884 9.84533 2.99884 12.0029 2.99884C13.9208 2.99884 14.5861 4.61884 14.676 6.11884M6.26131 9.41884C6.38118 8.63884 7.29216 6.81484 9.36586 6.63484C11.4635 6.55564 14.3403 6.58684 14.8797 6.67084C15.5869 6.73377 17.2951 7.43884 17.7506 9.41884C17.9124 10.4388 17.8285 11.8788 17.8524 12.7188C17.8165 13.5588 17.9207 15.2623 17.7565 16.1388C17.6367 17.0988 16.9894 18.4668 16.1024 19.3068C14.7838 20.7228 11.1639 22.2108 8.03534 19.4508C6.41713 17.8908 6.30925 16.3788 6.18939 15.7788C6.15725 15.4571 6.15875 13.8763 6.16541 12.3588C6.14144 11.046 6.17235 9.78063 6.26131 9.41884Z", "M3.01306 12.8988H5.9498", "M20.9929 12.8988L18.1161 12.8988", "M12.0033 16.4988L12.0033 20.2788") } + MaxLineLength:AppIcons.kt:AppIcons$val CalendarBlank: ImageVector by lazy { line("CalendarBlank", "M16 2V6M8 2V6", "M13 4H11C7.22876 4 5.34315 4 4.17157 5.17157C3 6.34315 3 8.22876 3 12V14C3 17.7712 3 19.6569 4.17157 20.8284C5.34315 22 7.22876 22 11 22H13C16.7712 22 18.6569 22 19.8284 20.8284C21 19.6569 21 17.7712 21 14V12C21 8.22876 21 6.34315 19.8284 5.17157C18.6569 4 16.7712 4 13 4Z", "M3 10H21", "M12.1258 14H12.0008M12.1258 18H12.0008M7.625 14H7.5M7.625 18H7.5M16.625 14H16.5M12.2508 14C12.2508 14.1381 12.1389 14.25 12.0008 14.25C11.8628 14.25 11.7508 14.1381 11.7508 14C11.7508 13.8619 11.8628 13.75 12.0008 13.75C12.1389 13.75 12.2508 13.8619 12.2508 14ZM12.2508 18C12.2508 18.1381 12.1389 18.25 12.0008 18.25C11.8628 18.25 11.7508 18.1381 11.7508 18C11.7508 17.8619 11.8628 17.75 12.0008 17.75C12.1389 17.75 12.2508 17.8619 12.2508 18ZM7.75 14C7.75 14.1381 7.63807 14.25 7.5 14.25C7.36193 14.25 7.25 14.1381 7.25 14C7.25 13.8619 7.36193 13.75 7.5 13.75C7.63807 13.75 7.75 13.8619 7.75 14ZM7.75 18C7.75 18.1381 7.63807 18.25 7.5 18.25C7.36193 18.25 7.25 18.1381 7.25 18C7.25 17.8619 7.36193 17.75 7.5 17.75C7.63807 17.75 7.75 17.8619 7.75 18ZM16.75 14C16.75 14.1381 16.6381 14.25 16.5 14.25C16.3619 14.25 16.25 14.1381 16.25 14C16.25 13.8619 16.3619 13.75 16.5 13.75C16.6381 13.75 16.75 13.8619 16.75 14Z") } + MaxLineLength:AppIcons.kt:AppIcons$val CaretLeft: ImageVector by lazy { line("CaretLeft", "M15 6C15 6 9.00001 10.4189 9 12C8.99999 13.5812 15 18 15 18", autoMirror = true) } + MaxLineLength:AppIcons.kt:AppIcons$val CaretRight: ImageVector by lazy { line("CaretRight", "M9.00005 6C9.00005 6 15 10.4189 15 12C15 13.5812 9 18 9 18", autoMirror = true) } + MaxLineLength:AppIcons.kt:AppIcons$val ChartBar: ImageVector by lazy { line("ChartBar", "M7 15V16C7 16.5523 7.44772 17 8 17H14C14.5523 17 15 16.5523 15 16V15C15 14.4477 14.5523 14 14 14H8C7.44772 14 7 14.4477 7 15Z", "M7 8V9C7 9.55228 7.44772 10 8 10H18C18.5523 10 19 9.55228 19 9V8C19 7.44772 18.5523 7 18 7H8C7.44772 7 7 7.44772 7 8Z", "M3 3V13C3 16.7712 3 18.6569 4.17157 19.8284C5.34315 21 7.22876 21 11 21H21") } + MaxLineLength:AppIcons.kt:AppIcons$val ChartLine: ImageVector by lazy { line("ChartLine", "M21 21H10C6.70017 21 5.05025 21 4.02513 19.9749C3 18.9497 3 17.2998 3 14V3", "M5 20C5.43938 16.8438 7.67642 8.7643 10.4282 8.7643C12.3301 8.7643 12.8226 12.6353 14.6864 12.6353C17.8931 12.6353 17.4282 4 21 4") } + MaxLineLength:AppIcons.kt:AppIcons$val ChartLineUp: ImageVector by lazy { line("ChartLineUp", "M21 21H10C6.70017 21 5.05025 21 4.02513 19.9749C3 18.9497 3 17.2998 3 14V3", "M7.99707 16.999C11.5286 16.999 18.9122 15.5348 18.6979 6.43269M16.4886 8.04302L18.3721 6.14612C18.5656 5.95127 18.8798 5.94981 19.0751 6.14286L20.9971 8.04302") } + MaxLineLength:AppIcons.kt:AppIcons$val CheckCircle: ImageVector by lazy { line("CheckCircle", "M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12Z", "M8 12.5L10.5 15L16 9") } + MaxLineLength:AppIcons.kt:AppIcons$val CloudArrowDown: ImageVector by lazy { line("CloudArrowDown", "M17.4776 9.01106C17.485 9.01102 17.4925 9.01101 17.5 9.01101C19.9853 9.01101 22 11.0294 22 13.5193C22 15.8398 20.25 17.7508 18 18M17.4776 9.01106C17.4924 8.84606 17.5 8.67896 17.5 8.51009C17.5 5.46695 15.0376 3 12 3C9.12324 3 6.76233 5.21267 6.52042 8.03192M17.4776 9.01106C17.3753 10.1476 16.9286 11.1846 16.2428 12.0165M6.52042 8.03192C3.98398 8.27373 2 10.4139 2 13.0183C2 15.4417 3.71776 17.4632 6 17.9273M6.52042 8.03192C6.67826 8.01687 6.83823 8.00917 7 8.00917C8.12582 8.00917 9.16474 8.38194 10.0005 9.01101", "M12 21L12 13M12 21C11.2998 21 9.99153 19.0057 9.5 18.5M12 21C12.7002 21 14.0085 19.0057 14.5 18.5") } + MaxLineLength:AppIcons.kt:AppIcons$val Copy: ImageVector by lazy { line("Copy", "M7.5 14.5C7.5 11.2002 7.5 9.55025 8.52513 8.52513C9.55025 7.5 11.2002 7.5 14.5 7.5C17.7998 7.5 19.4497 7.5 20.4749 8.52513C21.5 9.55025 21.5 11.2002 21.5 14.5C21.5 17.7998 21.5 19.4497 20.4749 20.4749C19.4497 21.5 17.7998 21.5 14.5 21.5C11.2002 21.5 9.55025 21.5 8.52513 20.4749C7.5 19.4497 7.5 17.7998 7.5 14.5Z", "M7.5 16.5C6.10355 16.5 5.40533 16.5 4.84402 16.3036C3.83866 15.9518 3.0482 15.1613 2.69641 14.156C2.5 13.5947 2.5 12.8964 2.5 11.5V9.5C2.5 6.20017 2.5 4.55025 3.52513 3.52513C4.55025 2.5 6.20017 2.5 9.5 2.5H11.5C12.8964 2.5 13.5947 2.5 14.156 2.69641C15.1613 3.0482 15.9518 3.83866 16.3036 4.84402C16.5 5.40533 16.5 6.10355 16.5 7.5") } + MaxLineLength:AppIcons.kt:AppIcons$val Cpu: ImageVector by lazy { line("Cpu", "M4 12C4 8.22876 4 6.34315 5.17157 5.17157C6.34315 4 8.22876 4 12 4C15.7712 4 17.6569 4 18.8284 5.17157C20 6.34315 20 8.22876 20 12C20 15.7712 20 17.6569 18.8284 18.8284C17.6569 20 15.7712 20 12 20C8.22876 20 6.34315 20 5.17157 18.8284C4 17.6569 4 15.7712 4 12Z", "M9.5 2V4", "M14.5 2V4", "M9.5 20V22", "M14.5 20V22", "M13 9L9 13", "M15 13L13 15", "M22 14.5L20 14.5", "M4 9.5L2 9.5", "M4 14.5L2 14.5", "M22 9.5L20 9.5") } + MaxLineLength:AppIcons.kt:AppIcons$val Dog: ImageVector by lazy { line("Dog", "M6.4922 6A2 2 0 1 0 10.4922 6A2 2 0 1 0 6.4922 6Z", "M13.4922 6A2 2 0 1 0 17.4922 6A2 2 0 1 0 13.4922 6Z", "M1.9922 11A2 2 0 1 0 5.9922 11A2 2 0 1 0 1.9922 11Z", "M17.9922 11A2 2 0 1 0 21.9922 11A2 2 0 1 0 17.9922 11Z", "M7.31094 20C6.65469 20 6.10417 19.7604 5.65938 19.2813C5.21458 18.8021 4.99219 18.2361 4.99219 17.5833C4.99219 16.8611 5.25104 16.2292 5.76875 15.6875C6.28646 15.1458 6.80052 14.6111 7.31094 14.0833C7.73385 13.6528 8.09844 13.184 8.40469 12.6771C8.71094 12.1701 9.07552 11.6944 9.49844 11.25C9.81927 10.8889 10.1911 10.5903 10.6141 10.3542C11.037 10.1181 11.4964 10 11.9922 10C12.488 10 12.9474 10.1111 13.3703 10.3333C13.7932 10.5556 14.1651 10.8472 14.4859 11.2083C14.8943 11.6528 15.2552 12.1319 15.5688 12.6458C15.8823 13.1597 16.2505 13.6389 16.6734 14.0833C17.1839 14.6111 17.6979 15.1458 18.2156 15.6875C18.7333 16.2292 18.9922 16.8611 18.9922 17.5833C18.9922 18.2361 18.7698 18.8021 18.325 19.2813C17.8802 19.7604 17.3297 20 16.6734 20C15.8859 20 15.1057 19.9375 14.3328 19.8125C13.5599 19.6875 12.7797 19.625 11.9922 19.625C11.2047 19.625 10.4245 19.6875 9.65156 19.8125C8.87865 19.9375 8.09844 20 7.31094 20Z") } + MaxLineLength:AppIcons.kt:AppIcons$val DotsThree: ImageVector by lazy { line("DotsThree", "M6.00449 12.5V12M18.0045 12.5V12M12.0045 12.5V12M7.00449 12.5C7.00449 11.9477 6.55677 11.5 6.00449 11.5C5.4522 11.5 5.00449 11.9477 5.00449 12.5C5.00449 13.0523 5.4522 13.5 6.00449 13.5C6.55677 13.5 7.00449 13.0523 7.00449 12.5ZM19.0045 12.5C19.0045 11.9477 18.5568 11.5 18.0045 11.5C17.4522 11.5 17.0045 11.9477 17.0045 12.5C17.0045 13.0523 17.4522 13.5 18.0045 13.5C18.5568 13.5 19.0045 13.0523 19.0045 12.5ZM13.0045 12.5C13.0045 11.9477 12.5568 11.5 12.0045 11.5C11.4522 11.5 11.0045 11.9477 11.0045 12.5C11.0045 13.0523 11.4522 13.5 12.0045 13.5C12.5568 13.5 13.0045 13.0523 13.0045 12.5Z") } + MaxLineLength:AppIcons.kt:AppIcons$val DownloadSimple: ImageVector by lazy { line("DownloadSimple", "M2.99969 17.0002C2.99969 17.9302 2.99969 18.3952 3.10192 18.7767C3.37932 19.8119 4.18796 20.6206 5.22324 20.898C5.60474 21.0002 6.06972 21.0002 6.99969 21.0002L16.9997 21.0002C17.9297 21.0002 18.3947 21.0002 18.7762 20.898C19.8114 20.6206 20.6201 19.8119 20.8975 18.7767C20.9997 18.3952 20.9997 17.9302 20.9997 17.0002", "M16.4998 11.5002C16.4998 11.5002 13.1856 16.0002 11.9997 16.0002C10.8139 16.0002 7.49976 11.5002 7.49976 11.5002M11.9997 15.0002V3.00016") } + MaxLineLength:AppIcons.kt:AppIcons$val Drop: ImageVector by lazy { line("Drop", "M3.5 13.678C3.5 9.49387 7.08079 5.35907 9.59413 2.97222C10.9591 1.67593 13.0409 1.67593 14.4059 2.97222C16.9192 5.35907 20.5 9.49387 20.5 13.678C20.5 17.7804 17.2812 22 12 22C6.71878 22 3.5 17.7804 3.5 13.678Z", "M16 14C16 16.2091 14.2091 18 12 18") } + MaxLineLength:AppIcons.kt:AppIcons$val FastWind: ImageVector by lazy { line("FastWind", "M2 5.94145C5.5 9.37313 10.5755 7.90241 11.7324 5.94145C11.9026 5.65301 12 5.31814 12 4.96096C12 3.87795 11.1046 3 10 3C8.89543 3 8 3.87795 8 4.96096", "M17 8.92814C17 7.31097 18.1193 6 19.5 6C20.8807 6 22 7.31097 22 8.92814C22 9.6452 21.7799 10.3021 21.4146 10.8111C19.3463 14.1915 9.2764 12.9164 4 11.8563", "M13.0854 19.8873C13.2913 20.5356 13.8469 21 14.5 21C15.3284 21 16 20.2528 16 19.331C16 19.0176 15.9224 18.7244 15.7873 18.4738C14.4999 15.9925 7.99996 14.3239 2 18.7746", "M19 15.5H21") } + MaxLineLength:AppIcons.kt:AppIcons$val Fire: ImageVector by lazy { line("Fire", "M13.8561 22C26.0783 19 19.2338 7 10.9227 2C9.9453 5.5 8.47838 6.5 5.54497 10C1.66121 14.6339 3.5895 20 8.96719 22C8.1524 21 6.04958 18.9008 7.5 16C8 15 9 14 8.5 12C9.47778 12.5 11.5 13 12 15.5C12.8148 14.5 13.6604 12.4 12.8783 10C19 14.5 16.5 19 13.8561 22Z") } + MaxLineLength:AppIcons.kt:AppIcons$val FlagCheckered: ImageVector by lazy { line("FlagCheckered", "M5.67181 13.9095C10 15.9322 14 7.84169 21 11.8869L18 2.78502C13.4239 -0.299918 8.56286 6.85641 3 4.62523L8.00007 22", "M19 7.00073C13.5 3.00076 9 12.0007 4.5 9.00064", "M8 4.90476L10.8235 13M13.1765 3L16 10.619") } + MaxLineLength:AppIcons.kt:AppIcons$val Flask: ImageVector by lazy { line("Flask", "M14.4973 2V8.25401C14.4973 8.72617 14.7093 9.1734 15.0747 9.47239L15.7421 10.0184C18.4717 12.2518 20.2277 15.4577 20.6397 18.9604C20.8301 20.5784 19.5659 22 17.9368 22H6.05789C4.42876 22 3.16459 20.5784 3.35494 18.9604C3.76702 15.4577 5.52295 12.2518 8.25261 10.0184L8.91998 9.47239C9.28541 9.1734 9.49734 8.72617 9.49734 8.25401V2", "M7.99707 2H15.9971", "M5.49707 14.0183C5.74095 14.0063 5.99072 14 6.24707 14C9.99707 14 13.7471 17 17.4971 17C18.2729 17 18.9332 16.9384 19.4971 16.8334") } + MaxLineLength:AppIcons.kt:AppIcons$val FlowerLotus: ImageVector by lazy { line("FlowerLotus", "M9.0923 16C8.78292 16.6819 8.0701 16.986 7.43887 17.3162L3.79262 19.2233C2.32845 19.9891 3.05949 22 4.62985 22C8.12204 22 10.8836 20.3064 14.0404 19C14.835 18.6711 15.2201 18.7415 16 19.0912", "M9 19.0912C9.77995 18.7415 10.165 18.6711 10.9596 19C14.1164 20.3064 16.878 22 20.3702 22C21.9405 22 22.6715 19.9891 21.2074 19.2233L17.5611 17.3162C16.9299 16.986 16.2171 16.6819 15.9077 16", "M10 4C10 5.10457 10.8954 6 12 6C13.1046 6 14 5.10457 14 4C14 2.89543 13.1046 2 12 2C10.8954 2 10 2.89543 10 4Z", "M12 8C8.68629 8 6 10.6863 6 14C9.31371 14 12 11.3137 12 8Z", "M12 8C15.3137 8 18 10.6863 18 14C14.6863 14 12 11.3137 12 8Z") } + MaxLineLength:AppIcons.kt:AppIcons$val FolderOpen: ImageVector by lazy { line("FolderOpen", "M2.5 20V8.87695C2.5 7.58945 2.5 6.9457 2.78533 6.47048C2.9541 6.18939 3.18939 5.9541 3.47048 5.78533C3.9457 5.5 4.59449 5.5 5.89206 5.5C6.52339 5.5 6.83906 5.5 7.12612 5.58819C7.31759 5.64702 7.49914 5.73428 7.66469 5.84705C7.91289 6.01611 8.10859 6.26074 8.5 6.75C8.89141 7.23926 9.08711 7.48389 9.33531 7.65295C9.50086 7.76572 9.68241 7.85298 9.87388 7.91181C10.1609 8 10.4742 8 11.1008 8H15C16.4045 8 17.1067 8 17.6111 8.33706C17.8295 8.48298 18.017 8.67048 18.1629 8.88886C18.5 9.39331 18.5 10.0955 18.5 11.5", "M4.42028 14.0144L3.63368 16.0144C2.65618 18.4998 2.16743 19.7425 2.7524 20.6213C3.33737 21.5 4.65337 21.5 7.28537 21.5H15.1903C16.4249 21.5 17.0422 21.5 17.5295 21.1795C18.0169 20.859 18.2702 20.2865 18.7769 19.1415L19.6618 17.1415C20.7866 14.5992 21.349 13.3281 20.7679 12.4141C20.1868 11.5 18.8163 11.5 16.0752 11.5H8.07196C6.78232 11.5 6.1375 11.5 5.63811 11.8439C5.13872 12.1877 4.89924 12.7966 4.42028 14.0144Z", "M11.5 4.51456C12.4151 3.28409 13.6662 2.55594 15.5125 2.50161C16.1155 2.48386 16.7152 2.61395 17.2682 2.85544C18.5748 3.42601 19.4185 4.15644 20 5.5L21.5 3") } + MaxLineLength:AppIcons.kt:AppIcons$val Footprints: ImageVector by lazy { line("Footprints", "M10.535 10.2187L9.99444 12.4393C9.746 13.4599 9.62179 13.9701 9.24607 14.2823C9.21466 14.3084 9.18215 14.3333 9.14863 14.3568C8.74779 14.6384 8.20467 14.6384 7.11844 14.6384H6.44056C5.71535 14.6384 5.35274 14.6384 5.05823 14.5126C4.7893 14.3978 4.56057 14.2103 4.39963 13.9727C4.22339 13.7125 4.16475 13.3672 4.04748 12.6765L3.5637 9.8272C3.16319 7.46831 4.68869 5.20279 7.08482 4.59797L7.29168 4.54575C8.7774 4.17073 10.2847 5.07654 10.5916 6.52879C10.8492 7.74826 10.8299 9.00715 10.535 10.2187Z", "M5.48863 17.5793H8.32119C8.93314 17.5793 9.42923 18.0581 9.42923 18.6487V19.0641C9.42923 20.4096 8.29905 21.5004 6.90491 21.5004C5.51077 21.5004 4.38059 20.4096 4.38059 19.0641V18.6487C4.38059 18.0581 4.87668 17.5793 5.48863 17.5793Z", "M13.463 8.25779L14.0036 10.4784C14.252 11.4989 14.3763 12.0092 14.752 12.3214C14.7834 12.3475 14.8159 12.3724 14.8494 12.3959C15.2502 12.6775 15.7934 12.6775 16.8796 12.6775H17.5575C18.2827 12.6775 18.6453 12.6775 18.9398 12.5517C19.2087 12.4368 19.4375 12.2493 19.5984 12.0118C19.7747 11.7516 19.8333 11.4063 19.9506 10.7156L20.4343 7.86626C20.8348 5.50737 19.3094 3.24185 16.9132 2.63703L16.7064 2.58482C15.2206 2.20979 13.7133 3.1156 13.4065 4.56785C13.1488 5.78732 13.1681 7.04621 13.463 8.25779Z", "M18.5094 15.6183H15.6769C15.0649 15.6183 14.5688 16.0971 14.5688 16.6877V17.1032C14.5688 18.4487 15.699 19.5394 17.0931 19.5394C18.4873 19.5394 19.6174 18.4487 19.6174 17.1032V16.6877C19.6174 16.0971 19.1214 15.6183 18.5094 15.6183Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Golf: ImageVector by lazy { line("Golf", "M5 9A7 7 0 1 0 19 9A7 7 0 1 0 5 9Z", "M9 18C10.5 18 12 19.4617 12 22C12 19.4617 13.5 18 15 18", "M10.125 7H10M10.25 7C10.25 7.13807 10.1381 7.25 10 7.25C9.86193 7.25 9.75 7.13807 9.75 7C9.75 6.86193 9.86193 6.75 10 6.75C10.1381 6.75 10.25 6.86193 10.25 7Z") } + MaxLineLength:AppIcons.kt:AppIcons$val HandPalm: ImageVector by lazy { line("HandPalm", "M18.5 11.5V8.75C18.5 7.7835 19.2835 7 20.25 7C21.2165 7 22 7.7835 22 8.75V14.8C22 18.7765 18.7764 22 14.8 22H12.0588M8 14L5.61507 11.2176C5.22468 10.7621 4.65476 10.5 4.05489 10.5H3.91898C2.85916 10.5 2 11.3592 2 12.419C2 12.7978 2.11214 13.1682 2.32229 13.4834L5.7812 18.6718C7.16738 20.7511 9.50102 22 12 22", "M18.5 8.5V5.75C18.5 4.7835 17.7165 4 16.75 4C15.7835 4 15 4.7835 15 5.75V10.5", "M15 10.5V3.75C15 2.7835 14.2165 2 13.25 2C12.2835 2 11.5 2.7835 11.5 3.75V10", "M11.5 10V5.75C11.5 4.7835 10.7165 4 9.75 4C8.7835 4 8 4.7835 8 5.75V14") } + MaxLineLength:AppIcons.kt:AppIcons$val Heart: ImageVector by lazy { line("Heart", "M10.4107 19.9677C7.58942 17.858 2 13.0348 2 8.69444C2 5.82563 4.10526 3.5 7 3.5C8.5 3.5 10 4 12 6C14 4 15.5 3.5 17 3.5C19.8947 3.5 22 5.82563 22 8.69444C22 13.0348 16.4106 17.858 13.5893 19.9677C12.6399 20.6776 11.3601 20.6776 10.4107 19.9677Z") } + MaxLineLength:AppIcons.kt:AppIcons$val HeartFill: ImageVector by lazy { bold("HeartFill", "M10.4107 19.9677C7.58942 17.858 2 13.0348 2 8.69444C2 5.82563 4.10526 3.5 7 3.5C8.5 3.5 10 4 12 6C14 4 15.5 3.5 17 3.5C19.8947 3.5 22 5.82563 22 8.69444C22 13.0348 16.4106 17.858 13.5893 19.9677C12.6399 20.6776 11.3601 20.6776 10.4107 19.9677Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Heartbeat: ImageVector by lazy { line("Heartbeat", "M10.4107 19.9677C7.58942 17.858 2 13.0348 2 8.69444C2 5.82563 4.10526 3.5 7 3.5C8.5 3.5 10 4 12 6C14 4 15.5 3.5 17 3.5C19.8947 3.5 22 5.82563 22 8.69444C22 13.0348 16.4106 17.858 13.5893 19.9677C12.6399 20.6776 11.3601 20.6776 10.4107 19.9677Z", "M20.001 13.0001H16.0288C15.8168 13.0001 15.7107 13.0001 15.619 12.9639C15.5691 12.9442 15.5229 12.9169 15.4821 12.8831C15.4072 12.8209 15.3598 12.7303 15.2649 12.5491C14.9921 12.0278 14.8557 11.7672 14.6597 11.7045C14.5567 11.6716 14.4453 11.6716 14.3422 11.7045C14.1462 11.7672 14.0098 12.0278 13.737 12.5491L13.1172 13.7335C12.6442 14.6372 12.4078 15.089 12.0706 15.0624C11.7335 15.0357 11.578 14.5529 11.267 13.5872L10.8024 12.1447C10.4668 11.1027 10.299 10.5817 9.95039 10.5639C9.60176 10.5462 9.377 11.0472 8.92748 12.0493L8.76073 12.4211C8.63475 12.7019 8.57176 12.8423 8.44652 12.9212C8.32129 13.0001 8.16139 13.0001 7.84158 13.0001H4.00098") } + MaxLineLength:AppIcons.kt:AppIcons$val Hourglass: ImageVector by lazy { line("Hourglass", "M4 3H20", "M5.5 3V5.03039C5.5 6.27227 6.07682 7.4437 7.06116 8.20089L12 12L16.9388 8.20089C17.9232 7.44371 18.5 6.27227 18.5 5.03039V3", "M5.5 21V18.9696C5.5 17.7277 6.07682 16.5563 7.06116 15.7991L12 12L16.9388 15.7991C17.9232 16.5563 18.5 17.7277 18.5 18.9696V21", "M4 21H20") } + MaxLineLength:AppIcons.kt:AppIcons$val HouseSimple: ImageVector by lazy { line("HouseSimple", "M3 11.9896V14.5C3 17.7998 3 19.4497 4.02513 20.4749C5.05025 21.5 6.70017 21.5 10 21.5H14C17.2998 21.5 18.9497 21.5 19.9749 20.4749C21 19.4497 21 17.7998 21 14.5V11.9896C21 10.3083 21 9.46773 20.6441 8.74005C20.2882 8.01237 19.6247 7.49628 18.2976 6.46411L16.2976 4.90855C14.2331 3.30285 13.2009 2.5 12 2.5C10.7991 2.5 9.76689 3.30285 7.70242 4.90855L5.70241 6.46411C4.37533 7.49628 3.71179 8.01237 3.3559 8.74005C3 9.46773 3 10.3083 3 11.9896Z") } + MaxLineLength:AppIcons.kt:AppIcons$val HouseSimpleFill: ImageVector by lazy { bold("HouseSimpleFill", "M3 11.9896V14.5C3 17.7998 3 19.4497 4.02513 20.4749C5.05025 21.5 6.70017 21.5 10 21.5H14C17.2998 21.5 18.9497 21.5 19.9749 20.4749C21 19.4497 21 17.7998 21 14.5V11.9896C21 10.3083 21 9.46773 20.6441 8.74005C20.2882 8.01237 19.6247 7.49628 18.2976 6.46411L16.2976 4.90855C14.2331 3.30285 13.2009 2.5 12 2.5C10.7991 2.5 9.76689 3.30285 7.70242 4.90855L5.70241 6.46411C4.37533 7.49628 3.71179 8.01237 3.3559 8.74005C3 9.46773 3 10.3083 3 11.9896Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Info: ImageVector by lazy { line("Info", "M2 12A10 10 0 1 0 22 12A10 10 0 1 0 2 12Z", "M12 16V12", "M12.125 8.25H12M12.25 8.25C12.25 8.11193 12.1381 8 12 8C11.8619 8 11.75 8.11193 11.75 8.25C11.75 8.38807 11.8619 8.5 12 8.5C12.1381 8.5 12.25 8.38807 12.25 8.25Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Key: ImageVector by lazy { line("Key", "M15.5 14.5C18.8137 14.5 21.5 11.8137 21.5 8.5C21.5 5.18629 18.8137 2.5 15.5 2.5C12.1863 2.5 9.5 5.18629 9.5 8.5C9.5 9.38041 9.68962 10.2165 10.0303 10.9697L2.5 18.5V21.5H5.5V19.5H7.5V17.5H9.5L13.0303 13.9697C13.7835 14.3104 14.6196 14.5 15.5 14.5Z", "M17.5 6.5L16.5 7.5") } + MaxLineLength:AppIcons.kt:AppIcons$val Lightning: ImageVector by lazy { line("Lightning", "M5.22576 11.3294L12.224 2.34651C12.7713 1.64397 13.7972 2.08124 13.7972 3.01707V9.96994C13.7972 10.5305 14.1995 10.985 14.6958 10.985H18.0996C18.8729 10.985 19.2851 12.0149 18.7742 12.6706L11.776 21.6535C11.2287 22.356 10.2028 21.9188 10.2028 20.9829V14.0301C10.2028 13.4695 9.80048 13.015 9.3042 13.015H5.90035C5.12711 13.015 4.71494 11.9851 5.22576 11.3294Z") } + MaxLineLength:AppIcons.kt:AppIcons$val LinkBreak: ImageVector by lazy { line("LinkBreak", "M12.1434 10.691L12.3503 10.4841C14.329 8.50532 17.5372 8.50532 19.5159 10.4841C21.4947 12.4628 21.4947 15.671 19.5159 17.6497L16.6497 20.5159C14.671 22.4947 11.4628 22.4947 9.48405 20.5159C7.50532 18.5372 7.50532 15.329 9.48405 13.3503L9.9484 12.886", "M20.0516 11.114L20.5159 10.6497C22.4947 8.67095 22.4947 5.46279 20.5159 3.48405C18.5372 1.50532 15.329 1.50532 13.3503 3.48405L10.4841 6.35031C8.50532 8.32904 8.50532 11.5372 10.4841 13.5159C12.4628 15.4947 15.671 15.4947 17.6497 13.5159L17.8566 13.309", "M4.5 4L6 6M2 8L5 9M3 13.5L5 12") } + MaxLineLength:AppIcons.kt:AppIcons$val Location01: ImageVector by lazy { line("Location01", "M13.6177 21.367C13.1841 21.773 12.6044 22 12.0011 22C11.3978 22 10.8182 21.773 10.3845 21.367C6.41302 17.626 1.09076 13.4469 3.68627 7.37966C5.08963 4.09916 8.45834 2 12.0011 2C15.5439 2 18.9126 4.09916 20.316 7.37966C22.9082 13.4393 17.599 17.6389 13.6177 21.367Z", "M15.5 11C15.5 12.933 13.933 14.5 12 14.5C10.067 14.5 8.5 12.933 8.5 11C8.5 9.067 10.067 7.5 12 7.5C13.933 7.5 15.5 9.067 15.5 11Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Lungs: ImageVector by lazy { line("Lungs", "M7.97769 11C8.38875 11.2063 8.71501 11.5615 8.97397 11.9954M8.97397 11.9954C9.97037 13.665 9.97025 16.5 9.97025 16.5C9.97025 20 8.18605 21 5.98512 21C4.98884 21 2 20.5 2 16C2 9.5 5.48698 5 8.47583 5C10.8669 5 9.97174 10 8.97397 11.9954Z", "M16.0216 11C15.6104 11.2063 15.2841 11.5615 15.0251 11.9954M15.0251 11.9954C14.0286 13.665 14.0287 16.5 14.0287 16.5C14.0287 20 15.8132 21 18.0144 21C19.0108 21 22 20.5 22 16C22 9.5 18.5126 5 15.5234 5C13.132 5 14.0301 10 15.0251 11.9954Z", "M14 7L12 5.66667M12 5.66667L10 7M12 5.66667V3") } + MaxLineLength:AppIcons.kt:AppIcons$val Moon: ImageVector by lazy { line("Moon", "M21.5 14.0784C20.3003 14.7189 18.9301 15.0821 17.4751 15.0821C12.7491 15.0821 8.91792 11.2509 8.91792 6.52485C8.91792 5.06986 9.28105 3.69968 9.92163 2.5C5.66765 3.49698 2.5 7.31513 2.5 11.8731C2.5 17.1899 6.8101 21.5 12.1269 21.5C16.6849 21.5 20.503 18.3324 21.5 14.0784Z") } + MaxLineLength:AppIcons.kt:AppIcons$val MoonFill: ImageVector by lazy { bold("MoonFill", "M21.5 14.0784C20.3003 14.7189 18.9301 15.0821 17.4751 15.0821C12.7491 15.0821 8.91792 11.2509 8.91792 6.52485C8.91792 5.06986 9.28105 3.69968 9.92163 2.5C5.66765 3.49698 2.5 7.31513 2.5 11.8731C2.5 17.1899 6.8101 21.5 12.1269 21.5C16.6849 21.5 20.503 18.3324 21.5 14.0784Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Mountains: ImageVector by lazy { line("Mountains", "M16 20H2L5.22457 10.7557C6.79555 6.25189 7.58104 4 9 4C10.3373 4 11.1119 6 12.5116 10", "M6 20H22L17.4066 12.6585C15.8806 10.2195 15.1176 9 14 9C12.8824 9 12.1194 10.2195 10.5934 12.6585L9.12837 15") } + MaxLineLength:AppIcons.kt:AppIcons$val MusicNotes: ImageVector by lazy { line("MusicNotes", "M11 7.13678V17M11 7.13678C12.8928 8.81698 14.5706 10.0042 16.0063 10.6818C16.6937 11.0062 17.3165 11.0682 18.0198 10.7552C19.7751 9.97419 21 8.20629 21 6.15045C19.0715 7.50911 16.6876 6.77163 14.6847 5.50548C13.0454 4.46918 12.2258 3.95102 11.8569 4.00364C11.5781 4.0434 11.4283 4.1242 11.244 4.33421C11 4.61216 11 5.4537 11 7.13678Z", "M11 17C11 19.2091 9.20914 21 7 21C4.79086 21 3 19.2091 3 17C3 14.7909 4.79086 13 7 13C9.20914 13 11 14.7909 11 17Z") } + MaxLineLength:AppIcons.kt:AppIcons$val PauseFill: ImageVector by lazy { solid("PauseFill", "M4 7C4 5.58579 4 4.87868 4.43934 4.43934C4.87868 4 5.58579 4 7 4C8.41421 4 9.12132 4 9.56066 4.43934C10 4.87868 10 5.58579 10 7V17C10 18.4142 10 19.1213 9.56066 19.5607C9.12132 20 8.41421 20 7 20C5.58579 20 4.87868 20 4.43934 19.5607C4 19.1213 4 18.4142 4 17V7Z", "M14 7C14 5.58579 14 4.87868 14.4393 4.43934C14.8787 4 15.5858 4 17 4C18.4142 4 19.1213 4 19.5607 4.43934C20 4.87868 20 5.58579 20 7V17C20 18.4142 20 19.1213 19.5607 19.5607C19.1213 20 18.4142 20 17 20C15.5858 20 14.8787 20 14.4393 19.5607C14 19.1213 14 18.4142 14 17V7Z") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonArmsSpread: ImageVector by lazy { line("PersonArmsSpread", "M16 5.5C16 6.32843 15.3284 7 14.5 7C13.6716 7 13 6.32843 13 5.5C13 4.67157 13.6716 4 14.5 4C15.3284 4 16 4.67157 16 5.5Z", "M14.3602 15L15.3039 14.454C16.3786 13.8323 16.9159 13.5214 16.9885 13.0784C16.9999 13.0092 17.0028 12.9391 16.9973 12.8694C16.9622 12.4229 16.4524 12.0789 15.4329 11.3907L10.7259 8.21359C8.87718 6.96577 8.45184 4.69114 9.75097 3", "M10.7259 8.21359C8.22588 10.7136 7 17.6324 7 21.0003M13.3725 10L10.7259 8.21359C8.87718 6.96577 8.45184 4.69114 9.75097 3M14.3602 15L15.3039 14.454C16.3786 13.8323 16.9159 13.5214 16.9885 13.0784C16.9999 13.0092 17.0028 12.9391 16.9973 12.8694C16.9622 12.4229 16.4524 12.0789 15.4329 11.3907L13.3725 10M13.3725 10C12.5697 11.0391 12.0164 12.0207 11.6026 12.8942C11.1636 13.8209 10.9441 14.2843 10.9845 14.8132C11.0249 15.3422 11.3666 15.8474 12.0502 16.8578C13.0257 18.3 14.0268 19.8647 15.0002 21.0003M10.9845 14.8132L8 14") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleBike: ImageVector by lazy { line("PersonSimpleBike", "M6 19.9986C8.20914 19.9986 10 18.2112 10 16.0063C10 13.8014 8.20914 12.0139 6 12.0139C3.79086 12.0139 2 13.8014 2 16.0063C2 18.2112 3.79086 19.9986 6 19.9986Z", "M18 19.9986C20.2091 19.9986 22 18.2112 22 16.0063C22 13.8014 20.2091 12.0139 18 12.0139C15.7909 12.0139 14 13.8014 14 16.0063C14 18.2112 15.7909 19.9986 18 19.9986Z", "M6 16.0062H10.3706C10.7302 16.0062 11.0622 15.8135 11.2399 15.5014L15.5 8.02148", "M12 13.012L7 7.02344M7 7.02344H5M7 7.02344H9", "M18.0531 15.9709L14.8057 4.03097C16.2565 3.96037 19.3585 3.83085 19.9908 6.21002") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleRun: ImageVector by lazy { line("PersonSimpleRun", "M17 4.5C17 5.32843 16.3284 6 15.5 6C14.6716 6 14 5.32843 14 4.5C14 3.67157 14.6716 3 15.5 3C16.3284 3 17 3.67157 17 4.5Z", "M15 21.0008L14.3359 18.3848C14.1161 17.5191 13.6615 16.7284 13.0207 16.0974L11.5 14.5998M20 8.19913C17.9627 10.4921 16.1547 10.1433 15 9.27743C14.8878 9.19326 14.7664 9.06672 14.6482 8.92548C14.2356 8.43256 14.0293 8.18609 13.8282 8.09214C13.6271 7.99818 13.3747 7.99813 12.8698 7.99805C12.5444 7.99799 12.2186 7.99877 12 8.00136C8.53767 8.0423 7 9.18366 6 11.1534M12 8.00136L10.7309 9.95956C10.0332 11.0362 9.68429 11.5745 9.67069 12.1397C9.66463 12.3914 9.70617 12.642 9.79313 12.8784C9.98834 13.409 10.4922 13.8059 11.5 14.5998M15 9.27743L11.5 14.5998", "M4 17.7303L4.67822 17.8916C6.40663 18.3028 8.20324 17.5164 9 16") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleSki: ImageVector by lazy { line("PersonSimpleSki", "M19.9983 4.5C19.9983 5.3284 19.3271 6 18.4992 6C17.6712 6 17 5.3284 17 4.5C17 3.67157 17.6712 3 18.4992 3C19.3271 3 19.9983 3.67157 19.9983 4.5Z", "M12.4787 7.81915L13.5537 6.85169C14.0995 6.36044 14.9207 6.33659 15.4942 6.79533L17 8M12.4787 7.81915L15.3 9.7L17 8M12.4787 7.81915L6 3.5M12.5 12.5L11.1068 11.5712C10.4329 11.1219 10.2587 10.2468 10.6307 9.58714M12.5 12.5L14.8608 14.1863C15.4191 14.5851 15.4191 15.4149 14.8608 15.8137L12.5 17.5M12.5 12.5L13.5 11.5M17 8L17.5 11.1667L21 13.5", "M3 12L17 21L19.5 20.5") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleSwim: ImageVector by lazy { line("PersonSimpleSwim", "M10.7184 6.86177L15 16C12.8333 16 12.1739 14.8571 9.91304 13.7143C8.10435 12.8 5.57971 12.9524 4.82609 13.1429L7.85739 10.9998C8.16585 10.7817 8.32008 10.6727 8.36937 10.5067C8.41866 10.3407 8.34893 10.1651 8.20947 9.81406L7.78519 8.74593C7.62654 8.34653 7.54721 8.14683 7.40384 8.00197C7.35388 7.9515 7.2987 7.9065 7.23922 7.86772C7.06848 7.7564 6.8569 7.71887 6.43376 7.64379L3.18315 7.06706C2.4987 6.94562 2 6.35065 2 5.65551C2 4.78222 2.77418 4.11181 3.63851 4.23665L8.0343 4.87152C8.82604 4.98586 9.22191 5.04304 9.5521 5.23177C9.68974 5.31045 9.81754 5.40521 9.93281 5.51407C10.2093 5.7752 10.379 6.13739 10.7184 6.86177Z", "M16 10A3 3 0 1 0 22 10A3 3 0 1 0 16 10Z", "M2 18.0843C3.05556 14.5527 7.7685 16.1736 11.5 18.0843C15.2315 19.995 19 21.2108 21 18.0843") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleTaiChi: ImageVector by lazy { line("PersonSimpleTaiChi", "M10.5054 15.0102L17.14 10.4989C17.3273 10.3598 17.4944 10.1955 17.6363 10.0112C18.1217 9.38041 18.1121 8.50551 17.8044 7.77366C17.1198 6.14514 15.4952 5 13.6 5C12.6644 5 11.7948 5.27908 11.0717 5.75762L3.99219 10.7567", "M5.99514 13.5066C5.99514 14.2026 6.36367 15.5859 8.0358 15.9655C9.04177 16.1939 11.9726 15.2305 10.5386 12.4385C9.10468 9.64659 5.6515 9.63202 4.24682 10.5675C3.3884 11.0889 1.72132 12.7163 2.03737 14.8796C2.14955 16.1903 3.1816 18.8496 6.41238 19.0017H16.3037C17.2255 18.9286 17.416 18.7949 18.1181 18.2562C19.0626 17.408 20.6401 15.8485 21.5565 14.8218C21.7545 14.6 21.9682 14.3717 21.9956 14.0757C22.1413 12.499 19.7488 13.1819 18.0157 12.994") } + MaxLineLength:AppIcons.kt:AppIcons$val PersonSimpleThrow: ImageVector by lazy { line("PersonSimpleThrow", "M2.39114 21.6387C1.81409 21.0816 1.8854 20.1398 2.53981 19.6752L6.17145 17.097C6.38333 16.9465 6.6739 16.9737 6.85404 17.1607C7.02764 17.3409 7.0488 17.6183 6.90454 17.8226L4.3387 21.4558C3.88384 22.0999 2.95883 22.1867 2.39114 21.6387Z", "M11.5 12.5L7 17", "M19.4291 12.5774C17.534 14.4725 13.869 14.5529 11.6581 12.3419C9.44711 10.131 9.52748 6.46597 11.4226 4.57087C13.912 2.08144 18.5975 0.980641 20.8084 3.19159C23.0194 5.40254 21.9186 10.088 19.4291 12.5774Z") } + MaxLineLength:AppIcons.kt:AppIcons$val PingPong: ImageVector by lazy { line("PingPong", "M6 8L16 18", "M2.48802 18.1868C4.27193 17.0655 6.999 15.3875 7.40038 13.6847C7.52522 13.1551 7.24468 12.6415 7.00243 12.1542C5.83072 9.79703 5.80432 7.05244 7.54627 5.31272C10.584 2.27886 17.1053 0.495103 20.307 3.69226C23.505 6.88574 21.7222 13.4012 18.6842 16.4348C16.942 18.1746 14.1945 18.149 11.8339 16.9787C11.3457 16.7368 10.8314 16.4565 10.3009 16.5812C8.59557 16.982 6.91469 19.7046 5.79184 21.4859C4.57177 23.4214 0.634453 19.3518 2.48802 18.1868Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Plant: ImageVector by lazy { line("Plant", "M19.4626 3.99352C16.7809 2.3486 14.4404 3.01148 13.0344 4.06738C12.4578 4.50033 12.1696 4.7168 12 4.7168C11.8304 4.7168 11.5422 4.50033 10.9656 4.06738C9.55962 3.01148 7.21909 2.3486 4.53744 3.99352C1.01807 6.1523 0.221719 13.2742 8.33953 19.2827C9.88572 20.4272 10.6588 20.9994 12 20.9994C13.3412 20.9994 14.1143 20.4272 15.6605 19.2827C23.7783 13.2742 22.9819 6.1523 19.4626 3.99352Z", "M12 9V15M9 12L15 12") } + MaxLineLength:AppIcons.kt:AppIcons$val PlantFill: ImageVector by lazy { bold("PlantFill", "M19.4626 3.99352C16.7809 2.3486 14.4404 3.01148 13.0344 4.06738C12.4578 4.50033 12.1696 4.7168 12 4.7168C11.8304 4.7168 11.5422 4.50033 10.9656 4.06738C9.55962 3.01148 7.21909 2.3486 4.53744 3.99352C1.01807 6.1523 0.221719 13.2742 8.33953 19.2827C9.88572 20.4272 10.6588 20.9994 12 20.9994C13.3412 20.9994 14.1143 20.4272 15.6605 19.2827C23.7783 13.2742 22.9819 6.1523 19.4626 3.99352Z", "M12 9V15M9 12L15 12") } + MaxLineLength:AppIcons.kt:AppIcons$val PlayFill: ImageVector by lazy { solid("PlayFill", "M18.8906 12.846C18.5371 14.189 16.8667 15.138 13.5257 17.0361C10.296 18.8709 8.6812 19.7884 7.37983 19.4196C6.8418 19.2671 6.35159 18.9776 5.95624 18.5787C5 17.6139 5 15.7426 5 12C5 8.2574 5 6.3861 5.95624 5.42132C6.35159 5.02245 6.8418 4.73288 7.37983 4.58042C8.6812 4.21165 10.296 5.12907 13.5257 6.96393C16.8667 8.86197 18.5371 9.811 18.8906 11.154C19.0365 11.7084 19.0365 12.2916 18.8906 12.846Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Route01: ImageVector by lazy { line("Route01", "M15 5A3 3 0 1 0 21 5A3 3 0 1 0 15 5Z", "M3 19A3 3 0 1 0 9 19A3 3 0 1 0 3 19Z", "M12 5H8.5C6.567 5 5 6.567 5 8.5C5 10.433 6.567 12 8.5 12H15.5C17.433 12 19 13.567 19 15.5C19 17.433 17.433 19 15.5 19H12") } + MaxLineLength:AppIcons.kt:AppIcons$val ShieldCheck: ImageVector by lazy { line("ShieldCheck", "M18.7088 3.49534C16.8165 2.55382 14.5009 2 12 2C9.4991 2 7.1835 2.55382 5.29116 3.49534C4.36318 3.95706 3.89919 4.18792 3.4496 4.91378C3 5.63965 3 6.34248 3 7.74814V11.2371C3 16.9205 7.54236 20.0804 10.173 21.4338C10.9067 21.8113 11.2735 22 12 22C12.7265 22 13.0933 21.8113 13.8269 21.4338C16.4576 20.0804 21 16.9205 21 11.2371L21 7.74814C21 6.34249 21 5.63966 20.5504 4.91378C20.1008 4.18791 19.6368 3.95706 18.7088 3.49534Z", "M9 11.5C9 11.5 10.4079 11.7519 11 13.5C11 13.5 12.5 10.5 15 9.5") } + MaxLineLength:AppIcons.kt:AppIcons$val ShutDown: ImageVector by lazy { line("ShutDown", "M7.86907 4C4.97674 5.49689 3 8.51664 3 11.9981C3 16.9686 7.02944 20.9981 12 20.9981C16.9706 20.9981 21 16.9686 21 11.9981C21 8.51664 19.0233 5.49689 16.1309 4", "M12 3V10") } + MaxLineLength:AppIcons.kt:AppIcons$val SlidersHorizontal: ImageVector by lazy { line("SlidersHorizontal", "M3.99963 5.00055L9.99963 5.00031", "M12.9996 5.00031L19.9996 5.00031", "M15.9996 9.00031L15.9996 15.0003", "M9.99963 2.00031L9.99963 8.00031", "M11.9996 16.0003L11.9996 22.0003", "M15.9996 12.0001L19.9996 12.0003", "M3.99963 12.0005L12.9996 12.0003", "M11.9996 19.0003L19.9996 19.0003", "M3.99963 19.0005L8.99963 19.0003") } + MaxLineLength:AppIcons.kt:AppIcons$val SneakerMove: ImageVector by lazy { line("SneakerMove", "M12.4059 18.9923C13.4443 19.7399 13.9635 20.1137 14.5623 20.3069C15.1611 20.5 15.8008 20.5 17.0804 20.5H19C20.4142 20.5 21.1213 20.5 21.5607 20.0607C22 19.6213 22 18.9142 22 17.5H16.7902C16.1504 17.5 15.8305 17.5 15.5311 17.4034C15.2318 17.3069 14.9722 17.12 14.453 16.7461L3 8.5L2.30911 9.53634C2.10755 9.83867 2 10.1939 2 10.5572C2 11.1492 2.2847 11.705 2.76507 12.0509L12.4059 18.9923Z", "M3 8.5L6 3.5L6.30704 5.34226C6.42827 6.06965 6.89023 6.69511 7.5498 7.0249C8.64393 7.57197 9.97486 7.16899 10.5818 6.10689L11.1396 5.13069C11.3625 4.74069 11.7772 4.5 12.2264 4.5C12.7005 4.5 13.1339 4.76787 13.346 5.19193L17.2764 13.0528C17.4134 13.3269 17.6936 13.5 18 13.5C20.2091 13.5 22 15.2909 22 17.5", "M12.5 9.5L14.5 8.5", "M14 12L16 11", "M6 20.5H18", "M2 17.5H5") } + MaxLineLength:AppIcons.kt:AppIcons$val SneakerMoveFill: ImageVector by lazy { bold("SneakerMoveFill", "M12.4059 18.9923C13.4443 19.7399 13.9635 20.1137 14.5623 20.3069C15.1611 20.5 15.8008 20.5 17.0804 20.5H19C20.4142 20.5 21.1213 20.5 21.5607 20.0607C22 19.6213 22 18.9142 22 17.5H16.7902C16.1504 17.5 15.8305 17.5 15.5311 17.4034C15.2318 17.3069 14.9722 17.12 14.453 16.7461L3 8.5L2.30911 9.53634C2.10755 9.83867 2 10.1939 2 10.5572C2 11.1492 2.2847 11.705 2.76507 12.0509L12.4059 18.9923Z", "M3 8.5L6 3.5L6.30704 5.34226C6.42827 6.06965 6.89023 6.69511 7.5498 7.0249C8.64393 7.57197 9.97486 7.16899 10.5818 6.10689L11.1396 5.13069C11.3625 4.74069 11.7772 4.5 12.2264 4.5C12.7005 4.5 13.1339 4.76787 13.346 5.19193L17.2764 13.0528C17.4134 13.3269 17.6936 13.5 18 13.5C20.2091 13.5 22 15.2909 22 17.5", "M12.5 9.5L14.5 8.5", "M14 12L16 11", "M6 20.5H18", "M2 17.5H5") } + MaxLineLength:AppIcons.kt:AppIcons$val SoccerBall: ImageVector by lazy { line("SoccerBall", "M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z", "M11.7077 9.34893C11.882 9.21702 12.118 9.21702 12.2923 9.34893L14.545 11.054C14.7193 11.1859 14.7922 11.4197 14.7256 11.6332L13.8652 14.3921C13.7986 14.6055 13.6077 14.75 13.3923 14.75H10.6077C10.3923 14.75 10.2014 14.6055 10.1348 14.3921L9.27437 11.6332C9.20781 11.4197 9.28073 11.1859 9.45499 11.054L11.7077 9.34893Z", "M12 9V5M15 11L19 9.5M14 15L16 18M10 14.5L8 17M9 11.5L5 10.5", "M9 2.5L12.0165 4.62241L15 2.5M2 12.7998L5.19655 10.4388L3.55548 6.72045M19.4703 18.8531L15.6158 18.1555L14.2655 22M20.0298 6.19586L18.8035 9.38978L22 11.7507M8.00992 21.4059L8.05142 17.1665L4.00331 17.21") } + MaxLineLength:AppIcons.kt:AppIcons$val Stairs: ImageVector by lazy { line("Stairs", "M17.5 3.5H18.5C19.4428 3.5 19.9142 3.5 20.2071 3.79289C20.5 4.08579 20.5 4.55719 20.5 5.5V16.5C20.5 18.3856 20.5 19.3284 19.9142 19.9142C19.3284 20.5 18.3856 20.5 16.5 20.5H5.5C4.55719 20.5 4.08579 20.5 3.79289 20.2071C3.5 19.9142 3.5 19.4428 3.5 18.5V17.5C3.5 16.5572 3.5 16.0858 3.79289 15.7929C4.08579 15.5 4.55719 15.5 5.5 15.5H7.5V13.5C7.5 12.5572 7.5 12.0858 7.79289 11.7929C8.08579 11.5 8.55719 11.5 9.5 11.5H11.5V9.5C11.5 8.55719 11.5 8.08579 11.7929 7.79289C12.0858 7.5 12.5572 7.5 13.5 7.5H15.5V5.5C15.5 4.55719 15.5 4.08579 15.7929 3.79289C16.0858 3.5 16.5572 3.5 17.5 3.5Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Steps: ImageVector by lazy { line("Steps", "M10 15H3.5C3.03406 15 2.80109 15 2.61732 15.0761C2.37229 15.1776 2.17761 15.3723 2.07612 15.6173C2 15.8011 2 16.0341 2 16.5C2 16.9659 2 17.1989 2.07612 17.3827C2.17761 17.6277 2.37229 17.8224 2.61732 17.9239C2.80109 18 3.03406 18 3.5 18H10V15Z", "M13 12H7C6.05719 12 5.58579 12 5.29289 12.2929C5 12.5858 5 13.0572 5 14V15H13V12Z", "M10 18H18C19.8856 18 20.8284 18 21.4142 17.4142C22 16.8284 22 15.8856 22 14V8C22 7.05719 22 6.58579 21.7071 6.29289C21.4142 6 20.9428 6 20 6H19", "M16 9H10C9.05719 9 8.58579 9 8.29289 9.29289C8 9.58579 8 10.0572 8 11V12H16V9Z", "M19 6H13C12.0572 6 11.5858 6 11.2929 6.29289C11 6.58579 11 7.05719 11 8V9H19V6Z") } + MaxLineLength:AppIcons.kt:AppIcons$val StopFill: ImageVector by lazy { solid("StopFill", "M4 12C4 8.72077 4 7.08116 4.81382 5.91891C5.1149 5.48891 5.48891 5.1149 5.91891 4.81382C7.08116 4 8.72077 4 12 4C15.2792 4 16.9188 4 18.0811 4.81382C18.5111 5.1149 18.8851 5.48891 19.1862 5.91891C20 7.08116 20 8.72077 20 12C20 15.2792 20 16.9188 19.1862 18.0811C18.8851 18.5111 18.5111 18.8851 18.0811 19.1862C16.9188 20 15.2792 20 12 20C8.72077 20 7.08116 20 5.91891 19.1862C5.48891 18.8851 5.1149 18.5111 4.81382 18.0811C4 16.9188 4 15.2792 4 12Z") } + MaxLineLength:AppIcons.kt:AppIcons$val TennisBall: ImageVector by lazy { line("TennisBall", "M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z", "M5 5C8.99009 8.52068 9.0099 15.4618 5 19", "M19 19C14.9901 15.4618 15.0099 8.52068 19 5") } + MaxLineLength:AppIcons.kt:AppIcons$val TerminalWindow: ImageVector by lazy { line("TerminalWindow", "M7.5 7.5L8.72654 8.55719C9.24218 9.00163 9.5 9.22386 9.5 9.5C9.5 9.77614 9.24218 9.99836 8.72654 10.4428L7.5 11.5", "M11.5 12.5H15.5", "M12 21C15.7497 21 17.6246 21 18.9389 20.0451C19.3634 19.7367 19.7367 19.3634 20.0451 18.9389C21 17.6246 21 15.7497 21 12C21 8.25027 21 6.3754 20.0451 5.06107C19.7367 4.6366 19.3634 4.26331 18.9389 3.95491C17.6246 3 15.7497 3 12 3C8.25027 3 6.3754 3 5.06107 3.95491C4.6366 4.26331 4.26331 4.6366 3.95491 5.06107C3 6.3754 3 8.25027 3 12C3 15.7497 3 17.6246 3.95491 18.9389C4.26331 19.3634 4.6366 19.7367 5.06107 20.0451C6.3754 21 8.25027 21 12 21Z") } + MaxLineLength:AppIcons.kt:AppIcons$val ThermometerSimple: ImageVector by lazy { line("ThermometerSimple", "M13.8801 15.9369L20.6738 8.17267C21.4217 7.31791 21.7956 6.89053 21.9252 6.41326C22.0376 5.99901 22.023 5.56052 21.8833 5.15466C21.7223 4.68707 21.3207 4.28551 20.5176 3.4824C19.7145 2.67928 19.3129 2.27773 18.8453 2.11672C18.4395 1.97696 18.001 1.96236 17.5867 2.07481C17.1095 2.20436 16.6821 2.57832 15.8273 3.32623L8.06312 10.1199C7.10741 10.9562 6.62955 11.3743 6.34846 11.9258C6.06738 12.4774 6.00989 13.1098 5.89492 14.3745L5.87152 14.6319C5.81078 15.3 5.78041 15.6341 5.65293 15.9386C5.52546 16.2431 5.30877 16.4992 4.8754 17.0114L2.27543 20.0841C1.88438 20.5462 1.91286 21.231 2.34094 21.6591C2.76903 22.0871 3.45379 22.1156 3.91594 21.7246L6.98863 19.1246C7.5008 18.6912 7.75689 18.4745 8.06139 18.3471C8.36589 18.2196 8.69998 18.1892 9.36814 18.1285L9.62553 18.1051C10.8902 17.9901 11.5226 17.9326 12.0741 17.6515C12.6257 17.3704 13.0438 16.8926 13.8801 15.9369Z", "M7.78906 9.89453L9.36983 10.843C9.74255 11.0666 9.86341 11.5501 9.63978 11.9228L9.34781 12.4094C8.96198 13.0524 9.06331 13.8755 9.59357 14.4058C10.1238 14.9361 10.9469 15.0374 11.59 14.6516L12.0766 14.3596C12.4493 14.136 12.9328 14.2568 13.1564 14.6296L14.1049 16.2103", "M17.2634 6.7373L14.1055 9.8952") } + MaxLineLength:AppIcons.kt:AppIcons$val Timer: ImageVector by lazy { line("Timer", "M18.0104 7.48959L19.5 6M20.5 13.5C20.5 18.1944 16.6944 22 12 22C7.30558 22 3.5 18.1944 3.5 13.5C3.5 8.80558 7.30558 5 12 5C16.6944 5 20.5 8.80558 20.5 13.5Z", "M14.5 2H9.5", "M12 13.5L15.5 10") } + MaxLineLength:AppIcons.kt:AppIcons$val Trash: ImageVector by lazy { line("Trash", "M19.5 5.5L18.8803 15.5251C18.7219 18.0864 18.6428 19.3671 18.0008 20.2879C17.6833 20.7431 17.2747 21.1273 16.8007 21.416C15.8421 22 14.559 22 11.9927 22C9.42312 22 8.1383 22 7.17905 21.4149C6.7048 21.1257 6.296 20.7408 5.97868 20.2848C5.33688 19.3626 5.25945 18.0801 5.10461 15.5152L4.5 5.5", "M3 5.5H21M16.0557 5.5L15.3731 4.09173C14.9196 3.15626 14.6928 2.68852 14.3017 2.39681C14.215 2.3321 14.1231 2.27454 14.027 2.2247C13.5939 2 13.0741 2 12.0345 2C10.9688 2 10.436 2 9.99568 2.23412C9.8981 2.28601 9.80498 2.3459 9.71729 2.41317C9.32164 2.7167 9.10063 3.20155 8.65861 4.17126L8.05292 5.5", "M9.5 16.5L9.5 10.5", "M14.5 16.5L14.5 10.5") } + MaxLineLength:AppIcons.kt:AppIcons$val UploadSimple: ImageVector by lazy { line("UploadSimple", "M2.99994 17C2.99994 17.93 2.99994 18.395 3.10216 18.7765C3.37956 19.8117 4.18821 20.6204 5.22348 20.8978C5.60498 21 6.06997 21 6.99994 21L16.9999 21C17.9299 21 18.3949 21 18.7764 20.8978C19.8117 20.6204 20.6203 19.8117 20.8977 18.7765C20.9999 18.395 20.9999 17.93 20.9999 17", "M16.5 7.49993C16.5 7.49993 13.1858 2.99997 12 2.99996C10.8141 2.99995 7.50002 7.49996 7.50002 7.49996M12 3.99996V16") } + MaxLineLength:AppIcons.kt:AppIcons$val User: ImageVector by lazy { line("User", "M20 21.0001C19.713 17.269 16.7289 14.3151 12.995 14.0662L12 13.9999C11.6446 14.0096 11.3134 14.0225 11.0008 14.0378C7.3 14.2192 4.28417 17.3057 4 21.0001", "M8 6.9999A4 4 0 1 0 16 6.9999A4 4 0 1 0 8 6.9999Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Vibrate: ImageVector by lazy { line("Vibrate", "M17 15V9C17 6.64298 17 5.46447 16.2678 4.73223C15.5355 4 14.357 4 12 4C9.64298 4 8.46447 4 7.73223 4.73223C7 5.46447 7 6.64298 7 9V15C7 17.357 7 18.5355 7.73223 19.2678C8.46447 20 9.64298 20 12 20C14.357 20 15.5355 20 16.2678 19.2678C17 18.5355 17 17.357 17 15Z", "M22 8L20.3578 9.23167C20.1325 9.40059 20 9.66569 20 9.94721C20 10.286 20.1914 10.5957 20.4944 10.7472L21.5528 11.2764C21.8269 11.4134 22 11.6936 22 12C22 12.3064 21.8269 12.5866 21.5528 12.7236L20.4944 13.2528C20.1914 13.4043 20 13.714 20 14.0528C20 14.3343 20.1325 14.5994 20.3578 14.7683L22 16", "M2 8L3.64223 9.23167C3.86745 9.40059 4 9.66569 4 9.94721C4 10.286 3.80859 10.5957 3.50557 10.7472L2.44721 11.2764C2.17313 11.4134 2 11.6936 2 12C2 12.3064 2.17313 12.5866 2.44721 12.7236L3.50557 13.2528C3.80859 13.4043 4 13.714 4 14.0528C4 14.3343 3.86745 14.5994 3.64223 14.7683L2 16") } + MaxLineLength:AppIcons.kt:AppIcons$val Volleyball: ImageVector by lazy { line("Volleyball", "M22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z", "M8.12046 3C7.67458 4.61905 7.76376 8.58571 11.6875 11.5M11.6875 11.5C16.6295 10.17 20.125 10.65 22 12.3498M11.6875 11.5C12.2525 16.1881 8.875 19.15 7 20", "M9 8C11.087 6.11368 16.3261 5.12819 20.5 7.01432", "M16.8885 11C17.4777 13.8947 15.7144 19.1883 12 22", "M10 17C7.37458 15.7396 4.15837 10.9595 4 6") } + MaxLineLength:AppIcons.kt:AppIcons$val Warning: ImageVector by lazy { line("Warning", "M13.9248 21H10.0752C5.44476 21 3.12955 21 2.27636 19.4939C1.42317 17.9879 2.60736 15.9914 4.97574 11.9985L6.90057 8.75333C9.17559 4.91778 10.3131 3 12 3C13.6869 3 14.8244 4.91777 17.0994 8.75332L19.0243 11.9985C21.3926 15.9914 22.5768 17.9879 21.7236 19.4939C20.8704 21 18.5552 21 13.9248 21Z", "M12 9V13", "M12.125 16.75H12M12.25 16.75C12.25 16.8881 12.1381 17 12 17C11.8619 17 11.75 16.8881 11.75 16.75C11.75 16.6119 11.8619 16.5 12 16.5C12.1381 16.5 12.25 16.6119 12.25 16.75Z") } + MaxLineLength:AppIcons.kt:AppIcons$val WarningCircle: ImageVector by lazy { line("WarningCircle", "M2 12A10 10 0 1 0 22 12A10 10 0 1 0 2 12Z", "M12 8V12", "M12.125 15.75H12M12.25 15.75C12.25 15.8881 12.1381 16 12 16C11.8619 16 11.75 15.8881 11.75 15.75C11.75 15.6119 11.8619 15.5 12 15.5C12.1381 15.5 12.25 15.6119 12.25 15.75Z") } + MaxLineLength:AppIcons.kt:AppIcons$val Watch: ImageVector by lazy { line("Watch", "M5 12A7 7 0 1 0 19 12A7 7 0 1 0 5 12Z", "M16 6L15.7276 4.91043C15.3931 3.5724 15.2258 2.90339 14.7499 2.49004C14.6973 2.44433 14.6423 2.40141 14.5852 2.36145C14.0688 2 13.3792 2 12 2C10.6208 2 9.93119 2 9.41476 2.36145C9.35765 2.40141 9.30268 2.44433 9.25006 2.49004C8.77415 2.90339 8.6069 3.5724 8.27239 4.91043L8 6", "M8 18L8.27239 19.0896C8.6069 20.4276 8.77415 21.0966 9.25006 21.51C9.30268 21.5557 9.35765 21.5986 9.41476 21.6386C9.93119 22 10.6208 22 12 22C13.3792 22 14.0688 22 14.5852 21.6386C14.6423 21.5986 14.6973 21.5557 14.7499 21.51C15.2258 21.0966 15.3931 20.4276 15.7276 19.0896L16 18", "M12 10V12.005L13 13") } + MaxLineLength:AppIcons.kt:AppIcons$val WatchFill: ImageVector by lazy { bold("WatchFill", "M5 12A7 7 0 1 0 19 12A7 7 0 1 0 5 12Z", "M16 6L15.7276 4.91043C15.3931 3.5724 15.2258 2.90339 14.7499 2.49004C14.6973 2.44433 14.6423 2.40141 14.5852 2.36145C14.0688 2 13.3792 2 12 2C10.6208 2 9.93119 2 9.41476 2.36145C9.35765 2.40141 9.30268 2.44433 9.25006 2.49004C8.77415 2.90339 8.6069 3.5724 8.27239 4.91043L8 6", "M8 18L8.27239 19.0896C8.6069 20.4276 8.77415 21.0966 9.25006 21.51C9.30268 21.5557 9.35765 21.5986 9.41476 21.6386C9.93119 22 10.6208 22 12 22C13.3792 22 14.0688 22 14.5852 21.6386C14.6423 21.5986 14.6973 21.5557 14.7499 21.51C15.2258 21.0966 15.3931 20.4276 15.7276 19.0896L16 18", "M12 10V12.005L13 13") } + MaxLineLength:AppIcons.kt:AppIcons$val Waves: ImageVector by lazy { line("Waves", "M2 12.1932C2.68524 13.2443 3.57104 13.2443 4.27299 12.1932C6.52985 8.7408 8.67954 14.6764 10.273 12.2321C12.703 8.56944 14.4508 14.9218 16.273 12.1932C18.6492 8.5582 20.1295 14.5776 22 12.5842", "M2 6.1932C2.68524 7.24434 3.57104 7.24434 4.27299 6.1932C6.52985 2.7408 8.67954 8.67642 10.273 6.23213C12.703 2.56944 14.4508 8.92184 16.273 6.1932C18.6492 2.5582 20.1295 8.57758 22 6.58418", "M2 18.1932C2.68524 19.2443 3.57104 19.2443 4.27299 18.1932C6.52985 14.7408 8.67954 20.6764 10.273 18.2321C12.703 14.5694 14.4508 20.9218 16.273 18.1932C18.6492 14.5582 20.1295 20.5776 22 18.5842") } + MaxLineLength:AppIcons.kt:AppIcons$val Wind: ImageVector by lazy { line("Wind", "M6 6C6 4.34315 7.34315 3 9 3C10.6569 3 12 4.34315 12 6C12 7.65685 10.6569 9 9 9H3", "M9 18C9 19.6569 10.3431 21 12 21C13.6569 21 15 19.6569 15 18C15 16.3431 13.6569 15 12 15H3", "M15 9C15 7.34315 16.3431 6 18 6C19.6569 6 21 7.34315 21 9C21 10.6569 19.6569 12 18 12H6.5") } + MaxLineLength:AppUpdater.kt:AppUpdater$info.signingInfo?.apkContentsSigners.orEmpty().mapTo(HashSet()) { hex(MessageDigest.getInstance("SHA-256").digest(it.toByteArray())) } + MaxLineLength:AutoBackup.kt:AutoBackup$if (resolver.persistedUriPermissions.none { it.uri == folder && it.isWritePermission }) throw Failed(BackupFiles.Failure.PermissionLost) + MaxLineLength:AutoDetectNotifier.kt:AutoDetectNotifier$val open = PendingIntent.getActivity(context, w.start.toInt(), intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + MaxLineLength:AutoDetectNotifier.kt:AutoDetectNotifier.Companion$return "${Workouts.sportName(w.sportType)} detected, ${clock.format(Instant.ofEpochSecond(w.start))}–${clock.format(Instant.ofEpochSecond(w.end))}" + MaxLineLength:AutoHealthConnect.kt:HealthConnectAutoSync$/** The user turned auto sync on, with the permissions granted: on, and the last 30 days written at once. Returns the records written. */ + MaxLineLength:AutoStart.kt:AutoStart$(stepsPerMinute ?: 0) >= BRISK_STEPS || recent.takeLast(10).let { it.isNotEmpty() && it.map { r -> r.second }.average() >= threshold } + MaxLineLength:AutoStart.kt:AutoStart$readings.groupBy { it.first / 60 * 60 }.map { (t, r) -> t to Math.round(r.map { it.second }.average()).toInt() }.sortedBy { it.first } + MaxLineLength:Backtest.kt:Backtest.Companion$"strap_sleep_hrv" + MaxLineLength:Backtest.kt:Backtest.Companion$row.result.load.f1() + MaxLineLength:Backtest.kt:Backtest.Companion$val + MaxLineLength:BackupPage.kt:PageRow + MaxLineLength:BackupPage.kt:PageRow("Restore", "Any OpenStrap zip. Fills in what this phone is missing", onClick = onRestore, below = true, icon = AppIcons.ArrowCounterClockwise) + MaxLineLength:BackupPage.kt:PageRow("Save a copy", "A zip saved where you choose, never cleared", onClick = onSaveCopy, below = true, icon = AppIcons.DownloadSimple) + MaxLineLength:BackupPage.kt:PickBackup$. + MaxLineLength:BackupPage.kt:PickBackup$if + MaxLineLength:BodySignals.kt:BodySignals$if (d >= RR_FLAG_PER_MIN && d >= FLAG_SCALES * rrBase.scale) { flags += Flag.RrFlag; signals += "breathing rate up ${"%.1f".format(d)}/min" } + MaxLineLength:BodySignals.kt:BodySignals$if (d >= TEMP_FLAG_C && d >= FLAG_SCALES * tempBase.scale) { flags += Flag.TempFlag; signals += "skin temperature up from your usual" } + MaxLineLength:BodySignals.kt:BodySignals$if (hrvBase != null) hrvOf(n)?.let { if (hrvBase.z(it) <= HRV_SUPPORT_Z) { flags += Flag.HrvSupport; signals += "HRV well below your normal" } } + MaxLineLength:BodySignals.kt:BodySignals$val hrvBase = Recovery.baseline(pool.filter { it.valid }.mapNotNull { it.hrvLn }.takeLast(ALERT_BASELINE_NIGHTS), Recovery.HRV_LN_SD_FLOOR, MIN_ALERT_NIGHTS) + MaxLineLength:BodySignals.kt:BodySignals$val rhrBase = Recovery.baseline(pool.filter { it.valid }.mapNotNull { it.rhrMeanSleep }.takeLast(ALERT_BASELINE_NIGHTS), Recovery.RHR_SD_FLOOR, MIN_ALERT_NIGHTS) + MaxLineLength:BodySignals.kt:BodySignals$val rrBase = Recovery.baseline(pool.mapNotNull { it.breathRate }.takeLast(ALERT_BASELINE_NIGHTS), Recovery.BREATH_SD_FLOOR, MIN_ALERT_NIGHTS) + MaxLineLength:BodySignals.kt:BodySignals$val tempBase = Recovery.baseline(pool.mapNotNull { it.skinTemperature }.takeLast(ALERT_BASELINE_NIGHTS), Recovery.TEMP_SD_FLOOR, MIN_ALERT_NIGHTS) + MaxLineLength:BodySignals.kt:BodySignals$while (k > 0 && out[k].flags.none { it.core } && out[k].state == State.Alert && nightsBetween(sorted[k - 1].day, sorted[k].day) - 1 <= 1) { clearRun++; k-- } + MaxLineLength:CalorieRecords.kt:CalorieRecords$fun + MaxLineLength:CaloriesCopy.kt:CaloriesCopy$else + MaxLineLength:CaloriesCopy.kt:CaloriesCopy$fun + MaxLineLength:CaloriesCopy.kt:CaloriesCopy$fun aDay + MaxLineLength:CanvasDraw.kt:bars.forEachIndexed { i, bar -> bar?.let { drawPillBar(canvas, it, if (i == shown) color else Color.White.copy(alpha = WeekBarsLook.DIM_ALPHA)) } } + MaxLineLength:CanvasDraw.kt:canvas.drawLine(s.x0, height / 2, s.x1, height / 2, roundStroke(stageColor(s.stage), height).apply { strokeJoin = Paint.Join.ROUND }) + MaxLineLength:CanvasDraw.kt:canvas.drawRoundRect(0f, 0f, w, h, r, r, Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = paint.hairlineWidth; shader = gloss }) + MaxLineLength:CanvasDraw.kt:canvas.drawRoundRect(bar.topLeft.x, bar.topLeft.y, bar.topLeft.x + bar.size.width, bar.topLeft.y + bar.size.height, bar.corner, bar.corner, fill(color)) + MaxLineLength:CanvasDraw.kt:fun + MaxLineLength:CanvasDraw.kt:g.usual?.let { canvas.drawPath(androidPath(polylineOps(it)), roundStroke(Color.White.copy(alpha = GhostLineLook.USUAL_ALPHA), GhostLineLook.USUAL_DP * density)) } + MaxLineLength:CanvasDraw.kt:val gloss = LinearGradient(0f, 0f, if (g.across) w else 0f, if (g.across) 0f else h, g.stops.map { it.second.toArgb() }.toIntArray(), g.stops.map { it.first }.toFloatArray(), Shader.TileMode.CLAMP) + MaxLineLength:CardLooks.kt:Hue$enum + MaxLineLength:CardToDetail.kt:fadeIn(spring(Spring.DampingRatioNoBouncy, stiffness)) + MaxLineLength:CardToDetail.kt:if (motion == StackMotion.Jump || motion == StackMotion.Idle) return ContentTransform(EnterTransition.None, ExitTransition.None, sizeTransform = null) + MaxLineLength:Celebrations.kt:. + MaxLineLength:Celebrations.kt:Column + MaxLineLength:Celebrations.kt:PageSwitchRow("Weekly goal moment", "A full screen once a week, when you reach your goal", goalMoment, onGoalMoment, divider = false) + MaxLineLength:Celebrations.kt:RewardsCopy.whenSet(best.best.date, sportName) + MaxLineLength:Celebrations.kt:Text("From your workouts so far", color = Color.White, style = GlowType.pageTitle, modifier = Modifier.padding(start = 4.dp, top = 8.dp)) + MaxLineLength:Celebrations.kt:Text("Weekly goal met", color = Color.White, style = GlowType.pageTitle, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) + MaxLineLength:Celebrations.kt:Text(RewardsCopy.beatLine(best, now), color = Color.White, style = GlowType.sentence, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:Celebrations.kt:Text(RewardsCopy.bestTitle(best.best.kind), color = Color.White, style = GlowType.pageTitle, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:Celebrations.kt:Text(RewardsCopy.momentLine(inARow), color = Color.White, style = GlowType.sentence, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:Celebrations.kt:Text(title.orEmpty(), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.weight(1f).padding(start = 4.dp)) + MaxLineLength:Celebrations.kt:fun + MaxLineLength:Celebrations.kt:internal + MaxLineLength:Celebrations.kt:note?.let { Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.caption, textAlign = TextAlign.Center) } + MaxLineLength:Celebrations.kt:short: String + MaxLineLength:ChartSpec.kt:fun + MaxLineLength:ChartSpec.kt:ops += PathOp.Cubic(pts[i].x + dx, pts[i].y + m[i] * dx, pts[i + 1].x - dx, pts[i + 1].y - m[i + 1] * dx, pts[i + 1].x, pts[i + 1].y) + MaxLineLength:ChartSpec.kt:val hi = (yRange?.endInclusive ?: maxOf(known.max(), band?.endInclusive ?: -Float.MAX_VALUE)).let { if (it - lo < 1e-3f) lo + 1f else it } + MaxLineLength:Charts.kt:drawPath(fill, Brush.verticalGradient(listOf(color.copy(alpha = 0.30f), color.copy(alpha = 0.02f)), startY = 0f, endY = h)) + MaxLineLength:Charts.kt:private + MaxLineLength:Charts.kt:val fillBrush = Brush.verticalGradient(*stops.map { (f, c) -> f to c.copy(alpha = 0.16f) }.toTypedArray(), startY = y1, endY = y0) + MaxLineLength:ChooseScreen.kt:Column + MaxLineLength:ChooseScreen.kt:Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp), content = choose) + MaxLineLength:ChooseScreen.kt:EdgeCard + MaxLineLength:ChooseScreen.kt:PageRow(Workouts.sportName(type), icon = SportIcons.of(type), divider = i > 0, onClick = { keyboard?.hide(); onPick(type) }) + MaxLineLength:ChooseScreen.kt:Workouts.sportName(s.type) + MaxLineLength:ChooseScreen.kt:color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA) + MaxLineLength:ChooseScreen.kt:else + MaxLineLength:ChooseScreen.kt:if (query.isEmpty()) Text(hint, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.body, maxLines = 1) + MaxLineLength:Coaching.kt:WorkoutTarget.Companion$Zones(1, 1) + MaxLineLength:CompanionLink.kt:CompanionLink$(myAssociationId(address) ?: LEGACY_ID).let { if (settings.companionAssociation.first() != it) settings.setCompanionAssociation(it) } + MaxLineLength:CompanionLink.kt:CompanionLink$. + MaxLineLength:CompanionLink.kt:CompanionLink.<no name provided>$onError("Android couldn't find the strap (${error ?: "no reason given"}). Keep it on its charger near the phone and try again.") + MaxLineLength:ComposeDraw.kt:drawPath(composePath(polylineOps(u)), Color.White.copy(alpha = GhostLineLook.USUAL_ALPHA), style = Stroke(GhostLineLook.USUAL_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round)) + MaxLineLength:ComposeDraw.kt:drawPath(fill, Brush.verticalGradient(listOf(color.copy(alpha = LineChartLook.FILL_ALPHA), Color.Transparent), startY = g.top, endY = g.plotBottom)) + MaxLineLength:ComposeDraw.kt:drawPath(line, color.copy(alpha = LineChartLook.HALO_ALPHA), style = Stroke(LineChartLook.HALO_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round)) + MaxLineLength:ComposeDraw.kt:if (rowColors == null) g.joins.forEach { j -> drawLine(Color.White.copy(alpha = HypnogramLook.JOIN_ALPHA), j.from, j.to, ChartInk.HAIRLINE_DP * density) } + MaxLineLength:CsvImporter.kt:CsvImporter$private suspend + MaxLineLength:CsvImporter.kt:CsvImporter$val + MaxLineLength:CsvImporter.kt:CsvImporter$val first = query("SELECT MIN(${key.column}) FROM ${key.table}") { c -> if (c.isNull(0)) null else c.getLong(0) }.single() + MaxLineLength:CsvImporter.kt:CsvImporter$val have = query("SELECT ${key.column} FROM ${key.table} WHERE ${key.column} BETWEEN ? AND ?", from, to) { it.getLong(0) }.toHashSet() + MaxLineLength:CsvTables.kt:"start,start_epoch,end_epoch,sport_type,source,pauses,end_reason,avg_hr,max_hr,ask_to_end,recorded_end_epoch,shown_start_epoch,target," + MaxLineLength:CsvTables.kt:"use_gps,keep_route,distance_m,distance_from_steps,rest,sets_target,laps,epoc,aerobic_te,recovery_minutes,vo2max,active_kcal,total_kcal,target_reached_epoch,structure,stretches" + MaxLineLength:CsvTables.kt:AppWorkoutRound + MaxLineLength:CsvTables.kt:AppWorkoutRoutePoint + MaxLineLength:CsvTables.kt:AppWorkoutSet + MaxLineLength:CsvTables.kt:CsvTable + MaxLineLength:CsvTables.kt:DailyEnergyRow + MaxLineLength:CsvTables.kt:aerobicTrainingEffect = it.floatOrNull("aerobic_te") + MaxLineLength:CsvTables.kt:distanceMeters = it.floatOrNull("distance_m") + MaxLineLength:CsvTables.kt:if (it.has("pauses")) it.string("pauses") else "" + MaxLineLength:CsvTables.kt:it.floatOrNull("speed_ms") + MaxLineLength:CsvTables.kt:it.rest + MaxLineLength:CsvTables.kt:targetReachedAt = it.longOrNull("target_reached_epoch") + MaxLineLength:CsvTables.kt:{ AppWorkoutHeartRate(it.long("workout_start_epoch"), it.long("epoch"), it.int("bpm"), it.has("from_history") && it.bool("from_history")) } + MaxLineLength:CsvTables.kt:{ DeletedWorkout(it.long("start_epoch"), it.longOrNull("effect_end_epoch"), it.longOrNull("deleted_epoch") ?: it.long("start_epoch")) } + MaxLineLength:CsvTables.kt:{ PaiSample(it.long("epoch"), it.float("daily"), it.float("total"), it.int("resting_hr"), it.int("max_hr"), it.floatOrNull("tomorrow_total")) } + MaxLineLength:CsvTables.kt:{ d, f, t -> d.appWorkoutHeartRateAll().filter { it.workoutStart in f..t } } + MaxLineLength:CsvTables.kt:{ d, f, t -> d.paiOnce(f, t) } + MaxLineLength:CsvTables.kt:{ d, f, t -> d.setsAll().filter { it.workoutStart in f..t } } + MaxLineLength:CsvTables.kt:{ listOf(iso(it.timestamp), it.timestamp, it.floors, it.run, it.fatBurn, it.stand, it.walk, it.sit, it.steps, it.calories, it.distanceCm) } + MaxLineLength:CsvTables.kt:{ listOf(it.workoutStart, it.timestamp, it.lat, it.lon, it.accuracy, it.speed, it.bearing, it.altitude, it.speedAccuracy, it.bearingAccuracy) } + MaxLineLength:DailyEnergyHistory.kt:DailyEnergyHistory$. + MaxLineLength:DailyEnergyHistory.kt:DailyEnergyHistory$private suspend + MaxLineLength:DailyEnergyHistory.kt:DailyEnergyHistory$suspend + MaxLineLength:Daos.kt:HealthDao$"AND (EXISTS (SELECT 1 FROM sleep_stage n WHERE n.sessionStart = sleep_stage.sessionStart AND n.start = sleep_stage.`end` + 60) " + MaxLineLength:Daos.kt:HealthDao$"WHERE NOT EXISTS (SELECT 1 FROM sleep_stage n WHERE n.sessionStart = sleep_stage.sessionStart AND n.start = sleep_stage.`end` AND n.start > sleep_stage.start) " + MaxLineLength:Daos.kt:HealthDao$@Query("DELETE FROM sleep_session WHERE nap = 1 AND EXISTS (SELECT 1 FROM sleep_session m WHERE m.nap = 0 AND sleep_session.start < m.`end` AND sleep_session.`end` > m.start)") + MaxLineLength:Daos.kt:HealthDao$@Query("DELETE FROM sleep_stage WHERE sessionStart IN (SELECT n.start FROM sleep_session n JOIN sleep_session m ON m.nap = 0 AND n.nap = 1 AND n.start < m.`end` AND n.`end` > m.start)") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT (SELECT COUNT(*) FROM activity_minute WHERE timestamp BETWEEN :from AND :to) + (SELECT COUNT(*) FROM heart_rate WHERE timestamp BETWEEN :from AND :to) * 7 + (SELECT COUNT(*) FROM sleep_session WHERE `end` BETWEEN :from AND :to) * 1009 + (SELECT COUNT(*) FROM workout WHERE start BETWEEN :from AND :to) * 100003 + (SELECT COUNT(*) + COALESCE(SUM(editedAt), 0) FROM sleep_edit) * 31") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(bpm) AS value, MIN(bpm) AS min, MAX(bpm) AS max, COUNT(*) AS count FROM heart_rate WHERE timestamp BETWEEN :from AND :to AND bpm BETWEEN 30 AND 230 GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(celsius) AS value, MIN(celsius) AS min, MAX(celsius) AS max, COUNT(*) AS count FROM skin_temperature WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(milliseconds) AS value, MIN(milliseconds) AS min, MAX(milliseconds) AS max, COUNT(*) AS count FROM hrv WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(milliseconds) AS value, MIN(milliseconds) AS min, MAX(milliseconds) AS max, COUNT(*) AS count FROM hrv_sdnn WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(perMinute) AS value, MIN(perMinute) AS min, MAX(perMinute) AS max, COUNT(*) AS count FROM breath_rate WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(percent) AS value, MIN(percent) AS min, MAX(percent) AS max, COUNT(*) AS count FROM sleep_spo2 WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(percent) AS value, MIN(percent) AS min, MAX(percent) AS max, COUNT(*) AS count FROM spo2 WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, CAST(strftime('%H', timestamp, 'unixepoch', 'localtime') AS INTEGER) AS hour, SUM(steps) AS steps FROM activity_minute WHERE timestamp BETWEEN :from AND :to AND steps BETWEEN 0 AND 250 GROUP BY day, hour ORDER BY day, hour") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, SUM(steps) AS value, MIN(steps) AS min, MAX(steps) AS max, COUNT(*) AS count FROM activity_minute WHERE timestamp BETWEEN :from AND :to AND steps BETWEEN 0 AND 250 GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, SUM(steps) AS value, MIN(steps) AS min, MAX(steps) AS max, COUNT(*) AS count FROM activity_minute WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, daily, total, MAX(timestamp) AS latest FROM pai WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT timestamp / 60 FROM heart_rate WHERE timestamp BETWEEN :from AND :to AND bpm BETWEEN 30 AND 230 UNION SELECT timestamp / 60 FROM activity_minute WHERE timestamp BETWEEN :from AND :to AND heartRate BETWEEN 30 AND 230") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT workoutStart, MAX(timestamp) AS lastSample, COUNT(*) AS samples, SUM(bpm) AS bpmSum FROM app_workout_hr GROUP BY workoutStart") + MaxLineLength:Daos.kt:HealthDao$@Query("SELECT workoutStart, MAX(timestamp) AS lastSample, COUNT(*) AS samples, SUM(bpm) AS bpmSum FROM workout_hr GROUP BY workoutStart") + MaxLineLength:Daos.kt:HealthDao$@Query("UPDATE app_workout SET epoc = :epoc, aerobicTrainingEffect = :aerobicTe, recoveryMinutes = :recoveryMinutes, vo2max = :vo2max, anaerobicTrainingEffect = :anaerobicTe, effectFromStrap = :fromStrap, ownRecoveryMinutes = :ownRecoveryMinutes WHERE start = :start") + MaxLineLength:Daos.kt:HealthDao$start: Long + MaxLineLength:DataStateParts.kt:drawCircle(Brush.radialGradient(listOf(color.copy(alpha = 0.55f), color.copy(alpha = 0f)), Offset(x, y), 9.dp.toPx()), 9.dp.toPx(), Offset(x, y)) + MaxLineLength:DataStates.kt:"When the strap was worn for under ${TodayCopy.duration(EnergyExpenditure.FULL_DAY_MINUTES * 60L)} of the day, or under that share of the day so far." + MaxLineLength:DataStates.kt:fun + MaxLineLength:DataStates.kt:readiness = readiness.copy(score = null, contributors = emptyList(), baselineNights = 0, phase = Recovery.Phase.Withheld, strainAlert = false, withheld = false) + MaxLineLength:DataStates.kt:readinessTrend = readinessTrend.map { if (it.first == date) it.first to null else it } + MaxLineLength:DayViewModel.kt:DayViewModel$SleepParts(session, st, naps, ns, before?.score, asDetected, main?.strap, if (main?.moved == true) whole else emptyList(), deleted, shown.mapNotNull { it.shown }) to spanSessions + MaxLineLength:DayViewModel.kt:DayViewModel$org.openstrap.app.data.UsualSteps.byHour(repo.hourlySteps(d.minusDays(org.openstrap.app.data.UsualSteps.DAYS.toLong()), d.minusDays(1)), d) + MaxLineLength:DayViewModel.kt:DayViewModel$return + MaxLineLength:DayViewModel.kt:DayViewModel$s?.let { w -> samples.filter { time(it) in w.start..w.end }.map(value).takeIf { it.isNotEmpty() }?.average()?.toFloat() } + MaxLineLength:DayViewModel.kt:DayViewModel$val + MaxLineLength:DayViewModel.kt:DayViewModel$val m = moreParts.copy(nights = n.nights, sleepHeartRate = n.heartRate, sleepHrv = n.hrv, sleepSkinTemperature = n.skinTemperature) + MaxLineLength:DayViewModel.kt:DayViewModel$val series = Summaries.heartRateSeries(hr.filter { it.timestamp in w.from..w.to }, act.filter { it.timestamp in w.from..w.to }) + MaxLineLength:DayViewModel.kt:DayViewModel$val sleepHrv = tonight?.let { w -> hrv.filter { it.timestamp in w.start..w.end }.map { ChartPoint(it.timestamp, it.milliseconds.toFloat()) } }.orEmpty() + MaxLineLength:DayViewModel.kt:DayViewModel$val sleepSkin = tonight?.let { w -> skin.filter { it.timestamp in w.start..w.end }.map { ChartPoint(it.timestamp, it.celsius) } }.orEmpty() + MaxLineLength:DeletedSleepPage.kt:SleepCopy.deletedLine(clock(r.start), clock(r.end), r.edit?.let { SleepEdits.daysLeft(it, now) } ?: SleepEdits.KEEP_DAYS) + MaxLineLength:DeletedSleepPage.kt:if + MaxLineLength:DeletedSleepPage.kt:item { Text(SleepCopy.DELETED_INTRO, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(horizontal = 4.dp)) } + MaxLineLength:DeviceScreen.kt:Column + MaxLineLength:DeviceScreen.kt:PageRow + MaxLineLength:DeviceScreen.kt:PageRow("Alarms", alarmsSummary(alarms) ?: waiting, onClick = onAlarms, divider = false, icon = AppIcons.Alarm, selected = selected == "Alarms") + MaxLineLength:DeviceScreen.kt:PageRow("Firmware", info?.firmware ?: (connection as? ConnectionState.Connected)?.firmware ?: "Check for updates", onClick = onFirmware, icon = AppIcons.Cpu, selected = selected == "Firmware") + MaxLineLength:DeviceScreen.kt:PageRow("Forget this strap", onClick = { confirmForget = true }, divider = !first, titleColor = DestructiveInk, icon = AppIcons.Trash) + MaxLineLength:DeviceScreen.kt:PageRow("Health alerts", healthAlertsSummary(alarms) ?: waiting, onClick = onHealthAlerts, icon = AppIcons.Heartbeat, selected = selected == "Health alerts") + MaxLineLength:DeviceScreen.kt:PageRow("Phone alerts", phoneAlertsSummary(calls, notifications), onClick = onPhoneAlerts, icon = AppIcons.Vibrate, selected = selected == "Phone alerts") + MaxLineLength:DeviceScreen.kt:PageRow("Zepp account", if (zeppSignedIn) "Signed in" else "Not signed in", onClick = onZeppAccount, icon = AppIcons.Key, selected = selected == "Zepp account") + MaxLineLength:DeviceScreen.kt:StepText("Force-stop or uninstall Zepp so it lets go of the strap. Don't unlink the strap in Zepp: that wipes its key.", muted = true) + MaxLineLength:DeviceScreen.kt:Text("% battery", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) + MaxLineLength:DeviceScreen.kt:confirm = "Disconnect" + MaxLineLength:DeviceScreen.kt:else -> PageButton("Connect", { onRequestBluetooth { vm.strap.connect() } }, Modifier.fillMaxWidth().height(56.dp), primary = true) + MaxLineLength:DeviceScreen.kt:if (!companion.onResult(result.resultCode)) vm.showMessage("Not linked. You can turn off battery optimization for OpenStrap instead.") + MaxLineLength:DeviceScreen.kt:led + MaxLineLength:DeviceViewModel.kt:DeviceViewModel$. + MaxLineLength:DeviceViewModel.kt:DeviceViewModel$container.app.contentResolver.openInputStream(uri)!!.use { org.openstrap.app.export.CsvImporter(container.database).importZip(it) } + MaxLineLength:DeviceViewModel.kt:DeviceViewModel$container.settings.healthConnectAuto.stateIn(viewModelScope, SharingStarted.Eagerly, org.openstrap.app.data.settings.HealthConnectAuto()) + MaxLineLength:DeviceViewModel.kt:DeviceViewModel$if (outcome == org.openstrap.app.export.AutoBackup.Outcome.Recording) org.openstrap.app.export.AutoBackup.afterWorkout(container.app) + MaxLineLength:DeviceViewModel.kt:DeviceViewModel$val name = withContext(Dispatchers.IO) { runCatching { androidx.documentfile.provider.DocumentFile.fromTreeUri(container.app, uri)?.name }.getOrNull() } + MaxLineLength:EnergyExpenditure.kt:EnergyExpenditure$b + net(if (steps >= RUNNING_CADENCE && neighbourAtLeast(minutes, k, RUNNING_CADENCE)) runningMet(steps, h) else cadenceMet(steps, a), w) + MaxLineLength:EnergyExpenditure.kt:EnergyExpenditure$val points = if (older) listOf(60f to 2f, 100f to 3f, 110f to 4f, 120f to 5f, 130f to 6f) else listOf(60f to 2f, 100f to 3f, 130f to 6f) + MaxLineLength:Entities.kt:AppWorkout$fun withPause + MaxLineLength:Entities.kt:AppWorkout$pauseRanges.sumOf { (minOf(it.last, now) - maxOf(it.first, from)).coerceAtLeast(0) } + (pausedAt?.let { (now - maxOf(it, from)).coerceAtLeast(0) } ?: 0) + MaxLineLength:Entities.kt:AppWorkoutRoutePoint$val point get() = org.openstrap.app.data.Route.Point(timestamp, lat, lon, accuracy, speed, bearing, altitude, speedAccuracy, bearingAccuracy) + MaxLineLength:Entities.kt:AppWorkoutRoutePoint.Companion$AppWorkoutRoutePoint(workoutStart, p.t, p.lat, p.lon, p.accuracy, p.speed, p.bearing, p.altitude, p.speedAccuracy, p.bearingAccuracy) + MaxLineLength:Entities.kt:Workout$/** The summary's Type.subtype: 1 auto-detected, 6 a phone workout mirrored on the strap, 0 normal (codemap 05 §8). Null before version 17. */ + MaxLineLength:FamilySummary.kt:FamilySummary$PeriodLine(name, WorkoutCopy.averageOver(readings, r.workStart, workEnd), Games.hardEfforts(inside, maxHr), workEnd - r.workStart) + MaxLineLength:FamilySummary.kt:FamilySummary$fun + MaxLineLength:FamilySummary.kt:FamilySummary$if (restSeconds != null) "You recovered $avg bpm in ${clockOrSeconds(restSeconds)} on average." else "You recovered $avg bpm in each rest on average." + MaxLineLength:FamilySummary.kt:FamilySummary$private + MaxLineLength:FamilySummary.kt:FamilySummary$return peaks.map { p -> p?.let { (if (top <= floor) 1.0 else (it - floor) / (top - floor)).toFloat().coerceIn(PEAK_MIN_SHARE, 1f) } } + MaxLineLength:FamilySummary.kt:FamilySummary$val low = r.restEnd?.takeIf { it > workEnd }?.let { end -> readings.filter { it.first in workEnd..end }.minOfOrNull { it.second } } + MaxLineLength:FamilySummary.kt:FamilySummary$val raw = org.openstrap.app.data.Summaries.bucketMeans(readings, points, { ((it.first - first) * (points - 1) / span).toInt().coerceIn(0, points - 1) }, { it.second.toFloat() }) + MaxLineLength:FirmwareCloud.kt:FirmwareCloud$401, 403 -> throw FirmwareCloudException("The server rejected the app token (HTTP $code). It may have expired.", rejected = true) + MaxLineLength:FirmwareCloud.kt:FirmwareCloud$else -> throw FirmwareCloudException("Update server returned HTTP $code${body.take(200).let { if (it.isBlank()) "" else ": $it" }}") + MaxLineLength:FirmwareCloud.kt:FirmwareCloud.Companion$fun + MaxLineLength:FirmwareCloud.kt:FirmwareCloud.Companion$val json = runCatching { JSONObject(body) }.getOrElse { throw FirmwareCloudException("Unexpected reply from the update server") } + MaxLineLength:FirmwareScreen.kt:Body("The strap is restarting. OpenStrap reconnects by itself. Check again afterwards in case there are more parts to install.") + MaxLineLength:FirmwareScreen.kt:InfoLine("Update service", when (info?.dfuAvailable) { true -> "Available"; false -> "Not found"; null -> "Connect to check" }) + MaxLineLength:FirmwareScreen.kt:PageButton(if (account == null) "Sign in with Zepp" else "Sign in again", { signIn = true }, Modifier.fillMaxWidth(), enabled = enabled, primary = true) + MaxLineLength:FirmwareScreen.kt:PageButton(if (next == null) "Unsupported" else "Download", { next?.let(vm::download) }, Modifier.weight(1f), enabled = next != null, primary = true) + MaxLineLength:FirmwareScreen.kt:PageRow("Install from a file", "For an image you have", onClick = { pickFile.launch(arrayOf("*/*")) }, enabled = !installing) + MaxLineLength:FirmwareScreen.kt:PageRow("Zepp account", if (account != null) "Signed in" else "Not signed in", onClick = onZeppAccount, divider = false, enabled = !installing) + MaxLineLength:FirmwareScreen.kt:Point(AppIcons.CheckCircle, "Safe install", "Downloads are checked against the server's checksum, and the strap verifies the signature before installing.") + MaxLineLength:FirmwareScreen.kt:Point(AppIcons.CloudArrowDown, "Update check", "Sends the update server your token, user ID and the strap's firmware, hardware and serial number. Nothing else.") + MaxLineLength:FirmwareScreen.kt:Point(AppIcons.Key, "Sign-in", "Happens on Zepp's own page. OpenStrap keeps only the app token and user ID, and fetches your device list once.") + MaxLineLength:FirmwareScreen.kt:confirm?.let { image -> ConfirmInstall(image, vm.blockers(), onDismiss = { confirm = null }, onInstall = { confirm = null; vm.install(image) }) } + MaxLineLength:FirmwareScreen.kt:else + MaxLineLength:FirmwareScreen.kt:if + MaxLineLength:FirmwareScreen.kt:if (image.warnings.isNotEmpty()) PageSwitchRow("I understand and want to install it anyway", null, accepted, { accepted = it }, divider = false) + MaxLineLength:FirmwareScreen.kt:item { AccountPanel(account, enabled = !installing, onSave = vm::saveAccount, onClear = vm::clearAccount, onSignIn = vm::saveSignIn) } + MaxLineLength:FirmwareScreen.kt:private + MaxLineLength:FirmwareViewModel.kt:FirmwareViewModel$ReadyImage(bytes, type, "${part.label} ${part.version}", part.version, fromCloud = true, trailer, trailerWarnings(bytes, trailer, type)) + MaxLineLength:FirmwareViewModel.kt:FirmwareViewModel$private val busy get() = _step.value.let { it is FirmwareStep.Checking || it is FirmwareStep.Downloading || it is FirmwareStep.Installing } + MaxLineLength:FirmwareViewModel.kt:FirmwareViewModel$val fresh = if (rejected) ZeppCloud.refreshAppToken(acc) else runCatching { ZeppCloud.refreshAppToken(acc) }.getOrNull() ?: return acc + MaxLineLength:Frame.kt:battery <= StrapChip.LOW_BATTERY -> StrapChip("$battery%", StrapChip.Dot.Low, "Strap connected, battery low, $battery percent") + MaxLineLength:Frame.kt:connection.charging && battery != null -> StrapChip("Charging $battery%", StrapChip.Dot.Charging, "Strap charging, battery $battery percent") + MaxLineLength:Frame.kt:fun + MaxLineLength:Frame.kt:if (connection !is ConnectionState.Connected && sinceStart < STARTUP_GRACE_S) return StrapChip("", StrapChip.Dot.Quiet, "Connecting to the strap") + MaxLineLength:Glass.kt:1.dp + MaxLineLength:Glass.kt:Box + MaxLineLength:Glass.kt:Box(Modifier.fillMaxWidth(), contentAlignment = if (icon != null) Alignment.Center else Alignment.CenterStart) { title() } + MaxLineLength:Glass.kt:Surface(shape = shape, color = GlassTint.copy(alpha = 0.72f), border = GlassEdge, contentColor = Color.White, modifier = modifier, content = content) + MaxLineLength:Glass.kt:fun + MaxLineLength:GlowActivityScreen.kt:TabCopy.AUTO_DETECTED + MaxLineLength:GlowActivityScreen.kt:if (shown.isEmpty()) ListRow("No workouts yet", MetricColors.heart, null, "Workouts you record show up here", dash = false, divider = divider()) + MaxLineLength:GlowActivityScreen.kt:modifier = Modifier.border(1.dp, Color.White.copy(alpha = 0.19f), RoundedCornerShape(6.dp)).padding(horizontal = 6.dp, vertical = 1.dp) + MaxLineLength:GlowActivityScreen.kt:private + MaxLineLength:GlowActivityScreen.kt:state: DayUiState + MaxLineLength:GlowCard.kt:GlowCache.peek(GlowCache.Key(still, IntSize(w.roundToInt(), h.roundToInt()), density.density)) ?: GlowCache.latest(still, density.density) + MaxLineLength:GlowCard.kt:it.nativeCanvas.drawBitmap(first.core, null, first.coreIn(w, h), Paint(FILTER).apply { alpha = (lit.coreOpacity * 255).roundToInt().coerceIn(0, 255) }) + MaxLineLength:GlowCharts.kt:drawRoundRect(color.copy(alpha = 0.35f), Offset(x - sw - 6.dp.toPx(), labelH / 2 - 4.dp.toPx()), Size(sw, 8.dp.toPx()), CornerRadius(3.dp.toPx())) + MaxLineLength:GlowCharts.kt:drawRoundRect(color.copy(alpha = NormalBandAlpha), Offset(0f, y0), Size(size.width, yOf(b.start) - y0), CornerRadius(12.dp.toPx())) + MaxLineLength:GlowCharts.kt:fun + MaxLineLength:GlowCharts.kt:if + MaxLineLength:GlowCharts.kt:if (stage != null && lastStage != null && stage != lastStage) haptics.perform(org.openstrap.app.ui.components.HapticEvent.ScrubStep) + MaxLineLength:GlowCharts.kt:text(m, v.roundToInt().toString(), valueText.copy(color = if (today) Ink else Ink.copy(alpha = 0.75f)), cx, y - 20.dp.toPx(), align = 0.5f) + MaxLineLength:GlowCharts.kt:val g = hypnogramGeometry(stages, rowNames.size, size.width, size.height, density, nameW, axis = labels.isNotEmpty()) ?: return@Canvas + MaxLineLength:GlowCharts.kt:val g = lineChartGeometry(values, size.width, size.height, density, inset, axis = labels.isNotEmpty(), yRange = yRange, band = band) ?: return@Canvas + MaxLineLength:GlowCharts.kt:val i = x?.takeIf { values.isNotEmpty() && width > 0f }?.let { tx -> (tx / (width / values.size)).toInt().coerceIn(0, values.size - 1) } + MaxLineLength:GlowHealthspan.kt:Box(Modifier.fillMaxWidth().height(16.dp)) { ScaleLabel(org.openstrap.app.ui.dashboard.TabCopy.ageRange(low, high), x((low + high) / 2f), w, label) } + MaxLineLength:GlowHealthspan.kt:Box(Modifier.fillMaxWidth().height(16.dp)) { ScaleLabel(org.openstrap.app.ui.dashboard.TabCopy.yourAge(age), x(age.toFloat()), w, label) } + MaxLineLength:GlowHealthspan.kt:Healthspan.Habit.Steps + MaxLineLength:GlowHealthspan.kt:Orchid + MaxLineLength:GlowHealthspan.kt:Text(text, color = color, style = MaterialTheme.typography.labelSmall, modifier = Modifier.align(androidx.compose.ui.BiasAlignment(bias, 0f))) + MaxLineLength:GlowHealthspan.kt:drawLine(Color.White.copy(alpha = 0.15f), Offset(px(from.toFloat()), y), Offset(px(to.toFloat()), y), 6.dp.toPx(), StrokeCap.Round) + MaxLineLength:GlowHealthspan.kt:drawLine(Orchid.copy(alpha = 0.5f), Offset(px(low.toFloat()), y), Offset(px(high.toFloat()), y), 12.dp.toPx(), StrokeCap.Round) + MaxLineLength:GlowHealthspan.kt:if (v == null) return if (t.habit == Healthspan.Habit.Vo2max) "No VO2 max from workouts yet, counted as typical" else "Not enough data yet, counted as typical" + MaxLineLength:GlowHealthspan.kt:org.openstrap.app.ui.dashboard.TabCopy.habitAgeUpdated(dateOf(it.asOf)) + MaxLineLength:GlowHealthspan.kt:private + MaxLineLength:GlowHealthspanScreen.kt:drawLine(Color.White.copy(alpha = 0.35f), Offset(mid, y - 5.dp.toPx()), Offset(mid, y + 5.dp.toPx()), 1.5.dp.toPx(), StrokeCap.Round) + MaxLineLength:GlowHealthspanScreen.kt:if (pace != null) PaceScale(marker = (pace + 1f) / 4f, color = MetricColors.habitAge, Modifier.padding(start = RowIcon + RowIconGap, bottom = GlowLayout.RowPadding)) + MaxLineLength:GlowLayout.kt:Layout + MaxLineLength:GlowLayout.kt:androidx.compose.runtime + MaxLineLength:GlowLayout.kt:val w = (t.width + g + c.width).let { if (cs.hasBoundedWidth) it.coerceIn(cs.minWidth, cs.maxWidth) else it.coerceAtLeast(cs.minWidth) } + MaxLineLength:GlowList.kt:* + MaxLineLength:GlowList.kt:. + MaxLineLength:GlowList.kt:Icon(icon, null, tint = iconTint.copy(alpha = iconTint.alpha * if (waiting) WAITING_ICON_ALPHA else 1f), modifier = Modifier.size(RowIcon)) + MaxLineLength:GlowMetricDetail.kt:/** A day's value of [metric]: the live or latest heart rate, the strap's resting heart rate, the day's steps, else the average of its readings. */ + MaxLineLength:GlowMetricDetail.kt:DetailEmpty(if (metric == Metric.Breathing) "Breathing rate is measured during sleep." else "No ${inSentence(metric.title)} for this day yet.") + MaxLineLength:GlowMetricDetail.kt:DetailEmpty(if (session == null) SleepCopy.noNight(state.date == LocalDate.now()) else "No ${inSentence(metric.title)} readings while asleep.") + MaxLineLength:GlowMetricDetail.kt:DetailHeadline(metric.format(v), listOf(metric.unit, label).filter { it.isNotEmpty() }.joinToString(", "), DetailCopy.statusWord(History.status(v, normal))) + MaxLineLength:GlowMetricDetail.kt:DetailSibling$internal + MaxLineLength:GlowMetricDetail.kt:Modifier.fillMaxWidth().padding(top = 6.dp).clip(shape).background(Color(0xFF121214)).border(1.dp, Color.White.copy(alpha = 0.07f), shape).padding(4.dp) + MaxLineLength:GlowMetricDetail.kt:Modifier.size(size).clip(CircleShape).background(Color.White.copy(alpha = 0.10f)).clickable(onClickLabel = label, role = Role.Button, onClick = onClick) + MaxLineLength:GlowMetricDetail.kt:Range.Month -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { dates[((dates.size - 1) * it).toInt()].format(DateTimeFormatter.ofPattern("d MMM")) } + MaxLineLength:GlowMetricDetail.kt:Text("Live", color = if (on) Color.Black else Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, maxLines = 1) + MaxLineLength:GlowMetricDetail.kt:Text(d.label, color = if (d.selected) Color.White else Color.White.copy(alpha = 0.5f), style = GlowType.secondary, maxLines = 1) + MaxLineLength:GlowMetricDetail.kt:Text(headline.label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:GlowMetricDetail.kt:band = nightNormal?.let { it.low..it.high } + MaxLineLength:GlowMetricDetail.kt:drawRect(Brush.radialGradient(listOf(color.copy(alpha = 0.4f), Color.Transparent), Offset(size.width / 2, -size.height * 0.1f), size.width * 0.7f)) + MaxLineLength:GlowMetricDetail.kt:else + MaxLineLength:GlowMetricDetail.kt:if + MaxLineLength:GlowMetricDetail.kt:if (heroValue == null && headline.word != null && headline.value != null) StatusWord(headline.word, color, Modifier.padding(top = 12.dp)) + MaxLineLength:GlowMetricDetail.kt:night && range == Range.Day -> SleepCopy.nightNoValue(state.sleep != null, isToday).let { (word, why) -> DetailHeadline.none(word, why) } + MaxLineLength:GlowMetricDetail.kt:onScrub = { h -> onScrub(h?.let { DetailHeadline("%,d".format(Math.round(state.hourlySteps[it])), "steps, %02d:00 to %02d:00".format(it, (it + 1) % 24), null) }) } + MaxLineLength:GlowMetricDetail.kt:onScrub(i?.let { values[it] }?.let { v -> DetailHeadline(metric.format(v), "${metric.unit} at ${clock(window.first + i * bucket)}".trim(), null) }) + MaxLineLength:GlowMetricDetail.kt:private + MaxLineLength:GlowMetricDetail.kt:v?.let { DetailHeadline(metric.format(it), "${metric.unit}, ${d.format(DateTimeFormatter.ofPattern("EEE d MMM"))}", DetailCopy.statusWord(History.status(it, nightNormal))) } + MaxLineLength:GlowMetricDetail.kt:val label = if (night) SleepCopy.nightLabel(metric, range) else DetailCopy.valueLabel(metric, range, live = live != null, today = isToday) + MaxLineLength:GlowMetricDetail.kt:values + MaxLineLength:GlowParts.kt:. + MaxLineLength:GlowParts.kt:fun + MaxLineLength:GlowQuiet.kt:Text(title, color = Color.White, style = GlowType.cardTitle, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + MaxLineLength:GlowRecoveryScreen.kt:HeroFigure(figure.toString(), if (score != null) TodayCopy.recoveryWord(score, alert) else shown?.word.orEmpty(), big = true) + MaxLineLength:GlowRecoveryScreen.kt:ListPanel + MaxLineLength:GlowRecoveryScreen.kt:if + MaxLineLength:GlowRecoveryScreen.kt:if (score != null) ready.let { TabCopy.learning(it.phase, it.baselineNights) }?.let { CaveatLine(it, MetricColors.readiness) } + MaxLineLength:GlowRecoveryScreen.kt:if (top.isEmpty()) ListRow("Waiting for a night", MetricColors.readiness, null, "What drove Recovery shows here each morning", dash = false, divider = false) + MaxLineLength:GlowRecoveryScreen.kt:noSleep = isToday && state.loaded && state.asleepSeconds == null + MaxLineLength:GlowRecoveryScreen.kt:r: RecoveryResult? + MaxLineLength:GlowRecoveryScreen.kt:r?.charge + MaxLineLength:GlowRecoveryScreen.kt:ready.phase == Recovery.Phase.Withheld -> TodayCopy.noScoreLine(TodayCopy.NoScore.Learning(ready.baselineNights, Recovery.SCORED_NIGHTS)) + MaxLineLength:GlowSleepScreen.kt:Box(Modifier.weight(seconds.toFloat()).fillMaxHeight().background(stageColor(stage), RoundedCornerShape(StageBarLook.CORNER_DP.dp))) + MaxLineLength:GlowSleepScreen.kt:SleepCard.Naps -> NapsCard(state, if (pair) m else Modifier.fillMaxWidth().height(GlowGrid.UnitHeight)) { onOpen(Detail.OfScore(Score.Naps)) } + MaxLineLength:GlowSleepScreen.kt:SmallCard(MetricColors.sleepLight, "Breathing disturbances", "%.1f".format(b.perHour), "an hour", TabCopy.breathingShort(b), modifier, onClick = onInfo) + MaxLineLength:GlowSleepScreen.kt:breathing != null -> if (pair) BreathingSmall(breathing, m) { breathingInfo = true } else BreathingCard(breathing) { breathingInfo = true } + MaxLineLength:GlowSleepScreen.kt:color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA) + MaxLineLength:GlowSleepScreen.kt:if (firstNights) TabCopy.FIRST_NIGHT_SLEEP else TabCopy.noNight(state.naps.isNotEmpty(), isToday, deleted = deleted != null) + MaxLineLength:GlowSleepScreen.kt:pair -> SmallCard(MetricColors.sleepLight, "Breathing disturbances", null, null, AFTER_FIRST_NIGHT, m, waiting = true) + MaxLineLength:GlowSleepScreen.kt:private + MaxLineLength:GlowSleepScreen.kt:return spans.map { GlowStage((it.start - start).toFloat() / (end - start), (it.end - start).toFloat() / (end - start), rows[it.stage] ?: 0) } + MaxLineLength:GlowSleepScreen.kt:val slots = sleepCards(SleepFacts(breathing = breathing != null || firstNights, naps = state.naps.isNotEmpty(), hasNight = state.sleep != null)) + MaxLineLength:GlowTabletop.kt:GlowLineChart(values, height = GlowLayout.ChartHeight, labels = listOf("00", "06", "12", "18", "24")) { _, v -> "${v.toInt()} bpm" } + MaxLineLength:GlowTodayScreen.kt:"Drained ${org.openstrap.app.ui.dashboard.TabCopy.drainedAmount(c.drainedExercise + c.drainedStress + c.drainedLiving)}" + MaxLineLength:GlowTodayScreen.kt:BodyRow("Breathing rate", MetricColors.sleepLight, s.breathRate.avg()?.toFloat(), normal(Metric.Breathing), "", Metric.Breathing) + MaxLineLength:GlowTodayScreen.kt:BodyRow("Resting heart rate", MetricColors.heart, s.restingHeartRate?.toFloat(), normal(Metric.RestingHr), "", Metric.RestingHr) + MaxLineLength:GlowTodayScreen.kt:ChargeWideCard(ctx.charge, status, slot.secondGlow, { openScore(org.openstrap.app.ui.dashboard.Score.Charge) }, soFar = soFar, upTo = ctx.upTo(ctx.newest), tag = ctx.partial) + MaxLineLength:GlowTodayScreen.kt:HeroKind.Waking -> WakingHero(ctx, lightAngle, { onOpenScore(org.openstrap.app.ui.dashboard.Score.Recovery) }, onRetryRecovery, m) + MaxLineLength:GlowTodayScreen.kt:TodayCard.Recovery -> RecoveryEarlierCard(ctx.recoveryShown, { openScore(org.openstrap.app.ui.dashboard.Score.Recovery) }, failure = ctx.recoveryFailure, onRetry = onRetryRecovery) + MaxLineLength:GlowTodayScreen.kt:else -> TodayCopy.charge(now, c?.let { org.openstrap.app.ui.dashboard.TabCopy.drainedAmount(it.drainedExercise + it.drainedStress + it.drainedLiving).toInt() }, c?.anchored == true, LocalTime.now().hour) + MaxLineLength:GlowTodayScreen.kt:habitAgeNew(Instant.ofEpochSecond(r.asOf).atZone(zone).toLocalDate(), habitAgeSeenAsOf == r.asOf, state.date, now.atZone(zone).toLocalDate()) + MaxLineLength:GlowTodayScreen.kt:if + MaxLineLength:GlowTodayScreen.kt:if (ctx.state.loaded) HeroSentence(TodayCopy.waking(ctx.state.asleepSeconds, score, ctx.alert, ctx.noScore, ctx.firstNights, ctx.isToday, deleted = ctx.state.deletedSleep != null)) + MaxLineLength:GlowTodayScreen.kt:if (slot.size == CardSize.Small) Modifier.fillMaxSize() else Modifier + MaxLineLength:GlowTodayScreen.kt:onLoad = { openScore(org.openstrap.app.ui.dashboard.Score.Load) } + MaxLineLength:GlowTodayScreen.kt:onOpenWorkout + MaxLineLength:GlowTodayScreen.kt:private + MaxLineLength:GlowTodayScreen.kt:val dim = if (pairing != null && slot.card != TodayCard.Hero && slot.card != TodayCard.EditToday) Modifier.alpha(0.45f) else Modifier + MaxLineLength:GlowTodayScreen.kt:when { ctx.recoveryFailure != null -> FAILED_SHORT; ctx.rec == null -> ctx.waiting; else -> TodayCopy.loadWord(ctx.loadStatus) } + MaxLineLength:GlowType.kt:GlowType$val + MaxLineLength:GlowWorkoutMode.kt:Modifier.fillMaxSize().then(if (scroll) Modifier.imePadding().verticalScroll(rememberScrollState()) else Modifier).statusBarsPadding() + MaxLineLength:GlowWorkoutMode.kt:Note("$day ${clock(s.start)}–${clock(s.end)}, mostly zone ${s.zone + 1}, ${s.averageHeartRate} bpm on average. Nobody pressed Start.") + MaxLineLength:GlowWorkoutMode.kt:Note("Your heart rate has been up since ${clock(o.start)}, ${o.averageHeartRate} bpm on average. Record a ${Workouts.sportName(o.sportType).lowercase()} from ${clock(o.start)}?") + MaxLineLength:GlowWorkoutMode.kt:Text("Trimmed to start at ${clock(a.shownStart)}. The rest is kept.", color = Color.White.copy(alpha = 0.8f), style = GlowType.secondary, modifier = Modifier.weight(1f)) + MaxLineLength:GlowWorkoutMode.kt:WorkoutPage + MaxLineLength:GlowWorkoutMode.kt:drawLine(color, Offset(x(a.first), y(a.second.toFloat())), Offset(x(b.first), y(b.second.toFloat())), strokeWidth = 2.dp.toPx(), cap = StrokeCap.Round) + MaxLineLength:GlowWorkoutMode.kt:drawRoundRect(Color.Black.copy(alpha = 0.55f), Offset(gx - grip / 2, h / 2 - 8.dp.toPx()), Size(grip, 16.dp.toPx()), CornerRadius(grip / 2)) + MaxLineLength:GlowWorkoutMode.kt:else + MaxLineLength:GlowWorkoutMode.kt:if + MaxLineLength:GlowWorkoutMode.kt:if (seconds >= 3600) "%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) else "%d:%02d".format(seconds / 60, seconds % 60) + MaxLineLength:GlowWorkoutMode.kt:internal + MaxLineLength:GlowWorkoutMode.kt:modifier = Modifier.clip(RoundedCornerShape(50)).pressClickable(onUndoTrim, RoundedCornerShape(50)).padding(horizontal = 12.dp, vertical = 8.dp) + MaxLineLength:GlowWorkoutMode.kt:private + MaxLineLength:GlowWorkoutMode.kt:return if (zone == 0) "under ${zones.startBpm(1, maxHr)} bpm" else "${zones.startBpm(zone, maxHr)} to ${zones.endBpm(zone, maxHr) ?: maxHr} bpm" + MaxLineLength:GlowWorkoutScreen.kt:"Dimmed: " + a.historyRanges.joinToString(", ") { "${clockAt(it.first)}–${clockAt(it.last + 1)}" } + " came from the strap's history." + MaxLineLength:GlowWorkoutScreen.kt:. + MaxLineLength:GlowWorkoutScreen.kt:CaloriesCopy.inYourDay(WorkoutDetailCopy.sport(w.sportType), k, dayActiveKcal?.roundToInt(), CaloriesCopy.dayWord(date, LocalDate.now()), true) + MaxLineLength:GlowWorkoutScreen.kt:CaloriesCopy.inYourDay(WorkoutDetailCopy.sport(w.sportType), k, dayActiveKcal?.roundToInt(), CaloriesCopy.dayWord(date, today), false) + MaxLineLength:GlowWorkoutScreen.kt:CompositionLocalProvider + MaxLineLength:GlowWorkoutScreen.kt:EdgeValue(at.second.toString(), "bpm", if (at.first > a.end) "At ${clockAt(at.first)}, after the end" else "At ${clockAt(at.first)}") + MaxLineLength:GlowWorkoutScreen.kt:ListRow("No workouts yet", MetricColors.heart, null, "Workouts you start in OpenStrap show up here, and the strap's own after a sync.", dash = false, divider = false) + MaxLineLength:GlowWorkoutScreen.kt:Text(text, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 8.dp)) + MaxLineLength:GlowWorkoutScreen.kt:a.sportName + MaxLineLength:GlowWorkoutScreen.kt:else + MaxLineLength:GlowWorkoutScreen.kt:from = Instant.ofEpochSecond(s.workout.start).atZone(ZoneId.systemDefault()).toLocalTime().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + MaxLineLength:GlowWorkoutScreen.kt:if (w.trainingLoad != null || (a.recoveryMinutes ?: 0) > 0 || drop != null) LoadCard(a, usual, best = null, drop = drop, onInfo = { info = true }) + MaxLineLength:GlowWorkoutScreen.kt:val zones = remember(a) { WorkoutRecorder.liveZoneSeconds(a.heartRate.filter { it.timestamp <= a.end }.map { it.timestamp to it.bpm }, a.maxHeartRate) } + MaxLineLength:GlowWorkoutSummary.kt:"Lap ${lap.index + 1}" + MaxLineLength:GlowWorkoutSummary.kt:"Zone ${z + 1}" + MaxLineLength:GlowWorkoutSummary.kt:CompositionLocalProvider + MaxLineLength:GlowWorkoutSummary.kt:FamilySummary.whenLine(a.shownStart, System.currentTimeMillis() / 1000) + MaxLineLength:GlowWorkoutSummary.kt:GoalMomentScreen(rewards.week.count, rewards.week.goal, rewards.inARow, starts, contentPadding, onSettings = { settings = true }, onContinue = done) + MaxLineLength:GlowWorkoutSummary.kt:Modifier.fillMaxWidth(best.toFloat() / seconds[i]).fillMaxHeight().clip(CircleShape).background(if (lit) Color.White else bar) + MaxLineLength:GlowWorkoutSummary.kt:Route.splits(route, summary.pauses) + MaxLineLength:GlowWorkoutSummary.kt:Text("bpm now", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) + MaxLineLength:GlowWorkoutSummary.kt:Text(hero.unit, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) + MaxLineLength:GlowWorkoutSummary.kt:Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 4.dp)) + MaxLineLength:GlowWorkoutSummary.kt:Text(r.time, color = Color.White, style = GlowType.secondary.copy(fontWeight = FontWeight.SemiBold), maxLines = 1, modifier = Modifier.padding(start = 14.dp)) + MaxLineLength:GlowWorkoutSummary.kt:celebrations.goalMoment + MaxLineLength:GlowWorkoutSummary.kt:drop?.let { Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 4.dp)) } + MaxLineLength:GlowWorkoutSummary.kt:else + MaxLineLength:GlowWorkoutSummary.kt:hero.line?.let { Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 6.dp)) } + MaxLineLength:GlowWorkoutSummary.kt:note = if (rows.isNotEmpty()) fastest?.let { "Fastest: ${rows[it].name.lowercase()}" } ?: summary.distanceMeters?.let { km(it) + if (it >= 1000) " km" else "" } else "${laps.size}" + MaxLineLength:GlowWorkoutSummary.kt:r.name + MaxLineLength:GlowWorkoutSummary.kt:trailingContent = { Spacer(Modifier.width(10.dp)); RoundButton(AppIcons.Info, "About the heart rate drop", onInfo, size = 32.dp) } + MaxLineLength:GlowWorkoutSummary.kt:val moment = rewards != null && celebrations != null && rewards.goalMet && celebrations.goalMoment && celebrations.momentWeek != rewards.weekStart + MaxLineLength:GoalsAndBests.kt:"Badges, ${view.earned.size} of ${Rewards.BADGES.size}" + MaxLineLength:GoalsAndBests.kt:"This week" + MaxLineLength:GoalsAndBests.kt:BadgeRow(earned.take(if (next != null) 3 else 4) + listOfNotNull(next), locked = { b -> view.earned.none { it.id == b.id } }, face = view::face) + MaxLineLength:GoalsAndBests.kt:RewardsCopy.bestTitle(b.kind) + MaxLineLength:GoalsAndBests.kt:Text("of $goal workouts", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) + MaxLineLength:GoalsAndBests.kt:b to (earned[b.id]?.let { shortDate(it.earnedAt) } ?: view.progress[b.id]?.let { (have, need) -> RewardsCopy.progressLine(have, need, b.unit) }) + MaxLineLength:GoalsAndBests.kt:color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA) + MaxLineLength:GoalsAndBests.kt:if (run > 0) "Weekly streak: $run ${if (run == 1) "week" else "weeks"} in a row" else "${(goal - count).coerceAtLeast(0)} short of your goal" + MaxLineLength:GoalsAndBests.kt:if (shown.isEmpty()) Text("Your bests show here after your first workouts.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(vertical = 10.dp)) + MaxLineLength:GoalsAndBests.kt:if (week?.met == true && run > 0) StoryChip("$run ${if (run == 1) "week" else "weeks"} in a row", best = false, met = true) else null + MaxLineLength:GoalsAndBests.kt:return (0L until 7L).map { first.plusDays(it).let { d -> d.dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.getDefault()) to (d in days) } } + MaxLineLength:GoalsAndBests.kt:val earned = view.earned.sortedByDescending { it.earnedAt }.take(5).mapNotNull { r -> Rewards.badge(r.id)?.let { it to shortDate(r.earnedAt) } } + MaxLineLength:GoalsAndBests.kt:val next = view.next?.let { b -> view.progress[b.id]?.let { (have, need) -> b to RewardsCopy.progressLine(have, need, b.unit) } } + MaxLineLength:GoalsSheet.kt:"Save" + MaxLineLength:GoalsSheet.kt:* + MaxLineLength:GoalsSheet.kt:. + MaxLineLength:GoalsSheet.kt:Box + MaxLineLength:GoalsSheet.kt:Modifier.fillMaxWidth().focusRequester(focus).onFocusChanged { if (it.isFocused) focused = true else if (focused) commit() } + MaxLineLength:GoalsSheet.kt:RoundButton(AppIcons.Minus, "Lower the calorie goal", { typing = false; refused = false; onChange(CalorieGoal.step(value, -Goals.CALORIES_STEP)) }, size = 44.dp) + MaxLineLength:GoalsSheet.kt:RoundButton(AppIcons.Plus, "Raise the calorie goal", { typing = false; refused = false; onChange(CalorieGoal.step(value, Goals.CALORIES_STEP)) }, size = 44.dp) + MaxLineLength:GrowFromButton.kt:Box(Modifier.fillMaxSize().graphicsLayer { alpha = if (origin == null) 1f else GrowMotion.contentAlpha(anim.value) }) { content(!visible) } + MaxLineLength:GrowFromButton.kt:GrowMotion$fun + MaxLineLength:HealthConnectExporter.kt:HealthConnectExporter$SleepSessionRecord.STAGE_TYPE_AWAKE, SleepSessionRecord.STAGE_TYPE_AWAKE_IN_BED, SleepSessionRecord.STAGE_TYPE_OUT_OF_BED -> SleepStage.AWAKE + MaxLineLength:HealthConnectExporter.kt:HealthConnectExporter$client.deleteRecords(SleepSessionRecord::class, TimeRangeFilter.between(Instant.ofEpochSecond(from - 86_400), Instant.ofEpochSecond(to))) + MaxLineLength:HealthConnectExporter.kt:HealthConnectExporter$fun minutes + MaxLineLength:HealthConnectExporter.kt:HealthConnectExporter$private fun meta + MaxLineLength:HealthConnectExporter.kt:HealthConnectExporter$val hr = read(HeartRateRecord::class).flatMap { r -> r.samples.map { HeartRateSample(it.time.epochSecond, it.beatsPerMinute.toInt()) } } + MaxLineLength:HealthConnectPage.kt:PageRow + MaxLineLength:HealthConnectPage.kt:PageRow("Copy now", "The last 30 days, to Android Health Connect", onClick = onCopyNow, below = true, divider = false, icon = AppIcons.Heartbeat) + MaxLineLength:HealthConnectPage.kt:PageRow("Restore", "Bring back what OpenStrap saved there", onClick = onRestore, below = true, icon = AppIcons.ArrowCounterClockwise) + MaxLineLength:HealthRepository.kt:HealthRepository$combine(dao.stress(w.from, w.to), stressExcluded(w.from, w.to)) { s, excluded -> s.filter { Recovery.stressCounts(it.timestamp, excluded) } } + MaxLineLength:HealthRepository.kt:HealthRepository$fun + MaxLineLength:HealthRepository.kt:HealthRepository$r.energy?.let { dao.insertDailyEnergy(listOf(DailyEnergyHistory.row(DayWindow.of(date).from, it, System.currentTimeMillis() / 1000))) } + MaxLineLength:Healthspan.kt:Healthspan$private + MaxLineLength:Healthspan.kt:Healthspan$return Value.of(VO2_WEIGHT * VO2_PER_MET * mets, VO2_WEIGHT * VO2_PER_MET_LOW * mets, VO2_WEIGHT * VO2_PER_MET_HIGH * mets, -VO2_LIMIT, VO2_LIMIT) + MaxLineLength:Healthspan.kt:Healthspan$val young = (interp(STEPS_UNDER_60, steps) - interp(STEPS_UNDER_60, STEPS_REF_UNDER_60)).coerceIn(-STEPS_LIMIT_YOUNG, STEPS_LIMIT_YOUNG) + MaxLineLength:Healthspan.kt:Healthspan.Result$val + MaxLineLength:Healthspan.kt:Healthspan.Terms$return Value(fitness.years + rest.sumOf { it.years.toDouble() }.toFloat(), fitness.low + rest.sumOf { it.low.toDouble() }.toFloat(), fitness.high + rest.sumOf { it.high.toDouble() }.toFloat()) + MaxLineLength:HealthspanEngine.kt:HealthspanEngine$result + MaxLineLength:HealthspanEngine.kt:HealthspanEngine$result?.takeIf { sunday == sundayOf(LocalDate.now(zone)) }?.let { dao.insertHabitAgeWeek(HabitAgeWeekRow(asOf, it.estimate, it.low, it.high, System.currentTimeMillis() / 1000)) } + MaxLineLength:HealthspanEngine.kt:HealthspanEngine$val + MaxLineLength:HeartZonesCopy.kt:HeartZonesCopy$MaxHeartRate.Source.Learned -> "Learned from your hardest efforts" + (m.learnedOn?.let { ", " + it.format(DateTimeFormatter.ofPattern("d MMM", locale)) } ?: "") + MaxLineLength:HeartZonesCopy.kt:HeartZonesCopy$fun + MaxLineLength:HeartZonesScreen.kt:0f to MetricColors.heart.copy(alpha = 0.55f) + MaxLineLength:HeartZonesScreen.kt:BpmStepper(HeartZonesCopy.ENDS_AT, end, { onZones(zones.withEnd(zone, end - 1, maxHr)) }, { onZones(zones.withEnd(zone, end + 1, maxHr)) }) + MaxLineLength:HeartZonesScreen.kt:BpmStepper(HeartZonesCopy.STARTS_AT, start, { onZones(zones.withStart(zone, start - 1, maxHr)) }, { onZones(zones.withStart(zone, start + 1, maxHr)) }) + MaxLineLength:HeartZonesScreen.kt:DotMatrixText(max?.bpm?.toString() ?: "0", dot = DotSize.Hero, color = if (max != null) Color.White else Color.White.copy(alpha = 0.2f)) + MaxLineLength:HeartZonesScreen.kt:PageButton(HeartZonesCopy.RESET, { onZones(HeartZones.STANDARD) }, Modifier.fillMaxWidth(), icon = AppIcons.ArrowCounterClockwise) + MaxLineLength:HeartZonesScreen.kt:PageButton(HeartZonesCopy.SAVE, { onMax(value) }, Modifier.weight(1f), enabled = value != null, primary = true, icon = AppIcons.Tick02) + MaxLineLength:HeartZonesScreen.kt:Text("$bpm bpm", color = Color.White, style = GlowType.sentence, modifier = Modifier.width(110.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center) + MaxLineLength:HeartZonesScreen.kt:ZoneRow(z, if (max != null) HeartZonesCopy.zoneBpm(zones, z, max.bpm) else "", divider = z > 0) { if (max != null) editing = z } + MaxLineLength:HeartZonesScreen.kt:if (max != null) Text(HeartZonesCopy.source(max), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:HeartZonesScreen.kt:if (max.source == MaxHeartRate.Source.Set) PageButton(HeartZonesCopy.LEARN_IT, { onMax(null) }, Modifier.weight(1f), icon = AppIcons.ArrowCounterClockwise) + MaxLineLength:HeartZonesScreen.kt:item { Text(HeartZonesCopy.NOTE, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(horizontal = 4.dp)) } + MaxLineLength:HeartZonesScreen.kt:text + MaxLineLength:History.kt:History$DayStat(monday, days.map { it.value }.average().toFloat(), days.minOf { it.value }, days.maxOf { it.value }, days.sumOf { it.count }) + MaxLineLength:HistorySync.kt:HistorySync$StrapReadinessRow(it.day, it.updatedAt, it.score, it.hrvScore, it.restingHrScore, it.skinTemperatureScore, it.breathingScore, it.sleepHrv, it.sleepRestingHr, it.algorithmVersion) + MaxLineLength:HistorySync.kt:HistorySync$else + MaxLineLength:HistorySync.kt:HistorySync$if + MaxLineLength:HistorySync.kt:HistorySync$if (!yours && Workouts.notifyAutoDetected(row, storedBefore, now)) runCatching { onAutoDetected(row) }.onFailure { log("auto-detect notification: $it") } + MaxLineLength:HistorySync.kt:HistorySync$if (only == null && session.services.has(Endpoints.READINESS)) runCatching { syncReadiness() }.onFailure { log("readiness: $it") } + MaxLineLength:HistorySync.kt:HistorySync$is OsaResult -> osaResults += OsaResultRow(r.timestamp, r.odiValidTime, r.odiRefined, r.odiScore, r.odiDispCode, r.odNumRefined, r.version) + MaxLineLength:HistorySync.kt:HistorySync$is RecoveryHeartRate -> if (r.samples.isNotEmpty()) recoveryHr += RecoveryHeartRateRow(r.trackId, r.samples.map { it.toByte() }.toByteArray()) + MaxLineLength:HistorySync.kt:HistorySync$is RestHrRealtime -> if (r.realtimeRestHr in 30..120) restingIfMissing += RestingHeartRate(dayStart(r.timestamp), r.realtimeRestHr) + MaxLineLength:HistorySync.kt:HistorySync$launch { gatt.notifications.filter { it.characteristic == HISTORY_CONTROL }.collect { control.send(FetchCodec.parse(it.value)) } } + MaxLineLength:HistorySync.kt:HistorySync$launch { session.messagesFor(Endpoints.HISTORY).collect { log("gdsp < ${it.toHex()}"); control.send(FetchCodec.parse(it)) } } + MaxLineLength:HistorySync.kt:HistorySync$log("history ${spec.type}: ${payload.size} bytes (${chunk.sizeField}×$unit) from ${Instant.ofEpochSecond(headerTime)}: ${payload.copyOf(minOf(32, payload.size)).toHex()}") + MaxLineLength:HistorySync.kt:HistorySync$log("history workout ${Instant.ofEpochSecond(w.summary.trackId)}: type ${w.summary.sportType}, subtype ${w.summary.subtype}, ${w.detail?.heartRate?.size ?: 0} HR samples") + MaxLineLength:HistorySync.kt:HistorySync$runCatching { org.openstrap.app.data.WorkoutEffectEngine(dao).takeStrapEffects(System.currentTimeMillis() / 1000 - WORKOUT_LOOKBACK) } + MaxLineLength:HistorySync.kt:HistorySync$val parsed = spec?.parser?.parse(payload, ParseContext(header, activityRecordSize = recordSize(code, payload, chunk.sizeField))) + MaxLineLength:HistorySync.kt:HistorySync$val reason = if (!gatt.connected.value) "The link to the strap dropped." else e.message?.takeUnless { e.cause != null && it == e.cause.toString() } + MaxLineLength:HistorySync.kt:HistorySync$val reply = session.request(Endpoints.READINESS, ReadinessCodec.scoreRequest(since), 8_000) { it.isNotEmpty() && it.u8(0) == ReadinessCodec.SCORE + 1 } + MaxLineLength:HistorySync.kt:HistorySync$when + MaxLineLength:InfoCopy.kt:InfoCopy$"${(Activity.HR_VIGOROUS_HRR * 100).toInt()}%. A vigorous minute counts double, as in the World Health Organization's 2020 guidelines." + MaxLineLength:InfoCopy.kt:InfoCopy$"Active kcal are what moving burned on top of your body's use at rest. The total adds that rest. Both are estimates, in kilocalories (kcal)." + MaxLineLength:InfoCopy.kt:InfoCopy$"Minutes of brisk movement or harder over the last 7 days, against the ${Activity.MVPA_WEEKLY_TARGET} a week health guidelines aim for." + MaxLineLength:InfoCopy.kt:InfoCopy$"Points for the minutes your heart rate is raised, harder minutes earning more, over the last 7 days. Aim for ${ActivityCopy.HEART_POINTS_TARGET}." + MaxLineLength:InfoCopy.kt:InfoCopy$e.sex?.let { add("That's typical of a ${if (it == org.openstrap.app.data.settings.UserProfile.Sex.Male) "man" else "woman"} aged about ${a.first} to ${a.last}.") } + MaxLineLength:InfoCopy.kt:InfoCopy$fun + MaxLineLength:InfoCopy.kt:InfoCopy$if (normal == null) return "Your normal shows after ${History.MIN_NORMAL_DAYS} $word with data. ${days.coerceAtMost(History.MIN_NORMAL_DAYS)} so far." + MaxLineLength:InfoCopy.kt:InfoCopy$ready.fallbackRestingHr?.let { add("Last night's own resting heart rate wasn't clean enough, so the strap's ${it.toInt()} bpm stands in.") } + MaxLineLength:InfoCopy.kt:InfoCopy$reg.midpointSdMinutes?.let { add("Meanwhile the middle of your sleep moved about ${TodayCopy.duration(Math.round(it) * 60L)} this week.") } + MaxLineLength:InfoSheet.kt:. + MaxLineLength:InfoSheet.kt:Modifier.padding(top = 24.dp).fillMaxWidth().background(Color(0xFF141416), GlowLayout.CardShape).padding(horizontal = 18.dp) + MaxLineLength:InfoSheet.kt:fun widest + MaxLineLength:Insights.kt:/* Black glass: what passes under is blurred and darkened until only its colour shows; a light catching the top edge, shade along the bottom. */ + MaxLineLength:LightSpec.kt:GlossSpec(false, listOf(0f to Color.White.copy(alpha = 0.04f), 0.75f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.35f))) + MaxLineLength:LightSpec.kt:GlossSpec(false, listOf(0f to Color.White.copy(alpha = 0.35f), 0.25f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.04f))) + MaxLineLength:LightSpec.kt:GlossSpec(true, listOf(0f to Color.White.copy(alpha = 0.35f), 0.25f to Color.White.copy(alpha = 0.08f), 1f to Color.White.copy(alpha = 0.04f))) + MaxLineLength:LightSpec.kt:rims += InsetShadow(-vx * g * 0.6f, -vy * g * 0.6f, blur = g * 1.2f, spread = -g * 0.5f, p.light.copy(alpha = k.coerceAtMost(1f))) + MaxLineLength:LiveDistance.kt:"${org.openstrap.app.ui.workout.StartCopy.structureName(structure)}, ${org.openstrap.app.ui.workout.StartCopy.restText(structure.rest)} rest" + MaxLineLength:LiveDistance.kt:Column + MaxLineLength:LiveDistance.kt:Modifier.clip(RoundedCornerShape(50)).background(org.openstrap.app.ui.glow.Raised).padding(horizontal = 14.dp, vertical = 9.dp) + MaxLineLength:LiveDistance.kt:Text("Rep ${round.index + 1}", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + MaxLineLength:LiveDistance.kt:Text(if (speed) "Speed" else "Pace", color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:LiveDistance.kt:Text(if (speed) "km/h" else "/km", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, maxLines = 1) + MaxLineLength:LiveDistance.kt:if (line != null) Text(line, color = Color.White, style = GlowType.sentence, textAlign = androidx.compose.ui.text.style.TextAlign.Center) + MaxLineLength:LiveDistance.kt:is org.openstrap.app.data.Phase.Work -> m.nextRest?.let { "Then rest ${org.openstrap.app.ui.workout.StartCopy.restText(it)}" } ?: "Last rep" + MaxLineLength:LiveDistance.kt:private + MaxLineLength:LiveDistance.kt:r + MaxLineLength:LiveDistance.kt:when { work -> "$repName rep"; rest -> "Rest"; m.phase == org.openstrap.app.data.Phase.WarmUp -> "Warming up"; else -> "Done" } + MaxLineLength:LiveDistance.kt:} + MaxLineLength:LiveFrame.kt:Icon(if (r.paused) AppIcons.PlayFill else AppIcons.PauseFill, label, tint = Color.White, modifier = Modifier.size(20.dp)) + MaxLineLength:LiveFrame.kt:Icon(if (r.paused) AppIcons.PlayFill else AppIcons.PauseFill, pauseLabel, tint = Color.Black, modifier = Modifier.size(30.dp)) + MaxLineLength:LiveFrame.kt:Row + MaxLineLength:LiveFrame.kt:WorkoutButton("Pause", Raised, Color.White, controls.onPause, Modifier.weight(1f), icon = AppIcons.PauseFill, height = 52.dp) + MaxLineLength:LiveFrame.kt:WorkoutButton("Resume", Color.White, Color.Black, controls.onResume, Modifier.weight(1f), icon = AppIcons.PlayFill, height = 64.dp) + MaxLineLength:LiveFrame.kt:else + MaxLineLength:LiveFrame.kt:if + MaxLineLength:LiveFrame.kt:if (!connected) Note("The strap isn't connected. Recording carries on, and the minutes missed are filled from its own history after it syncs.") + MaxLineLength:LiveFrame.kt:internal + MaxLineLength:LiveFrame.kt:internal val LiveFigure = GlowType.sentence.copy(fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.02).em, fontFeatureSettings = "tnum") + MaxLineLength:LiveFrame.kt:val side = org.openstrap.app.ui.glow.GlowLayout.pageSide(maxWidth, org.openstrap.app.ui.glow.LocalPageColumn.current, org.openstrap.app.ui.glow.GlowLayout.Gutter) + MaxLineLength:LiveGame.kt:Box(Modifier.weight(1f).height(8.dp).clip(RoundedCornerShape(50)).background(if (on) ZONE_COLORS[i] else Color.Black.copy(alpha = 0.35f))) + MaxLineLength:LiveGame.kt:if (bpm != null) DotMatrixText("$bpm", dot = DotSize.Detail) else Text(WorkoutCopy.noHeartRate(r.paused, connected), color = Color.White, style = GlowType.sentence) + MaxLineLength:LiveGame.kt:if (periods != null && phase is Phase.Rest) WorkoutButton("Start ${StartCopy.periodName(phase.round + 1, periods.rounds).lowercase()}", Color.White, Color.Black, onHalfTime, icon = AppIcons.PlayFill, height = 64.dp) + MaxLineLength:LiveGame.kt:if (periods != null && phase is Phase.Work) WorkoutButton(StartCopy.breakName(periods.rounds), Color.White, Color.Black, onHalfTime, icon = AppIcons.PauseFill, height = 64.dp) + MaxLineLength:LiveGame.kt:workoutClock((WorkoutRecorder.activeSeconds(r.workout, now()) - WorkoutRecorder.activeSeconds(r.workout, phase.start)).coerceAtLeast(0)) + MaxLineLength:LiveGlance.kt:Glance$action = if (m.everyMinute) GlanceAction.Skip else GlanceAction.Pause + MaxLineLength:LiveGlance.kt:Glance$figure = "${i.activeSeconds / 60}" + MaxLineLength:LiveGlance.kt:Glance$figure = figure + MaxLineLength:LiveGlance.kt:Glance$goal = listOfNotNull(heart?.let { "$it." }, s.restBelow?.let { "Ends sooner once you stay under $it." }).joinToString(" ").ifEmpty { null } + MaxLineLength:LiveGlance.kt:Glance$line1 = "All ${m.rounds} ${word.lowercase()}s done" + MaxLineLength:LiveGlance.kt:Glance$line1 = "Warming up, ${base.clock}" + MaxLineLength:LiveGlance.kt:Glance$line2 + MaxLineLength:LiveGlance.kt:Glance$progress = s.restTotal?.takeIf { it > 0 }?.let { GlanceProgress((s.restLeft.toFloat() / it).coerceIn(0f, 1f), drain = true) } + MaxLineLength:LiveGlance.kt:Glance$tint = GlanceTint.Asking + MaxLineLength:LiveGlance.kt:Glance$val done = if (distance && i.distanceMeters != null) "$sport, ${kmWithUnit(i.distanceMeters)} so far" else "$sport, $clock so far" + MaxLineLength:LiveGlance.kt:internal + MaxLineLength:LiveGlance.kt:val ran = route.filter { it.t >= p.start }.takeIf { it.size > 1 }?.let { Route.distance(it, r.workout.pauseRanges).toFloat() } ?: 0f + MaxLineLength:LiveHeart.kt:LiveDistanceScreen(r, liveHeartRate, recent, stats, connection, contentPadding, controls, route, history, onLap, liveView, onLiveView, kcal, onSkip, split, compact) + MaxLineLength:LiveHeart.kt:LiveGameScreen(r, liveHeartRate, WorkoutCopy.readings(history, recent), stats, zoneSeconds, connection, contentPadding, controls, kcal, onSkip, split, compact) + MaxLineLength:LiveHeart.kt:LiveIntervalsScreen(r, liveHeartRate, WorkoutCopy.readings(history, recent), connection, contentPadding, controls, onSkip, onAddRest, split, compact) + MaxLineLength:LiveHeart.kt:WorkoutHeartCard(r, liveHeartRate, recent, zoneSeconds, connected, activeSeconds(r), Modifier.fillMaxSize(), fill = true, kcal = kcal?.active) + MaxLineLength:LiveHeart.kt:drawArc(ZONE_COLORS[z].copy(alpha = if (on) 1f else 0.22f), start, seg, false, topLeft, box, style = Stroke(stroke, cap = StrokeCap.Round)) + MaxLineLength:LiveHeart.kt:else -> LiveIndoorScreen(r, liveHeartRate, recent, stats, zoneSeconds, connection, contentPadding, controls, onLap, kcal, split, compact) + MaxLineLength:LiveHeart.kt:family == Workouts.Family.MindBody -> LiveMindScreen(r, liveHeartRate, recent, connection, contentPadding, controls, split, compact) + MaxLineLength:LiveHeart.kt:if (on) drawArc(ZONE_COLORS[z].copy(alpha = 0.3f), start, seg, false, topLeft, box, style = Stroke(stroke * 2.6f, cap = StrokeCap.Round)) + MaxLineLength:LiveHeart.kt:return org.openstrap.app.data.Summaries.bucketMeans(recent, (WINDOW_S / BUCKET_S).toInt(), { ((it.first - from) / BUCKET_S).toInt() }, { it.second.toFloat() }) + MaxLineLength:LiveIndoor.kt:else + MaxLineLength:LiveIndoor.kt:if (r.structure is org.openstrap.app.data.Structure.Reps) RepCard(r, emptyList(), recent, liveHeartRate.takeIf { connected && !r.paused }) + MaxLineLength:LiveIntervals.kt:Column + MaxLineLength:LiveIntervals.kt:Icon(if (label == "Done") AppIcons.Tick02 else AppIcons.SkipFill, label, tint = Color.White, modifier = Modifier.size(20.dp)) + MaxLineLength:LiveIntervals.kt:Modifier.fillMaxWidth().height(56.dp).semantics { contentDescription = "Peak heart rate per round: " + peaks.filterNotNull().joinToString(", ") } + MaxLineLength:LiveIntervals.kt:Phase.WarmUp -> PhaseFigure("Warm up", "${m.rounds} rounds after it", "Warming up", null, "Start the rounds when you're ready", compact) + MaxLineLength:LiveIntervals.kt:Row + MaxLineLength:LiveIntervals.kt:Text(peaks.filterNotNull().joinToString(", ") + " bpm", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:LiveIntervals.kt:if (line.isNotEmpty()) Text(line, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, textAlign = TextAlign.Center) + MaxLineLength:LiveIntervals.kt:is Phase.Rest -> if (!compact) WorkoutButton("+10 s", OverGlow, Color.White, onAddRest, Modifier.align(Alignment.CenterHorizontally).width(140.dp), height = 52.dp) + MaxLineLength:LiveIntervals.kt:is Phase.Work -> if (m.everyMinute) WorkoutButton("Done", Color.White, Color.Black, onSkip, icon = AppIcons.Tick02, height = tall) + MaxLineLength:LiveIntervals.kt:val h = if (p == null) 4.dp.toPx() else ((p - low).toFloat() / (top - low).coerceAtLeast(1)).coerceIn(0.15f, 1f) * size.height + MaxLineLength:LiveIntervals.kt:where: String + MaxLineLength:LiveIntervals.kt:zone?.let { Spacer(Modifier.width(16.dp)); Text(WorkoutCopy.zoneLine(it), color = Color.White, style = GlowType.secondary, maxLines = 1) } + MaxLineLength:LiveMap.kt:Box(Modifier.size(6.dp).clip(CircleShape).background(if (acc != null && acc <= Route.MAX_ACCURACY_M) TrendGood else TrendBad)) + MaxLineLength:LiveMap.kt:MapButton(AppIcons.MapFold, if (routeOnly) "Show the map" else "Show route only, no map", lit = routeOnly) { routeOnly = !routeOnly } + MaxLineLength:LiveMap.kt:RoundIcon(icon, label, onClick, surface = if (lit) Color.White else OverMap, ink = if (lit) Color.Black else Color.White, iconSize = 20.dp) + MaxLineLength:LiveMap.kt:Text(line, color = Color.White, style = GlowType.body.copy(fontFeatureSettings = "tnum"), maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:LiveMind.kt:LiveFrame + MaxLineLength:LivePaused.kt:internal + MaxLineLength:LiveSets.kt:Box(Modifier.size(14.dp).clip(CircleShape).background(if (i < done || now) Color.White else Color.Black.copy(alpha = 0.35f))) + MaxLineLength:LiveSets.kt:Box(Modifier.weight(1f)) { WorkoutButton("Start set ${moment.setNumber}", Color.White, Color.Black, onStartSet, icon = AppIcons.PlayFill, height = tall) } + MaxLineLength:LiveSets.kt:Column + MaxLineLength:LiveSets.kt:LogStepper("Reps", reps.toString(), "Fewer reps", "More reps", Modifier.weight(1f), { reps = Strength.stepReps(reps, -1) }, { reps = Strength.stepReps(reps, 1) }) + MaxLineLength:LiveSets.kt:LogStepper("kg", Strength.kgText(kg), "Less weight", "More weight", Modifier.weight(1f), { kg = Strength.stepKg(kg, -1) }, { kg = Strength.stepKg(kg, 1) }) + MaxLineLength:LiveSets.kt:Modifier.fillMaxWidth().clip(GlowLayout.CardShape).border(1.5.dp, Color.White.copy(alpha = 0.2f), GlowLayout.CardShape).padding(20.dp) + MaxLineLength:LiveSets.kt:Text("All $target sets done. Hold the square to end, or keep going.", color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + MaxLineLength:LiveSets.kt:Text("Filled from your last set. Change only what's different.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:LiveSets.kt:Text("after " + WorkoutCopy.setOf(done, r.workout.setsTarget).lowercase(), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:LiveSets.kt:Text(WorkoutCopy.restRule(rule.belowBpm), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:LiveSets.kt:Text(WorkoutCopy.setLine(s, Strength.peak(history, s.start, s.end!!)), color = Color.White.copy(alpha = 0.72f), style = GlowType.body, maxLines = 1, overflow = TextOverflow.Ellipsis) + MaxLineLength:LiveSets.kt:Text(WorkoutCopy.setOf(number, r.workout.setsTarget) + " next. Tap Start when you pick up the weight.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:LiveSets.kt:Text(WorkoutCopy.setOf(number, r.workout.setsTarget), color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:LiveSets.kt:Text(if (number == 1) "Ready for set 1" else "Rest over", color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:LiveSets.kt:drawArc(RestGreen, -90f, 360f * (left.toFloat() / total).coerceIn(0f, 1f), false, inset, arc, style = Stroke(w, cap = StrokeCap.Round)) + MaxLineLength:LiveSets.kt:if (bpm != null) Text("$bpm bpm" + (drop?.takeIf { it > 0 }?.let { ", down $it" } ?: ""), color = Color.White, style = GlowType.sentence) + MaxLineLength:LiveSets.kt:if (naming) ExerciseSheet(r.exercise, exercises.map { it.name }, onPick = { onExercise(it); naming = false }, onDismiss = { naming = false }) + MaxLineLength:LiveSets.kt:if (typed.isEmpty()) Text("Type a name", color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.body) + MaxLineLength:LiveSets.kt:if (typed.isNotBlank()) Icon(AppIcons.Tick02, "Use this name", tint = Color.White, modifier = Modifier.size(28.dp).clip(CircleShape).pressClickable({ onPick(typed) }, CircleShape).padding(4.dp)) + MaxLineLength:LiveSets.kt:names.forEachIndexed { i, name -> PageRow(name, value = if (name.equals(current, ignoreCase = true)) "Now" else null, divider = i > 0, onClick = { onPick(name) }) } + MaxLineLength:LiveSets.kt:private + MaxLineLength:LiveSets.kt:r.inSet -> WorkoutButton("Set done", Color.White, Color.Black, { haptics.perform(HapticEvent.SetDone); onSetDone() }, icon = AppIcons.Tick02, height = tall) + MaxLineLength:LiveSets.kt:r.resting -> "Rest after " + WorkoutCopy.setOf(moment.done, r.workout.setsTarget).lowercase() to workoutClock((moment.restLeft ?: 0).coerceAtLeast(0)) + MaxLineLength:MainActivity.kt:) + MaxLineLength:MainActivity.kt:*(if (page == tab) emptyArray() else arrayOf(androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner provides idleBack)) + MaxLineLength:MainActivity.kt:CompositionLocalProvider + MaxLineLength:MainActivity.kt:LaunchedEffect(org.openstrap.app.update.Releases.newer(updates.releases, updater.installed).firstOrNull()?.tag) { updater.markSeen() } + MaxLineLength:MainActivity.kt:MainActivity$((application as OpenStrapApp).container.strap as? org.openstrap.app.device.StrapManager)?.simulate(script, intent.getIntExtra("simulate_steps", 0), intent.getIntExtra("simulate_battery", 80)) + MaxLineLength:MainActivity.kt:MainActivity$// Debug: `--es simulate_hr 140x120,80x600 --ei simulate_steps 0 --ei simulate_battery 80` plays heart rate as a connected strap (emulator). + MaxLineLength:MainActivity.kt:MainActivity$container.settings.savePairedDevice(org.openstrap.app.data.settings.PairedDevice("00:00:00:00:00:00", "00", "Helio Strap")) + MaxLineLength:MainActivity.kt:MainActivity$runCatching { strap.buzz(pattern) }.onFailure { Toast.makeText(this@MainActivity, "Buzz failed: ${it.message}", Toast.LENGTH_SHORT).show() } + MaxLineLength:MainActivity.kt:MainActivity$setContent { OpenStrapTheme { AppRoot(container, metricRequests, todayRequests, strapRequests, forcedMoment, strapPreview, dataPreview) } } + MaxLineLength:MainActivity.kt:MainActivity$startService(Intent(this, StrapService::class.java).setAction(StrapService.ACTION_SAMPLE).putExtra(StrapService.EXTRA_SAMPLE, sample)) + MaxLineLength:MainActivity.kt:MainActivity$val script = simulate.split(',').mapNotNull { p -> p.split('x').takeIf { it.size == 2 }?.let { (b, s) -> b.toIntOrNull()?.let { bpm -> s.toIntOrNull()?.let { bpm to it } } } } + MaxLineLength:MainActivity.kt:Page$You + MaxLineLength:MainActivity.kt:Page.PhoneAlerts -> org.openstrap.app.ui.alarms.PhoneAlertsScreen(container.phoneAlerts, container.settings, padding, onBack = close) + MaxLineLength:MainActivity.kt:Tab.Today -> todayStack = stack + MaxLineLength:MainActivity.kt:Toast.makeText(context, "Health Connect isn't available on this phone.", Toast.LENGTH_LONG).show() + MaxLineLength:MainActivity.kt:activity?.let { androidx.window.layout.WindowInfoTracker.getOrCreate(it).windowLayoutInfo(it) } ?: kotlinx.coroutines.flow.emptyFlow() + MaxLineLength:MainActivity.kt:androidx.compose.foundation.layout + MaxLineLength:MainActivity.kt:androidx.compose.foundation.layout.Spacer(androidx.compose.ui.Modifier.width(with(density) { bookHinge.bounds.width().toDp() })) + MaxLineLength:MainActivity.kt:androidx.compose.runtime.SideEffect { if (freshClockFacts != null && freshClockFacts != keptClockFacts) keptClockFacts = freshClockFacts } + MaxLineLength:MainActivity.kt:androidx.compose.ui.Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).hazeSource(navGlass, zIndex = 1f) + MaxLineLength:MainActivity.kt:androidx.compose.ui.Modifier.fillMaxSize().padding(start = if (rail) org.openstrap.app.ui.components.NavRailWidth else 0.dp) + MaxLineLength:MainActivity.kt:androidx.lifecycle.compose.LifecycleResumeEffect(Unit) { deviceVm.checkAutoHealthConnect(); onPauseOrDispose { } } + MaxLineLength:MainActivity.kt:container + MaxLineLength:MainActivity.kt:day + MaxLineLength:MainActivity.kt:earned.mapNotNull { org.openstrap.app.data.Rewards.badge(it.id) } + MaxLineLength:MainActivity.kt:glance.value?.let { g -> org.openstrap.app.ui.glow.WorkoutCapsule(g, onClick = { session.minimized.value = false }, onAction = onGlanceAction, modifier = place) } + MaxLineLength:MainActivity.kt:header = { org.openstrap.app.ui.glow.PageHeader("Recovery", pop, subtitle = org.openstrap.app.ui.glow.dayName(day.date)) } + MaxLineLength:MainActivity.kt:healthspan = healthspan + MaxLineLength:MainActivity.kt:if (pages.lastOrNull() != Page.WeeklyRecap && (pages.isEmpty() || closePages())) pages = listOf(Page.You, Page.GoalsAndBests, Page.WeeklyRecap) + MaxLineLength:MainActivity.kt:if (plan.useGps && !org.openstrap.app.device.WorkoutLocation.granted(container.app)) requestLocation(withBluetooth) else withBluetooth() + MaxLineLength:MainActivity.kt:if (result.values.none { it }) Toast.makeText(context, "Without location, distance comes from the strap's steps.", Toast.LENGTH_LONG).show() + MaxLineLength:MainActivity.kt:if (workouts.isNotEmpty()) value = runCatching { container.rewards.view(workouts, goals.workoutsPerWeek, bests, earned) }.getOrNull() + MaxLineLength:MainActivity.kt:initializer { org.openstrap.app.ui.workout.WorkoutsViewModel(container.database.healthDao(), container.settings.profile, container.maxHeartRates) } + MaxLineLength:MainActivity.kt:launch { pager.interactionSource.interactions.collect { if (it is androidx.compose.foundation.interaction.DragInteraction.Start) swiped.set(true) } } + MaxLineLength:MainActivity.kt:liveHero = org.openstrap.app.ui.glow.LiveHeroHooks({ liveGlance.value }, onGlanceAction, { liveHeroShown = it }) + MaxLineLength:MainActivity.kt:onEditsChange = { e -> scope.launch { container.settings.saveTodayEdits(e.hidden.map { it.name }, e.pinned.map { it.name }) } } + MaxLineLength:MainActivity.kt:onOpenScore = { push(Detail.OfScore(it)) } + MaxLineLength:MainActivity.kt:org.openstrap.app.data.MomentResolver.resolve(clock, zone, clockDate, recording, lastWorkout?.end, sleepEnd, bedtime, workoutStarts) + MaxLineLength:MainActivity.kt:org.openstrap.app.data.Workouts.recentSports(workouts.filter { it.startedByYou }.sortedByDescending { it.workout.start }.map { it.workout.sportType }) + MaxLineLength:MainActivity.kt:org.openstrap.app.device.WidgetUpdater.changed(container.app, setOf(org.openstrap.app.widget.WidgetKind.Steps, org.openstrap.app.widget.WidgetKind.Moment)) + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.components.PullToSync(realSync, realConnection is ConnectionState.Connected, container.strap::syncNow) { screen() } + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.device.DeletedSleepPage(deleted, padding, onRestore = dayVm::restoreSleep, onBack = onBack) + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.glow.ChooseSport(type, last?.let { org.openstrap.app.ui.workout.StartCopy.lastTime(it, today, zone) } ?: "") + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.glow.LocalGlowMood provides (todayMoment?.let { org.openstrap.app.ui.dashboard.momentMood(it) } ?: org.openstrap.app.ui.glow.GlowMood.Day) + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.glow.LocalGlowPagePaused provides remember(pager, page) { { pager.isScrollInProgress || pager.settledPage != page } } + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.glow.metricIndex(day, recovery, healthspan, workouts, strapScore = strapReadiness?.takeIf { it.day == day.dayStart }?.score) + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.workout.GlanceAction.AddRest -> container.workout.let { if ((it.state.value as? org.openstrap.app.device.WorkoutState.Recording)?.strength == true) it.extendRest() else it.addRest() } + MaxLineLength:MainActivity.kt:org.openstrap.app.ui.workout.GlanceAction.End, org.openstrap.app.ui.workout.GlanceAction.Open -> container.workout.minimized.value = false + MaxLineLength:MainActivity.kt:pages.ifEmpty { keptPages[0] } + MaxLineLength:MainActivity.kt:paired = pairedKnown + MaxLineLength:MainActivity.kt:private + MaxLineLength:MainActivity.kt:profile + MaxLineLength:MainActivity.kt:sleepEditing = org.openstrap.app.ui.glow.SleepEditing(dayVm::setSleepTimes, dayVm::deleteSleep, dayVm::restoreSleep) + MaxLineLength:MainActivity.kt:startPlan(org.openstrap.app.data.WorkoutSetup.plan(type, settings, sides, org.openstrap.app.ui.glow.locationAvailable(context)), null) + MaxLineLength:MainActivity.kt:updates + MaxLineLength:MainActivity.kt:val + MaxLineLength:MainActivity.kt:val dayVm: DayViewModel = viewModel(factory = viewModelFactory { initializer { DayViewModel(container.health, container.strap.todaySteps, container.settings.profile, container.autoHealthConnect::sleepEdited) } }) + MaxLineLength:MainActivity.kt:val energyHistory: org.openstrap.app.ui.glow.EnergyHistory = remember(container) { { from, to -> container.health.dailyEnergy(from, to) } } + MaxLineLength:MainActivity.kt:val firmwareInstalling: () -> Boolean = { Page.Firmware in pages && firmwareVm.step.value is org.openstrap.app.ui.firmware.FirmwareStep.Installing } + MaxLineLength:MainActivity.kt:val first = view?.thisWeek?.minusWeeks(1) ?: org.openstrap.app.data.Rewards.weekStart(java.time.LocalDate.now(), container.rewards.firstDay).minusWeeks(1) + MaxLineLength:MainActivity.kt:val goal = earned.firstOrNull { it.id == b.id }?.let { org.openstrap.app.data.Rewards.goalAt(it, weeks, container.rewards.firstDay, java.time.ZoneId.systemDefault()) } + MaxLineLength:MainActivity.kt:val granted = container.app.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED + MaxLineLength:MainActivity.kt:val ok = granted.containsAll(container.healthConnect.permissions) || runCatching { container.healthConnect.hasPermissions() }.getOrDefault(false) + MaxLineLength:MainActivity.kt:val realStep = if (!realPairedKnown || firstConnect) pairStep(realPairedKnown, scan.scanning, scan.found.isNotEmpty(), realConnection) else null + MaxLineLength:MainActivity.kt:val rewardsKey = remember(workouts, recordingStart) { workouts.filter { it.workout.start != recordingStart }.map { Triple(it.workout.start, it.shownStart, it.end) } } + MaxLineLength:MainActivity.kt:val series by androidx.compose.runtime.produceState<List<Pair<Long, Int>>?>(null, date) { value = workoutsVm.dayMinuteHeartRate(date) } + MaxLineLength:MainActivity.kt:val sinceStart = if (preview != null) Long.MAX_VALUE else (android.os.SystemClock.uptimeMillis() - android.os.Process.getStartUptimeMillis()) / 1000 + MaxLineLength:MainActivity.kt:val sport = if (m == org.openstrap.app.data.Moment.During) (workoutState as? org.openstrap.app.device.WorkoutState.Recording)?.workout?.sportType else lastWorkout?.workout?.sportType + MaxLineLength:MainActivity.kt:val sports = remember(mine) { org.openstrap.app.data.WorkoutSetup.choose(mine.map { it.workout.sportType to it.workout.start }, System.currentTimeMillis() / 1000) } + MaxLineLength:MainActivity.kt:val strapReadiness by remember(day.dayStart) { container.database.healthDao().strapReadiness(day.dayStart) }.collectAsStateWithLifecycle(null) + MaxLineLength:MainActivity.kt:val suggestedCalories by produceState<Int?>(null) { value = runCatching { container.health.suggestedCalorieGoal() }.getOrNull() } + MaxLineLength:MainActivity.kt:val summary by remember(state) { workoutsVm.phoneSummary(state.start, backfill = !state.preview) }.collectAsStateWithLifecycle(null) + MaxLineLength:MainActivity.kt:workouts = workouts + MaxLineLength:MaxHeartRates.kt:MaxHeartRates$private suspend fun work: MaxHeartRate + MaxLineLength:MomentHeader.kt:RoundButton(AppIcons.User, if (updateWaiting) "You, a new version is ready" else "You", "Open You", g.onClick, g.modifier, dot = updateWaiting) + MaxLineLength:MomentHeader.kt:icon: androidx.compose.ui.graphics.vector.ImageVector + MaxLineLength:MomentWidget.kt:Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_MOMENT, MainActivity.MOMENT_NOW).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + MaxLineLength:MomentWidget.kt:MomentChartKind.Line -> ghostLineGeometry(chart.shares, null, 1f, w, h, f.density)?.let { drawGhostLine(c, it, Color.White, f.density) } + MaxLineLength:MomentWidget.kt:MomentWidget$override fun sample: MomentWidgetModel + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$. + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$SleepWidgetNight.of(today, main, org.openstrap.app.data.SleepSource(dao).stagesOnce(main), SleepHistory.mainSleeps(sessions, zone)) + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$internal suspend + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$val + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$val ended = strap.map { MomentWorkout(it.sportType, it.end) } + phone.mapNotNull { w -> w.end?.let { MomentWorkout(w.sportType, it) } } + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$val recording = (container.workout.state.value as? WorkoutState.Recording)?.let { MomentRecordingInput(it.workout.sportType, it.workout.start) } + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$val span = DayWindow(DayWindow.nightEndingOn(today.minusDays(SLEEP_NIGHTS - 1L), zone).from, DayWindow.nightEndingOn(today, zone).to) + MaxLineLength:MomentWidget.kt:MomentWidget.Companion$val usual = usualSport(yours) ?: usualSport(all.filter { it.first != Workouts.GENERIC_ACTIVITY }) ?: Workouts.DEFAULT_SPORTS.first() + MaxLineLength:MomentWidget.kt:fit.sentence?.let { Gap(); Text(it.text, style = WidgetType.hero.copy(fontSize = it.sp.sp), maxLines = MomentLayout.SENTENCE_LINES) } + MaxLineLength:MomentWidgetModel.kt:MomentLayout$else + MaxLineLength:MomentWidgetModel.kt:MomentLayout$fun + MaxLineLength:MomentWidgetModel.kt:MomentLayout$m: MomentWidgetModel + MaxLineLength:MomentWidgetModel.kt:MomentLayout$return Fit(name, dot = dot, row = row, sentence = sentence, chart = chart, waiting = waiting, content = blocks.sum(), inner = blocks.size - 1, top = statusTop) + MaxLineLength:MomentWidgetModel.kt:MomentLayout$return Fit(name, waiting = waiting, play = play, content = content, inner = (if (waiting != null) 1 else 0) + (if (play) 1 else 0), top = statusTop) + MaxLineLength:MomentWidgetModel.kt:MomentLayout$val + MaxLineLength:MomentWidgetModel.kt:MomentLayout$val name = statusTop?.let { Line(m.name, it.titleSp) } ?: (line(listOf(m.name), nameSizes, width, fontScale) ?: Line(m.name, nameSizes.last())) + MaxLineLength:MomentWidgetModel.kt:MomentLayout$val parts = listOfNotNull(name.height(fontScale), tile.takeIf { it > 0f }, sport?.height(fontScale), since?.height(fontScale)) + MaxLineLength:MomentWidgetModel.kt:MomentLayout$val waiting = if (m.number == null && sentence == null) m.waiting?.let { line(listOf(it), FIGURE_SIZES, width, fontScale) } else null + MaxLineLength:MomentWidgetModel.kt:MomentLight$data + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$e.points.forEach { p -> if (p.t in wake..t) { val i = ((p.t - wake) / step).toInt().coerceAtMost(n); sums[i] += p.value; counts[i]++ } } + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$figures = if (e != null && left != null && drained != null) listOf("Drained $drained, recharged ${e.recharged}", "Drained $drained") else emptyList() + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$return MomentChart(MomentChartKind.Line, values = values, low = low, high = high, from = clock(start), to = clock(points.last().x), description = description) + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$return MomentChart(MomentChartKind.Stages, stages = n.stages, from = from, to = to, description = "Last night's stages, $from to $to") + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$sentences = shorter(TodayCopy.waking(input.night?.asleepSeconds, input.score, input.alert, input.noScore, input.firstNights, isToday = true, deleted = input.nightDeleted)) + MaxLineLength:MomentWidgetModel.kt:MomentWidgetModel.Content$val wake = input.inputs.sleepEnd ?: today.atTime(org.openstrap.app.data.MomentResolver.WAKE_FALLBACK).atZone(zone).toEpochSecond() + MaxLineLength:Motion.kt:fun + MaxLineLength:MovingBorder.kt:drawRoundRect(brush, Offset(inset, inset), Size(size.width - thin, size.height - thin), CornerRadius(corner - inset), style = Stroke(thin)) + MaxLineLength:MovingBorder.kt:drawRoundRect(brush, Offset(inset, inset), Size(size.width - thin, size.height - thin), CornerRadius(corner), alpha = 0.28f, style = Stroke(halo)) + MaxLineLength:Narrative.kt:Narrative$/** Under 6 h asleep: the bottom of Zepp's duration bands (codemap 06 §4.2), and 56 on [org.openstrap.app.data.Recovery.durationScore]. */ + MaxLineLength:Narrative.kt:Narrative$add(Contributor("Continuity", ratingFor(Math.round(c)), c / 100f, "${formatDuration(wasoMinutes * 60L)} awake after falling asleep")) + MaxLineLength:Narrative.kt:Narrative$add(Contributor("Total sleep", totalSleepRating(asleepMin, duration), asleepMin / needMinutes.toFloat(), formatDuration(asleepSeconds) + " of ${formatDuration(needMinutes * 60L)}")) + MaxLineLength:Narrative.kt:Narrative$calm != null -> "Stress counts from ${t.value}: ${org.openstrap.app.data.Recovery.STRESS_MARGIN} above your calm level of $calm over the last ${t.days} days." + MaxLineLength:Narrative.kt:Narrative$left > 0 -> "Stress counts from ${t.value} for now. After ${if (left == 1) "1 more day" else "$left more days"} of wear it adjusts to your own calm level." + MaxLineLength:NotificationCopy.kt:NotificationCopy$gps -> distanceMeters?.takeIf { it >= 10 }?.let { (if (distanceFromSteps) "≈ " else "") + km(it) + if (it >= 1000) " km" else "" } + MaxLineLength:OpenStrapApp.kt:AppContainer$app + MaxLineLength:OpenStrapApp.kt:AppContainer$val updater = org.openstrap.app.update.AppUpdater(app, settings, recording = { workout.state.value is org.openstrap.app.device.WorkoutState.Recording }) + MaxLineLength:OpenStrapApp.kt:OpenStrapApp$if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) container.strap.backgroundConnect() + MaxLineLength:PageParts.kt:Text(placeholder, color = Color.White.copy(alpha = 0.3f), style = GlowType.body, maxLines = 1, overflow = TextOverflow.Ellipsis) + MaxLineLength:PageParts.kt:Text(title, color = Color.White, style = GlowType.body, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + MaxLineLength:PageParts.kt:Text(title, color = Color.White, style = GlowType.cardTitle, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + MaxLineLength:PageParts.kt:drawRoundRect(Color.White, topLeft = Offset(-w + (size.width + w) * slide.value, 0f), size = Size(w, size.height), cornerRadius = r) + MaxLineLength:PageParts.kt:fun + MaxLineLength:PageParts.kt:if (note != null) Text(note, color = RowValueColor, style = GlowType.secondary, modifier = Modifier.padding(bottom = GlowLayout.RowPadding)) + MaxLineLength:PageParts.kt:if (subtitle != null) Text(subtitle, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + MaxLineLength:PageParts.kt:if (value != null) Text(value, color = RowValueColor, style = GlowType.secondary, maxLines = 2, overflow = TextOverflow.Ellipsis) + MaxLineLength:PageParts.kt:modifier.fillMaxWidth().clip(PanelShape).background(PanelSurface, PanelShape).border(1.dp, Color.White.copy(alpha = 0.06f), PanelShape) + MaxLineLength:PageParts.kt:tonalElevation = 0.dp + MaxLineLength:PageParts.kt:trailingIcon = if (on) ({ Icon(AppIcons.Tick02, null, tint = Color.White, modifier = Modifier.size(18.dp)) }) else null + MaxLineLength:Phases.kt:Phases$fun + MaxLineLength:Phases.kt:Phases$if (p.start == 0L || rounds.last().workEnd != null) return Step(rounds + AppWorkoutRound(workoutStart, p.round, now), Switch.WorkStarts) + MaxLineLength:Phases.kt:Phases$private + MaxLineLength:Phases.kt:Phases$return if (restToo) Step(done + AppWorkoutRound(r.workoutStart, r.index + 1, now), Switch.WorkStarts) else Step(done, Switch.RestStarts) + MaxLineLength:Phases.kt:Structure.Companion$EVERY.matchEntire(body)?.destructured?.let { (p, n) -> return EveryMinute(p.toLong(), n.toInt(), warm).takeIf { it.period > 0 && it.rounds > 0 } } + MaxLineLength:Phases.kt:Structure.Companion$val reps = if (unit == "m") Reps(v.toInt(), null, r.toLong(), n.toInt(), warm) else Reps(null, v.toLong(), r.toLong(), n.toInt(), warm) + MaxLineLength:Phases.kt:Structure.Reps$data + MaxLineLength:PhoneAlerts.kt:PhoneAlerts$if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && m is AudioManager.OnModeChangedListener) audio.removeOnModeChangedListener(m) + MaxLineLength:PhoneAlerts.kt:PhoneAlerts$log("call notification ${sbn.packageName}: type $callType, ongoing ${sbn.isOngoing}, full screen ${n.fullScreenIntent != null}, update $update, filter ${ranking?.matchesInterruptionFilter()} -> ${if (incoming) "ringing" else "not ringing"}") + MaxLineLength:PhoneAlerts.kt:PhoneAlerts$runCatching { strap.startCallBuzz(callStyle == CallBuzz.PULSED) }.onFailure { log("call buzz failed: ${it.message}"); return@launch } + MaxLineLength:PhoneAlertsScreen.kt:. + MaxLineLength:PhoneAlertsScreen.kt:callPreviewError = runCatching { alerts.previewCall(style) }.exceptionOrNull()?.let { it.message ?: "Couldn't buzz the strap" } + MaxLineLength:PhoneAlertsScreen.kt:previewError = runCatching { alerts.preview(p) }.exceptionOrNull()?.let { it.message ?: "Couldn't buzz the strap" } + MaxLineLength:ProfileScreen.kt:NumberField(feet, { feet = it.take(1) }, "Feet", "ft", !heightBlank && heightValue == null, Modifier.weight(1f)) + MaxLineLength:ProfileScreen.kt:NumberField(inches, { inches = it.take(2) }, "Inches", "in", !heightBlank && heightValue == null, Modifier.weight(1f)) + MaxLineLength:ProfileScreen.kt:NumberField(sleepNeed, { sleepNeed = it }, "Sleep need (7–9, blank for your age's default)", "h", sleepNeed.isNotBlank() && sleepNeedValue == null, decimal = true) + MaxLineLength:ProfileScreen.kt:PageNote(if (activity in 0..4) org.openstrap.app.data.Vo2max.ACTIVITY_LEVELS[activity] else "Pick the level that best matches a usual week.") + MaxLineLength:ProfileScreen.kt:ScreenList + MaxLineLength:ProfileScreen.kt:fun + MaxLineLength:ProfileScreen.kt:org.openstrap.app.ui.glow.GlowSegments((1..5).map { "$it" }, activity, { i -> activity = if (activity == i) -1 else i }) + MaxLineLength:ProfileScreen.kt:org.openstrap.app.ui.glow.GlowSegments(listOf("Female", "Male"), if (sex in 0..1) sex else -1, { i -> sex = if (sex == i) 2 else i }) + MaxLineLength:ProfileScreen.kt:org.openstrap.app.ui.glow.GlowSegments(listOf("Metric", "Imperial"), if (imperial) 1 else 0, { switchUnits(it == 1) }) + MaxLineLength:ProfileScreen.kt:org.openstrap.app.ui.workout.HeartZonesCopy.MAX_ROW + MaxLineLength:ProfileScreen.kt:val dirty = listOf(age, sex, imperial, weight, heightCm, feet, inches, activity, betaBlocker, pregnant, sleepNeed).joinToString("\u0000") != initial + MaxLineLength:ProfileScreen.kt:val initial = rememberSaveable { listOf(age, sex, imperial, weight, heightCm, feet, inches, activity, betaBlocker, pregnant, sleepNeed).joinToString("\u0000") } + MaxLineLength:ProfileScreen.kt:var sex by rememberSaveable { mutableIntStateOf(when (profile.sex) { UserProfile.Sex.Female -> 0; UserProfile.Sex.Male -> 1; null -> 2 }) } + MaxLineLength:ProfileScreen.kt:var weight by rememberSaveable { mutableStateOf(profile.weightKg?.let { formatNumber(if (imperial) Units.kgToLb(it) else it) }.orEmpty()) } + MaxLineLength:PullToSync.kt:* + MaxLineLength:PullToSync.kt:label + MaxLineLength:ReadyCardio.kt:. + MaxLineLength:ReadyCardio.kt:Box + MaxLineLength:ReadyCardio.kt:Column + MaxLineLength:ReadyCardio.kt:Modifier.clip(RoundedCornerShape(12.dp)).clickable(onClickLabel = StartCopy.TAP_TO_TYPE, onClick = onClick).padding(horizontal = 8.dp) + MaxLineLength:ReadyCardio.kt:Modifier.fillMaxWidth().bringIntoViewRequester(inView).clip(GlowLayout.CardShape).background(ListSurface).border(1.dp, RowHairline, GlowLayout.CardShape).padding(16.dp) + MaxLineLength:ReadyCardio.kt:Modifier.focusRequester(focus[i]).onFocusChanged { if (it.isFocused) { focused = i; everFocused = true } else if (focused == i) focused = null } + MaxLineLength:ReadyCardio.kt:Modifier.focusRequester(minutesFocus).tracked(1).onFocusChanged { if (!it.isFocused) hoursDone = false } + MaxLineLength:ReadyCardio.kt:Modifier.weight(1f).height(34.dp).clip(CircleShape).border(1.dp, if (chosen) Color.White else Color.White.copy(alpha = 0.15f), CircleShape) + MaxLineLength:ReadyCardio.kt:PageButton(StartCopy.SAVE_INTERVALS, { onSave(name.trim()) }, Modifier.fillMaxWidth(), enabled = name.isNotBlank(), primary = true, icon = AppIcons.Plus) + MaxLineLength:ReadyCardio.kt:PageButton(StartCopy.TURN_ON_LOCATION, onTurnOnLocation, Modifier.fillMaxWidth().padding(bottom = 14.dp), icon = SportIconsLocation) + MaxLineLength:ReadyCardio.kt:Presets(StartCopy.INTERVAL_PRESETS.map { it to StartCopy.structureName(it) }, reps.copy(warmUp = false)) { onReps(it.warm(reps.warmUp)) } + MaxLineLength:ReadyCardio.kt:ReadyPlanCard(family, choices.map { it.second }, at?.let { choices[it].second } ?: other(), at, { onChoose(choices[it].first) }, typing, onTyping, content) + MaxLineLength:ReadyCardio.kt:Row + MaxLineLength:ReadyCardio.kt:Text("×", color = Color.White.copy(alpha = 0.5f), style = GlowType.goalValueMinor, modifier = Modifier.alignByBaseline().padding(horizontal = 4.dp)) + MaxLineLength:ReadyCardio.kt:Text(StartCopy.planTitle(family), color = Color.White.copy(alpha = 0.6f), style = GlowType.body, modifier = Modifier.padding(horizontal = 4.dp)) + MaxLineLength:ReadyCardio.kt:Text(label, color = if (on) Color.Black else Color.White.copy(alpha = 0.85f), style = GlowType.secondary.copy(fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal)) + MaxLineLength:ReadyCardio.kt:Text(text, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:ReadyCardio.kt:TypedValueScope$/** What a [TypedValue]'s lines have: its numbers ([Number]), what's typed so far ([typed], null at rest), the number being typed ([focused]). */ + MaxLineLength:ReadyCardio.kt:ValueStepper + MaxLineLength:ReadyCardio.kt:fun Modifier.tracked + MaxLineLength:ReadyCardio.kt:hours = selected("${seconds / 3600}"); minutes = selected("${seconds / 60 % 60}"); everFocused = false; hoursDone = false; onTyping(true) + MaxLineLength:ReadyCardio.kt:hours = v.copy(text = v.text.filter { it.isDigit() }.takeLast(1), selection = TextRange(1).takeIf { v.text.length > 1 } ?: v.selection) + MaxLineLength:ReadyCardio.kt:if + MaxLineLength:ReadyCardio.kt:if (hint != null) Text(hint, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.caption, modifier = Modifier.padding(top = 4.dp)) + MaxLineLength:ReadyCardio.kt:if (maxHr != null) Text("${zones.startBpm(z, maxHr)}", color = ink.copy(alpha = ink.alpha * 0.75f), style = GlowType.caption) + MaxLineLength:ReadyCardio.kt:if (maxHr != null) Text(StartCopy.zoneRangeLine(goal, zones, maxHr), color = Color.White.copy(alpha = 0.7f), style = GlowType.body, textAlign = TextAlign.Center) + MaxLineLength:ReadyCardio.kt:private + MaxLineLength:ReadyCardio.kt:texts[i] + MaxLineLength:ReadyCardio.kt:} + MaxLineLength:ReadyPlans.kt:Modifier.fillMaxWidth().height(10.dp).semantics { contentDescription = "${s.rounds} rounds of ${s.work} seconds work and ${s.rest} seconds rest" } + MaxLineLength:ReadyPlans.kt:PlanChoice + MaxLineLength:ReadyPlans.kt:Workouts.Family.Intervals + MaxLineLength:ReadyPlans.kt:if (i < s.rounds - 1 && s.rest > 0) Box(Modifier.weight(s.rest.toFloat()).height(10.dp).clip(CircleShape).background(MetricColors.sleepDeep)) + MaxLineLength:ReadyPlans.kt:internal + MaxLineLength:ReadyPlans.kt:listOf(NumberField("${rounds.period}", "s a round"), NumberField("${rounds.rounds}", "rounds", length = 2, style = GlowType.goalValueMinor)) + MaxLineLength:ReadyScreen.kt:PageSwitchRow(StartCopy.WARM_UP, StartCopy.WARM_UP_NOTE, rounds.warmUp, { structureCode = rounds.warm(it).code }, divider = false) + MaxLineLength:ReadyScreen.kt:Row(Modifier.weight(1f), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, content = value) + MaxLineLength:ReadyScreen.kt:SportPicker(type, recentSports, contentPadding, onPick = { onSport(it); picking = false }, onBack = { if (pickSport) onBack() else picking = false }) + MaxLineLength:ReadyScreen.kt:Text(StartCopy.strapLine(connection, liveHeartRate), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:ReadyScreen.kt:Text(text, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.caption, modifier = Modifier.padding(start = 4.dp)) + MaxLineLength:ReadyScreen.kt:pinned = if (typing) null else ({ WorkoutButton(StartCopy.startLabel(type), Color.White, Color.Black, start, icon = AppIcons.PlayFill, height = 64.dp) }) + MaxLineLength:ReadyScreen.kt:type + MaxLineLength:ReadyScreen.kt:{ draft = if (it == null) draft.copy(kind = WorkoutSetup.GoalKind.None) else draft.copy(kind = WorkoutSetup.GoalKind.Time, seconds = it) } + MaxLineLength:Recovery.kt:Recovery$fun + MaxLineLength:Recovery.kt:Recovery$if (rhr != null && strapRhr != null && abs(rhr - strapRhr) > RHR_QA_BPM) add("Resting HR: ours ${rhr.toInt()} bpm, the strap's ${strapRhr.toInt()} bpm") + MaxLineLength:Recovery.kt:Recovery$out += Contributor(Factor.Sleep, tonight.strapScore ?: sleepScore(tonight, sleepNeed), formatMinutes(tonight.asleepMinutes) + " asleep") + MaxLineLength:Recovery.kt:Recovery$return Readiness(null, emptyList(), max(valid.count { it.hrvLn != null }, valid.count { it.restingHr != null }), false, emptyList(), false) + MaxLineLength:Recovery.kt:Recovery$return if (SOFT_MIN_FUSION) extras(soft, alert && soft == ALERT_CAP, false, soft) else extras(score.coerceIn(0, 100), capped, false, soft) + MaxLineLength:Recovery.kt:Recovery$stressRun = if (m.sleepStage == null && m.workout == null && m.stress != null && m.stress >= params.stressFrom) stressRun + 1 else 0 + MaxLineLength:Recovery.kt:Recovery$val seconds = stages.filter { it.third == SleepStage.AWAKE }.sumOf { (max(it.first, onset) until min(it.second, wake)).let { r -> max(0L, r.last - r.first + 1) } } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$Recovery.maxHeartRate(profile.age(), strapMax, Recovery.observedPeak(dailyMaxima(hrAll, from, to, zone).values.toList()), profile.maxHeartRate) + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$d to (if (back == 0) readiness.score else Recovery.readiness(nights.lastOrNull { it.day == m }, nights, loadsBefore(loadDays, m), causes, sleepNeed).score) + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$fun + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$if (range.isEmpty()) null else range to Recovery.workoutMinutes(range, dao.workoutHeartRateOnce(w.start).map { it.timestamp to it.bpm }) + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$loadTrend + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$phoneWorkoutKcal(w, profile, age, restingHr, maxHr, vo2max, now)?.forEach { if (it.t in day.from..day.to) phone[it.t] = it.kcal } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$private suspend + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$suspend + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val calm = Recovery.calmStress(dao.stressOnce(calmFrom, to).map { it.timestamp to it.value }, hr.toMap(), calmSteps, restingHr, maxHr, stressExcluded, asleep) + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val dailyLoad = { series: List<Pair<Long, Int>> -> Recovery.dailyLoad(series, restingHr, maxHr, female, workoutRanges, asleep) { stepsByMinute[it] } } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val gridStart = date.minusDays(SleepRegularity.SRI_DAYS.toLong() + 1).atTime(SleepRegularity.DAY_START_HOUR, 0).atZone(zone).toEpochSecond() + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val midpoints = mains.filterKeys { it > midnight - 7 * 86_400 + 3600 && it <= midnight }.values.map { minutesAfterNoon((it.start + it.end) / 2).toFloat() } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val minutes = Recovery.minutes(simFrom, simTo, simHr, steps, stress, stages, workouts = workoutRanges, offWrist = offWrist, wear = wear) + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val noon = date.minusDays(SleepRegularity.SRI_DAYS.toLong() + 1 - k).atTime(SleepRegularity.DAY_START_HOUR, 0).atZone(zone).toEpochSecond() + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val period = stagesTonight.takeIf { it.isNotEmpty() }?.let { ((it.maxOf { st -> st.end } - it.minOf { st -> st.start }) / 60).toInt() } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val rmssd = hrv.filter { inSleep(it.timestamp) && it.milliseconds in Recovery.HRV_MIN_MS..Recovery.HRV_MAX_MS }.map { it.milliseconds.toFloat() } + MaxLineLength:RecoveryEngine.kt:RecoveryEngine$val times = (minuteSteps.keys.filter { it in day.from..day.to } + minuteHr.keys.filter { it in day.from..day.to } + sleep + phone.keys).toSortedSet() + MaxLineLength:RecoveryEngine.kt:RecoveryEngine.Companion$fun + MaxLineLength:RecoveryEngine.kt:RecoveryEngine.Companion$fun afterNoon + MaxLineLength:RecoveryEngine.kt:RecoveryEngine.Companion$val end = recent.map { (afterNoon(it.start) + (it.end - it.start).coerceIn(0L, 86_399L)).toFloat() }.median()?.toLong() ?: (18 * 3600L) + MaxLineLength:RecoveryWidget.kt:else -> LargeContent(model, RecoveryLayout.large(model, frame.contentWidth, frame.contentHeight, frame.fontScale, frame.status), frame) + MaxLineLength:RecoveryWidget.kt:frame.bucket == WidgetSizes.Small -> SmallContent(model, RecoveryLayout.small(model, frame.contentWidth, frame.contentHeight, frame.fontScale, frame.status), frame) + MaxLineLength:RecoveryWidget.kt:frame.bucket == WidgetSizes.Wide -> WideContent(model, RecoveryLayout.wide(model, frame.contentWidth, frame.contentHeight, frame.fontScale, frame.status), frame) + MaxLineLength:RecoveryWidget.kt:val bitmap = renderToBitmap(width * density, WeekBarsLook.HEIGHT_DP * density) { drawWeekBars(it, bars, week.indexOfFirst { d -> d.shown }, Ink) } + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$WidgetHero.ladder(WidgetSizes.Small, height).map { at(it, true) }.firstOrNull { it.spread.fits(height, MIN_GAP_DP) }?.let { return it } + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$WidgetHero.ladder(WidgetSizes.Wide, height).map { at(it, true) }.firstOrNull { it.spread.fits(height, MIN_GAP_DP) }?.let { return it } + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$fun + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$org.openstrap.app.ui.glow.draw.WeekBarsLook.column(days, content - dots - WORD_GAP_DP - textWidth(word, WORD_SP, fontScale) - WEEK_GAP_DP) + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$return Fit(top, d, m.word != null, Spread.of(top.height, row, if (figures) figuresHeight(fontScale) else null), figures = figures) + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$return Fit(top, d, w, Spread.of(top.height, WidgetDots.height(d), if (w) WidgetText.line(wordSp, fontScale) else null), wordSp = wordSp ?: WORD_SP) + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$rowWidth(m, d, fontScale) + WEEK_GAP_DP + org.openstrap.app.ui.glow.draw.WeekBarsLook.width(m.week.size, column) <= width + 0.01f + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$val lines = if (sentence) m.sentence?.let { WidgetText.wrappedLines(it, sp, width, fontScale) }?.takeIf { it <= SENTENCE_LINES } ?: return null else 0 + MaxLineLength:RecoveryWidgetModel.kt:RecoveryLayout$val spread = Spread.of(top.height, lines.takeIf { it > 0 }?.let { it * WidgetText.line(sp, fontScale) }, row, if (figures) figuresHeight(fontScale) else null) + MaxLineLength:Rewards.kt:Rewards$fun + MaxLineLength:Rewards.kt:Rewards$if (structure is Structure.Timed && structure.work == Structure.TABATA.work && structure.rest == Structure.TABATA.rest) earn("first_tabata", e) + MaxLineLength:Rewards.kt:Rewards$ordered.flatMap { w -> w.sets.filter { it.end != null && it.weightKg != null && (it.reps ?: 0) > 0 && !it.exercise.isNullOrBlank() }.map { w to it } } + MaxLineLength:Rewards.kt:Rewards$private + MaxLineLength:Rewards.kt:Rewards$runs.maxByOrNull { it.gpsMeters!! }?.let { out += PersonalBest(Kind.LONGEST_RUN, it.gpsMeters!!.toDouble(), it.shownStart, it.start) } + MaxLineLength:Rewards.kt:Rewards.Stretch.Companion$Stretch(m.toIntOrNull() ?: return@mapNotNull null, s.toLongOrNull() ?: return@mapNotNull null, t.toLongOrNull() ?: return@mapNotNull null) + MaxLineLength:RewardsCopy.kt:RewardsCopy$"${week.count} of ${week.goal} workouts" + if (week.met) ", goal met." + (if (inARow > 1) " $inARow weeks in a row." else "") else "." + MaxLineLength:RewardsCopy.kt:RewardsCopy$* + MaxLineLength:RewardsCopy.kt:RewardsCopy$EarnedLine(EarnedLine.Kind.Best, bestTitle(n.best.kind), bestValue(n.best.kind, n.best.value), "New best, ${beatBy(n.best, n.before)}") + MaxLineLength:RewardsCopy.kt:RewardsCopy$return "${beatBy(n.best, n.before).replaceFirstChar { it.uppercase() }} than your best from ${d.format(DateTimeFormatter.ofPattern(pattern, locale))}." + MaxLineLength:RewardsCopy.kt:RewardsCopy$return EarnedLine(EarnedLine.Kind.Goal, if (week.metByThis) "Weekly goal met" else "Weekly goal", "${week.count} of ${week.goal}", note, met = week.count >= week.goal) + MaxLineLength:RewardsCopy.kt:suspend + MaxLineLength:RewardsCopy.kt:val progress = Rewards.BADGES.mapNotNull { b -> Rewards.progress(b, f.workouts, f.days, weeks, thisWeek)?.let { b.id to it } }.toMap() + MaxLineLength:RewardsKeeper.kt:RewardsKeeper$if (!settings.rewardsStarted.first() && dao.appWorkoutsAll().isEmpty() && dao.workouts().first().isEmpty()) settings.startRewards(historyBadges = false) + MaxLineLength:RewardsKeeper.kt:RewardsKeeper$stretches = Rewards.Stretch.parse(p?.stretches) + MaxLineLength:ScoreDetail.kt:DetailSentence(DetailCopy.charge(now, atWake, TabCopy.drainedAmount(c.drainedExercise + c.drainedStress + c.drainedLiving).toInt(), c.anchored, isToday, LocalTime.now().hour)) + MaxLineLength:ScoreDetail.kt:Score.HeartPoints + MaxLineLength:ScoreDetail.kt:m.title + MaxLineLength:ScoreDetail.kt:onOpen: (Detail) -> Unit + MaxLineLength:ScoreDetail.kt:range == Range.Week && known.isNotEmpty() -> DetailHeadline(Math.round(known.average()).toString(), "Average of the last 7 days", null) + MaxLineLength:ScoreDetail.kt:ready.phase == Recovery.Phase.Withheld -> TodayCopy.noScoreLine(TodayCopy.NoScore.Learning(ready.baselineNights, Recovery.SCORED_NIGHTS)) + MaxLineLength:ScoreDetail.kt:score + MaxLineLength:ScoreDetail.kt:score != null -> DetailHeadline(score.toString(), if (isToday) "this morning" else "that morning", TodayCopy.recoveryWord(score, alert)) + MaxLineLength:ScoreDetail.kt:scrub = i?.let { values[it] }?.let { v -> DetailHeadline(chargeShown(v).toString(), "at ${clockAt(day.first + i * step)}", Narrative.chargeHeadline(chargeShown(v))) } + MaxLineLength:ScoreDetail.kt:scrub = i?.let { week[it] }?.let { (d, v) -> v?.let { DetailHeadline(it.toString(), d.format(DateTimeFormatter.ofPattern("EEE d MMM")), TodayCopy.recoveryWord(it, false)) } } + MaxLineLength:ScoreDetail.kt:val stats by remember(metric, from, to) { if (from < to) history(metric, from.plusDays(1), to) else flowOf(emptyList()) }.collectAsState(emptyList()) + MaxLineLength:ScoreDetail.kt:values + MaxLineLength:SettingsStore.kt:SettingsStore$val + MaxLineLength:SettingsStore.kt:SettingsStore$val keep = setOf(birthYear, sex, weight, height, imperial, sleepNeed, maxHr, activityLevel, activityAt, bodyAt, betaBlocker, pregnant, stepGoal, sleepGoal, activeGoal, calorieGoal, todayHidden, todayPinned, hcAuto, hcSynced, hcStopped, buzzCalls, buzzNotifications, buzzPattern, callBuzz, quietOn, quietFrom, quietTo, quietCalls, backupFolder, backupFolderName, backupDone, backupFailed, backupFailedAt) + MaxLineLength:SettingsStore.kt:SettingsStore$val todayPinnedNames: Flow<List<String>?> = context.dataStore.data.map { prefs -> prefs[todayPinned]?.split(',')?.filter { it.isNotEmpty() } } + MaxLineLength:ShareCards.kt:0f to color.copy(alpha = strength) + MaxLineLength:ShareCards.kt:?: + MaxLineLength:ShareCards.kt:Box(Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 10.dp), contentAlignment = Alignment.Center) { StoryNumber(card.count, DotSize.Live, 310.dp) } + MaxLineLength:ShareCards.kt:Box(Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 10.dp), contentAlignment = Alignment.Center) { StoryNumber(card.time, DotSize.Detail, 310.dp) } + MaxLineLength:ShareCards.kt:Brush.verticalGradient(0f to Color.Transparent, 0.35f to Color.Black.copy(alpha = 0.55f), 1f to Color.Black.copy(alpha = 0.85f)) + MaxLineLength:ShareCards.kt:Canvas(Modifier.size(14.dp)) { drawCircle(Color.White.copy(alpha = 0.8f), radius = size.minDimension * 0.34f, style = Stroke(2.4.dp.toPx())) } + MaxLineLength:ShareCards.kt:Modifier.fillMaxWidth().fillMaxHeight(0.3f).background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent))) + MaxLineLength:ShareCards.kt:StoryCard.Calm$class + MaxLineLength:ShareCards.kt:StoryCard.Indoor$class + MaxLineLength:ShareCards.kt:StoryCard.Lift$class + MaxLineLength:ShareCards.kt:StoryCard.Rounds$class + MaxLineLength:ShareCards.kt:StoryCard.Week$class + MaxLineLength:ShareCards.kt:StoryRouteShape$return points.indices.map { i -> Offset((left + (xs[i] - xs.min()) * scale).toFloat(), (top + (ys[i] - ys.min()) * scale).toFloat()) } + MaxLineLength:ShareCards.kt:Text("My week", color = Color.White, style = GlowType.cardTitle, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:ShareCards.kt:Text("Workout time", color = Color.White.copy(alpha = StoryDim), style = GlowType.secondary, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:ShareCards.kt:Text(card.footer, color = Color.White.copy(alpha = StoryDim), style = GlowType.secondary, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:ShareCards.kt:Text(card.goalLine, color = Color.White.copy(alpha = StoryDim), style = GlowType.secondary, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:ShareCards.kt:Text(card.sport, color = Color.White, style = GlowType.cardTitle, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + MaxLineLength:ShareCards.kt:Text(card.whenLine, color = Color.White.copy(alpha = 0.55f), style = GlowType.secondary, maxLines = 1, modifier = Modifier.padding(top = 2.dp)) + MaxLineLength:ShareCards.kt:Text(name, color = Color.White, style = GlowType.body, maxLines = 1, modifier = Modifier.weight(1f).padding(end = 12.dp)) + MaxLineLength:ShareCards.kt:WeekFigure$internal + MaxLineLength:ShareCards.kt:f.unit?.let { Text(" $it", color = Color.White, style = GlowType.body, maxLines = 1, modifier = Modifier.padding(bottom = 3.dp)) } + MaxLineLength:ShareCards.kt:if + MaxLineLength:ShareCards.kt:internal + MaxLineLength:ShareCards.kt:s.unit?.let { Text(" $it", color = Color.White, style = GlowType.body, maxLines = 1, modifier = Modifier.padding(bottom = 3.dp)) } + MaxLineLength:ShareCards.kt:val (icon, tint) = if (chip.best) AppIcons.FlagCheckered to MetricColors.calories else AppIcons.Target to if (chip.met) MetricColors.good else Color.White + MaxLineLength:ShareScreen.kt:Row + MaxLineLength:ShareScreen.kt:StoryCardView(card, photo, shown, Modifier.drawWithContent { layer.record { this@drawWithContent.drawContent() }; recorded.complete(Unit) }) + MaxLineLength:ShareScreen.kt:StoryFiles$/** Writes [bitmap] as a PNG named [name] to Pictures/OpenStrap; true once it's there. Below Android 10 it needs the storage permission. */ + MaxLineLength:ShareScreen.kt:Text("Route", color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1, modifier = Modifier.width(84.dp)) + MaxLineLength:ShareScreen.kt:WorkoutButton("Share", Color.White, Color.Black, { if (export == null) export = Export.Share }, Modifier.weight(1f), icon = AppIcons.UploadSimple) + MaxLineLength:ShareScreen.kt:WorkoutButton(saveLabel, Raised, Color.White, { if (export == null) save() }, Modifier.weight(1f), icon = if (saved == true) AppIcons.Tick02 else AppIcons.DownloadSimple) + MaxLineLength:ShareScreen.kt:modifier.height(44.dp).clip(CircleShape).background(Raised).pressClickable(onClick, CircleShape).padding(start = 16.dp, end = 20.dp) + MaxLineLength:ShareScreen.kt:storyCard(FamilySummary.familyOf(a.workout.sportType, summary.structure), summary, hero, earned, usualLoad, System.currentTimeMillis() / 1000) + MaxLineLength:ShareScreen.kt:style = if (on) GlowType.secondary.copy(fontWeight = FontWeight.SemiBold) else GlowType.secondary + MaxLineLength:ShareScreen.kt:val + MaxLineLength:SleepCopy.kt:SleepCopy$const val DELETED_INTRO = "Sleep and naps you deleted in the last 7 days. Restoring puts it back in your totals, scores and Health Connect." + MaxLineLength:SleepCopy.kt:SleepCopy$if (count == 1) "One nap, ${TodayCopy.duration(totalSeconds)}." else "${DetailCopy.count(count, "nap").replaceFirstChar { it.uppercase() }}, ${TodayCopy.duration(totalSeconds)} in all." + MaxLineLength:SleepCopy.kt:SleepCopy$return History.normal(previous(state).mapNotNull { n -> p(n)?.let { DayStat(n.date, it, it, it, 1) } }, state.date.minusDays(1), SLEEP_NIGHTS - 1) + MaxLineLength:SleepCopy.kt:SleepCopy$sd != null -> "The middle of your sleep moved about ${TodayCopy.duration(sd.roundToInt() * 60L)} this week. The full measure needs more wear." + MaxLineLength:SleepDetail.kt:) + MaxLineLength:SleepDetail.kt:Caption(SleepCopy.napHeart(hr.minOf { it.y }.toInt(), hr.map { it.y }.average().toInt()), Modifier.padding(top = 6.dp, bottom = 8.dp)) + MaxLineLength:SleepDetail.kt:ChartTitle("Heart rate", "Lowest ${hr.minOf { it.y }.toInt()}, average ${hr.map { it.y }.average().toInt()} bpm", scrubbed = hrAt) + MaxLineLength:SleepDetail.kt:ContributorRow(f.name, color, f.word, f.fraction, offNormal = f.off, divider = i > 0, detail = f.detail, onClick = { info = true }) + MaxLineLength:SleepDetail.kt:DetailCopy.span(Range.Week, DetailCopy.levels(values, normal), nightly = true, normalKnown = normal != null, days = before.size) + MaxLineLength:SleepDetail.kt:Modifier.align(Alignment.BottomCenter).padding(bottom = contentPadding.calculateBottomPadding() + 16.dp, start = 16.dp, end = 16.dp) + MaxLineLength:SleepDetail.kt:Score.Naps.title + MaxLineLength:SleepDetail.kt:Score.Sleep.title + MaxLineLength:SleepDetail.kt:ScrubLine(hr, window, 100.dp, listOf(clock(nap.start), clock(nap.end)), MetricColors.heart, "bpm") { scrub = it } + MaxLineLength:SleepDetail.kt:Text(SLEEP_NAMES[st].orEmpty(), color = Color.White.copy(alpha = 0.85f), style = GlowType.secondary, modifier = Modifier.width(56.dp)) + MaxLineLength:SleepDetail.kt:UndoHost + MaxLineLength:SleepDetail.kt:band = normal?.let { it.low..it.high } + MaxLineLength:SleepDetail.kt:if + MaxLineLength:SleepDetail.kt:item { DeletedNight(SleepCopy.deletedNight(clock(deleted.start), clock(deleted.end), isToday)) { editing.restore(deleted.strap) } } + MaxLineLength:SleepDetail.kt:listOf(clock(session.start) to SleepCopy.FELL_ASLEEP, clock(session.end) to SleepCopy.WOKE, clockDuration(asleep) to SleepCopy.ASLEEP) + MaxLineLength:SleepDetail.kt:notes = listOfNotNull(SleepCopy.SCORE_FROM_YOUR_TIMES.takeIf { ownScore }) + InfoCopy.sleepNotes(r?.sleepNeed ?: Recovery.SLEEP_NEED_MINUTES) + MaxLineLength:SleepDetail.kt:onAdjust = { adjusting = nap.start } + MaxLineLength:SleepDetail.kt:onOpen: (Detail) -> Unit + MaxLineLength:SleepDetail.kt:onScrub = { i -> onScrub(i?.let { values[it] }?.let { v -> DetailHeadline("%.${decimals}f".format(v), "$unit at ${clock(window.first + i * step)}", null) }) } + MaxLineLength:SleepDetail.kt:private + MaxLineLength:SleepDetail.kt:private fun minutesOf: Int + MaxLineLength:SleepDetail.kt:range == Range.Week && known.isNotEmpty() -> DetailHeadline(Math.round(known.average()).toString(), "Average of the last 7 nights", null) + MaxLineLength:SleepDetail.kt:scrub = i?.let { week[it] }?.let { (d, v) -> v?.let { DetailHeadline(Math.round(it).toString(), d.format(DAY), TabCopy.sleepScoreWord(Math.round(it))) } } + MaxLineLength:SleepDetail.kt:val before = state.nights.filter { it.date < state.date }.mapNotNull { n -> n.session?.score?.let { DayStat(n.date, it.toFloat(), it.toFloat(), it.toFloat(), 1) } } + MaxLineLength:SleepDetail.kt:val others = state.nights.takeLast(SleepHistory.WEEK_NIGHTS).filter { it.date != state.date }.mapNotNull { it.asleepSeconds } + MaxLineLength:SleepDetail.kt:val week = state.nights.takeLast(SleepHistory.WEEK_NIGHTS).map { n -> n.date to if (n.date == state.date) score?.toFloat() else n.session?.score?.toFloat() } + MaxLineLength:SleepEdits.kt:SleepEdits$fun + MaxLineLength:SleepEdits.kt:SleepEdits$overlap != null -> "That runs into your ${if (overlap.nap) "nap" else "sleep"} at ${CLOCK.format(Instant.ofEpochSecond(overlap.start).atZone(zone))}." + MaxLineLength:SleepEdits.kt:SleepEdits$return s.copy(score = score(strap, s, Part(((end - start) / 60).toInt(), null), Part(((strap.end - strap.start) / 60).toInt(), null))) + MaxLineLength:SleepEdits.kt:SleepEdits$start < strap.start - MAX_SHIFT_SECONDS || end > strap.end + MAX_SHIFT_SECONDS -> "Keep it within 3 hours of what the strap saw." + MaxLineLength:SleepEdits.kt:SleepEdits$val s = strap.copy(start = start, end = end, deepMinutes = null, lightMinutes = null, remMinutes = null, awakeMinutes = null, wakeCount = null) + MaxLineLength:SleepPanels.kt:NightVital(Metric.HeartRate, "Average heart rate", MetricColors.heart, average?.toString(), if (average != null) "While asleep" else missing) + MaxLineLength:SleepPanels.kt:val spans = week.map { n -> n.session?.let { sinceNoon(it.start, false) to sinceNoon(it.end, true).coerceAtLeast(sinceNoon(it.start, false) + 1) } } + MaxLineLength:SleepRegularity.kt:SleepRegularity$fun + MaxLineLength:SleepRegularity.kt:SleepRegularity.Band$* + MaxLineLength:SleepSource.kt:SleepSource$if (strap == null) dao.sleepStages(session.start) else dao.sleepStages(strap).map { SleepEdits.clip(it, session.start, session.end) } + MaxLineLength:SleepSource.kt:SleepSource$private suspend + MaxLineLength:SleepSource.kt:SleepSource$return edits.filter { it.deleted && !SleepEdits.expired(it, now) }.sortedByDescending { it.editedAt }.mapNotNull { e -> strapOf(e, edits) } + MaxLineLength:SleepSource.kt:SleepSource$val e = edits.firstOrNull { !it.deleted && it.nap == session.nap && it.start == session.start && it.end == session.end } ?: return null + MaxLineLength:SleepWidget.kt:SleepWidget.Companion$suspend + MaxLineLength:SleepWidget.kt:SleepWidget.Companion$val deleted = tonight == null && DayWindow.nightEndingOn(today, zone).let { w -> org.openstrap.app.data.SleepSource(dao).nightDeleted(w.from, w.to) } + MaxLineLength:SleepWidget.kt:SleepWidget.Companion$val span = DayWindow(DayWindow.nightEndingOn(today.minusDays(SLEEP_NIGHTS - 1L), zone).from, DayWindow.nightEndingOn(today, zone).to) + MaxLineLength:SleepWidgetModel.kt:SleepLayout$listOfNotNull(note, asleep) to hasBar + MaxLineLength:SleepWidgetModel.kt:SleepWidgetModel.Companion$fun + MaxLineLength:SleepWidgetModel.kt:SleepWidgetModel.Companion$return SleepWidgetModel(null, TITLE, null, false, null, null, null, null, null, null, emptyList(), emptyList(), emptyList(), line) + MaxLineLength:SleepWidgetModel.kt:SleepWidgetModel.Companion$return totals.map { (stage, seconds) -> SleepStageRow(stage, SLEEP_NAMES[stage].orEmpty(), TodayCopy.duration(seconds), seconds.toFloat() / all) } + MaxLineLength:SleepWidgetModel.kt:SleepWidgetNight.Companion$fun + MaxLineLength:StartCopy.kt:StartCopy$"Ends near " + now.plusSeconds(seconds).format(DateTimeFormatter.ofPattern(if (is24Hour) "HH:mm" else "h:mm a", Locale.getDefault())) + MaxLineLength:StartCopy.kt:StartCopy$fun + MaxLineLength:StartCopy.kt:StartCopy$val names = if (goal.low == goal.high) ZONE_NAMES[goal.low].lowercase() else "${ZONE_NAMES[goal.low].lowercase()} to ${ZONE_NAMES[goal.high].lowercase()}" + MaxLineLength:StartWidget.kt:* + MaxLineLength:StartWidget.kt:frame.bucket == WidgetSizes.Small -> Column(GlanceModifier.fillMaxWidth().defaultWeight().then(Inset)) { SmallContent(m, frame) } + MaxLineLength:StartWidget.kt:rec != null && frame.bucket == WidgetSizes.Small -> Column(GlanceModifier.fillMaxWidth().defaultWeight().then(Inset)) { SmallRecording(m, rec, frame) } + MaxLineLength:StartWidget.kt:return renderToBitmap(s, s) { c -> c.drawCircle(s / 2, s / 2, s / 2, Paint(Paint.ANTI_ALIAS_FLAG).apply { this.color = color.toArgb() }) } + MaxLineLength:StartWidgetModel.kt:StartLayout$WidgetSizes.Wide -> wide(m, width, height, fontScale, top.height).let { it.content + (it.gaps - 2) * MIN_GAP_DP <= height + 0.01f } + MaxLineLength:StartWidgetModel.kt:StartLayout$WidgetText.width(label, WidgetText.CAPTION_SP, fontScale) + FIGURE_GAP_DP + WidgetText.width(value, WidgetText.BODY_SP, fontScale) <= width + MaxLineLength:StartWidgetModel.kt:StartLayout$fun + MaxLineLength:StartWidgetModel.kt:StartLayout$if (grown.isNotEmpty() && grown.first().spread.fits(height, MIN_GAP_DP)) return Spread.grow(grown, height, MIN_GAP_DP) { it.spread } + MaxLineLength:StartWidgetModel.kt:StartLayout$if (tile <= room && COLUMNS * tile + (COLUMNS - 1) * CAPTION_SPACE_DP <= width) Tiles(next.take(COLUMNS - 1), tile, 0f, tile) else null + MaxLineLength:StartWidgetModel.kt:StartLayout$return Recording(tile, open, if (r.paused) r.status else name, false, TIMER_MIN_SP, SincePlace.None, null, top + timerLine(TIMER_MIN_SP, fontScale) + words, 3) + MaxLineLength:StartWidgetModel.kt:StartLayout$return Recording(tile, open, name, false, sp, if (since) SincePlace.Under else SincePlace.None, null, base + if (since) sinceH else 0f, 3) + MaxLineLength:StartWidgetModel.kt:StartLayout$val title = listOf(StartWidgetModel.TITLE, SHORT_TITLE).firstOrNull { WidgetText.fitWidth(it, TopRow.TITLE_SIZES.last(), fontScale) + beside <= width } ?: SHORT_TITLE + MaxLineLength:StartWidgetModel.kt:StartWidgetModel.Companion$val + MaxLineLength:StepsWidget.kt:WeekChart(ImageProvider(weekBitmap(m, week.column, density)), "Steps each day this week", m.week.map { it.letter to (it.state == StepsDay.Today) }, week.column) + MaxLineLength:StepsWidgetModel.kt:StepsLayout$height - top.height - r.height(fontScale) - (week?.height ?: 0f) - inner * MIN_GAP_DP >= (if (m.showLine) MIN_LINE_DP else BAR_DP) + MaxLineLength:StepsWidgetModel.kt:StepsLayout$return WidgetHero.ladder(WidgetSizes.Small, height).map { at(it, word, withShare) }.firstOrNull { it.spread.fits(height, gap) } ?: base + MaxLineLength:StepsWidgetModel.kt:StepsLayout$val rows = WidgetHero.ladder(if (large) WidgetSizes.Large else WidgetSizes.Wide, height).map { numberRow(m.stepsText, m.toGo, width, fontScale, it) } + MaxLineLength:StepsWidgetModel.kt:StepsLayout$val title = WidgetText.width(StepsWidgetModel.THIS_WEEK, sp, fontScale) + WEEK_GAP_DP + WidgetText.width(weekLine, sp, fontScale) <= width + MaxLineLength:StrapController.kt:StrapController$suspend + MaxLineLength:StrapController.kt:SyncRecord$is SyncState.Failed -> copy(lastAttempt = if (previous is SyncState.Running) lastAttempt ?: now else now, failingSince = failingSince ?: now) + MaxLineLength:StrapDrawing.kt:0f to white.copy(alpha = 0f) + MaxLineLength:StrapDrawing.kt:for ((x, y) in listOf(43f to 36f, 77f to 36f, 43f to 110f, 77f to 110f)) drawLine(white.copy(alpha = 0.5f), o(x, y), o(x, y + 4f), 1.2f * k, StrokeCap.Round) + MaxLineLength:StrapDrawing.kt:for (x in listOf(44f, 52f, 60f, 68f)) drawLine(white.copy(alpha = 0.09f), o(x, y + 1.5f), o(x + 3f, y + 1.5f), k, StrokeCap.Round) + MaxLineLength:StrapDrawing.kt:for (x in listOf(48f, 56f, 64f, 72f)) drawLine(white.copy(alpha = 0.09f), o(x, y + 4.5f), o(x + 3f, y + 4.5f), k, StrokeCap.Round) + MaxLineLength:StrapManager.kt:StrapManager$(sync.value as? SyncState.Done)?.let { d -> runCatching { settings.setLastSyncDone(d.atEpochSeconds) }.onFailure { log("sync time not kept: $it") } } + MaxLineLength:StrapManager.kt:StrapManager$TimeSync.parseVersion(s.request(Endpoints.TIME, TimeSync.versionRequest, 5_000) { it.isNotEmpty() && it.u8(0) == TimeSync.VERSION + 1 }) + MaxLineLength:StrapManager.kt:StrapManager$androidx.core.content.ContextCompat.registerReceiver(context, timeChanged, filter, androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED) + MaxLineLength:StrapManager.kt:StrapManager$buzzLock.withLock { if (callHold) runCatching { playMotor(CALL_PULSE) }.onFailure { if (callHold) log("call pulse failed: $it") } } + MaxLineLength:StrapManager.kt:StrapManager$else + MaxLineLength:StrapManager.kt:StrapManager$if (!AlarmCodec.isSuccess(request, command(Endpoints.ALARMS, request))) throw StrapCommandException("The strap didn't delete the alarm") + MaxLineLength:StrapManager.kt:StrapManager$if (!AlarmCodec.isSuccess(request, command(Endpoints.ALARMS, request))) throw StrapCommandException("The strap didn't delete the alarms") + MaxLineLength:StrapManager.kt:StrapManager$if (!AlarmCodec.isSuccess(request, command(Endpoints.ALARMS, request))) throw StrapCommandException("The strap didn't save the alarm") + MaxLineLength:StrapManager.kt:StrapManager$if (!MotorVibrate.isOk(command(Endpoints.MOTOR_VIBRATE, MotorVibrate.play(pattern)))) throw StrapCommandException("The strap didn't buzz") + MaxLineLength:StrapManager.kt:StrapManager$if (!SettingsCodec.isWriteSuccess(reply)) throw StrapCommandException("The strap didn't accept the restart (code ${reply.getOrNull(1)})") + MaxLineLength:StrapManager.kt:StrapManager$if (!SettingsCodec.isWriteSuccess(reply)) throw StrapCommandException("The strap didn't accept the setting (code ${reply.getOrNull(1)})") + MaxLineLength:StrapManager.kt:StrapManager$if (!firmwareUpdating.value) runCatching { s.send(Endpoints.REALTIME_ACTIVITY, RealtimeSteps.QUERY) }.onFailure { log("steps query failed: $it") } + MaxLineLength:StrapManager.kt:StrapManager$if (session?.services?.has(Endpoints.SETTINGS) == true && fastRechargeOn() == true) throw DfuException(-1, "Turn off fast-charge mode on the strap first") + MaxLineLength:StrapManager.kt:StrapManager$launch { s.messagesFor(Endpoints.OPEN_APP).collect { p -> if (opens++ == 0 || !p.contentEquals(KEEP_ALIVE_REPLY)) log("live hr 001d < ${p.toHex()}") } } + MaxLineLength:StrapManager.kt:StrapManager$log("device features ${r.toHex()} -> ${s.features?.let { "mask %02x g2 %s alg %s".format(it.mask, it.group2?.toHex(), it.algorithm) } ?: "unparsed"}") + MaxLineLength:StrapManager.kt:StrapManager$log("live hr subscribe: ${s.request(Endpoints.HEART_RATE, LiveHeartRate.SUBSCRIBE, 5_000) { it.u8(0) == 0x04 }.toHex()}") + MaxLineLength:StrapManager.kt:StrapManager$log("steps today=${it.steps} dist=${it.distanceMeters} kcal=${it.calories}" + if (!it.isPlausible) " (total doesn't match distance: counting from synced history)" else "") + MaxLineLength:StrapManager.kt:StrapManager$override val sportSessionPushes = MutableSharedFlow<SportSession.Push>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) + MaxLineLength:StrapManager.kt:StrapManager$return SettingsCodec.parse(reply, protocol?.version) ?: throw StrapCommandException("The strap couldn't read its settings (code ${SettingsCodec.replyCode(reply)})") + MaxLineLength:StrapManager.kt:StrapManager$runCatching { WorkoutGapFill(dao) { settings.profile.first() }.run()?.let { log(it) } }.onFailure { log("workout gap fill: $it") } + MaxLineLength:StrapManager.kt:StrapManager$scope.launch { HistorySync(s, client, dao, settings, sync, ::log).dumpRaw(System.currentTimeMillis() / 1000 - days * 86_400L, java.io.File(context.filesDir, "raw"), types) } + MaxLineLength:StrapManager.kt:StrapManager$settingsQuery(SettingsCodec.GROUP_HEALTH, listOf(SettingsCodec.KEY_CALORIE_TARGET), withOptions = false) + MaxLineLength:StrapManager.kt:StrapManager$val + MaxLineLength:StrapManager.kt:StrapManager$val ack = command(Endpoints.SETTINGS, SettingsCodec.write(SettingsCodec.GROUP_GENERAL, group.version, listOf(SettingsCodec.boolSetting(key, true)))) + MaxLineLength:StrapManager.kt:StrapManager$val acks = link?.let { l -> launch { l.controlNotifications.collect { log("probe %04x ack < %s".format(endpoint, it.toHex())) } } } + MaxLineLength:StrapManager.kt:StrapManager$val keys = SettingsCodec.parse(r, settingsProtocol?.version)?.settings?.values?.joinToString(" ") { "${it.key}=${it.text ?: it.number ?: it.raw.toHex()}" } + MaxLineLength:StrapManager.kt:StrapManager$val r = s.request(DeviceFeatures.ENDPOINT, DeviceFeatures.request, 5_000) { it.isNotEmpty() && it.u8(0) == DeviceFeatures.REPLY } + MaxLineLength:StrapManager.kt:StrapManager$val r = s.request(Endpoints.SETTINGS, SettingsCodec.query(group, listOf(SettingsCodec.KEY_RESTART), withOptions = false), 5_000) { SettingsCodec.isReplyTo(group, it) } + MaxLineLength:StrapManager.kt:StrapManager$val r = s.request(Endpoints.SETTINGS, SettingsCodec.query(group, withOptions = false), 5_000) { SettingsCodec.isReplyTo(group, it) } + MaxLineLength:StrapManager.kt:StrapManager$val r = s.request(Endpoints.SETTINGS, SettingsCodec.versionRequest, 5_000) { it.isNotEmpty() && it.u8(0) == SettingsCodec.VERSION + 1 } + MaxLineLength:StrapManager.kt:StrapManager$val reply = settingsQuery(SettingsCodec.GROUP_DEVICE_INFORMATION, listOf(SettingsCodec.KEY_WEAR_STATUS), withOptions = false) + MaxLineLength:StrapManager.kt:StrapManager$val request = if (isNew) AlarmCodec.add(alarm, caps.titles, caps.workdaysSupported) else AlarmCodec.modify(alarm, caps.titles, caps.workdaysSupported) + MaxLineLength:StrapManager.kt:StrapManager$val written = org.openstrap.app.data.DailyEnergyHistory(dao).update(profile, profileChanged = settings.lastSync(DAILY_ENERGY_PROFILE) != key) + MaxLineLength:StrapService.kt:StrapService$* + MaxLineLength:StrapService.kt:StrapService$. + MaxLineLength:StrapService.kt:StrapService$PendingIntent.getService(this, action.hashCode(), Intent(this, StrapService::class.java).setAction(action), PendingIntent.FLAG_IMMUTABLE) + MaxLineLength:StrapService.kt:StrapService$Status + MaxLineLength:StrapService.kt:StrapService$else -> listOfNotNull(if (connected.charging) "Strap charging" else "Strap connected", battery?.let { "battery $it%" }).joinToString(", ") + MaxLineLength:StrapService.kt:StrapService$n = workoutNotification(w, hr.takeIf { c is ConnectionState.Connected }, c is ConnectionState.Connected, confirming, workout) + MaxLineLength:StrapService.kt:StrapService$private + MaxLineLength:StrapService.kt:StrapService$return Figures(r.readiness.score, r.charge.latest?.let(::chargeShown), sport, (container.strap.sync.value as? SyncState.Done)?.atEpochSeconds ?: figures.value?.syncedAt) + MaxLineLength:StrapService.kt:StrapService$setContentText(NotificationCopy.END_QUESTION).apply { WorkoutNotification.actions(paused = false, confirming = true, ::service).forEach(::addAction) } + MaxLineLength:StrapService.kt:StrapService$style.setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(target.kcal.coerceAtLeast(1)).setColor(color))) + MaxLineLength:StrapService.kt:StrapService$style.setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(target.seconds.toInt().coerceAtLeast(1)).setColor(color))) + MaxLineLength:StrapService.kt:StrapService$val + MaxLineLength:StrapService.kt:StrapService$val auto = w.endReason == org.openstrap.app.data.db.AppWorkout.END_AUTO || w.endReason == org.openstrap.app.data.db.AppWorkout.END_CAR + MaxLineLength:StrapService.kt:StrapService$val time = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + MaxLineLength:StrapService.kt:StrapService$val time = java.time.Instant.ofEpochSecond(end).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + MaxLineLength:StrapService.kt:StrapService$val time = java.time.Instant.ofEpochSecond(o.start).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + MaxLineLength:StrapService.kt:StrapService$val withGps = if (gps && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) base or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else base + MaxLineLength:StrapService.kt:StrapService$w.distanceMeters?.takeIf { it >= 10 }?.let { (if (w.distanceFromSteps) "≈ " else "") + org.openstrap.app.ui.glow.km(it) + if (it >= 1000) " km" else "" } + MaxLineLength:StrapService.kt:StrapService.Companion$if (zone == 0) "under ${60 * maxHr / 100} bpm" else "${(50 + zone * 10) * maxHr / 100}–${if (zone == 4) maxHr else (60 + zone * 10) * maxHr / 100 - 1} bpm" + MaxLineLength:StrapStatus.kt:fun + MaxLineLength:Summaries.kt:Summaries$bucketMeans(items, ((range.last - range.first + 1) / seconds).toInt().coerceAtLeast(2), { ((time(it) - range.first) / seconds).toInt() }, value) + MaxLineLength:SummaryCopy.kt:SummaryCopy$SplitRow(if (whole) "Km ${i + 1}" else "Last ${km(last)}", workoutClock(sp.seconds), WorkoutCopy.averageOver(readings, from, sp.endT)) + MaxLineLength:SummarySections.kt:"${p.hardEfforts} hard ${if (p.hardEfforts == 1) "effort" else "efforts"}" + MaxLineLength:SummarySections.kt:) + MaxLineLength:SummarySections.kt:. + MaxLineLength:SummarySections.kt:Box(Modifier.fillMaxWidth(share).fillMaxHeight().clip(CircleShape).background(if (share >= 1f) color else color.copy(alpha = 0.6f))) + MaxLineLength:SummarySections.kt:Column + MaxLineLength:SummarySections.kt:Modifier.weight(1f).height(RoundColumnHeight * share).clip(shape).background(if (i == best) color else color.copy(alpha = 0.45f)) + MaxLineLength:SummarySections.kt:Row + MaxLineLength:SummarySections.kt:SummaryHero$internal + MaxLineLength:SummarySections.kt:Text("$it", color = Color.White, style = GlowType.rowValue, maxLines = 1, modifier = Modifier.padding(start = 12.dp)) + MaxLineLength:SummarySections.kt:Text("Earned", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(start = 4.dp)) + MaxLineLength:SummarySections.kt:Text("Zone ${z + 1}, ${ZONE_NAMES[z].lowercase()}", color = Color.White, style = style, maxLines = 1, modifier = Modifier.weight(1f).padding(start = 10.dp)) + MaxLineLength:SummarySections.kt:Text(p.name, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:SummarySections.kt:WeekDaysPanel(weekStarts, note = week?.let { FamilySummary.weekGoalLine(it.count, it.goal) }, day = day, color = MetricColors.evening) + MaxLineLength:SummarySections.kt:days: List<Pair<String, Boolean>> + MaxLineLength:SummarySections.kt:fun at + MaxLineLength:SummarySections.kt:if (peak) drawLine(Color.White.copy(alpha = 0.12f), Offset(0f, size.height - 0.5f), Offset(size.width, size.height - 0.5f), 1.dp.toPx()) + MaxLineLength:SummarySections.kt:if (share != null) Box(Modifier.fillMaxWidth(share).fillMaxHeight().clip(CircleShape).background(zoneColor(s.peak, maxHr))) + MaxLineLength:SummarySections.kt:letter + MaxLineLength:SummarySections.kt:lifted != null -> SummaryHero("%,d".format(Math.round(lifted)), "kg lifted", listOf(time to "Time", "$sets" to "Sets", rest to "Avg rest")) + MaxLineLength:SummarySections.kt:line = listOfNotNull(a.workout.averageHeartRate?.let { "Average $it bpm" }, a.activeKcal?.let { "${Math.round(it)} active kcal" }).joinToString(", ").ifEmpty { null } + MaxLineLength:SummarySections.kt:listOf(load?.toString() to (load?.let { FamilySummary.loadLabel(it, usualLoad) } ?: "Load"), kcal to "Active kcal", peak to "Peak bpm") + MaxLineLength:SummarySections.kt:private + MaxLineLength:SummarySections.kt:s.peak?.let { "peak $it" }.orEmpty() + MaxLineLength:SummarySections.kt:sets > 0 -> SummaryHero("$sets", if (sets == 1) "set" else "sets", listOf(time to "Time", rest to "Avg rest", peak to "Peak")) + MaxLineLength:SummarySections.kt:val text = note ?: days.count { it.second }.let { n -> "$n ${if (n == 1) "day" else "days"} with a workout ${if (thisWeek) "this" else "that"} week." } + MaxLineLength:SummarySections.kt:val zones = remember(a) { WorkoutRecorder.liveZoneSeconds(a.heartRate.filter { it.timestamp <= a.end }.map { it.timestamp to it.bpm }, a.maxHeartRate) } + MaxLineLength:SummarySections.kt:workoutClock(t) + MaxLineLength:SyncNowAction.kt:SyncNowAction$context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED -> strap.backgroundConnect() + MaxLineLength:SyncStates.kt:StrapPreview.Charging -> copy(paired = true, connection = connected.copy(charging = true), sync = SyncState.Idle, record = SyncRecord()) + MaxLineLength:SyncStates.kt:StrapPreview.Disconnected -> copy(paired = true, connection = ConnectionState.Disconnected, sync = SyncState.Idle, record = SyncRecord(), lastSeen = now - 18 * 60) + MaxLineLength:SyncStates.kt:StrapPreview.NotPaired -> copy(paired = false, connection = ConnectionState.NotPaired, sync = SyncState.Idle, record = SyncRecord(), lastSeen = null) + MaxLineLength:SyncStates.kt:record = SyncRecord(lastSuccess = now - STUCK_AFTER_S - 3600, lastAttempt = now - 10 * 60, failingSince = now - STUCK_AFTER_S - 60) + MaxLineLength:TabCopy.kt:TabCopy$fun + MaxLineLength:TabParts.kt:Text(name, color = Color.White, style = GlowType.body, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis) + MaxLineLength:TabParts.kt:Text(word, color = if (offNormal) color else Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + MaxLineLength:TabParts.kt:internal + MaxLineLength:TabPlans.kt:(if (pair) small(SleepCard.Breathing, Hue.Sleep, Edge.Bottom, Pane.Left) else TabSlot(SleepCard.Breathing, edge(Hue.Sleep, Edge.Bottom), Pane.Right)) + MaxLineLength:TabPlans.kt:(if (pair) small(SleepCard.Naps, Hue.Sleep, Edge.Left, Pane.Left) else TabSlot(SleepCard.Naps, edge(Hue.Sleep, Edge.Left), Pane.Left)) + MaxLineLength:TabPlans.kt:lookClashes(slots.map { it.look }, tabPanes(slots, smallsAcross && wide), wide, fullTouchesColumns = true, sizes = slots.map { it.size }) + MaxLineLength:Theme.kt:style.copy(fontFamily = FunnelDisplay, fontWeight = weight, fontSize = size.sp, letterSpacing = tracking.sp, fontFeatureSettings = "tnum") + MaxLineLength:TodayCards.kt:/** The workout just done, after it: how long it was, the average heart rate and the zone it mostly sat in. Lit from the left, about earlier. */ + MaxLineLength:TodayCards.kt:BodyRow$val name: String + MaxLineLength:TodayCards.kt:Column(Modifier.weight(1f)) { EdgeValue(now?.toString(), null, status, Color.White.copy(alpha = 0.8f), dot = DotSize.Second) } + MaxLineLength:TodayCards.kt:ListRow("Load today", MetricColors.load, load?.roundToInt()?.toString(), loadLine, divider = false, onClick = onLoad, icon = Score.Load.rowIcon()) + MaxLineLength:TodayCards.kt:Text("$name, your usual", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + MaxLineLength:TodayCards.kt:color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA) + MaxLineLength:TodayCards.kt:contentPadding = StripPadding + MaxLineLength:TodayCards.kt:else + MaxLineLength:TodayCards.kt:if + MaxLineLength:TodayCards.kt:if (feeling != null) Text(CheckIn.label(feeling), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:TodayCards.kt:if (status != null) Text(status, color = statusColor, style = GlowType.secondary, modifier = Modifier.padding(top = 6.dp), maxLines = 2, overflow = TextOverflow.Ellipsis) + MaxLineLength:TodayCards.kt:if (unit != null && value != null) Text(unit, color = Color.White.copy(alpha = 0.7f), style = GlowType.secondary, modifier = Modifier.padding(start = 6.dp)) + MaxLineLength:TodayCards.kt:if (unit != null && value != null) Text(unit, color = Color.White.copy(alpha = 0.7f), style = GlowType.secondary, modifier = Modifier.padding(start = 8.dp)) + MaxLineLength:TodayCards.kt:internal + MaxLineLength:TodayCards.kt:kind: EdgeKind = EdgeKind.Value + MaxLineLength:TodayCards.kt:line + MaxLineLength:TodayCards.kt:title: String + MaxLineLength:TodayCards.kt:val line = remember(state.hourlySteps, isToday, hour) { org.openstrap.app.data.Summaries.runningSteps(state.hourlySteps, if (isToday) hour else null) } + MaxLineLength:TodayCopy.kt:TodayCopy$estimate != null -> RecoveryShown(estimate, "Strap's estimate", noScore?.let(::noScoreLine) ?: "OpenStrap's own score isn't ready yet.", estimate = true) + MaxLineLength:TodayCopy.kt:TodayCopy$fun + MaxLineLength:TodayCopy.kt:TodayCopy$workouts: List<String?> + MaxLineLength:TodayLooks.kt:fun + MaxLineLength:TodayLooks.kt:private fun opposite + MaxLineLength:TodayLooks.kt:val + MaxLineLength:TodayLooks.kt:val pick = options(out[at]).firstOrNull { o -> settled(out.toMutableList().also { it[at] = o }, i, wide, across) } ?: return false + MaxLineLength:TodayLooks.kt:val places = (top until end).filter { it != from && it in starts }.sortedWith(compareBy({ kotlin.math.abs(it - from) }, { it < from })) + MaxLineLength:UpdateCopy.kt:UpdateCopy$fun + MaxLineLength:UpdateCopy.kt:UpdateCopy$return if (install.total > 0) "Downloading, ${install.done * 100 / install.total}%" else "Downloading, ${megabytes(install.done)}" + MaxLineLength:UpdateNotifier.kt:UpdateNotifier$fun available + MaxLineLength:UpdatesPage.kt:PageRow + MaxLineLength:UpdatesPage.kt:Text(release.versionName, color = Color.White, style = GlowType.body.copy(fontWeight = FontWeight.Medium), modifier = Modifier.weight(1f)) + MaxLineLength:UpdatesPage.kt:Text(spans(block.spans), color = Color.White.copy(alpha = 0.75f), style = GlowType.secondary, modifier = Modifier.padding(top = 1.dp)) + MaxLineLength:UpdatesPage.kt:if (blocks.isEmpty()) Text("No notes for this release.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MaxLineLength:UpdatesPage.kt:spans(block.spans) + MaxLineLength:UsualSteps.kt:UsualSteps$/** Within this share of your usual counts as about usual: the band Today uses for Load ([org.openstrap.app.ui.dashboard.TodayCopy.LOAD_BAND]). */ + MaxLineLength:WidgetMoment.kt:val + MaxLineLength:WidgetMoment.kt:val sleepEnd = org.openstrap.app.data.SleepSource(dao).sessionsOnce(night.from, night.to).filter { !it.nap }.maxByOrNull { it.end - it.start }?.end + MaxLineLength:WidgetParts.kt:) + MaxLineLength:WidgetParts.kt:CardWidget(open, bitmapExtra, WidgetSizes.PADDING_DP, showStatus, { w, h, corner, density -> glowBackground(style, w, h, corner, density) }, content) + MaxLineLength:WidgetParts.kt:GlanceModifier.padding(top = (WidgetSizes.PADDING_DP + top - reach).coerceAtLeast(0f).dp, end = (WidgetSizes.PADDING_DP - reach).dp) + MaxLineLength:WidgetParts.kt:Text(letter, style = (if (lit) WidgetType.captionShown else WidgetType.caption).copy(textAlign = TextAlign.Center), maxLines = 1) + MaxLineLength:WidgetParts.kt:TopRowView(title, null, TopRow.of(title, null, f.status, f.contentWidth, f.fontScale, withWords = f.bucket != WidgetSizes.Small), f, above = 0f) + MaxLineLength:WidgetParts.kt:WidgetType$val caption = TextStyle(color = ColorProvider(Color.White.copy(alpha = 0.50f)), fontSize = WidgetText.CAPTION_SP.sp, fontWeight = FontWeight.Medium) + MaxLineLength:WidgetParts.kt:c.drawCircle(s / 2, s / 2, s / 2, android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { this.color = ControlSurface.toArgb() }) + MaxLineLength:WidgetParts.kt:internal + MaxLineLength:WidgetParts.kt:internal suspend + MaxLineLength:WidgetParts.kt:runCatching { context.resources.getDimension(android.R.dimen.system_app_widget_background_radius) / context.resources.displayMetrics.density }.getOrNull() + MaxLineLength:WidgetParts.kt:val allPixels = WidgetSizes.all.sumOf { (it.width.value * it.height.value).toDouble() } * bitmapExtra * metrics.density * metrics.density + MaxLineLength:WidgetParts.kt:val density = metrics.density * WidgetSizes.bitmapScale(allPixels.toLong(), WidgetSizes.bitmapBudget(metrics.widthPixels, metrics.heightPixels)) + MaxLineLength:WidgetSamples.kt:WidgetSamples$fun sleep: SleepWidgetModel + MaxLineLength:WidgetSamples.kt:WidgetSamples$val session = SleepSession(start, at(today, 6, 56), score = 82, wakeCount = 3, deepMinutes = 92, remMinutes = 101, lightMinutes = 267, awakeMinutes = 18) + MaxLineLength:WidgetSizes.kt:WidgetDots$dot * ((1 + org.openstrap.app.ui.glow.DotSize.DOT_GAP) * org.openstrap.app.ui.glow.draw.DotFont.ROWS - org.openstrap.app.ui.glow.DotSize.DOT_GAP) + MaxLineLength:WidgetSizes.kt:WidgetDots$dot * ((1 + org.openstrap.app.ui.glow.DotSize.DOT_GAP) * org.openstrap.app.ui.glow.draw.DotFont.columns(text) - org.openstrap.app.ui.glow.DotSize.DOT_GAP) + MaxLineLength:WidgetStatusLine.kt:TopRow.Companion$WidgetText.fitWidth(title, sp, fontScale) + StatusButton.TITLE_GAP_DP + WidgetText.fitWidth(note, WidgetText.SECONDARY_SP, fontScale) <= width + MaxLineLength:WidgetStatusLine.kt:TopRow.Companion$fun room + MaxLineLength:WidgetStatusLine.kt:TopRow.Companion$val h = maxOf(WidgetText.line(sp, fontScale), StatusButton.SIZE_DP, if (words != null) WidgetText.line(StatusButton.WORDS_SP, fontScale) else 0f) + MaxLineLength:WidgetUpdater.kt:WidgetUpdater$fun + MaxLineLength:WidgetUpdater.kt:WidgetUpdater$if (at == null) cancel(context, ACTION_MOMENT) else setAlarm(context, ACTION_MOMENT, at.toEpochMilli(), wake = momentWakes(next, tick)) + MaxLineLength:WidgetWork.kt:WidgetWork$if (workerClassName == SessionWorker::class.java.name) SessionWorker(appContext, workerParameters, coroutineContext = dispatcher) else null + MaxLineLength:WorkoutCapsule.kt:Text(Workouts.sportName(sportType), color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + MaxLineLength:WorkoutCapsule.kt:Text(glance.line1, color = Color.White, style = MediumBody.copy(fontFeatureSettings = "tnum"), maxLines = 1, overflow = TextOverflow.Ellipsis) + MaxLineLength:WorkoutCapsule.kt:Text(it, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + MaxLineLength:WorkoutCapsule.kt:Text(label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + MaxLineLength:WorkoutCapsule.kt:if + MaxLineLength:WorkoutCapsule.kt:if (glance.figure != null) DotMatrixText(glance.figure, dot = DotSize.Hero) else WaitingDash(Modifier.padding(vertical = 12.dp)) + MaxLineLength:WorkoutCapsule.kt:infiniteRepeatable(keyframes { durationMillis = period; 1.5f at period * 15 / 100; 1f at period * 40 / 100 }, RepeatMode.Restart) + MaxLineLength:WorkoutCapsule.kt:internal + MaxLineLength:WorkoutCapsule.kt:modifier.fillMaxWidth().height(if (quiet) 56.dp else 64.dp).then(if (quiet) Modifier else Modifier.shadow(24.dp, shape, ambientColor = color, spotColor = color)) + MaxLineLength:WorkoutCapsule.kt:private + MaxLineLength:WorkoutCapsule.kt:val move = androidx.compose.animation.core.tween<androidx.compose.ui.unit.IntOffset>(swift, easing = org.openstrap.app.ui.components.Motion.easing) + MaxLineLength:WorkoutCopy.kt:WorkoutCopy$if (belowBpm == null) "Rest ends when the time is up." else "Rest ends when the time is up, or sooner once you stay under $belowBpm." + MaxLineLength:WorkoutCopy.kt:WorkoutCopy$return "Km ${i + 1} in ${workoutClock(sp.seconds)}" + (avg?.let { ", avg $it" } ?: "") to if (ago < 1) "just now" else "$ago min ago" + MaxLineLength:WorkoutCopy.kt:WorkoutCopy$return if (target.meters < 1000) "${m.toInt()} of ${target.label}" else String.format(java.util.Locale.ROOT, "%.2f of %s", m / 1000f, target.label) + MaxLineLength:WorkoutDetailCopy.kt:WorkoutDetailCopy$WorkoutFact("Distance", if (m >= 1000) km(m) else m.toInt().toString(), if (fromSteps) "$unit, estimated from your steps" else unit) + MaxLineLength:WorkoutDetailCopy.kt:WorkoutDetailCopy$if + MaxLineLength:WorkoutDetailCopy.kt:WorkoutDetailCopy$vo2max?.let { "VO2 max" to "The most oxygen your body can use, in ml per kg per minute, estimated from this workout: ${Math.round(it)}." } + MaxLineLength:WorkoutEffect.kt:WorkoutEffect$fun + MaxLineLength:WorkoutEffect.kt:WorkoutEffect$if (start.isNotEmpty() && end.isNotEmpty() && kotlin.math.abs(end.average() - start.average()) > MAX_GRADE * v * WINDOW_S) return Double.NaN + MaxLineLength:WorkoutEffect.kt:WorkoutEffect$private + MaxLineLength:WorkoutEffect.kt:WorkoutEffect.Result$data + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$dao.updateAppWorkoutEffect(w.start, e.epoc, e.aerobicTrainingEffect, e.recoveryMinutes, e.vo2max, e.anaerobicTrainingEffect, e.effectFromStrap, e.ownRecoveryMinutes) + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$dao.updateAppWorkoutEffect(w.start, s.epoc, s.aerobicTrainingEffect, s.recoveryMinutes, s.vo2max, s.anaerobicTrainingEffect, fromStrap = true, ownRecoveryMinutes = null) + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$return WorkoutRecorder.withStrapRecord(w, s, Workouts.effectOf(s, dao.sportEffectsOnce(s.start, s.start + Workouts.EFFECT_WINDOW_S))) + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$return phone.mapNotNull { p -> p.epoc?.let { p.from to it } } + strap.mapNotNull { s -> s.trainingLoad?.let { s.start to it.toFloat() } } + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$s.start < w.from && !Workouts.sameWorkout(s.start..s.end, w.from..w.end!!) && phone.none { p -> Workouts.sameWorkout(s.start..s.end, p.from..p.end!!) } + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$val + MaxLineLength:WorkoutEffectEngine.kt:WorkoutEffectEngine$w.copy(activeKcal = null, totalKcal = null) + MaxLineLength:WorkoutMap.kt:val px = with(density) { listOf(fitPadding.calculateLeftPadding(LayoutDirection.Ltr), fitPadding.calculateTopPadding(), fitPadding.calculateRightPadding(LayoutDirection.Ltr), fitPadding.calculateBottomPadding()).map { it.roundToPx() } } + MaxLineLength:WorkoutNotification.kt:WorkoutNotification$Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_SHOW_WORKOUT, true).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + MaxLineLength:WorkoutNotification.kt:WorkoutNotification$fun + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$/** The state that closes the strap's session: STOP for a workout that is kept, ABANDON for one that isn't, so the strap drops its record. */ + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$fun + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$return + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$shownStart = w.from + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$val bpm = hr.filter { it.first in a until b }.map { it.second }.takeIf { it.isNotEmpty() }?.average()?.let { Math.round(it).toInt() } + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$val hr = (history.filter { it.fromHistory } + live.map { AppWorkoutHeartRate(w.start, it.first, it.second) }).sortedBy { it.timestamp } + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$val minutes = kcalMinutes(w, hr, minuteSteps(stepTotals) + syncedSteps, restingHr, maxHr, profile, age, vo2max, now) ?: return previous + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder$val z = liveZoneSeconds(a.heartRate.filter { it.timestamp in a.shownStart..a.end }.map { it.timestamp to it.bpm }, a.maxHeartRate) + MaxLineLength:WorkoutRecorder.kt:WorkoutRecorder.Usual$data + MaxLineLength:WorkoutSession.kt:WorkoutSession$(w.pausedAt?.let { w.withPause(it, now()) } ?: w).also { if (it != w) strapState(org.openstrap.protocol.services.SportSession.State.RESUME, it, send = toStrap) } + MaxLineLength:WorkoutSession.kt:WorkoutSession$_state.value = it.copy(sets = sets, restUntil = restUntil, exercise = sets.lastOrNull()?.exercise, logSets = log, rounds = rounds) + MaxLineLength:WorkoutSession.kt:WorkoutSession$_stats.value = WorkoutRecorder.Stats(Math.round(sum.toDouble() / count).toInt(), maxOf(_stats.value?.max ?: 0, bpm), minOf(_stats.value?.min ?: bpm, bpm)) + MaxLineLength:WorkoutSession.kt:WorkoutSession$dao.activityOnce(r.workout.from - 60, t + 60).filter { it.steps in 0..250 }.associate { it.timestamp / 60 * 60 to it.steps } + MaxLineLength:WorkoutSession.kt:WorkoutSession$dao.upsertSavedWorkout(org.openstrap.app.data.db.SavedWorkout(name = name, sport = sport, structure = structure, createdAt = now())) + MaxLineLength:WorkoutSession.kt:WorkoutSession$if + MaxLineLength:WorkoutSession.kt:WorkoutSession$if (filter == android.app.NotificationManager.INTERRUPTION_FILTER_NONE || filter == android.app.NotificationManager.INTERRUPTION_FILTER_ALARMS) return + MaxLineLength:WorkoutSession.kt:WorkoutSession$return runCatching { org.openstrap.app.data.WorkoutEffectEngine(dao).withEffect(stats, hr, settings.profile.first(), route) }.getOrDefault(stats) + MaxLineLength:WorkoutSession.kt:WorkoutSession$scope.launch { runCatching { strap.buzz(c.pattern) }.onFailure { (strap as? StrapManager)?.log("coach $c not played: ${it.message}") } } + MaxLineLength:WorkoutSession.kt:WorkoutSession$scope.launch { runCatching { strap.buzz(pattern) }.onFailure { (strap as? StrapManager)?.log("buzz $what not played: ${it.message}") } } + MaxLineLength:WorkoutSession.kt:WorkoutSession$step.switch?.let { buzz(org.openstrap.app.data.WorkoutBuzz.of(it, game = s is org.openstrap.app.data.Structure.Periods), it.name) } + MaxLineLength:WorkoutSession.kt:WorkoutSession$val log = runCatching { settings.sportSettings.first()[org.openstrap.app.data.Workouts.tileType(w.sportType)]?.log }.getOrNull() == true + MaxLineLength:WorkoutSession.kt:WorkoutSession$val peak = { a: Long, b: Long -> org.openstrap.app.data.Strength.peak(synchronized(allReadings) { allReadings.toList() }, a, b) } + MaxLineLength:WorkoutSession.kt:WorkoutSession$val restUntil = sets.lastOrNull()?.takeIf { it.end != null && it.restEnd == null }?.let { it.end!! + restRule(w).seconds } + MaxLineLength:WorkoutSession.kt:WorkoutSession$val resting = runCatching { dao.restingHrOnce(now() - RESTING_LOOKBACK_S, now()).lastOrNull()?.bpm }.getOrNull() ?: DEFAULT_RESTING_HR + MaxLineLength:WorkoutSession.kt:WorkoutSession$val resting = runCatching { org.openstrap.app.data.WorkoutEffectEngine(dao).restingHr(w.from, zone) }.getOrDefault(DEFAULT_RESTING_HR.toFloat()) + MaxLineLength:WorkoutSession.kt:WorkoutSession$val stretches = org.openstrap.app.data.Rewards.Stretch.format(org.openstrap.app.data.Rewards.stretches(_route.value, ended.pauseRanges)) + MaxLineLength:WorkoutSession.kt:WorkoutSession$val w = AppWorkout(start = from, end = to, sportType = sportType, source = AppWorkout.SOURCE_SUGGESTED, endReason = AppWorkout.END_USER) + MaxLineLength:WorkoutSession.kt:WorkoutSession$val w = r.workout.let { w -> distanceNow(w)?.let { (m, steps) -> w.copy(distanceMeters = m, distanceFromSteps = steps) } ?: w } + MaxLineLength:WorkoutSession.kt:WorkoutSession.<no name provided>$if (cls != android.bluetooth.BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO && cls != android.bluetooth.BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE) return + MaxLineLength:WorkoutSession.kt:WorkoutSession.<no name provided>$val device = androidx.core.content.IntentCompat.getParcelableExtra(intent, android.bluetooth.BluetoothDevice.EXTRA_DEVICE, android.bluetooth.BluetoothDevice::class.java) + MaxLineLength:WorkoutSetup.kt:WorkoutSetup$current != null && current.low == current.high && current.low != tapped -> WorkoutTarget.Zones(minOf(current.low, tapped), maxOf(current.low, tapped)) + MaxLineLength:WorkoutSetup.kt:WorkoutSetup$saved.filter { Structure.parse(it.structure) != null }.take(SAVED_CHIPS).map { RecentChip.Saved(it.name, it.sport, it.structure) } + MaxLineLength:WorkoutSuggestions.kt:WorkoutSuggestions$out += Suggestion(run.first().first, run.last().first + 60, Math.round(run.map { it.second }.average()).toInt(), zones.maxBy { it.value }.key) + MaxLineLength:WorkoutTileService.kt:WorkoutTileService$val pending = PendingIntent.getActivity(this, REQUEST_TILE, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + MaxLineLength:Workouts.kt:Workouts$fun + MaxLineLength:Workouts.kt:Workouts$return (strap + phone.map { WorkoutRecorder.analyse(it, emptyList(), maxHr).copy(sets = sets[it.start]) }).sortedByDescending { it.workout.start } + MaxLineLength:Workouts.kt:Workouts$val strap = ws.filter { phoneTwin(it, phone, now) == null }.map { w -> analyse(w, listOfNotNull(lastByWorkout[w.start]), effects, maxHr) } + MaxLineLength:Workouts.kt:Workouts.Sides$data + MaxLineLength:WorkoutsMigration.kt:WorkoutsMigration$"`workStart` INTEGER NOT NULL, `workEnd` INTEGER, `restEnd` INTEGER, `peakBpm` INTEGER, PRIMARY KEY(`workoutStart`, `index`))" + MaxLineLength:WorkoutsMigration.kt:WorkoutsMigration$* + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$. + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$WorkoutRecorder.laps(w.from, w.end ?: a.end, marks, hr.filter { !it.fromHistory }.map { it.timestamp to it.bpm }, route, w.pauseRanges) + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$combine + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$combine(dao.appWorkouts(), dao.setCounts()) { phone, sets -> phone to sets.associate { it.workoutStart to it.sets } } + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$org.openstrap.app.data.Summaries.bucketAverage(org.openstrap.app.data.Summaries.heartRateSeries(dao.heartRateOnce(w.from, w.to), dao.activityOnce(w.from, w.to)), 60) + MaxLineLength:WorkoutsViewModel.kt:WorkoutsViewModel$val + MaxLineLength:YouPage.kt:"Deleted sleep" + MaxLineLength:YouPage.kt:"Goals and bests" + MaxLineLength:YouPage.kt:"Health Connect" + MaxLineLength:YouPage.kt:PageRow("Backup", BackupCopy.summary(backup, now, zone), onClick = onBackup, below = true, icon = AppIcons.ShieldCheck, selected = selected == "Backup") + MaxLineLength:YouPage.kt:PageRow("Diagnostics", "$logLines lines, for bug reports", onClick = onDiagnostics, below = true, icon = AppIcons.Bug, selected = selected == "Diagnostics") + MaxLineLength:YouPage.kt:PageRow("Profile", profile.summary(), onClick = onProfile, below = true, divider = false, icon = AppIcons.User, selected = selected == "Profile") + MaxLineLength:YouPage.kt:fun + MaxLineLength:YouPage.kt:if (onSampleData != null) PageRow("Load sample data", "Debug builds only", onClick = onSampleData, below = true, icon = AppIcons.Flask) + MaxLineLength:YouPage.kt:org.openstrap.app.ui.workout.HeartZonesCopy.TITLE + MaxLineLength:ZeppCloud.kt:BoundDevice$override fun toString + MaxLineLength:ZeppCloud.kt:ZeppCloud$. + MaxLineLength:ZeppCloud.kt:ZeppCloud$if (result.isNotEmpty() && result != "ok") throw ZeppCloudException("Zepp refused to renew the sign-in ($result). Sign in again.", rejected = true) + MaxLineLength:ZeppCloud.kt:ZeppCloud$val bases = (listOfNotNull(preferredBase?.trim()?.takeIf { it.startsWith("https://") }?.let { if (it.endsWith("/")) it else "$it/" }) + API_HOSTS).distinct() + MaxLineLength:ZeppCloud.kt:ZeppCloud$val login = account.loginToken?.takeIf { it.isNotBlank() } ?: throw ZeppCloudException("No login token to renew the sign-in with. Sign in again.", rejected = true) + MaxLineLength:ZeppCloud.kt:ZeppCloud$val root = runCatching { JSONObject(body) }.getOrElse { throw ZeppCloudException("Unexpected reply when renewing the Zepp sign-in") } + MaxLineLength:ZeppSignIn.kt:"$provider sign-in doesn't work inside OpenStrap, because $provider blocks sign-in in embedded pages.\n\n" + MaxLineLength:ZeppSignIn.kt:Column + MaxLineLength:ZeppSignIn.kt:Modifier.size(44.dp).clip(CircleShape).background(ControlSurface).clickable(onClickLabel = "Close", onClick = onDismiss) + MaxLineLength:ZeppSignIn.kt:Row + MaxLineLength:ZeppSignIn.kt:Text("Auth key", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.weight(1f)) + MaxLineLength:ZeppSignIn.kt:Text(if (s.thirdParty) "Use email sign-in" else "Couldn't get your devices", color = Color.White, style = GlowType.cardTitle, maxLines = 1) + MaxLineLength:ZeppSignIn.kt:Text(label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, maxLines = 1, modifier = Modifier.padding(end = 12.dp)) + MaxLineLength:ZeppSignIn.kt:value + MemberNameEqualsClassName:Pai.kt:Pai$fun pai: Double + MissingNonRestartableComposable:MainActivity.kt:@androidx.compose.runtime.Composable private fun MetricDetail + MissingReadOnlyComposable:Charts.kt:@Composable private fun axisStyle + MissingReadOnlyComposable:Charts.kt:@Composable private fun readoutStyle + ModifierComposed:Motion.kt:fun Modifier.pressClickable: Modifier + ModifierMissing:Adaptive.kt:@Composable fun <S> ListDetail + ModifierMissing:AllMetricsScreen.kt:@Composable fun AllMetricsSheet + ModifierMissing:Celebrations.kt:@Composable fun BestCardScreen + ModifierMissing:Celebrations.kt:@Composable fun CelebrationsSheet + ModifierMissing:Celebrations.kt:@Composable fun GoalMomentScreen + ModifierMissing:Celebrations.kt:@Composable fun HistoryBadgesScreen + ModifierMissing:ChooseScreen.kt:@Composable fun GlowChooseScreen + ModifierMissing:DayCalendar.kt:@Composable fun DayCalendarSheet + ModifierMissing:DeletedSleepPage.kt:@Composable fun DeletedSleepPage + ModifierMissing:DeviceScreen.kt:@Composable fun DeviceScreen + ModifierMissing:DeviceScreen.kt:@Composable fun DiagnosticsScreen + ModifierMissing:FirmwareScreen.kt:@Composable fun FirmwareScreen + ModifierMissing:Glass.kt:@Composable fun GlassAlertDialog + ModifierMissing:Glass.kt:@Composable fun GlassOverlay + ModifierMissing:Glass.kt:@Composable fun GlassSheet + ModifierMissing:GlowLayout.kt:@Composable fun GlowHeader + ModifierMissing:GlowLayout.kt:@Composable fun GlowScreenList + ModifierMissing:GlowMetricDetail.kt:@Composable fun GlowMetricDetailScreen + ModifierMissing:GlowQuiet.kt:@Composable fun PanelTitle + ModifierMissing:GlowSleepScreen.kt:@Composable fun GlowSleepScreen + ModifierMissing:GlowTodayScreen.kt:@Composable fun GlowTodayScreen + ModifierMissing:GlowWorkoutMode.kt:@Composable fun GlowAddWorkoutScreen + ModifierMissing:GlowWorkoutSummary.kt:@Composable fun GlowWorkoutSummaryScreen + ModifierMissing:GoalsAndBests.kt:@Composable fun AllBadgesPage + ModifierMissing:GoalsAndBests.kt:@Composable fun GoalsAndBestsPage + ModifierMissing:GoalsAndBests.kt:@Composable fun WeeklyGoalSheet + ModifierMissing:GoalsAndBests.kt:@Composable fun WeeklyRecapPage + ModifierMissing:GoalsSheet.kt:@Composable fun GoalsSheet + ModifierMissing:HeartZonesScreen.kt:@Composable fun HeartZonesScreen + ModifierMissing:InfoSheet.kt:@Composable fun InfoSheet + ModifierMissing:LiveHeart.kt:@Composable fun GlowWorkoutLiveScreen + ModifierMissing:MomentHeader.kt:@Composable fun MomentHeader + ModifierMissing:PageParts.kt:@Composable fun <T> PageMenuRow + ModifierMissing:PageParts.kt:@Composable fun BackButton + ModifierMissing:PageParts.kt:@Composable fun PageConfirm + ModifierMissing:PageParts.kt:@Composable fun PageHeader + ModifierMissing:PageParts.kt:@Composable fun PageNote + ModifierMissing:PageParts.kt:@Composable fun PageRow + ModifierMissing:PageParts.kt:@Composable fun PageSheet + ModifierMissing:PageParts.kt:@Composable fun PageSwitchRow + ModifierMissing:PhoneAlertsScreen.kt:@Composable fun PhoneAlertsScreen + ModifierMissing:ProfileScreen.kt:@Composable fun ProfileScreen + ModifierMissing:PullToSync.kt:@Composable fun PullSyncReveal + ModifierMissing:ReadyScreen.kt:@Composable fun GlowWorkoutReadyScreen + ModifierMissing:ScreenParts.kt:@Composable fun ScreenList + ModifierMissing:UpdatesPage.kt:@Composable fun UpdatesPage + ModifierMissing:WorkoutCapsule.kt:@Composable fun PulseDot + ModifierMissing:WorkoutCapsule.kt:@Composable fun WorkoutMorph + ModifierMissing:YouPage.kt:@Composable fun YouPage + ModifierNaming:ReadyCardio.kt:field: Modifier + ModifierNotUsedAtRoot:GlowTodayScreen.kt:modifier + ModifierWithoutDefault:ChooseScreen.kt:modifier: Modifier + ModifierWithoutDefault:DataStateParts.kt:modifier: Modifier + ModifierWithoutDefault:DeviceScreen.kt:modifier: Modifier + ModifierWithoutDefault:GlowActivityScreen.kt:modifier: Modifier + ModifierWithoutDefault:GlowRecoveryScreen.kt:modifier: Modifier + ModifierWithoutDefault:GlowSleepScreen.kt:modifier: Modifier + ModifierWithoutDefault:GlowTodayScreen.kt:modifier: Modifier + ModifierWithoutDefault:Insights.kt:modifier: Modifier + ModifierWithoutDefault:LiveDistance.kt:modifier: Modifier + ModifierWithoutDefault:LiveFrame.kt:modifier: Modifier + ModifierWithoutDefault:LiveHeart.kt:modifier: Modifier + ModifierWithoutDefault:LiveSets.kt:modifier: Modifier + ModifierWithoutDefault:MomentHeader.kt:modifier: Modifier + ModifierWithoutDefault:ReadyCardio.kt:field: Modifier + ModifierWithoutDefault:ShareCards.kt:modifier: Modifier + ModifierWithoutDefault:ShareScreen.kt:modifier: Modifier + ModifierWithoutDefault:StartWidget.kt:modifier: GlanceModifier + ModifierWithoutDefault:StrapDrawing.kt:modifier: Modifier + ModifierWithoutDefault:WorkoutCapsule.kt:modifier: Modifier + ModifierWithoutDefault:ZeppSignIn.kt:modifier: Modifier + MultipleEmitters:FirmwareScreen.kt:@Composable private fun CheckResult + MultipleEmitters:GlowWorkoutMode.kt:@Composable internal fun TrimSection + MultipleEmitters:ReadyCardio.kt:@Composable private fun IntervalsGoal + MultipleEmitters:ReadyCardio.kt:@Composable private fun ReadyPlanCard + MultipleEmitters:ReadyCardio.kt:@Composable private fun ZoneGoal + MultipleEmitters:ShareCards.kt:@Composable private fun StoryHeading + MultipleEmitters:TodayCards.kt:@Composable internal fun EdgeValue + MultipleEmitters:TodayCards.kt:@Composable internal fun RecoveryFailed + MultipleEmitters:WidgetParts.kt:@Composable internal fun WaitingContent + MutableStateAutoboxing:ActivityDetail.kt:mutableStateOf(0f) + MutableStateAutoboxing:GlowCharts.kt:mutableStateOf(0f) + MutableStateAutoboxing:GlowWorkoutMode.kt:mutableStateOf(from) + MutableStateAutoboxing:GlowWorkoutMode.kt:mutableStateOf(to) + MutableStateAutoboxing:GlowWorkoutScreen.kt:mutableStateOf(0f) + MutableStateAutoboxing:ReadyCardio.kt:mutableStateOf(0) + NestedBlockDepth:BodySignals.kt:BodySignals$fun evaluate: List<Day> + NestedBlockDepth:Recovery.kt:Recovery$fun charge: ChargeDay + NestedBlockDepth:StartWidgetModel.kt:StartLayout$fun tiles: Tiles? + NoNameShadowing:HistorySync.kt:HistorySync${ if (kept.isEmpty()) it else it.coerceAtMost(kept.maxOf { r -> r.timestamp } + 60) } + NoNameShadowing:HistorySync.kt:HistorySync${ it.start == w.summary.trackId } + NoNameShadowing:HistorySync.kt:HistorySync${ it.timestamp in MIN_VALID_EPOCH..now + FUTURE_SLACK } + NoNameShadowing:HistorySync.kt:HistorySync${ log("auto-detect notification: $it") } + ParameterNaming:ChooseScreen.kt:onSaved: (RecentChip.Saved) -> Unit = {} + ParameterNaming:GlowLayout.kt:onFocused: () -> Unit = {} + ParameterNaming:GlowRecoveryScreen.kt:onChargeShown: () -> Unit = {} + ParameterNaming:LiveHeart.kt:onSetDone: () -> Unit = {} + ParameterNaming:LiveSets.kt:onSetDone: () -> Unit + ParameterNaming:MainActivity.kt:onOpenSaved: (sport: Int, structure: String) -> Unit = { s, _ -> onReadySport(s) } + ParameterNaming:SleepDetail.kt:onGone: () -> Unit + ParameterNaming:WorkoutMap.kt:onPanned: () -> Unit = {} + RedundantSuspendModifier:CompanionLink.kt:CompanionLink$suspend + RedundantSuspendModifier:DemoData.kt:DemoData$suspend + RedundantSuspendModifier:SampleData.kt:SampleData$suspend + RememberMissing:MainActivity.kt:mutableStateOf(null) + ReturnCount:AlarmsScreen.kt:fun alarmsSummary: String? + ReturnCount:AppUpdater.kt:AppUpdater$suspend fun dailyCheck + ReturnCount:AutoHealthConnect.kt:HealthConnectAutoSync$suspend fun check: Boolean + ReturnCount:AutoStart.kt:AutoStart$fun offer: Offer? + ReturnCount:BackupCopy.kt:BackupCopy$fun status: String + ReturnCount:BackupCopy.kt:BackupCopy$fun summary: String + ReturnCount:BackupFiles.kt:BackupFiles$fun classify: Failure + ReturnCount:CardMotion.kt:CardMotion$fun detailTakesPart: Boolean + ReturnCount:CardToDetail.kt:@Composable fun Modifier.cardNumber: Modifier + ReturnCount:CardToDetail.kt:@Composable fun Modifier.detailNumber: Modifier + ReturnCount:CardToDetail.kt:@Composable fun Modifier.detailSurface: Modifier + ReturnCount:ChartSpec.kt:fun smoothOps: List<PathOp> + ReturnCount:Coaching.kt:Coach$private fun zoneCue: Cue? + ReturnCount:DailyEnergyHistory.kt:DailyEnergyHistory$suspend fun update: Int + ReturnCount:DataStates.kt:fun wear: Wear? + ReturnCount:DayViewModel.kt:DayViewModel$private suspend fun yesterdayScore: Int? + ReturnCount:DetailCopy.kt:DetailCopy$fun span: String? + ReturnCount:EnergyExpenditure.kt:EnergyExpenditure$fun bmrPerMinute: Float? + ReturnCount:EnergyExpenditure.kt:EnergyExpenditure$fun cadenceMet: Float + ReturnCount:EnergyExpenditure.kt:EnergyExpenditure$fun day: Day? + ReturnCount:EnergyExpenditure.kt:EnergyExpenditure$fun workoutMinutes: List<WorkoutMinute>? + ReturnCount:FamilySummary.kt:FamilySummary$fun gameRead: String? + ReturnCount:FamilySummary.kt:FamilySummary$fun settled: Pair<Int, Int>? + ReturnCount:FamilySummary.kt:FamilySummary$fun versusLast: String? + ReturnCount:FirmwareViewModel.kt:FirmwareViewModel$private suspend fun renewed: ZeppAccount + ReturnCount:Frame.kt:fun strapChip: StrapChip + ReturnCount:GlowCharts.kt:private fun upToLength: List<Offset> + ReturnCount:GlowWorkoutSummary.kt:@Composable fun GlowWorkoutSummaryScreen + ReturnCount:Haptics.kt:ScrubTicks$fun next: HapticEvent? + ReturnCount:HealthConnectExporter.kt:HealthConnectExporter$suspend fun exportWorkout: Int + ReturnCount:HealthRepository.kt:HealthRepository$fun daily: Flow<List<DayStat>> + ReturnCount:Healthspan.kt:Healthspan$fun blockOffset: Float? + ReturnCount:Healthspan.kt:Healthspan$fun estimate: Pair<Result?, Withheld?> + ReturnCount:Healthspan.kt:Healthspan$fun pace: Float? + ReturnCount:Healthspan.kt:Healthspan$internal fun interp: Float + ReturnCount:HeartZones.kt:HeartZones.Companion$fun parse: HeartZones? + ReturnCount:HistorySync.kt:HistorySync$private fun holdBackPartialMinute: Pair<List<HistoryRecord>, Long?> + ReturnCount:HistorySync.kt:HistorySync$private suspend fun syncType + ReturnCount:HistorySync.kt:HistorySync$private suspend fun syncWorkouts + ReturnCount:LiveGlance.kt:Glance$fun goalLine: Pair<String?, String?> + ReturnCount:LiveGlance.kt:Glance$fun of: LiveGlance + ReturnCount:LiveGlance.kt:internal fun intervalMoment: IntervalMoment? + ReturnCount:LiveSteps.kt:LiveSteps$fun today: Int + ReturnCount:MomentResolver.kt:MomentResolver$fun resolve: Moment + ReturnCount:MomentWidgetModel.kt:MomentLayout$fun ladder: List<Fit> + ReturnCount:MomentWidgetModel.kt:MomentLayout$fun numberRow: NumberRow? + ReturnCount:MomentWidgetModel.kt:MomentLayout$fun small: Fit + ReturnCount:MomentWidgetModel.kt:MomentWidgetModel.Companion$fun lead: MomentLead + ReturnCount:MomentWidgetModel.kt:MomentWidgetModel.Content$fun energyChart: MomentChart? + ReturnCount:MomentWidgetModel.kt:MomentWidgetModel.Content$fun heartChart: MomentChart? + ReturnCount:MomentWidgetModel.kt:MomentWidgetModel.Content$fun stagesChart: MomentChart? + ReturnCount:Phases.kt:Phases$fun tick: Step + ReturnCount:Phases.kt:Structure.Companion$fun parse: Structure? + ReturnCount:PhoneAlerts.kt:PhoneAlerts$fun onNotification + ReturnCount:PhoneAlerts.kt:PhoneAlerts$private fun shouldBuzz: Boolean + ReturnCount:Recovery.kt:Recovery$fun daytimeRestingHr: Float? + ReturnCount:Recovery.kt:Recovery$fun readiness: Readiness + ReturnCount:Recovery.kt:Recovery$fun withWorkoutMinutes: List<Pair<Long, Int>> + ReturnCount:RecoveryEngine.kt:RecoveryEngine$suspend fun phoneWorkoutKcal: List<EnergyExpenditure.WorkoutMinute>? + ReturnCount:RecoveryWidgetModel.kt:RecoveryWidgetModel.Companion$fun from: RecoveryWidgetModel + ReturnCount:Releases.kt:Releases$fun version: List<Int>? + ReturnCount:Rewards.kt:Rewards$fun progress: Pair<Long, Long>? + ReturnCount:RewardsCopy.kt:RewardsCopy$fun bestShort: String + ReturnCount:RewardsCopy.kt:RewardsCopy$fun bestTitle: String + ReturnCount:Route.kt:Route$fun zoneAt: Int + ReturnCount:Route.kt:Route$private fun velocity: Velocity? + ReturnCount:ShareCards.kt:StoryRouteShape$fun fit: List<Offset> + ReturnCount:ShareCards.kt:StoryRouteShape$fun trimEnds: List<Route.Point> + ReturnCount:SleepCopy.kt:SleepCopy$fun nightNormal: History.Normal? + ReturnCount:SleepEdits.kt:SleepEdits$fun stillHeartRate: Int? + ReturnCount:SleepWidgetModel.kt:SleepLayout$fun legendRows: Int + ReturnCount:SleepWidgetModel.kt:SleepLayout$fun scoreRow: ScoreRow + ReturnCount:SleepWidgetModel.kt:SleepLayout$private fun smallRow: ScoreRow + ReturnCount:SleepWidgetModel.kt:SleepWidgetModel.Companion$fun from: SleepWidgetModel + ReturnCount:StartCopy.kt:StartCopy$fun parseTime: Long? + ReturnCount:StartWidgetModel.kt:StartLayout$fun figures: Figures + ReturnCount:StartWidgetModel.kt:StartLayout$fun small: Small + ReturnCount:StartWidgetModel.kt:StartLayout$fun statusSmall: StatusSmall + ReturnCount:StartWidgetModel.kt:StartLayout$fun tiles: Tiles? + ReturnCount:StartWidgetModel.kt:StartLayout$private fun block: Block + ReturnCount:StartWidgetModel.kt:StartWidgetModel.Companion$fun fact: String + ReturnCount:StrapManager.kt:StrapManager$override suspend fun buzz + ReturnCount:StrapManager.kt:StrapManager$override suspend fun startCallBuzz + ReturnCount:StrapManager.kt:StrapManager$private fun startSync + ReturnCount:StrapManager.kt:StrapManager$private suspend fun notifyBodySignals + ReturnCount:StrapService.kt:StrapService$override fun onStartCommand: Int + ReturnCount:StrapService.kt:StrapService$private fun workoutNotification: Notification + ReturnCount:Strength.kt:Strength$fun restOver: Boolean + ReturnCount:Strength.kt:Strength.Rest.Companion$fun parse: Rest? + ReturnCount:SummaryCopy.kt:SummaryCopy$fun carriedOver: String? + ReturnCount:SummaryCopy.kt:SummaryCopy$fun dropLine: String + ReturnCount:SummarySections.kt:@Composable internal fun FamilySection: Boolean + ReturnCount:SyncStates.kt:fun syncStuck: Boolean + ReturnCount:TabCopy.kt:TabCopy$fun loadBalance: String? + ReturnCount:TodayCopy.kt:TodayCopy$fun waking: String + ReturnCount:TodayLooks.kt:fun varyLooks: List<TodaySlot> + ReturnCount:TodayLooks.kt:private fun options: List<TodaySlot> + ReturnCount:UpdateCopy.kt:UpdateCopy$fun checkStatus: String + ReturnCount:UpdateCopy.kt:UpdateCopy$fun install: String + ReturnCount:UpdateCopy.kt:UpdateCopy$fun summary: String + ReturnCount:Vo2max.kt:Vo2max$fun estimate: Pair<Estimate?, Withheld?> + ReturnCount:WeeklyRecap.kt:WeeklyRecap$suspend fun check + ReturnCount:WidgetUpdater.kt:UpdateThrottle$@Synchronized fun request: Long? + ReturnCount:WorkoutCopy.kt:WorkoutCopy$fun splitLine: SplitLine? + ReturnCount:WorkoutEffect.kt:WorkoutEffect$fun aerobicTe: Float + ReturnCount:WorkoutEffect.kt:WorkoutEffect$fun distAt: Double? + ReturnCount:WorkoutEffect.kt:WorkoutEffect$fun speed: Double? + ReturnCount:WorkoutEffect.kt:WorkoutEffect$fun vo2max: Float? + ReturnCount:WorkoutEffect.kt:WorkoutEffect$private fun window: Double? + ReturnCount:WorkoutEffectEngine.kt:WorkoutEffectEngine$suspend fun backfill + ReturnCount:WorkoutEffectEngine.kt:WorkoutEffectEngine$suspend fun backfillKcal + ReturnCount:WorkoutEffectEngine.kt:WorkoutEffectEngine$suspend fun strapEffect: AppWorkout? + ReturnCount:WorkoutGapFill.kt:WorkoutGapFill$suspend fun fill: Int + ReturnCount:WorkoutLocation.kt:WorkoutLocation$@SuppressLint("MissingPermission") fun start: Boolean + ReturnCount:WorkoutRecorder.kt:WorkoutRecorder$fun stoppedSince: Long? + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun cadence: Int? + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun checkRest + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun checkStopped + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun cue + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun onStrapPush + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun record + ReturnCount:WorkoutSession.kt:WorkoutSession$private fun stepsDistance: Float? + ReturnCount:WorkoutSession.kt:WorkoutSession$private suspend fun countKcal + ReturnCount:WorkoutSession.kt:WorkoutSession$private suspend fun saveDistance + ReturnCount:WorkoutSession.kt:WorkoutSession.<no name provided>$override fun onReceive + ReturnCount:Workouts.kt:Workouts$fun activeKcal: Int? + ReturnCount:Workouts.kt:Workouts$fun indoorSide: Boolean? + ReturnCount:Workouts.kt:Workouts$private fun recovery: Int? + ReturnCount:YouPage.kt:fun healthConnectLine: String + ReturnCount:ZeppCloud.kt:ZeppCloud$fun needsAppTokenRefresh: Boolean + ReturnCount:ZeppCloud.kt:ZeppCloud$fun sessionFromCookies: ZeppSession? + ReturnCount:ZeppCloud.kt:ZeppCloud$private fun decodeJson: JSONObject? + SpreadOperator:Charts.kt:(*stops, startY = y1, endY = y0) + SpreadOperator:Charts.kt:(*stops.map { (f, c) -> f to c.copy(alpha = 0.16f) }.toTypedArray(), startY = y1, endY = y0) + SpreadOperator:ComposeDraw.kt:(*colorStops(), start = Offset(x0, y0), end = Offset(x1, y1)) + SpreadOperator:ComposeDraw.kt:(*stops.toTypedArray()) + SpreadOperator:Insights.kt:(*PinnedFade.stops(-ramp, 0f, size.height), startY = -ramp, endY = size.height) + SpreadOperator:Insights.kt:TabBarFade$(*stops, startY = top, endY = bottom) + SpreadOperator:MainActivity.kt:( // Remembered: a new lambda each time would be a new static local, recomposing the whole tab under it. org.openstrap.app.ui.glow.LocalGlowPagePaused provides remember(pager, page) { { pager.isScrollInProgress || pager.settledPage != page } }, org.openstrap.app.ui.dashboard.LocalScrollToTop provides scrollToTop[page], *(if (page == tab) emptyArray() else arrayOf(androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner provides idleBack)), ) + SpreadOperator:OpenStrapDatabase.kt:OpenStrapDatabase.Companion$(*MIGRATIONS) + SpreadOperator:WorkoutMap.kt:( *listOfNotNull( PropertyFactory.visibility(if (hereColor != null) Property.VISIBLE else Property.NONE), hereColor?.let { PropertyFactory.circleColor(it.toArgb()) }, ).toTypedArray(), ) + SuspendFunSwallowedCancellation:AlarmsViewModel.kt:AlarmsViewModel$runCatching + SuspendFunSwallowedCancellation:DailyEnergyHistory.kt:DailyEnergyHistory$runCatching + SuspendFunSwallowedCancellation:HistorySync.kt:HistorySync$catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e log("history failed: $e") // A wrapped system error's message is just its class name (android.os.DeadObjectException), not for the card. // Whatever a call on a dropped link threw (a missing characteristic, a closed op), the cause is the link. val reason = if (!gatt.connected.value) "The link to the strap dropped." else e.message?.takeUnless { e.cause != null && it == e.cause.toString() } state.value = SyncState.Failed(reason ?: "Sync failed") } + SuspendFunSwallowedCancellation:HistorySync.kt:HistorySync$runCatching + SuspendFunSwallowedCancellation:MainActivity.kt:MainActivity$runCatching + SuspendFunSwallowedCancellation:MainActivity.kt:runCatching + SuspendFunSwallowedCancellation:PhoneAlerts.kt:PhoneAlerts$runCatching + SuspendFunSwallowedCancellation:PhoneAlertsScreen.kt:runCatching + SuspendFunSwallowedCancellation:ShareScreen.kt:runCatching + SuspendFunSwallowedCancellation:StrapManager.kt:StrapManager$catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e log("connect failed: $e") connection.value = ConnectionState.Failed(e.message ?: "Connection failed") close() scheduleReconnect() } + SuspendFunSwallowedCancellation:StrapManager.kt:StrapManager$catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e log("live hr failed: $e") liveHeartRate.value = null } + SuspendFunSwallowedCancellation:StrapManager.kt:StrapManager$catch (e: Exception) { log("dfu failed: ${e.message}") throw e } + SuspendFunSwallowedCancellation:StrapManager.kt:StrapManager$runCatching + SuspendFunSwallowedCancellation:StrapService.kt:StrapService$runCatching + SuspendFunSwallowedCancellation:WorkoutGapFill.kt:WorkoutGapFill$runCatching + SuspendFunSwallowedCancellation:WorkoutSession.kt:WorkoutSession$runCatching + SuspendFunSwallowedCancellation:WorkoutSession.kt:WorkoutSession.<no name provided>$runCatching + SuspendFunSwallowedCancellation:WorkoutsViewModel.kt:WorkoutsViewModel$runCatching + SwallowedException:DayViewModel.kt:DayViewModel$e: Exception + SwallowedException:HistorySync.kt:HistorySync$e: FastRechargeModeException + SwallowedException:StrapService.kt:StrapService$e: SecurityException + ThrowsCount:AppUpdater.kt:AppUpdater$fun install + ThrowsCount:AutoBackup.kt:AutoBackup$private suspend fun write: String + ThrowsCount:StrapManager.kt:StrapManager$override suspend fun installFirmware + ThrowsCount:StrapManager.kt:StrapManager$override suspend fun restartStrap + ThrowsCount:StrapManager.kt:StrapManager$override suspend fun sportSession + ThrowsCount:StrapManager.kt:StrapManager$private suspend fun command: ByteArray + ThrowsCount:StrapManager.kt:StrapManager$private suspend fun sendFindDevice + ThrowsCount:ZeppCloud.kt:ZeppCloud$fun parseAppTokens: AppTokenRefresh + TooGenericExceptionCaught:DayViewModel.kt:DayViewModel$e: Exception + TooGenericExceptionCaught:GattClient.kt:GattClient$e: RuntimeException + TooGenericExceptionCaught:HistorySync.kt:HistorySync$e: Exception + TooGenericExceptionCaught:MomentWidget.kt:MomentWidget.Companion$e: Exception + TooGenericExceptionCaught:RecoveryWidget.kt:RecoveryWidget.Companion$e: Exception + TooGenericExceptionCaught:ShareScreen.kt:StoryFiles$e: Exception + TooGenericExceptionCaught:SleepWidget.kt:SleepWidget.Companion$e: Exception + TooGenericExceptionCaught:StrapManager.kt:StrapManager$e: Exception + TooGenericExceptionCaught:SyncNowAction.kt:SyncNowAction$e: Exception + TooGenericExceptionCaught:WearReminder.kt:WearEventLog$e: Exception + TooGenericExceptionCaught:WidgetParts.kt:e: Exception + TooManyFunctions:ActivityCopy.kt:ActivityCopy + TooManyFunctions:ActivityDetail.kt:org.openstrap.app.ui.glow.ActivityDetail.kt + TooManyFunctions:AppUpdater.kt:AppUpdater + TooManyFunctions:CaloriesCopy.kt:CaloriesCopy + TooManyFunctions:CanvasDraw.kt:org.openstrap.app.ui.glow.draw.CanvasDraw.kt + TooManyFunctions:CardMotion.kt:CardMotion + TooManyFunctions:ChartSpec.kt:org.openstrap.app.ui.glow.draw.ChartSpec.kt + TooManyFunctions:ChooseScreen.kt:org.openstrap.app.ui.glow.ChooseScreen.kt + TooManyFunctions:ComposeDraw.kt:org.openstrap.app.ui.glow.draw.ComposeDraw.kt + TooManyFunctions:CsvTables.kt:CsvRow + TooManyFunctions:Daos.kt:HealthDao + TooManyFunctions:DataStates.kt:org.openstrap.app.ui.dashboard.DataStates.kt + TooManyFunctions:DetailCopy.kt:DetailCopy + TooManyFunctions:DeviceScreen.kt:org.openstrap.app.ui.device.DeviceScreen.kt + TooManyFunctions:DeviceViewModel.kt:DeviceViewModel : ViewModel + TooManyFunctions:FamilySummary.kt:FamilySummary + TooManyFunctions:FirmwareScreen.kt:org.openstrap.app.ui.firmware.FirmwareScreen.kt + TooManyFunctions:FirmwareViewModel.kt:FirmwareViewModel : ViewModel + TooManyFunctions:GattClient.kt:GattClient + TooManyFunctions:GlowActivityScreen.kt:org.openstrap.app.ui.glow.GlowActivityScreen.kt + TooManyFunctions:GlowCharts.kt:org.openstrap.app.ui.glow.GlowCharts.kt + TooManyFunctions:GlowLayout.kt:org.openstrap.app.ui.glow.GlowLayout.kt + TooManyFunctions:GlowMetricDetail.kt:org.openstrap.app.ui.glow.GlowMetricDetail.kt + TooManyFunctions:GlowRecoveryScreen.kt:org.openstrap.app.ui.glow.GlowRecoveryScreen.kt + TooManyFunctions:GlowTodayScreen.kt:org.openstrap.app.ui.glow.GlowTodayScreen.kt + TooManyFunctions:GlowWorkoutMode.kt:org.openstrap.app.ui.glow.GlowWorkoutMode.kt + TooManyFunctions:HealthConnectExporter.kt:HealthConnectExporter + TooManyFunctions:HealthRepository.kt:HealthRepository + TooManyFunctions:Healthspan.kt:Healthspan + TooManyFunctions:HistorySync.kt:HistorySync + TooManyFunctions:InfoCopy.kt:InfoCopy + TooManyFunctions:LiveFrame.kt:org.openstrap.app.ui.glow.live.LiveFrame.kt + TooManyFunctions:LiveGlance.kt:Glance + TooManyFunctions:LiveSets.kt:org.openstrap.app.ui.glow.live.LiveSets.kt + TooManyFunctions:MomentWidget.kt:org.openstrap.app.widget.MomentWidget.kt + TooManyFunctions:MomentWidgetModel.kt:MomentLayout + TooManyFunctions:MomentWidgetModel.kt:MomentWidgetModel$Content + TooManyFunctions:PageParts.kt:org.openstrap.app.ui.glow.PageParts.kt + TooManyFunctions:PhoneAlerts.kt:PhoneAlerts + TooManyFunctions:ReadyCardio.kt:org.openstrap.app.ui.glow.ReadyCardio.kt + TooManyFunctions:Recovery.kt:Recovery + TooManyFunctions:RecoveryEngine.kt:RecoveryEngine + TooManyFunctions:Rewards.kt:Rewards + TooManyFunctions:RewardsCopy.kt:RewardsCopy + TooManyFunctions:Route.kt:Route + TooManyFunctions:SettingsStore.kt:SettingsStore + TooManyFunctions:ShareCards.kt:org.openstrap.app.ui.glow.ShareCards.kt + TooManyFunctions:SleepCopy.kt:SleepCopy + TooManyFunctions:SleepDetail.kt:org.openstrap.app.ui.glow.SleepDetail.kt + TooManyFunctions:SleepEdits.kt:SleepEdits + TooManyFunctions:SleepSource.kt:SleepSource + TooManyFunctions:SleepWidget.kt:org.openstrap.app.widget.SleepWidget.kt + TooManyFunctions:SleepWidgetModel.kt:SleepLayout + TooManyFunctions:StartCopy.kt:StartCopy + TooManyFunctions:StartWidget.kt:org.openstrap.app.widget.StartWidget.kt + TooManyFunctions:StartWidgetModel.kt:StartLayout + TooManyFunctions:StepsWidget.kt:org.openstrap.app.widget.StepsWidget.kt + TooManyFunctions:StepsWidgetModel.kt:StepsLayout + TooManyFunctions:StrapController.kt:StrapController + TooManyFunctions:StrapManager.kt:StrapManager : StrapController + TooManyFunctions:StrapService.kt:StrapService : LifecycleService + TooManyFunctions:SummaryCopy.kt:SummaryCopy + TooManyFunctions:SummarySections.kt:org.openstrap.app.ui.glow.SummarySections.kt + TooManyFunctions:TabCopy.kt:TabCopy + TooManyFunctions:TodayCards.kt:org.openstrap.app.ui.glow.TodayCards.kt + TooManyFunctions:TodayCopy.kt:TodayCopy + TooManyFunctions:TodayLooks.kt:org.openstrap.app.ui.dashboard.TodayLooks.kt + TooManyFunctions:WidgetParts.kt:org.openstrap.app.widget.WidgetParts.kt + TooManyFunctions:WidgetUpdater.kt:WidgetUpdater + TooManyFunctions:WorkoutCopy.kt:WorkoutCopy + TooManyFunctions:WorkoutDetailCopy.kt:WorkoutDetailCopy + TooManyFunctions:WorkoutRecorder.kt:WorkoutRecorder + TooManyFunctions:WorkoutSession.kt:WorkoutSession + TooManyFunctions:Workouts.kt:Workouts + TooManyFunctions:ZeppCloud.kt:ZeppCloud + TopLevelPropertyNaming:GlowCharts.kt:/** Your normal behind a chart: a band of the ink at 12% (spec 2.7). */ private const val NormalBandAlpha = ChartInk.NORMAL_BAND_ALPHA + TopLevelPropertyNaming:GlowWorkoutScreen.kt:/** The heart rate line is drawn in ten-second steps: readable on a phone, and the card still says the time. */ private const val HeartStep = 10L + TopLevelPropertyNaming:GlowWorkoutSummary.kt:/** How much higher the hero's light reaches at the top of a personal best's swell. */ private const val SwellLevel = 0.25f + TopLevelPropertyNaming:GlowWorkoutSummary.kt:/** The drop line shows the last minute of the workout before the end. */ private const val BeforeS = 60L + TopLevelPropertyNaming:ShareCards.kt:/** White for a card's dimmer lines, as on the boards. */ private const val StoryDim = 0.7f + TopLevelPropertyNaming:SummarySections.kt:/** How many points a heart line is drawn through. */ private const val HeartPoints = 60 + TopLevelPropertyNaming:SummarySections.kt:/** Rounds shown with their figures; past this many there's no room, and the columns stand alone. */ private const val LabelledRounds = 10 + UnnecessaryLaunchedEffect:ChooseScreen.kt:LaunchedEffect + UnnecessaryLaunchedEffect:DeviceScreen.kt:LaunchedEffect + UnnecessaryLaunchedEffect:GlowTodayScreen.kt:LaunchedEffect + UnnecessaryLaunchedEffect:GlowWorkoutMode.kt:LaunchedEffect + UnnecessaryLaunchedEffect:GoalsSheet.kt:LaunchedEffect + UnnecessaryLaunchedEffect:LiveDistance.kt:LaunchedEffect + UnnecessaryLaunchedEffect:LiveHeart.kt:LaunchedEffect + UnnecessaryLaunchedEffect:MainActivity.kt:LaunchedEffect + UnnecessaryLaunchedEffect:ReadyCardio.kt:LaunchedEffect + UnnecessaryLaunchedEffect:WorkoutMap.kt:LaunchedEffect + UnsafeCallOnNullableType:Activity.kt:Activity$minutes[k].hrr!! + UnsafeCallOnNullableType:ActivityDetail.kt:shown.first!! + UnsafeCallOnNullableType:ChartSpec.kt:values[lastIndex]!! + UnsafeCallOnNullableType:CsvTables.kt:CsvRow$cell(name)!! + UnsafeCallOnNullableType:EnergyExpenditure.kt:EnergyExpenditure$hrSum[m.t]!! + UnsafeCallOnNullableType:EnergyExpenditure.kt:EnergyExpenditure$m.hrr!! + UnsafeCallOnNullableType:FamilySummary.kt:FamilySummary$last.workout.averageHeartRate!! + UnsafeCallOnNullableType:FamilySummary.kt:FamilySummary$lines[it].peak!! + UnsafeCallOnNullableType:FamilySummary.kt:FamilySummary$r.workEnd!! + UnsafeCallOnNullableType:FamilySummary.kt:FamilySummary$s.end!! + UnsafeCallOnNullableType:GattClient.kt:GattClient$gatt!! + UnsafeCallOnNullableType:GlowCharts.kt:values[i]!! + UnsafeCallOnNullableType:GlowWorkoutMode.kt:WorkoutRecorder.liveZone(a.second, maxHr)!! + UnsafeCallOnNullableType:GlowWorkoutSummary.kt:set.end!! + UnsafeCallOnNullableType:GoalsSheet.kt:activeValue!! + UnsafeCallOnNullableType:GoalsSheet.kt:sleepValue!! + UnsafeCallOnNullableType:GoalsSheet.kt:stepsValue!! + UnsafeCallOnNullableType:HistorySync.kt:HistorySync$next!! + UnsafeCallOnNullableType:HistorySync.kt:HistorySync$spec.parser!! + UnsafeCallOnNullableType:LiveDistance.kt:round.workEnd!! + UnsafeCallOnNullableType:LiveIntervals.kt:it.workEnd!! + UnsafeCallOnNullableType:LiveSets.kt:s.end!! + UnsafeCallOnNullableType:MomentWidgetModel.kt:MomentWidgetModel.Content$input.lastWorkout!! + UnsafeCallOnNullableType:MomentWidgetModel.kt:MomentWidgetModel.Content$input.night!! + UnsafeCallOnNullableType:MomentWidgetModel.kt:MomentWidgetModel.Content$input.recording!! + UnsafeCallOnNullableType:MomentWidgetModel.kt:MomentWidgetModel.Content$n.session.score!! + UnsafeCallOnNullableType:Phases.kt:Phases$r.workEnd!! + UnsafeCallOnNullableType:ReadyPlans.kt:periods!! + UnsafeCallOnNullableType:ReadyPlans.kt:rounds!! + UnsafeCallOnNullableType:ReadyScreen.kt:pair!! + UnsafeCallOnNullableType:Recovery.kt:Recovery$percentile(values, 0.5f)!! + UnsafeCallOnNullableType:Recovery.kt:Recovery$percentile(values.map { abs(it - centre) }, 0.5f)!! + UnsafeCallOnNullableType:Recovery.kt:Recovery$steps!! + UnsafeCallOnNullableType:Recovery.kt:Recovery$tonight.hrv!! + UnsafeCallOnNullableType:ReleaseNotes.kt:ReleaseNotes$bullet.find(line)!! + UnsafeCallOnNullableType:ReleaseNotes.kt:ReleaseNotes$heading.find(line)!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$badge(id)!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$badge(id)!!.needs!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$it.gpsMeters!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$it.second.exercise!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$it.second.weightKg!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$s.exercise!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$s.weightKg!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$sets.maxByOrNull { it.second.weightKg!! }!! + UnsafeCallOnNullableType:Rewards.kt:Rewards$w.gpsMeters!! + UnsafeCallOnNullableType:RewardsCopy.kt:RewardsView$progress[b.id]!! + UnsafeCallOnNullableType:ShareCards.kt:it.exercise!! + UnsafeCallOnNullableType:ShareCards.kt:mine.first().exercise!! + UnsafeCallOnNullableType:SleepEdits.kt:SleepEdits.Resolved$edit.end!! + UnsafeCallOnNullableType:SleepEdits.kt:SleepEdits.Resolved$edit.start!! + UnsafeCallOnNullableType:StrapStatus.kt:lastSync!! + UnsafeCallOnNullableType:Strength.kt:Strength$it.reps!! + UnsafeCallOnNullableType:Strength.kt:Strength$it.weightKg!! + UnsafeCallOnNullableType:SummaryCopy.kt:SummaryCopy$usual!! + UnsafeCallOnNullableType:SummarySections.kt:it.value!! + UnsafeCallOnNullableType:TabCopy.kt:TabCopy$reg.band!! + UnsafeCallOnNullableType:TabParts.kt:GlowGrid.height(CardSize.Tall)!! + UnsafeCallOnNullableType:TodayLooks.kt:out[a].look!! + UnsafeCallOnNullableType:TodayLooks.kt:slots[a].look!! + UnsafeCallOnNullableType:Vo2max.kt:Vo2max$Recovery.percentile(recent.takeLast(RHR_NIGHTS_USED), 0.5f)!! + UnsafeCallOnNullableType:WeekStory.kt:WeekStory$it.steps!! + UnsafeCallOnNullableType:WorkoutEffectEngine.kt:WorkoutEffectEngine$p.end!! + UnsafeCallOnNullableType:WorkoutEffectEngine.kt:WorkoutEffectEngine$w.end!! + UnsafeCallOnNullableType:WorkoutRecorder.kt:WorkoutRecorder$liveZone(a.second, maxHr)!! + UnsafeCallOnNullableType:WorkoutSession.kt:WorkoutSession$WorkoutRecorder.liveZone(pbpm, r.maxHeartRate)!! + UnsafeCallOnNullableType:WorkoutSession.kt:WorkoutSession$it.end!! + UnsafeCallOnNullableType:WorkoutSuggestions.kt:WorkoutSuggestions$WorkoutRecorder.liveZone(it.second, maxHr)!! + UnstableCollections:ActivityDetail.kt:List<Color> + UnstableCollections:ActivityDetail.kt:List<Float> + UnstableCollections:ActivityDetail.kt:List<List<Float>> + UnstableCollections:ActivityDetail.kt:List<Pair<String, Color>> + UnstableCollections:ActivityDetail.kt:List<String> + UnstableCollections:ActivityDetail.kt:Map<LocalDate, CaloriesCopy.DayKcal> + UnstableCollections:AllMetricsScreen.kt:List<IndexGroup> + UnstableCollections:Celebrations.kt:List<Long> + UnstableCollections:Celebrations.kt:List<Pair<Rewards.Badge, String?>> + UnstableCollections:Celebrations.kt:List<Rewards.Badge> + UnstableCollections:Charts.kt:List<ChartPoint> + UnstableCollections:Charts.kt:List<ChartSegment> + UnstableCollections:Charts.kt:List<Pair<Float, Color>>? + UnstableCollections:ChooseScreen.kt:List<Int> + UnstableCollections:ChooseScreen.kt:List<RecentChip> + UnstableCollections:ChooseScreen.kt:Map<Int, Boolean> + UnstableCollections:DayCalendar.kt:Set<LocalDate> + UnstableCollections:DeletedSleepPage.kt:List<SleepEdits.Resolved> + UnstableCollections:DeviceScreen.kt:List<String> + UnstableCollections:FirmwareScreen.kt:List<String> + UnstableCollections:GlowActivityScreen.kt:List<Float> + UnstableCollections:GlowActivityScreen.kt:List<Float>? + UnstableCollections:GlowActivityScreen.kt:List<Int> + UnstableCollections:GlowActivityScreen.kt:List<WorkoutAnalysis> + UnstableCollections:GlowCharts.kt:List<Color> + UnstableCollections:GlowCharts.kt:List<Color>? + UnstableCollections:GlowCharts.kt:List<Float> + UnstableCollections:GlowCharts.kt:List<Float>? + UnstableCollections:GlowCharts.kt:List<Float?> + UnstableCollections:GlowCharts.kt:List<GlowDay> + UnstableCollections:GlowCharts.kt:List<GlowStage> + UnstableCollections:GlowCharts.kt:List<Int> + UnstableCollections:GlowCharts.kt:List<Int?> + UnstableCollections:GlowCharts.kt:List<String> + UnstableCollections:GlowCharts.kt:List<org.openstrap.app.ui.components.StageSpan> + UnstableCollections:GlowCharts.kt:List<org.openstrap.app.ui.dashboard.DetailCopy.Level?> + UnstableCollections:GlowGrid.kt:List<GridCard> + UnstableCollections:GlowLayout.kt:List<@Composable () -> Unit> + UnstableCollections:GlowMetricDetail.kt:List<DayStat> + UnstableCollections:GlowMetricDetail.kt:List<DetailCopy.StripDay> + UnstableCollections:GlowMetricDetail.kt:List<DetailSibling> + UnstableCollections:GlowMetricDetail.kt:List<Metric> + UnstableCollections:GlowMetricDetail.kt:List<Pair<String?, String>> + UnstableCollections:GlowMetricDetail.kt:List<Range> + UnstableCollections:GlowMetricDetail.kt:List<String> + UnstableCollections:GlowRecoveryScreen.kt:List<BodySignal> + UnstableCollections:GlowSleepScreen.kt:List<Pair<Int, Long>> + UnstableCollections:GlowSleepScreen.kt:List<StageSpan> + UnstableCollections:GlowTodayScreen.kt:List<Float>? + UnstableCollections:GlowTodayScreen.kt:List<WorkoutAnalysis> + UnstableCollections:GlowWorkoutMode.kt:List<Int> + UnstableCollections:GlowWorkoutMode.kt:List<Pair<Long, Int>> + UnstableCollections:GlowWorkoutMode.kt:List<Pair<Long, Int>>? + UnstableCollections:GlowWorkoutMode.kt:Map<Int, Boolean> + UnstableCollections:GlowWorkoutScreen.kt:List<Float?> + UnstableCollections:GlowWorkoutScreen.kt:List<Int> + UnstableCollections:GlowWorkoutScreen.kt:List<LongRange> + UnstableCollections:GlowWorkoutScreen.kt:List<WorkoutAnalysis> + UnstableCollections:GlowWorkoutScreen.kt:List<WorkoutFact> + UnstableCollections:GlowWorkoutSummary.kt:List<Pair<Long, Int>> + UnstableCollections:GlowWorkoutSummary.kt:List<Route.Point> + UnstableCollections:GlowWorkoutSummary.kt:List<SplitRow> + UnstableCollections:GlowWorkoutSummary.kt:List<WorkoutAnalysis> + UnstableCollections:GoalsAndBests.kt:List<Pair<String, Boolean>> + UnstableCollections:InfoSheet.kt:List<Pair<String, String>>? + UnstableCollections:InfoSheet.kt:List<String> + UnstableCollections:Insights.kt:List<NavItem> + UnstableCollections:LiveDistance.kt:List<Pair<Long, Int>> + UnstableCollections:LiveDistance.kt:List<Route.Point> + UnstableCollections:LiveDistance.kt:List<Route.Split> + UnstableCollections:LiveFrame.kt:List<Pair<String, Color>> + UnstableCollections:LiveGame.kt:List<Pair<Long, Int>> + UnstableCollections:LiveHeart.kt:List<LongRange> + UnstableCollections:LiveHeart.kt:List<Pair<Long, Int>> + UnstableCollections:LiveHeart.kt:List<Route.Point> + UnstableCollections:LiveHeart.kt:List<org.openstrap.app.data.db.ExerciseRow> + UnstableCollections:LiveIndoor.kt:List<Pair<Long, Int>> + UnstableCollections:LiveIntervals.kt:List<AppWorkoutRound> + UnstableCollections:LiveIntervals.kt:List<Pair<Long, Int>> + UnstableCollections:LiveIntervals.kt:List<Route.Point> + UnstableCollections:LiveMap.kt:List<LongRange> + UnstableCollections:LiveMap.kt:List<Pair<Long, Int>> + UnstableCollections:LiveMap.kt:List<Pair<String?, String>> + UnstableCollections:LiveMap.kt:List<Route.Point> + UnstableCollections:LiveMind.kt:List<Float?> + UnstableCollections:LiveMind.kt:List<Pair<Long, Int>> + UnstableCollections:LivePaused.kt:List<Pair<Long, Int>> + UnstableCollections:LivePaused.kt:List<Pair<String?, String>> + UnstableCollections:LiveSets.kt:List<AppWorkoutSet> + UnstableCollections:LiveSets.kt:List<ExerciseRow> + UnstableCollections:LiveSets.kt:List<Pair<Long, Int>> + UnstableCollections:LiveSets.kt:List<String> + UnstableCollections:MainActivity.kt:List<Page> + UnstableCollections:PageParts.kt:List<T> + UnstableCollections:ReadyCardio.kt:List<GoalKind> + UnstableCollections:ReadyCardio.kt:List<NumberField> + UnstableCollections:ReadyCardio.kt:List<Pair<Long, String>> + UnstableCollections:ReadyCardio.kt:List<Pair<String, String>> + UnstableCollections:ReadyCardio.kt:List<Pair<T, PlanOption>> + UnstableCollections:ReadyCardio.kt:List<Pair<T, String>> + UnstableCollections:ReadyCardio.kt:List<PlanOption> + UnstableCollections:ReadyCardio.kt:List<SavedWorkout> + UnstableCollections:ReadyScreen.kt:List<Int> + UnstableCollections:ReadyScreen.kt:List<org.openstrap.app.data.db.SavedWorkout> + UnstableCollections:ReadyScreen.kt:Map<Int, Boolean> + UnstableCollections:RecoveryWidget.kt:List<RecoveryFigure> + UnstableCollections:RecoveryWidget.kt:List<RecoveryWeekDay> + UnstableCollections:ScoreDetail.kt:List<org.openstrap.app.data.WorkoutAnalysis> + UnstableCollections:ShareCards.kt:List<Float?> + UnstableCollections:ShareCards.kt:List<Int> + UnstableCollections:ShareCards.kt:List<Int?> + UnstableCollections:ShareCards.kt:List<Route.Point> + UnstableCollections:ShareCards.kt:List<WeekFigure> + UnstableCollections:ShareScreen.kt:List<EarnedLine> + UnstableCollections:ShareScreen.kt:List<Route.Point> + UnstableCollections:ShareScreen.kt:List<WorkoutAnalysis> + UnstableCollections:SleepDetail.kt:List<ChartPoint> + UnstableCollections:SleepDetail.kt:List<SleepSession> + UnstableCollections:SleepDetail.kt:List<String> + UnstableCollections:SleepPanels.kt:List<SleepNight> + UnstableCollections:SleepWidget.kt:List<SleepLegendItem> + UnstableCollections:SleepWidget.kt:List<SleepStageRow> + UnstableCollections:SummarySections.kt:List<EarnedLine> + UnstableCollections:SummarySections.kt:List<ExerciseSets> + UnstableCollections:SummarySections.kt:List<Float?> + UnstableCollections:SummarySections.kt:List<Long> + UnstableCollections:SummarySections.kt:List<Pair<Long, Int>> + UnstableCollections:SummarySections.kt:List<Pair<String, Boolean>> + UnstableCollections:SummarySections.kt:List<Pair<String?, String>> + UnstableCollections:SummarySections.kt:List<PeriodLine> + UnstableCollections:SummarySections.kt:List<RoundLine> + UnstableCollections:SummarySections.kt:List<Route.Point> + UnstableCollections:SummarySections.kt:List<WorkoutAnalysis> + UnstableCollections:TabParts.kt:List<Pair<String?, String>> + UnstableCollections:TodayCards.kt:List<BodyRow> + UnstableCollections:TodayCards.kt:List<Float>? + UnstableCollections:TodayCards.kt:List<Float?>? + UnstableCollections:TodayCards.kt:List<HealthAlertRow> + UnstableCollections:TodayCards.kt:List<String> + UnstableCollections:TodayCards.kt:List<org.openstrap.app.data.WorkoutAnalysis> + UnstableCollections:WidgetParts.kt:List<Pair<String, Boolean>> + UnstableCollections:WorkoutMap.kt:List<Pair<List<Route.Point>, Int>> + UnusedParameter:Narrative.kt:Narrative$session: SleepSession + UnusedParameter:Rewards.kt:Rewards$days: List<Day> + UnusedParameter:SampleData.kt:SampleData$dao: HealthDao + UnusedPrivateFunction:StrapService.kt:StrapService.Companion$private fun zoneRange + UnusedPrivateFunction:SummaryCopy.kt:SummaryCopy$private fun month: String + UnusedPrivateProperty:SummaryCopy.kt:SummaryCopy$/** Shorter than these and a best means little. */ private const val BEST_MIN_M = 1000f + UnusedPrivateProperty:SummaryCopy.kt:SummaryCopy$private const val BEST_MIN_S = 10 * 60L + UnusedVariable:LiveDistance.kt:val density = LocalDensity.current + UnusedVariable:WorkoutsViewModel.kt:WorkoutsViewModel$all + UseIsNullOrEmpty:WeeklyRecap.kt:WeeklyRecap$week == null || week.count == 0 + UseOrEmpty:ActivityDetail.kt:e?.let { mapOf(date to CaloriesCopy.DayKcal(date, it.activeKcal, it.totalKcal, it.workoutKcal, it.partial)) } ?: emptyMap() + UseOrEmpty:ActivityDetail.kt:it.second ?: emptyList() + UseOrEmpty:Backtest.kt:Backtest.Companion$it?.toString() ?: "" + UseOrEmpty:CsvExporter.kt:CsvExporter.Companion$v?.toString()?.let(::escape) ?: "" + UseOrEmpty:GlowSleepScreen.kt:SLEEP_NAMES[it.key] ?: "" + UseOrEmpty:GlowSleepScreen.kt:byRow[it] ?: "" + UseOrEmpty:GlowWorkoutSummary.kt:r.averageBpm?.let { ", average $it bpm" } ?: "" + UseOrEmpty:HeartZonesCopy.kt:HeartZonesCopy$m.learnedOn?.let { ", " + it.format(DateTimeFormatter.ofPattern("d MMM", locale)) } ?: "" + UseOrEmpty:HistorySync.kt:HistorySync$only?.let { ", syncing $it" } ?: "" + UseOrEmpty:LiveGlance.kt:Glance$(i.target as? WorkoutTarget.Time)?.let { " of ${it.seconds / 60} min" } ?: "" + UseOrEmpty:LiveGlance.kt:Glance$m.drop?.takeIf { it > 0 }?.let { ", down $it" } ?: "" + UseOrEmpty:LiveGlance.kt:Glance$s.drop?.takeIf { it > 0 }?.let { ", down $it" } ?: "" + UseOrEmpty:LiveGlance.kt:Glance$s.setsTarget?.let { " of $it" } ?: "" + UseOrEmpty:LiveIntervals.kt:m.drop?.takeIf { it > 0 }?.let { ", down $it" } ?: "" + UseOrEmpty:LiveMind.kt:first?.minus(b)?.takeIf { it > 0 }?.let { ", down $it since you started" } ?: "" + UseOrEmpty:LivePaused.kt:drop?.takeIf { it > 0 }?.let { ", down $it since you stopped" } ?: "" + UseOrEmpty:LiveSets.kt:drop?.takeIf { it > 0 }?.let { ", down $it" } ?: "" + UseOrEmpty:LiveSets.kt:target?.let { ", ${(it - done).coerceAtLeast(0)} to go" } ?: "" + UseOrEmpty:MainActivity.kt:last?.let { org.openstrap.app.ui.workout.StartCopy.lastTime(it, today, zone) } ?: "" + UseOrEmpty:MomentWidgetModel.kt:MomentWidgetModel.Content$nowValue?.let { ", $it now" } ?: "" + UseOrEmpty:MomentWidgetModel.kt:MomentWidgetModel.Content$word?.let { ", $it" } ?: "" + UseOrEmpty:ShareCards.kt:top?.let { ", top ${Strength.kgText(it)} kg" } ?: "" + UseOrEmpty:TodayCopy.kt:TodayCopy$score?.let { s -> ", score $s" } ?: "" + UseOrEmpty:WorkoutCapsule.kt:glance.line2?.let { ". $it" } ?: "" + UseOrEmpty:WorkoutCapsule.kt:glance.secondLabel ?: "" + UseOrEmpty:WorkoutCopy.kt:WorkoutCopy$avg?.let { ", avg $it" } ?: "" + UseOrEmpty:WorkoutCopy.kt:WorkoutCopy$target?.let { " of $it" } ?: "" + UseOrEmpty:WorkoutDetailCopy.kt:WorkoutDetailCopy$drop120?.let { ", $it in two" } ?: "" + UseOrEmpty:WorkoutDetailCopy.kt:WorkoutDetailCopy$mostlyZone(zoneSeconds)?.let { ", mostly in zone ${it + 1}" } ?: "" + UseOrEmpty:WorkoutDetailCopy.kt:WorkoutDetailCopy$usualLoad?.let { " Your usual is ${Math.round(it)}." } ?: "" + UseRequire:CsvImporter.kt:CsvImporter$throw IllegalArgumentException("This isn't an OpenStrap export.") + VarCouldBeVal:ReadyScreen.kt:var belowLevel by rememberSaveable(key, settings) { mutableStateOf(lastRest.belowBpm ?: StartCopy.DEFAULT_BELOW) } + VarCouldBeVal:Route.kt:Route$var i = times.binarySearch(t).let { if (it < 0) -it - 1 else it } + VarsWithoutStateBacking:ActivityDetail.kt:var first = true + VarsWithoutStateBacking:AlarmsScreen.kt:var first = true + VarsWithoutStateBacking:DeviceScreen.kt:var first = true + VarsWithoutStateBacking:GlowActivityScreen.kt:var day: LocalDate? = null + VarsWithoutStateBacking:GlowActivityScreen.kt:var first = true + VarsWithoutStateBacking:GlowWorkoutSummary.kt:var first = true + VarsWithoutStateBacking:ReadyCardio.kt:var rows = 0 + VarsWithoutStateBacking:ReadyScreen.kt:var divider = false + VarsWithoutStateBacking:StartWidget.kt:var x = -StartLayout.CAPTION_BLEED_DP + VarsWithoutStateBacking:SummarySections.kt:var first = true + VarsWithoutStateBacking:SummarySections.kt:var i = 0 + ViewModelForwarding:AlarmsScreen.kt:HeartAlertsPanel(state, vm) + ViewModelForwarding:DeviceScreen.kt:FindStrapButton(vm, Modifier.weight(1f).height(56.dp)) + ViewModelForwarding:DeviceScreen.kt:KeepConnectedRow(vm, keepConnected, device.address) + ViewModelForwarding:DeviceScreen.kt:ManualPairSheet(vm, scan, onRequestBluetooth, onDismiss = { vm.stopScan(); manual = false }) + ViewModelForwarding:DeviceScreen.kt:PairPanel(vm, scan, onRequestBluetooth) + ViewModelForwarding:FirmwareScreen.kt:CheckResult(step.result, vm) + ViewModelForwarding:FirmwareScreen.kt:StepPanel(step, vm, onInstall = { confirm = it }) + ViewModelForwarding:MainActivity.kt:DeviceScreen( deviceVm, padding, requestBluetooth, container.settings, alarmsState, account != null, lastSeen, onAlarms = { rowOpens(Page.Alarms) }, onPhoneAlerts = { rowOpens(Page.PhoneAlerts) }, onHealthAlerts = { rowOpens(Page.HealthAlerts) }, onFirmware = { rowOpens(Page.Firmware) }, onZeppAccount = { rowOpens(Page.ZeppAccount) }, onBack = back, preview = preview, selected = selected?.row, ) + ViewModelForwarding:MainActivity.kt:FirmwareScreen(firmwareVm, padding, onBack = close, onZeppAccount = { open(Page.ZeppAccount) }) + ViewModelForwarding:MainActivity.kt:ZeppAccountScreen(firmwareVm, padding, onBack = close) + ViewModelInjection:MainActivity.kt:val dayVm: DayViewModel = viewModel(factory = viewModelFactory { initializer { DayViewModel(container.health, container.strap.todaySteps, container.settings.profile, container.autoHealthConnect::sleepEdited) } }) + ViewModelInjection:MainActivity.kt:val deviceVm: DeviceViewModel = viewModel(factory = viewModelFactory { initializer { DeviceViewModel(container) } }) + ViewModelInjection:MainActivity.kt:val firmwareVm: FirmwareViewModel = viewModel(factory = viewModelFactory { initializer { FirmwareViewModel(container) } }) + ViewModelInjection:MainActivity.kt:val workoutsVm: org.openstrap.app.ui.workout.WorkoutsViewModel = viewModel(factory = viewModelFactory { initializer { org.openstrap.app.ui.workout.WorkoutsViewModel(container.database.healthDao(), container.settings.profile, container.maxHeartRates) } }) + WildcardImport:LiveFrame.kt:import org.openstrap.app.ui.glow.* + WildcardImport:LiveHeart.kt:import org.openstrap.app.ui.glow.* + WildcardImport:LiveMap.kt:import org.openstrap.app.ui.glow.* + + diff --git a/app/ktlint-baseline.xml b/app/ktlint-baseline.xml new file mode 100644 index 00000000..5a74acb8 --- /dev/null +++ b/app/ktlint-baseline.xml @@ -0,0 +1,25222 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml new file mode 100644 index 00000000..98ef1bc3 --- /dev/null +++ b/app/lint-baseline.xml @@ -0,0 +1,4294 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/schemas/org.openstrap.app.data.db.OpenStrapDatabase/25.json b/app/schemas/org.openstrap.app.data.db.OpenStrapDatabase/25.json new file mode 100644 index 00000000..4f8f4b89 --- /dev/null +++ b/app/schemas/org.openstrap.app.data.db.OpenStrapDatabase/25.json @@ -0,0 +1,1647 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "dcdd9ac4750fa9031ce6afde25c0b213", + "entities": [ + { + "tableName": "heart_rate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, `source` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "activity_minute", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `intensity` INTEGER NOT NULL, `kind` INTEGER NOT NULL, `heartRate` INTEGER, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intensity", + "columnName": "intensity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "heartRate", + "columnName": "heartRate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "sleep_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `day` INTEGER NOT NULL DEFAULT 0, `nap` INTEGER NOT NULL DEFAULT 0, `score` INTEGER, `averageHeartRate` INTEGER, `deepMinutes` INTEGER, `lightMinutes` INTEGER, `remMinutes` INTEGER, `awakeMinutes` INTEGER, `wakeCount` INTEGER, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nap", + "columnName": "nap", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER" + }, + { + "fieldPath": "averageHeartRate", + "columnName": "averageHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "deepMinutes", + "columnName": "deepMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "lightMinutes", + "columnName": "lightMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "remMinutes", + "columnName": "remMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "awakeMinutes", + "columnName": "awakeMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "wakeCount", + "columnName": "wakeCount", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + }, + "indices": [ + { + "name": "index_sleep_session_day", + "unique": false, + "columnNames": [ + "day" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sleep_session_day` ON `${TABLE_NAME}` (`day`)" + } + ] + }, + { + "tableName": "sleep_stage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `sessionStart` INTEGER NOT NULL, `stage` INTEGER NOT NULL, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionStart", + "columnName": "sessionStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + }, + "indices": [ + { + "name": "index_sleep_stage_sessionStart", + "unique": false, + "columnNames": [ + "sessionStart" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sleep_stage_sessionStart` ON `${TABLE_NAME}` (`sessionStart`)" + } + ] + }, + { + "tableName": "spo2", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `percent` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "percent", + "columnName": "percent", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "stress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "hrv", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `milliseconds` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "milliseconds", + "columnName": "milliseconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "resting_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "skin_temperature", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `celsius` REAL NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "celsius", + "columnName": "celsius", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "breath_rate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `perMinute` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "perMinute", + "columnName": "perMinute", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "pai", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `daily` REAL NOT NULL, `total` REAL NOT NULL, `restingHeartRate` INTEGER NOT NULL, `maxHeartRate` INTEGER NOT NULL, `tomorrowTotal` REAL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "daily", + "columnName": "daily", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "total", + "columnName": "total", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "restingHeartRate", + "columnName": "restingHeartRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxHeartRate", + "columnName": "maxHeartRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tomorrowTotal", + "columnName": "tomorrowTotal", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "hrv_sdnn", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `milliseconds` REAL NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "milliseconds", + "columnName": "milliseconds", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "workout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `sportType` INTEGER NOT NULL, `exerciseSeconds` INTEGER, `calories` INTEGER, `averageHeartRate` INTEGER, `maxHeartRate` INTEGER, `minHeartRate` INTEGER, `aerobicTrainingEffect` REAL, `anaerobicTrainingEffect` REAL, `trainingLoad` INTEGER, `vo2max` INTEGER, `steps` INTEGER, `subtype` INTEGER, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sportType", + "columnName": "sportType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exerciseSeconds", + "columnName": "exerciseSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "calories", + "columnName": "calories", + "affinity": "INTEGER" + }, + { + "fieldPath": "averageHeartRate", + "columnName": "averageHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxHeartRate", + "columnName": "maxHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "minHeartRate", + "columnName": "minHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "aerobicTrainingEffect", + "columnName": "aerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "anaerobicTrainingEffect", + "columnName": "anaerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "trainingLoad", + "columnName": "trainingLoad", + "affinity": "INTEGER" + }, + { + "fieldPath": "vo2max", + "columnName": "vo2max", + "affinity": "INTEGER" + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER" + }, + { + "fieldPath": "subtype", + "columnName": "subtype", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + } + }, + { + "tableName": "workout_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, PRIMARY KEY(`workoutStart`, `timestamp`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "timestamp" + ] + } + }, + { + "tableName": "sport_effect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`end` INTEGER NOT NULL, `recoveryMinutes` INTEGER, `vo2max` REAL, `epoc` REAL, `weeklyLoad` REAL, `monthlyLoad` REAL, `optimalLoadMin` INTEGER, `optimalLoadMax` INTEGER, `maximalHeartRate` INTEGER, PRIMARY KEY(`end`))", + "fields": [ + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recoveryMinutes", + "columnName": "recoveryMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "vo2max", + "columnName": "vo2max", + "affinity": "REAL" + }, + { + "fieldPath": "epoc", + "columnName": "epoc", + "affinity": "REAL" + }, + { + "fieldPath": "weeklyLoad", + "columnName": "weeklyLoad", + "affinity": "REAL" + }, + { + "fieldPath": "monthlyLoad", + "columnName": "monthlyLoad", + "affinity": "REAL" + }, + { + "fieldPath": "optimalLoadMin", + "columnName": "optimalLoadMin", + "affinity": "INTEGER" + }, + { + "fieldPath": "optimalLoadMax", + "columnName": "optimalLoadMax", + "affinity": "INTEGER" + }, + { + "fieldPath": "maximalHeartRate", + "columnName": "maximalHeartRate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "end" + ] + } + }, + { + "tableName": "strap_readiness", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `score` INTEGER, `hrvScore` INTEGER, `restingHrScore` INTEGER, `skinTemperatureScore` INTEGER, `breathingScore` INTEGER, `sleepHrv` INTEGER, `sleepRestingHr` INTEGER, `algorithmVersion` TEXT NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER" + }, + { + "fieldPath": "hrvScore", + "columnName": "hrvScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "restingHrScore", + "columnName": "restingHrScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "skinTemperatureScore", + "columnName": "skinTemperatureScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "breathingScore", + "columnName": "breathingScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "sleepHrv", + "columnName": "sleepHrv", + "affinity": "INTEGER" + }, + { + "fieldPath": "sleepRestingHr", + "columnName": "sleepRestingHr", + "affinity": "INTEGER" + }, + { + "fieldPath": "algorithmVersion", + "columnName": "algorithmVersion", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "sleep_spo2", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `percent` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "percent", + "columnName": "percent", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "osa_event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `drop` INTEGER NOT NULL, `spo2` BLOB, `heartRate` BLOB, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "drop", + "columnName": "drop", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spo2", + "columnName": "spo2", + "affinity": "BLOB" + }, + { + "fieldPath": "heartRate", + "columnName": "heartRate", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "osa_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`belongTime` INTEGER NOT NULL, `validTime` INTEGER NOT NULL, `odiRaw` INTEGER NOT NULL, `odiScore` INTEGER NOT NULL, `dispCode` INTEGER NOT NULL, `eventCount` INTEGER NOT NULL, `version` INTEGER NOT NULL, PRIMARY KEY(`belongTime`))", + "fields": [ + { + "fieldPath": "belongTime", + "columnName": "belongTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "validTime", + "columnName": "validTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "odiRaw", + "columnName": "odiRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "odiScore", + "columnName": "odiScore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dispCode", + "columnName": "dispCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventCount", + "columnName": "eventCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "belongTime" + ] + } + }, + { + "tableName": "daily_max_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "recovery_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `samples` BLOB NOT NULL, PRIMARY KEY(`workoutStart`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "samples", + "columnName": "samples", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart" + ] + } + }, + { + "tableName": "activity_extended", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `floors` INTEGER NOT NULL, `run` INTEGER NOT NULL, `fatBurn` INTEGER NOT NULL, `stand` INTEGER NOT NULL, `walk` INTEGER NOT NULL, `sit` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `calories` INTEGER NOT NULL, `distanceCm` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "floors", + "columnName": "floors", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "run", + "columnName": "run", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fatBurn", + "columnName": "fatBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stand", + "columnName": "stand", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walk", + "columnName": "walk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sit", + "columnName": "sit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calories", + "columnName": "calories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distanceCm", + "columnName": "distanceCm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "raw_record", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `payload` BLOB NOT NULL, PRIMARY KEY(`type`, `timestamp`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "type", + "timestamp" + ] + } + }, + { + "tableName": "health_alert", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`timestamp`, `type`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp", + "type" + ] + } + }, + { + "tableName": "check_in", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `feeling` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feeling", + "columnName": "feeling", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "day_tag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `tag` TEXT NOT NULL, PRIMARY KEY(`day`, `tag`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day", + "tag" + ] + } + }, + { + "tableName": "night_metrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `hrvLn` REAL, `hrvSamples` INTEGER NOT NULL, `rhrLowest30` REAL, `rhrMeanSleep` REAL, `rhrP10` REAL, `hrMinutes` INTEGER NOT NULL, `strapHrv` INTEGER, `strapRestingHr` INTEGER, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hrvLn", + "columnName": "hrvLn", + "affinity": "REAL" + }, + { + "fieldPath": "hrvSamples", + "columnName": "hrvSamples", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rhrLowest30", + "columnName": "rhrLowest30", + "affinity": "REAL" + }, + { + "fieldPath": "rhrMeanSleep", + "columnName": "rhrMeanSleep", + "affinity": "REAL" + }, + { + "fieldPath": "rhrP10", + "columnName": "rhrP10", + "affinity": "REAL" + }, + { + "fieldPath": "hrMinutes", + "columnName": "hrMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "strapHrv", + "columnName": "strapHrv", + "affinity": "INTEGER" + }, + { + "fieldPath": "strapRestingHr", + "columnName": "strapRestingHr", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "app_workout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER, `sportType` INTEGER NOT NULL, `source` INTEGER NOT NULL, `pauses` TEXT NOT NULL, `pausedAt` INTEGER, `endReason` INTEGER, `averageHeartRate` INTEGER, `maxHeartRate` INTEGER, `askToEnd` INTEGER NOT NULL DEFAULT 1, `recordedEnd` INTEGER, `shownStart` INTEGER, `target` TEXT, `useGps` INTEGER NOT NULL DEFAULT 0, `keepRoute` INTEGER NOT NULL DEFAULT 0, `distanceMeters` REAL, `distanceFromSteps` INTEGER NOT NULL DEFAULT 0, `rest` TEXT, `setsTarget` INTEGER, `laps` TEXT NOT NULL DEFAULT '', `epoc` REAL, `aerobicTrainingEffect` REAL, `recoveryMinutes` INTEGER, `vo2max` REAL, `anaerobicTrainingEffect` REAL, `effectFromStrap` INTEGER NOT NULL DEFAULT 0, `ownRecoveryMinutes` INTEGER, `activeKcal` REAL, `totalKcal` REAL, `targetReachedAt` INTEGER, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "sportType", + "columnName": "sportType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pauses", + "columnName": "pauses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pausedAt", + "columnName": "pausedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endReason", + "columnName": "endReason", + "affinity": "INTEGER" + }, + { + "fieldPath": "averageHeartRate", + "columnName": "averageHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxHeartRate", + "columnName": "maxHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "askToEnd", + "columnName": "askToEnd", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "recordedEnd", + "columnName": "recordedEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "shownStart", + "columnName": "shownStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "target", + "columnName": "target", + "affinity": "TEXT" + }, + { + "fieldPath": "useGps", + "columnName": "useGps", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepRoute", + "columnName": "keepRoute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "distanceMeters", + "columnName": "distanceMeters", + "affinity": "REAL" + }, + { + "fieldPath": "distanceFromSteps", + "columnName": "distanceFromSteps", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rest", + "columnName": "rest", + "affinity": "TEXT" + }, + { + "fieldPath": "setsTarget", + "columnName": "setsTarget", + "affinity": "INTEGER" + }, + { + "fieldPath": "laps", + "columnName": "laps", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "epoc", + "columnName": "epoc", + "affinity": "REAL" + }, + { + "fieldPath": "aerobicTrainingEffect", + "columnName": "aerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "recoveryMinutes", + "columnName": "recoveryMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "vo2max", + "columnName": "vo2max", + "affinity": "REAL" + }, + { + "fieldPath": "anaerobicTrainingEffect", + "columnName": "anaerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "effectFromStrap", + "columnName": "effectFromStrap", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ownRecoveryMinutes", + "columnName": "ownRecoveryMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "activeKcal", + "columnName": "activeKcal", + "affinity": "REAL" + }, + { + "fieldPath": "totalKcal", + "columnName": "totalKcal", + "affinity": "REAL" + }, + { + "fieldPath": "targetReachedAt", + "columnName": "targetReachedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + } + }, + { + "tableName": "app_workout_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, `fromHistory` INTEGER NOT NULL, PRIMARY KEY(`workoutStart`, `timestamp`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromHistory", + "columnName": "fromHistory", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "timestamp" + ] + } + }, + { + "tableName": "app_workout_dismissed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + } + }, + { + "tableName": "app_workout_route", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `lat` REAL NOT NULL, `lon` REAL NOT NULL, `accuracy` REAL NOT NULL, `speed` REAL, `bearing` REAL, `altitude` REAL, `speedAccuracy` REAL, `bearingAccuracy` REAL, PRIMARY KEY(`workoutStart`, `timestamp`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lat", + "columnName": "lat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lon", + "columnName": "lon", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accuracy", + "columnName": "accuracy", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "REAL" + }, + { + "fieldPath": "bearing", + "columnName": "bearing", + "affinity": "REAL" + }, + { + "fieldPath": "altitude", + "columnName": "altitude", + "affinity": "REAL" + }, + { + "fieldPath": "speedAccuracy", + "columnName": "speedAccuracy", + "affinity": "REAL" + }, + { + "fieldPath": "bearingAccuracy", + "columnName": "bearingAccuracy", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "timestamp" + ] + } + }, + { + "tableName": "app_workout_set", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `index` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER, `restEnd` INTEGER, PRIMARY KEY(`workoutStart`, `index`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "restEnd", + "columnName": "restEnd", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "index" + ] + } + }, + { + "tableName": "wear_event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `worn` INTEGER, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worn", + "columnName": "worn", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "habit_age_week", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`asOf` INTEGER NOT NULL, `estimate` REAL NOT NULL, `low` INTEGER NOT NULL, `high` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`asOf`))", + "fields": [ + { + "fieldPath": "asOf", + "columnName": "asOf", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "estimate", + "columnName": "estimate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "low", + "columnName": "low", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "high", + "columnName": "high", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "asOf" + ] + } + }, + { + "tableName": "workout_deleted", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `effectEnd` INTEGER, `deletedAt` INTEGER NOT NULL, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "effectEnd", + "columnName": "effectEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + } + }, + { + "tableName": "daily_energy", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `totalKcal` REAL NOT NULL, `activeKcal` REAL NOT NULL, `bmrKcal` REAL NOT NULL, `minutesWithData` INTEGER NOT NULL, `partial` INTEGER NOT NULL, `workoutKcal` REAL NOT NULL, `walkingKcal` REAL NOT NULL, `hourly` TEXT NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalKcal", + "columnName": "totalKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "activeKcal", + "columnName": "activeKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "bmrKcal", + "columnName": "bmrKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "minutesWithData", + "columnName": "minutesWithData", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partial", + "columnName": "partial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workoutKcal", + "columnName": "workoutKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "walkingKcal", + "columnName": "walkingKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "hourly", + "columnName": "hourly", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "sleep_edit", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`strapStart` INTEGER NOT NULL, `nap` INTEGER NOT NULL DEFAULT 0, `start` INTEGER, `end` INTEGER, `deleted` INTEGER NOT NULL DEFAULT 0, `editedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`strapStart`))", + "fields": [ + { + "fieldPath": "strapStart", + "columnName": "strapStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nap", + "columnName": "nap", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "strapStart" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dcdd9ac4750fa9031ce6afde25c0b213')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.openstrap.app.data.db.OpenStrapDatabase/26.json b/app/schemas/org.openstrap.app.data.db.OpenStrapDatabase/26.json new file mode 100644 index 00000000..2db92acb --- /dev/null +++ b/app/schemas/org.openstrap.app.data.db.OpenStrapDatabase/26.json @@ -0,0 +1,1904 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "b13f9feb0334c575a1ce303479764874", + "entities": [ + { + "tableName": "heart_rate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, `source` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "activity_minute", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `intensity` INTEGER NOT NULL, `kind` INTEGER NOT NULL, `heartRate` INTEGER, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intensity", + "columnName": "intensity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "heartRate", + "columnName": "heartRate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "sleep_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `day` INTEGER NOT NULL DEFAULT 0, `nap` INTEGER NOT NULL DEFAULT 0, `score` INTEGER, `averageHeartRate` INTEGER, `deepMinutes` INTEGER, `lightMinutes` INTEGER, `remMinutes` INTEGER, `awakeMinutes` INTEGER, `wakeCount` INTEGER, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nap", + "columnName": "nap", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER" + }, + { + "fieldPath": "averageHeartRate", + "columnName": "averageHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "deepMinutes", + "columnName": "deepMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "lightMinutes", + "columnName": "lightMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "remMinutes", + "columnName": "remMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "awakeMinutes", + "columnName": "awakeMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "wakeCount", + "columnName": "wakeCount", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + }, + "indices": [ + { + "name": "index_sleep_session_day", + "unique": false, + "columnNames": [ + "day" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sleep_session_day` ON `${TABLE_NAME}` (`day`)" + } + ] + }, + { + "tableName": "sleep_stage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `sessionStart` INTEGER NOT NULL, `stage` INTEGER NOT NULL, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionStart", + "columnName": "sessionStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + }, + "indices": [ + { + "name": "index_sleep_stage_sessionStart", + "unique": false, + "columnNames": [ + "sessionStart" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sleep_stage_sessionStart` ON `${TABLE_NAME}` (`sessionStart`)" + } + ] + }, + { + "tableName": "spo2", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `percent` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "percent", + "columnName": "percent", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "stress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "hrv", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `milliseconds` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "milliseconds", + "columnName": "milliseconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "resting_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "skin_temperature", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `celsius` REAL NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "celsius", + "columnName": "celsius", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "breath_rate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `perMinute` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "perMinute", + "columnName": "perMinute", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "pai", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `daily` REAL NOT NULL, `total` REAL NOT NULL, `restingHeartRate` INTEGER NOT NULL, `maxHeartRate` INTEGER NOT NULL, `tomorrowTotal` REAL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "daily", + "columnName": "daily", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "total", + "columnName": "total", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "restingHeartRate", + "columnName": "restingHeartRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxHeartRate", + "columnName": "maxHeartRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tomorrowTotal", + "columnName": "tomorrowTotal", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "hrv_sdnn", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `milliseconds` REAL NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "milliseconds", + "columnName": "milliseconds", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "workout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `sportType` INTEGER NOT NULL, `exerciseSeconds` INTEGER, `calories` INTEGER, `averageHeartRate` INTEGER, `maxHeartRate` INTEGER, `minHeartRate` INTEGER, `aerobicTrainingEffect` REAL, `anaerobicTrainingEffect` REAL, `trainingLoad` INTEGER, `vo2max` INTEGER, `steps` INTEGER, `subtype` INTEGER, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sportType", + "columnName": "sportType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exerciseSeconds", + "columnName": "exerciseSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "calories", + "columnName": "calories", + "affinity": "INTEGER" + }, + { + "fieldPath": "averageHeartRate", + "columnName": "averageHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxHeartRate", + "columnName": "maxHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "minHeartRate", + "columnName": "minHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "aerobicTrainingEffect", + "columnName": "aerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "anaerobicTrainingEffect", + "columnName": "anaerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "trainingLoad", + "columnName": "trainingLoad", + "affinity": "INTEGER" + }, + { + "fieldPath": "vo2max", + "columnName": "vo2max", + "affinity": "INTEGER" + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER" + }, + { + "fieldPath": "subtype", + "columnName": "subtype", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + } + }, + { + "tableName": "workout_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, PRIMARY KEY(`workoutStart`, `timestamp`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "timestamp" + ] + } + }, + { + "tableName": "sport_effect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`end` INTEGER NOT NULL, `recoveryMinutes` INTEGER, `vo2max` REAL, `epoc` REAL, `weeklyLoad` REAL, `monthlyLoad` REAL, `optimalLoadMin` INTEGER, `optimalLoadMax` INTEGER, `maximalHeartRate` INTEGER, PRIMARY KEY(`end`))", + "fields": [ + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recoveryMinutes", + "columnName": "recoveryMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "vo2max", + "columnName": "vo2max", + "affinity": "REAL" + }, + { + "fieldPath": "epoc", + "columnName": "epoc", + "affinity": "REAL" + }, + { + "fieldPath": "weeklyLoad", + "columnName": "weeklyLoad", + "affinity": "REAL" + }, + { + "fieldPath": "monthlyLoad", + "columnName": "monthlyLoad", + "affinity": "REAL" + }, + { + "fieldPath": "optimalLoadMin", + "columnName": "optimalLoadMin", + "affinity": "INTEGER" + }, + { + "fieldPath": "optimalLoadMax", + "columnName": "optimalLoadMax", + "affinity": "INTEGER" + }, + { + "fieldPath": "maximalHeartRate", + "columnName": "maximalHeartRate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "end" + ] + } + }, + { + "tableName": "strap_readiness", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `score` INTEGER, `hrvScore` INTEGER, `restingHrScore` INTEGER, `skinTemperatureScore` INTEGER, `breathingScore` INTEGER, `sleepHrv` INTEGER, `sleepRestingHr` INTEGER, `algorithmVersion` TEXT NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER" + }, + { + "fieldPath": "hrvScore", + "columnName": "hrvScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "restingHrScore", + "columnName": "restingHrScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "skinTemperatureScore", + "columnName": "skinTemperatureScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "breathingScore", + "columnName": "breathingScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "sleepHrv", + "columnName": "sleepHrv", + "affinity": "INTEGER" + }, + { + "fieldPath": "sleepRestingHr", + "columnName": "sleepRestingHr", + "affinity": "INTEGER" + }, + { + "fieldPath": "algorithmVersion", + "columnName": "algorithmVersion", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "sleep_spo2", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `percent` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "percent", + "columnName": "percent", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "osa_event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `drop` INTEGER NOT NULL, `spo2` BLOB, `heartRate` BLOB, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "drop", + "columnName": "drop", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spo2", + "columnName": "spo2", + "affinity": "BLOB" + }, + { + "fieldPath": "heartRate", + "columnName": "heartRate", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "osa_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`belongTime` INTEGER NOT NULL, `validTime` INTEGER NOT NULL, `odiRaw` INTEGER NOT NULL, `odiScore` INTEGER NOT NULL, `dispCode` INTEGER NOT NULL, `eventCount` INTEGER NOT NULL, `version` INTEGER NOT NULL, PRIMARY KEY(`belongTime`))", + "fields": [ + { + "fieldPath": "belongTime", + "columnName": "belongTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "validTime", + "columnName": "validTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "odiRaw", + "columnName": "odiRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "odiScore", + "columnName": "odiScore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dispCode", + "columnName": "dispCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventCount", + "columnName": "eventCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "belongTime" + ] + } + }, + { + "tableName": "daily_max_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "recovery_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `samples` BLOB NOT NULL, PRIMARY KEY(`workoutStart`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "samples", + "columnName": "samples", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart" + ] + } + }, + { + "tableName": "activity_extended", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `floors` INTEGER NOT NULL, `run` INTEGER NOT NULL, `fatBurn` INTEGER NOT NULL, `stand` INTEGER NOT NULL, `walk` INTEGER NOT NULL, `sit` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `calories` INTEGER NOT NULL, `distanceCm` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "floors", + "columnName": "floors", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "run", + "columnName": "run", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fatBurn", + "columnName": "fatBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stand", + "columnName": "stand", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walk", + "columnName": "walk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sit", + "columnName": "sit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calories", + "columnName": "calories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distanceCm", + "columnName": "distanceCm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "raw_record", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `payload` BLOB NOT NULL, PRIMARY KEY(`type`, `timestamp`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "type", + "timestamp" + ] + } + }, + { + "tableName": "health_alert", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`timestamp`, `type`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp", + "type" + ] + } + }, + { + "tableName": "check_in", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `feeling` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feeling", + "columnName": "feeling", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "day_tag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `tag` TEXT NOT NULL, PRIMARY KEY(`day`, `tag`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day", + "tag" + ] + } + }, + { + "tableName": "night_metrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `hrvLn` REAL, `hrvSamples` INTEGER NOT NULL, `rhrLowest30` REAL, `rhrMeanSleep` REAL, `rhrP10` REAL, `hrMinutes` INTEGER NOT NULL, `strapHrv` INTEGER, `strapRestingHr` INTEGER, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hrvLn", + "columnName": "hrvLn", + "affinity": "REAL" + }, + { + "fieldPath": "hrvSamples", + "columnName": "hrvSamples", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rhrLowest30", + "columnName": "rhrLowest30", + "affinity": "REAL" + }, + { + "fieldPath": "rhrMeanSleep", + "columnName": "rhrMeanSleep", + "affinity": "REAL" + }, + { + "fieldPath": "rhrP10", + "columnName": "rhrP10", + "affinity": "REAL" + }, + { + "fieldPath": "hrMinutes", + "columnName": "hrMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "strapHrv", + "columnName": "strapHrv", + "affinity": "INTEGER" + }, + { + "fieldPath": "strapRestingHr", + "columnName": "strapRestingHr", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "app_workout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER, `sportType` INTEGER NOT NULL, `source` INTEGER NOT NULL, `pauses` TEXT NOT NULL, `pausedAt` INTEGER, `endReason` INTEGER, `averageHeartRate` INTEGER, `maxHeartRate` INTEGER, `askToEnd` INTEGER NOT NULL DEFAULT 1, `recordedEnd` INTEGER, `shownStart` INTEGER, `target` TEXT, `useGps` INTEGER NOT NULL DEFAULT 0, `keepRoute` INTEGER NOT NULL DEFAULT 0, `distanceMeters` REAL, `distanceFromSteps` INTEGER NOT NULL DEFAULT 0, `rest` TEXT, `setsTarget` INTEGER, `laps` TEXT NOT NULL DEFAULT '', `epoc` REAL, `aerobicTrainingEffect` REAL, `recoveryMinutes` INTEGER, `vo2max` REAL, `anaerobicTrainingEffect` REAL, `effectFromStrap` INTEGER NOT NULL DEFAULT 0, `ownRecoveryMinutes` INTEGER, `activeKcal` REAL, `totalKcal` REAL, `targetReachedAt` INTEGER, `structure` TEXT, `stretches` TEXT, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "sportType", + "columnName": "sportType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pauses", + "columnName": "pauses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pausedAt", + "columnName": "pausedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endReason", + "columnName": "endReason", + "affinity": "INTEGER" + }, + { + "fieldPath": "averageHeartRate", + "columnName": "averageHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxHeartRate", + "columnName": "maxHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "askToEnd", + "columnName": "askToEnd", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "recordedEnd", + "columnName": "recordedEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "shownStart", + "columnName": "shownStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "target", + "columnName": "target", + "affinity": "TEXT" + }, + { + "fieldPath": "useGps", + "columnName": "useGps", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepRoute", + "columnName": "keepRoute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "distanceMeters", + "columnName": "distanceMeters", + "affinity": "REAL" + }, + { + "fieldPath": "distanceFromSteps", + "columnName": "distanceFromSteps", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rest", + "columnName": "rest", + "affinity": "TEXT" + }, + { + "fieldPath": "setsTarget", + "columnName": "setsTarget", + "affinity": "INTEGER" + }, + { + "fieldPath": "laps", + "columnName": "laps", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "epoc", + "columnName": "epoc", + "affinity": "REAL" + }, + { + "fieldPath": "aerobicTrainingEffect", + "columnName": "aerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "recoveryMinutes", + "columnName": "recoveryMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "vo2max", + "columnName": "vo2max", + "affinity": "REAL" + }, + { + "fieldPath": "anaerobicTrainingEffect", + "columnName": "anaerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "effectFromStrap", + "columnName": "effectFromStrap", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ownRecoveryMinutes", + "columnName": "ownRecoveryMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "activeKcal", + "columnName": "activeKcal", + "affinity": "REAL" + }, + { + "fieldPath": "totalKcal", + "columnName": "totalKcal", + "affinity": "REAL" + }, + { + "fieldPath": "targetReachedAt", + "columnName": "targetReachedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "structure", + "columnName": "structure", + "affinity": "TEXT" + }, + { + "fieldPath": "stretches", + "columnName": "stretches", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + } + }, + { + "tableName": "app_workout_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, `fromHistory` INTEGER NOT NULL, PRIMARY KEY(`workoutStart`, `timestamp`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromHistory", + "columnName": "fromHistory", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "timestamp" + ] + } + }, + { + "tableName": "app_workout_dismissed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + } + }, + { + "tableName": "app_workout_route", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `lat` REAL NOT NULL, `lon` REAL NOT NULL, `accuracy` REAL NOT NULL, `speed` REAL, `bearing` REAL, `altitude` REAL, `speedAccuracy` REAL, `bearingAccuracy` REAL, PRIMARY KEY(`workoutStart`, `timestamp`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lat", + "columnName": "lat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lon", + "columnName": "lon", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accuracy", + "columnName": "accuracy", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "REAL" + }, + { + "fieldPath": "bearing", + "columnName": "bearing", + "affinity": "REAL" + }, + { + "fieldPath": "altitude", + "columnName": "altitude", + "affinity": "REAL" + }, + { + "fieldPath": "speedAccuracy", + "columnName": "speedAccuracy", + "affinity": "REAL" + }, + { + "fieldPath": "bearingAccuracy", + "columnName": "bearingAccuracy", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "timestamp" + ] + } + }, + { + "tableName": "app_workout_set", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `index` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER, `restEnd` INTEGER, `exercise` TEXT, `reps` INTEGER, `weightKg` REAL, PRIMARY KEY(`workoutStart`, `index`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "restEnd", + "columnName": "restEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "exercise", + "columnName": "exercise", + "affinity": "TEXT" + }, + { + "fieldPath": "reps", + "columnName": "reps", + "affinity": "INTEGER" + }, + { + "fieldPath": "weightKg", + "columnName": "weightKg", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "index" + ] + } + }, + { + "tableName": "wear_event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `worn` INTEGER, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worn", + "columnName": "worn", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "habit_age_week", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`asOf` INTEGER NOT NULL, `estimate` REAL NOT NULL, `low` INTEGER NOT NULL, `high` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`asOf`))", + "fields": [ + { + "fieldPath": "asOf", + "columnName": "asOf", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "estimate", + "columnName": "estimate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "low", + "columnName": "low", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "high", + "columnName": "high", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "asOf" + ] + } + }, + { + "tableName": "workout_deleted", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `effectEnd` INTEGER, `deletedAt` INTEGER NOT NULL, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "effectEnd", + "columnName": "effectEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + } + }, + { + "tableName": "daily_energy", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `totalKcal` REAL NOT NULL, `activeKcal` REAL NOT NULL, `bmrKcal` REAL NOT NULL, `minutesWithData` INTEGER NOT NULL, `partial` INTEGER NOT NULL, `workoutKcal` REAL NOT NULL, `walkingKcal` REAL NOT NULL, `hourly` TEXT NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalKcal", + "columnName": "totalKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "activeKcal", + "columnName": "activeKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "bmrKcal", + "columnName": "bmrKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "minutesWithData", + "columnName": "minutesWithData", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partial", + "columnName": "partial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workoutKcal", + "columnName": "workoutKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "walkingKcal", + "columnName": "walkingKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "hourly", + "columnName": "hourly", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "sleep_edit", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`strapStart` INTEGER NOT NULL, `nap` INTEGER NOT NULL DEFAULT 0, `start` INTEGER, `end` INTEGER, `deleted` INTEGER NOT NULL DEFAULT 0, `editedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`strapStart`))", + "fields": [ + { + "fieldPath": "strapStart", + "columnName": "strapStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nap", + "columnName": "nap", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "strapStart" + ] + } + }, + { + "tableName": "app_workout_round", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `index` INTEGER NOT NULL, `workStart` INTEGER NOT NULL, `workEnd` INTEGER, `restEnd` INTEGER, `peakBpm` INTEGER, PRIMARY KEY(`workoutStart`, `index`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workStart", + "columnName": "workStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workEnd", + "columnName": "workEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "restEnd", + "columnName": "restEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "peakBpm", + "columnName": "peakBpm", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "index" + ] + } + }, + { + "tableName": "saved_workout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `sport` INTEGER NOT NULL, `structure` TEXT NOT NULL, `goal` TEXT, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sport", + "columnName": "sport", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "structure", + "columnName": "structure", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "goal", + "columnName": "goal", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "exercise", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `lastReps` INTEGER, `lastKg` REAL, `usedAt` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastReps", + "columnName": "lastReps", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastKg", + "columnName": "lastKg", + "affinity": "REAL" + }, + { + "fieldPath": "usedAt", + "columnName": "usedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "personal_best", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`kind` TEXT NOT NULL, `value` REAL NOT NULL, `date` INTEGER NOT NULL, `workoutStart` INTEGER, PRIMARY KEY(`kind`))", + "fields": [ + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "kind" + ] + } + }, + { + "tableName": "badge", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `earnedAt` INTEGER NOT NULL, `workoutStart` INTEGER, `day` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "earnedAt", + "columnName": "earnedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "week_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`weekStart` INTEGER NOT NULL, `goal` INTEGER NOT NULL, `count` INTEGER NOT NULL, `met` INTEGER NOT NULL, PRIMARY KEY(`weekStart`))", + "fields": [ + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "goal", + "columnName": "goal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "met", + "columnName": "met", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "weekStart" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b13f9feb0334c575a1ce303479764874')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.openstrap.app.data.db.OpenStrapDatabase/27.json b/app/schemas/org.openstrap.app.data.db.OpenStrapDatabase/27.json new file mode 100644 index 00000000..8d97ebb1 --- /dev/null +++ b/app/schemas/org.openstrap.app.data.db.OpenStrapDatabase/27.json @@ -0,0 +1,1936 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "1c7a80241f204d939a3fa8b54fc3bd4c", + "entities": [ + { + "tableName": "heart_rate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, `source` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "activity_minute", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `intensity` INTEGER NOT NULL, `kind` INTEGER NOT NULL, `heartRate` INTEGER, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intensity", + "columnName": "intensity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "heartRate", + "columnName": "heartRate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "sleep_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `day` INTEGER NOT NULL DEFAULT 0, `nap` INTEGER NOT NULL DEFAULT 0, `score` INTEGER, `averageHeartRate` INTEGER, `deepMinutes` INTEGER, `lightMinutes` INTEGER, `remMinutes` INTEGER, `awakeMinutes` INTEGER, `wakeCount` INTEGER, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nap", + "columnName": "nap", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER" + }, + { + "fieldPath": "averageHeartRate", + "columnName": "averageHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "deepMinutes", + "columnName": "deepMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "lightMinutes", + "columnName": "lightMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "remMinutes", + "columnName": "remMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "awakeMinutes", + "columnName": "awakeMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "wakeCount", + "columnName": "wakeCount", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + }, + "indices": [ + { + "name": "index_sleep_session_day", + "unique": false, + "columnNames": [ + "day" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sleep_session_day` ON `${TABLE_NAME}` (`day`)" + }, + { + "name": "index_sleep_session_nap_end", + "unique": false, + "columnNames": [ + "nap", + "end" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sleep_session_nap_end` ON `${TABLE_NAME}` (`nap`, `end`)" + } + ] + }, + { + "tableName": "sleep_stage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `sessionStart` INTEGER NOT NULL, `stage` INTEGER NOT NULL, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionStart", + "columnName": "sessionStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + }, + "indices": [ + { + "name": "index_sleep_stage_sessionStart", + "unique": false, + "columnNames": [ + "sessionStart" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sleep_stage_sessionStart` ON `${TABLE_NAME}` (`sessionStart`)" + } + ] + }, + { + "tableName": "spo2", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `percent` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "percent", + "columnName": "percent", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "stress", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "hrv", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `milliseconds` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "milliseconds", + "columnName": "milliseconds", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "resting_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "skin_temperature", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `celsius` REAL NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "celsius", + "columnName": "celsius", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "breath_rate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `perMinute` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "perMinute", + "columnName": "perMinute", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "pai", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `daily` REAL NOT NULL, `total` REAL NOT NULL, `restingHeartRate` INTEGER NOT NULL, `maxHeartRate` INTEGER NOT NULL, `tomorrowTotal` REAL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "daily", + "columnName": "daily", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "total", + "columnName": "total", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "restingHeartRate", + "columnName": "restingHeartRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxHeartRate", + "columnName": "maxHeartRate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tomorrowTotal", + "columnName": "tomorrowTotal", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "hrv_sdnn", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `milliseconds` REAL NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "milliseconds", + "columnName": "milliseconds", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "workout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `sportType` INTEGER NOT NULL, `exerciseSeconds` INTEGER, `calories` INTEGER, `averageHeartRate` INTEGER, `maxHeartRate` INTEGER, `minHeartRate` INTEGER, `aerobicTrainingEffect` REAL, `anaerobicTrainingEffect` REAL, `trainingLoad` INTEGER, `vo2max` INTEGER, `steps` INTEGER, `subtype` INTEGER, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sportType", + "columnName": "sportType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exerciseSeconds", + "columnName": "exerciseSeconds", + "affinity": "INTEGER" + }, + { + "fieldPath": "calories", + "columnName": "calories", + "affinity": "INTEGER" + }, + { + "fieldPath": "averageHeartRate", + "columnName": "averageHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxHeartRate", + "columnName": "maxHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "minHeartRate", + "columnName": "minHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "aerobicTrainingEffect", + "columnName": "aerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "anaerobicTrainingEffect", + "columnName": "anaerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "trainingLoad", + "columnName": "trainingLoad", + "affinity": "INTEGER" + }, + { + "fieldPath": "vo2max", + "columnName": "vo2max", + "affinity": "INTEGER" + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER" + }, + { + "fieldPath": "subtype", + "columnName": "subtype", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + } + }, + { + "tableName": "workout_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, PRIMARY KEY(`workoutStart`, `timestamp`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "timestamp" + ] + } + }, + { + "tableName": "sport_effect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`end` INTEGER NOT NULL, `recoveryMinutes` INTEGER, `vo2max` REAL, `epoc` REAL, `weeklyLoad` REAL, `monthlyLoad` REAL, `optimalLoadMin` INTEGER, `optimalLoadMax` INTEGER, `maximalHeartRate` INTEGER, PRIMARY KEY(`end`))", + "fields": [ + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recoveryMinutes", + "columnName": "recoveryMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "vo2max", + "columnName": "vo2max", + "affinity": "REAL" + }, + { + "fieldPath": "epoc", + "columnName": "epoc", + "affinity": "REAL" + }, + { + "fieldPath": "weeklyLoad", + "columnName": "weeklyLoad", + "affinity": "REAL" + }, + { + "fieldPath": "monthlyLoad", + "columnName": "monthlyLoad", + "affinity": "REAL" + }, + { + "fieldPath": "optimalLoadMin", + "columnName": "optimalLoadMin", + "affinity": "INTEGER" + }, + { + "fieldPath": "optimalLoadMax", + "columnName": "optimalLoadMax", + "affinity": "INTEGER" + }, + { + "fieldPath": "maximalHeartRate", + "columnName": "maximalHeartRate", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "end" + ] + } + }, + { + "tableName": "strap_readiness", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `score` INTEGER, `hrvScore` INTEGER, `restingHrScore` INTEGER, `skinTemperatureScore` INTEGER, `breathingScore` INTEGER, `sleepHrv` INTEGER, `sleepRestingHr` INTEGER, `algorithmVersion` TEXT NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER" + }, + { + "fieldPath": "hrvScore", + "columnName": "hrvScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "restingHrScore", + "columnName": "restingHrScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "skinTemperatureScore", + "columnName": "skinTemperatureScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "breathingScore", + "columnName": "breathingScore", + "affinity": "INTEGER" + }, + { + "fieldPath": "sleepHrv", + "columnName": "sleepHrv", + "affinity": "INTEGER" + }, + { + "fieldPath": "sleepRestingHr", + "columnName": "sleepRestingHr", + "affinity": "INTEGER" + }, + { + "fieldPath": "algorithmVersion", + "columnName": "algorithmVersion", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "sleep_spo2", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `percent` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "percent", + "columnName": "percent", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "osa_event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `drop` INTEGER NOT NULL, `spo2` BLOB, `heartRate` BLOB, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "drop", + "columnName": "drop", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spo2", + "columnName": "spo2", + "affinity": "BLOB" + }, + { + "fieldPath": "heartRate", + "columnName": "heartRate", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "osa_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`belongTime` INTEGER NOT NULL, `validTime` INTEGER NOT NULL, `odiRaw` INTEGER NOT NULL, `odiScore` INTEGER NOT NULL, `dispCode` INTEGER NOT NULL, `eventCount` INTEGER NOT NULL, `version` INTEGER NOT NULL, PRIMARY KEY(`belongTime`))", + "fields": [ + { + "fieldPath": "belongTime", + "columnName": "belongTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "validTime", + "columnName": "validTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "odiRaw", + "columnName": "odiRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "odiScore", + "columnName": "odiScore", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dispCode", + "columnName": "dispCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventCount", + "columnName": "eventCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "belongTime" + ] + } + }, + { + "tableName": "daily_max_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "recovery_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `samples` BLOB NOT NULL, PRIMARY KEY(`workoutStart`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "samples", + "columnName": "samples", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart" + ] + } + }, + { + "tableName": "activity_extended", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `floors` INTEGER NOT NULL, `run` INTEGER NOT NULL, `fatBurn` INTEGER NOT NULL, `stand` INTEGER NOT NULL, `walk` INTEGER NOT NULL, `sit` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `calories` INTEGER NOT NULL, `distanceCm` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "floors", + "columnName": "floors", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "run", + "columnName": "run", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fatBurn", + "columnName": "fatBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "stand", + "columnName": "stand", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walk", + "columnName": "walk", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sit", + "columnName": "sit", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calories", + "columnName": "calories", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distanceCm", + "columnName": "distanceCm", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "raw_record", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `payload` BLOB NOT NULL, PRIMARY KEY(`type`, `timestamp`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "type", + "timestamp" + ] + } + }, + { + "tableName": "health_alert", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `value` INTEGER NOT NULL, PRIMARY KEY(`timestamp`, `type`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp", + "type" + ] + } + }, + { + "tableName": "check_in", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `feeling` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "feeling", + "columnName": "feeling", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "day_tag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `tag` TEXT NOT NULL, PRIMARY KEY(`day`, `tag`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day", + "tag" + ] + } + }, + { + "tableName": "night_metrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `hrvLn` REAL, `hrvSamples` INTEGER NOT NULL, `rhrLowest30` REAL, `rhrMeanSleep` REAL, `rhrP10` REAL, `hrMinutes` INTEGER NOT NULL, `strapHrv` INTEGER, `strapRestingHr` INTEGER, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hrvLn", + "columnName": "hrvLn", + "affinity": "REAL" + }, + { + "fieldPath": "hrvSamples", + "columnName": "hrvSamples", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rhrLowest30", + "columnName": "rhrLowest30", + "affinity": "REAL" + }, + { + "fieldPath": "rhrMeanSleep", + "columnName": "rhrMeanSleep", + "affinity": "REAL" + }, + { + "fieldPath": "rhrP10", + "columnName": "rhrP10", + "affinity": "REAL" + }, + { + "fieldPath": "hrMinutes", + "columnName": "hrMinutes", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "strapHrv", + "columnName": "strapHrv", + "affinity": "INTEGER" + }, + { + "fieldPath": "strapRestingHr", + "columnName": "strapRestingHr", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "app_workout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER, `sportType` INTEGER NOT NULL, `source` INTEGER NOT NULL, `pauses` TEXT NOT NULL, `pausedAt` INTEGER, `endReason` INTEGER, `averageHeartRate` INTEGER, `maxHeartRate` INTEGER, `askToEnd` INTEGER NOT NULL DEFAULT 1, `recordedEnd` INTEGER, `shownStart` INTEGER, `target` TEXT, `useGps` INTEGER NOT NULL DEFAULT 0, `keepRoute` INTEGER NOT NULL DEFAULT 0, `distanceMeters` REAL, `distanceFromSteps` INTEGER NOT NULL DEFAULT 0, `rest` TEXT, `setsTarget` INTEGER, `laps` TEXT NOT NULL DEFAULT '', `epoc` REAL, `aerobicTrainingEffect` REAL, `recoveryMinutes` INTEGER, `vo2max` REAL, `anaerobicTrainingEffect` REAL, `effectFromStrap` INTEGER NOT NULL DEFAULT 0, `ownRecoveryMinutes` INTEGER, `activeKcal` REAL, `totalKcal` REAL, `targetReachedAt` INTEGER, `structure` TEXT, `stretches` TEXT, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "sportType", + "columnName": "sportType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pauses", + "columnName": "pauses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pausedAt", + "columnName": "pausedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "endReason", + "columnName": "endReason", + "affinity": "INTEGER" + }, + { + "fieldPath": "averageHeartRate", + "columnName": "averageHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxHeartRate", + "columnName": "maxHeartRate", + "affinity": "INTEGER" + }, + { + "fieldPath": "askToEnd", + "columnName": "askToEnd", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "recordedEnd", + "columnName": "recordedEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "shownStart", + "columnName": "shownStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "target", + "columnName": "target", + "affinity": "TEXT" + }, + { + "fieldPath": "useGps", + "columnName": "useGps", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "keepRoute", + "columnName": "keepRoute", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "distanceMeters", + "columnName": "distanceMeters", + "affinity": "REAL" + }, + { + "fieldPath": "distanceFromSteps", + "columnName": "distanceFromSteps", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rest", + "columnName": "rest", + "affinity": "TEXT" + }, + { + "fieldPath": "setsTarget", + "columnName": "setsTarget", + "affinity": "INTEGER" + }, + { + "fieldPath": "laps", + "columnName": "laps", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "epoc", + "columnName": "epoc", + "affinity": "REAL" + }, + { + "fieldPath": "aerobicTrainingEffect", + "columnName": "aerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "recoveryMinutes", + "columnName": "recoveryMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "vo2max", + "columnName": "vo2max", + "affinity": "REAL" + }, + { + "fieldPath": "anaerobicTrainingEffect", + "columnName": "anaerobicTrainingEffect", + "affinity": "REAL" + }, + { + "fieldPath": "effectFromStrap", + "columnName": "effectFromStrap", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ownRecoveryMinutes", + "columnName": "ownRecoveryMinutes", + "affinity": "INTEGER" + }, + { + "fieldPath": "activeKcal", + "columnName": "activeKcal", + "affinity": "REAL" + }, + { + "fieldPath": "totalKcal", + "columnName": "totalKcal", + "affinity": "REAL" + }, + { + "fieldPath": "targetReachedAt", + "columnName": "targetReachedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "structure", + "columnName": "structure", + "affinity": "TEXT" + }, + { + "fieldPath": "stretches", + "columnName": "stretches", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + }, + "indices": [ + { + "name": "index_app_workout_end", + "unique": false, + "columnNames": [ + "end" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_app_workout_end` ON `${TABLE_NAME}` (`end`)" + } + ] + }, + { + "tableName": "app_workout_hr", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL, `fromHistory` INTEGER NOT NULL, PRIMARY KEY(`workoutStart`, `timestamp`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fromHistory", + "columnName": "fromHistory", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "timestamp" + ] + } + }, + { + "tableName": "app_workout_dismissed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `end` INTEGER NOT NULL, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + }, + "indices": [ + { + "name": "index_app_workout_dismissed_end", + "unique": false, + "columnNames": [ + "end" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_app_workout_dismissed_end` ON `${TABLE_NAME}` (`end`)" + } + ] + }, + { + "tableName": "app_workout_route", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `lat` REAL NOT NULL, `lon` REAL NOT NULL, `accuracy` REAL NOT NULL, `speed` REAL, `bearing` REAL, `altitude` REAL, `speedAccuracy` REAL, `bearingAccuracy` REAL, PRIMARY KEY(`workoutStart`, `timestamp`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lat", + "columnName": "lat", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "lon", + "columnName": "lon", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "accuracy", + "columnName": "accuracy", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "speed", + "columnName": "speed", + "affinity": "REAL" + }, + { + "fieldPath": "bearing", + "columnName": "bearing", + "affinity": "REAL" + }, + { + "fieldPath": "altitude", + "columnName": "altitude", + "affinity": "REAL" + }, + { + "fieldPath": "speedAccuracy", + "columnName": "speedAccuracy", + "affinity": "REAL" + }, + { + "fieldPath": "bearingAccuracy", + "columnName": "bearingAccuracy", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "timestamp" + ] + } + }, + { + "tableName": "app_workout_set", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `index` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER, `restEnd` INTEGER, `exercise` TEXT, `reps` INTEGER, `weightKg` REAL, PRIMARY KEY(`workoutStart`, `index`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "restEnd", + "columnName": "restEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "exercise", + "columnName": "exercise", + "affinity": "TEXT" + }, + { + "fieldPath": "reps", + "columnName": "reps", + "affinity": "INTEGER" + }, + { + "fieldPath": "weightKg", + "columnName": "weightKg", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "index" + ] + } + }, + { + "tableName": "wear_event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `worn` INTEGER, PRIMARY KEY(`timestamp`))", + "fields": [ + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worn", + "columnName": "worn", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "timestamp" + ] + } + }, + { + "tableName": "habit_age_week", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`asOf` INTEGER NOT NULL, `estimate` REAL NOT NULL, `low` INTEGER NOT NULL, `high` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`asOf`))", + "fields": [ + { + "fieldPath": "asOf", + "columnName": "asOf", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "estimate", + "columnName": "estimate", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "low", + "columnName": "low", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "high", + "columnName": "high", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "asOf" + ] + } + }, + { + "tableName": "workout_deleted", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`start` INTEGER NOT NULL, `effectEnd` INTEGER, `deletedAt` INTEGER NOT NULL, PRIMARY KEY(`start`))", + "fields": [ + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "effectEnd", + "columnName": "effectEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "start" + ] + } + }, + { + "tableName": "daily_energy", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `totalKcal` REAL NOT NULL, `activeKcal` REAL NOT NULL, `bmrKcal` REAL NOT NULL, `minutesWithData` INTEGER NOT NULL, `partial` INTEGER NOT NULL, `workoutKcal` REAL NOT NULL, `walkingKcal` REAL NOT NULL, `hourly` TEXT NOT NULL, PRIMARY KEY(`day`))", + "fields": [ + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalKcal", + "columnName": "totalKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "activeKcal", + "columnName": "activeKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "bmrKcal", + "columnName": "bmrKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "minutesWithData", + "columnName": "minutesWithData", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partial", + "columnName": "partial", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workoutKcal", + "columnName": "workoutKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "walkingKcal", + "columnName": "walkingKcal", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "hourly", + "columnName": "hourly", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "day" + ] + } + }, + { + "tableName": "sleep_edit", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`strapStart` INTEGER NOT NULL, `nap` INTEGER NOT NULL DEFAULT 0, `start` INTEGER, `end` INTEGER, `deleted` INTEGER NOT NULL DEFAULT 0, `editedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`strapStart`))", + "fields": [ + { + "fieldPath": "strapStart", + "columnName": "strapStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nap", + "columnName": "nap", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "strapStart" + ] + } + }, + { + "tableName": "app_workout_round", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`workoutStart` INTEGER NOT NULL, `index` INTEGER NOT NULL, `workStart` INTEGER NOT NULL, `workEnd` INTEGER, `restEnd` INTEGER, `peakBpm` INTEGER, PRIMARY KEY(`workoutStart`, `index`))", + "fields": [ + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workStart", + "columnName": "workStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workEnd", + "columnName": "workEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "restEnd", + "columnName": "restEnd", + "affinity": "INTEGER" + }, + { + "fieldPath": "peakBpm", + "columnName": "peakBpm", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "workoutStart", + "index" + ] + } + }, + { + "tableName": "saved_workout", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `sport` INTEGER NOT NULL, `structure` TEXT NOT NULL, `goal` TEXT, `createdAt` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sport", + "columnName": "sport", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "structure", + "columnName": "structure", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "goal", + "columnName": "goal", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "exercise", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `lastReps` INTEGER, `lastKg` REAL, `usedAt` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastReps", + "columnName": "lastReps", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastKg", + "columnName": "lastKg", + "affinity": "REAL" + }, + { + "fieldPath": "usedAt", + "columnName": "usedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "personal_best", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`kind` TEXT NOT NULL, `value` REAL NOT NULL, `date` INTEGER NOT NULL, `workoutStart` INTEGER, PRIMARY KEY(`kind`))", + "fields": [ + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "kind" + ] + } + }, + { + "tableName": "badge", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `earnedAt` INTEGER NOT NULL, `workoutStart` INTEGER, `day` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "earnedAt", + "columnName": "earnedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workoutStart", + "columnName": "workoutStart", + "affinity": "INTEGER" + }, + { + "fieldPath": "day", + "columnName": "day", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "week_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`weekStart` INTEGER NOT NULL, `goal` INTEGER NOT NULL, `count` INTEGER NOT NULL, `met` INTEGER NOT NULL, PRIMARY KEY(`weekStart`))", + "fields": [ + { + "fieldPath": "weekStart", + "columnName": "weekStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "goal", + "columnName": "goal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "met", + "columnName": "met", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "weekStart" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1c7a80241f204d939a3fa8b54fc3bd4c')" + ] + } +} \ No newline at end of file diff --git a/app/src/debug/kotlin/org/openstrap/app/DebugIntents.kt b/app/src/debug/kotlin/org/openstrap/app/DebugIntents.kt new file mode 100644 index 00000000..6c6db13c --- /dev/null +++ b/app/src/debug/kotlin/org/openstrap/app/DebugIntents.kt @@ -0,0 +1,184 @@ +package org.openstrap.app + +import android.content.Intent +import android.util.Log +import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.openstrap.app.MainActivity.Companion.EXTRA_DATA_STATE +import org.openstrap.app.MainActivity.Companion.EXTRA_MOMENT +import org.openstrap.app.MainActivity.Companion.EXTRA_SHOW_SUMMARY +import org.openstrap.app.MainActivity.Companion.EXTRA_STRAP_STATE +import org.openstrap.app.MainActivity.Companion.EXTRA_WORKOUT_NOTIFICATION +import org.openstrap.app.MainActivity.Companion.MOMENT_NOW +import org.openstrap.app.data.Cue +import org.openstrap.app.data.Moment +import org.openstrap.app.data.catching +import org.openstrap.app.data.settings.PairedDevice +import org.openstrap.app.device.StrapManager +import org.openstrap.app.device.StrapService +import org.openstrap.app.device.WorkoutSession +import org.openstrap.app.ui.dashboard.DataPreview +import org.openstrap.app.ui.dashboard.StrapPreview +import org.openstrap.app.ui.glow.debugRecoveryScore +import org.openstrap.app.ui.root.DebugPreviews + +private const val TAG = "OpenStrap" +private const val HEX = 16 +private const val SIMULATED_BATTERY = 80 +private const val SCORE_MAX = 100 + +private val MainActivity.container: AppContainer get() = (application as OpenStrapApp).container + +/** + * The debug extras, each acted on once and removed from the intent. A release build has a twin that ignores them. + * `am start -n org.openstrap.app/.MainActivity --ei dump_raw_days 7` dumps raw history to files/raw. + */ +internal fun MainActivity.handleDebugIntent(previews: DebugPreviews) { + demoData() + strapCommands() + shownStates(previews) + workoutSamples() + dumpRaw() +} + +/** + * `--ez demo_data true` fills an empty database with sample data (for the emulator), and stands in a strap when none + * is paired, so the screens show a paired phone. Pair with `simulate_hr` to have it connected. + */ +private fun MainActivity.demoData() { + if (!intent.getBooleanExtra("demo_data", false)) return + intent.removeExtra("demo_data") + val container = container + lifecycleScope.launch { + DemoData.fill(container.database.healthDao()) + if (container.settings.pairedDevice.first() == null) { + container.settings.savePairedDevice(PairedDevice("00:00:00:00:00:00", "00", "Helio Strap")) + } + } +} + +/** Commands to the strap: raw bytes, strap sessions on or off, a cue's buzz, and simulated heart rate. */ +private fun MainActivity.strapCommands() { + val manager = container.strap as? StrapManager + // `--ei probe_endpoint 73 --es probe_hex 00` sends raw bytes to a channel endpoint and logs replies. + val probeEndpoint = intent.getIntExtra("probe_endpoint", -1) + if (probeEndpoint >= 0) { + val hex = intent.getStringExtra("probe_hex").orEmpty() + intent.removeExtra("probe_endpoint") + manager?.probe(probeEndpoint, hex.chunked(2).map { it.toInt(HEX).toByte() }.toByteArray()) + } + // `--ez strap_sessions false` stops phone workouts running a session on the strap (0x0019), `true` brings it back. + if (intent.hasExtra("strap_sessions")) { + val on = intent.getBooleanExtra("strap_sessions", false) + intent.removeExtra("strap_sessions") + val settings = container.settings + lifecycleScope.launch { settings.setStrapSessions(on) } + Toast.makeText(this, "Strap sessions ${if (on) "on" else "off"}", Toast.LENGTH_SHORT).show() + } + buzz() + // `--es simulate_hr 140x120,80x600 --ei simulate_steps 0 --ei simulate_battery 80` plays heart rate as a connected + // strap (emulator). + val simulate = intent.getStringExtra("simulate_hr") ?: return + intent.removeExtra("simulate_hr") + val script = simulate.split(',').mapNotNull { p -> + p.split('x').takeIf { it.size == 2 }?.let { (b, s) -> b.toIntOrNull()?.let { bpm -> s.toIntOrNull()?.let { bpm to it } } } + } + manager?.simulate(script, intent.getIntExtra("simulate_steps", 0), intent.getIntExtra("simulate_battery", SIMULATED_BATTERY)) +} + +/** `--es buzz AboveTarget` plays a cue's pattern on the strap (a Cue name, Stop or Offer), to feel it. */ +private fun MainActivity.buzz() { + val buzz = intent.getStringExtra("buzz") ?: return + intent.removeExtra("buzz") + val pattern = when (buzz) { + "Stop" -> WorkoutSession.STOP_BUZZ + "Offer" -> WorkoutSession.OFFER_BUZZ + else -> Cue.entries.firstOrNull { it.name == buzz }?.pattern + } ?: return + val strap = container.strap + lifecycleScope.launch { + catching { strap.buzz(pattern) }.onFailure { Toast.makeText(this@buzz, "Buzz failed: ${it.message}", Toast.LENGTH_SHORT).show() } + } +} + +/** + * What the screens are shown in place of the real state, so each can be checked on a phone, and "auto" goes back to + * the real one. Only what the screens are shown changes: no Bluetooth, no scan, no sync, nothing written. + */ +private fun MainActivity.shownStates(previews: DebugPreviews) { + // `--es moment Evening` forces Today's moment (a Moment name). + val moment = intent.getStringExtra(EXTRA_MOMENT) + if (moment != null && moment != MOMENT_NOW) { + intent.removeExtra(EXTRA_MOMENT) + if (moment.equals("auto", ignoreCase = true)) { + previews.moment.value = null + } else { + Moment.entries.firstOrNull { it.name.equals(moment, ignoreCase = true) }?.let { previews.moment.value = it } + ?: Log.w(TAG, "moment: no moment called $moment") + } + } + // `--ei recovery_score 78` shows that score on the Recovery detail (its arc gauge) while Recovery is still learning, + // and `--ei recovery_score -1` goes back to the real one. + if (intent.hasExtra("recovery_score")) { + val score = intent.getIntExtra("recovery_score", -1) + intent.removeExtra("recovery_score") + debugRecoveryScore.value = score.takeIf { it in 0..SCORE_MAX } + } + // `--es strap_state stuck` shows a strap state on a phone whose strap is fine. + intent.getStringExtra(EXTRA_STRAP_STATE)?.let { state -> + intent.removeExtra(EXTRA_STRAP_STATE) + if (state.equals("auto", ignoreCase = true)) { + previews.strap.value = null + } else { + StrapPreview.of(state)?.let { previews.strap.value = it } ?: Log.w(TAG, "strap_state: no state called $state") + } + Log.i(TAG, "strap_state: showing ${previews.strap.value ?: "the real state"}") + } + // `--es data_state nosleep` shows a data state (the pairing stage's steps, first nights, no sleep, a partial day, + // the first score) on a phone whose data is fine. + intent.getStringExtra(EXTRA_DATA_STATE)?.let { state -> + intent.removeExtra(EXTRA_DATA_STATE) + if (state.equals("auto", ignoreCase = true)) { + previews.data.value = null + } else { + DataPreview.of(state)?.let { previews.data.value = it } ?: Log.w(TAG, "data_state: no state called $state") + } + Log.i(TAG, "data_state: showing ${previews.data.value ?: "the real state"}") + } +} + +/** The workout screens and notification with nothing recording and nothing written. */ +private fun MainActivity.workoutSamples() { + // `--ez show_summary true` opens the summary right after a workout for the latest stored phone workout. + if (intent.getBooleanExtra(EXTRA_SHOW_SUMMARY, false)) { + intent.removeExtra(EXTRA_SHOW_SUMMARY) + val container = container + lifecycleScope.launch { + val latest = container.database.healthDao().appWorkouts().first().filter { it.end != null }.maxByOrNull { it.start } + if (latest != null) { + container.workout.showSummary(latest.start) + } else { + Toast.makeText(this@workoutSamples, "No phone workout to show", Toast.LENGTH_SHORT).show() + } + } + } + // `--es workout_notification running` posts the workout notification with sample figures, built as a real + // workout's is: running, paused, indoor, end or clear. + val sample = intent.getStringExtra(EXTRA_WORKOUT_NOTIFICATION) ?: return + intent.removeExtra(EXTRA_WORKOUT_NOTIFICATION) + startService(Intent(this, StrapService::class.java).setAction(StrapService.ACTION_SAMPLE).putExtra(StrapService.EXTRA_SAMPLE, sample)) +} + +/** `--ei dump_raw_days 7` dumps raw history to files/raw, and `--es dump_types 5,6,70` limits it to these GDSP codes. */ +private fun MainActivity.dumpRaw() { + val days = intent.getIntExtra("dump_raw_days", 0) + if (days <= 0) return + intent.removeExtra("dump_raw_days") + // Even codes the strap doesn't advertise. + val types = intent.getStringExtra("dump_types")?.split(',')?.mapNotNull { it.trim().toIntOrNull() }?.toSet() + intent.removeExtra("dump_types") + Log.i(TAG, "dump: requested $days days, types ${types ?: "all"}") + (container.strap as? StrapManager)?.dumpRaw(days, types) +} diff --git a/app/src/debug/kotlin/org/openstrap/app/gallery/ComponentGalleryActivity.kt b/app/src/debug/kotlin/org/openstrap/app/gallery/ComponentGalleryActivity.kt index ea12b654..c2a0eeea 100644 --- a/app/src/debug/kotlin/org/openstrap/app/gallery/ComponentGalleryActivity.kt +++ b/app/src/debug/kotlin/org/openstrap/app/gallery/ComponentGalleryActivity.kt @@ -28,22 +28,22 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.openstrap.app.ui.glow.CardSize -import org.openstrap.app.ui.glow.CoreShape +import org.openstrap.app.ui.glow.draw.CoreShape import org.openstrap.app.ui.glow.DotMatrixText -import org.openstrap.app.ui.glow.DotSize +import org.openstrap.app.ui.glow.draw.DotSize import org.openstrap.app.ui.glow.EdgeCard -import org.openstrap.app.ui.glow.EdgeKind -import org.openstrap.app.ui.glow.EdgeStatus +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.EdgeStatus import org.openstrap.app.ui.glow.GlowCard import org.openstrap.app.ui.glow.GlowGrid import org.openstrap.app.ui.glow.GlowLayout import org.openstrap.app.ui.glow.GlowGridColumn -import org.openstrap.app.ui.glow.GlowMood -import org.openstrap.app.ui.glow.GlowMotion -import org.openstrap.app.ui.glow.GlowStyle +import org.openstrap.app.ui.glow.draw.GlowMood +import org.openstrap.app.ui.glow.draw.GlowMotion +import org.openstrap.app.ui.glow.draw.GlowStyle import org.openstrap.app.ui.glow.GlowType import org.openstrap.app.ui.glow.GridCard -import org.openstrap.app.ui.glow.LightAngle +import org.openstrap.app.ui.glow.draw.LightAngle import org.openstrap.app.ui.glow.ListPanel import org.openstrap.app.ui.glow.ListRow import org.openstrap.app.ui.glow.LocalGlowMood diff --git a/app/src/debug/kotlin/org/openstrap/app/widget/debug/WidgetGalleryActivity.kt b/app/src/debug/kotlin/org/openstrap/app/widget/debug/WidgetGalleryActivity.kt index de32d70f..63c0606f 100644 --- a/app/src/debug/kotlin/org/openstrap/app/widget/debug/WidgetGalleryActivity.kt +++ b/app/src/debug/kotlin/org/openstrap/app/widget/debug/WidgetGalleryActivity.kt @@ -5,7 +5,6 @@ import android.appwidget.AppWidgetHostView import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProviderInfo import android.content.ComponentName -import android.os.Build import android.os.Bundle import android.util.Log import android.util.SizeF @@ -22,11 +21,11 @@ import androidx.compose.ui.unit.dp import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.launch -import org.openstrap.app.ui.glow.GlowMood -import org.openstrap.app.device.WidgetUpdater -import org.openstrap.app.widget.WidgetKind +import org.openstrap.app.ui.glow.draw.GlowMood +import org.openstrap.app.data.WidgetKind import org.openstrap.app.widget.WidgetRefresh import org.openstrap.app.widget.WidgetSizes +import org.openstrap.app.widget.widgets /** * Debug builds only: a real widget hosted at exactly its three sizes (Small, Wide, Large) on black, for checking on @@ -112,14 +111,14 @@ class WidgetGalleryActivity : ComponentActivity() { WidgetRefresh.debugMoment = null WidgetRefresh.debugPlaced = null WidgetRefresh.debugStrap = null - WidgetUpdater.changedAll(applicationContext) + widgets.changedAll() } super.onDestroy() } /** The kind named [name] ("steps", "recovery", "sleep", "start") that has a widget yet; null for none. */ private fun kindOf(name: String?): WidgetKind? = - WidgetKind.entries.firstOrNull { it.name.equals(name, ignoreCase = true) }?.takeIf { WidgetUpdater.registration(it) != null } + WidgetKind.entries.firstOrNull { it.name.equals(name, ignoreCase = true) }?.takeIf { widgets.registration(it) != null } /** Hosts [next] in place of the kind shown. */ private fun show(next: WidgetKind) { @@ -137,7 +136,7 @@ class WidgetGalleryActivity : ComponentActivity() { /** Binds an id per size and hosts its view at that size exactly. */ private fun bindAll() { val manager = AppWidgetManager.getInstance(this) - val registration = WidgetUpdater.registration(kind) ?: run { note("No $kind widget yet."); return } + val registration = widgets.registration(kind) ?: run { note("No $kind widget yet."); return } val provider = ComponentName(this, registration.receiver) val info: AppWidgetProviderInfo? = manager.getInstalledProvidersForPackage(packageName, null).firstOrNull { it.provider == provider } if (info == null) { note("The $kind widget's provider isn't installed."); return } @@ -157,7 +156,7 @@ class WidgetGalleryActivity : ComponentActivity() { refresh() } - /** The size bundle Glance reads to pick its layout: Android 12's size list, and the min and max before it. */ + /** The size bundle Glance reads to pick its layout: the size list, and the min and max for launchers without it. */ private fun sizeOptions(size: DpSize): Bundle = Bundle().apply { val w = size.width.value.toInt() val h = size.height.value.toInt() @@ -165,16 +164,14 @@ class WidgetGalleryActivity : ComponentActivity() { putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, w) putInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, h) putInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, h) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - putParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, arrayListOf(SizeF(w.toFloat(), h.toFloat()))) - } + putParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, arrayListOf(SizeF(w.toFloat(), h.toFloat()))) } /** Loads the widget again and renders every bound id. */ private fun refresh() { val ids = bound.toList() val shown = kind - val widget = WidgetUpdater.registration(shown)?.widget ?: return + val widget = widgets.registration(shown)?.widget ?: return lifecycleScope.launch { try { WidgetRefresh.bump(shown) @@ -228,7 +225,7 @@ class WidgetGalleryActivity : ComponentActivity() { /** Asks the launcher to pin a widget of [pin] to the home screen. */ private fun requestPin(pin: WidgetKind) { val manager = AppWidgetManager.getInstance(this) - val registration = WidgetUpdater.registration(pin) ?: return + val registration = widgets.registration(pin) ?: return if (!manager.isRequestPinAppWidgetSupported) { Toast.makeText(this, "This launcher can't pin widgets", Toast.LENGTH_SHORT).show() return @@ -241,7 +238,7 @@ class WidgetGalleryActivity : ComponentActivity() { orientation = LinearLayout.VERTICAL addView(LinearLayout(this@WidgetGalleryActivity).apply { orientation = LinearLayout.HORIZONTAL - WidgetKind.entries.filter { WidgetUpdater.registration(it) != null }.forEach { k -> + WidgetKind.entries.filter { widgets.registration(it) != null }.forEach { k -> addView(Button(this@WidgetGalleryActivity).apply { text = k.name setOnClickListener { if (k != kind) show(k) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8de3e003..1d4463b4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,13 +13,9 @@ + android:usesPermissionFlags="neverForLocation" /> - - - - @@ -200,7 +196,7 @@ - + + + + (null) - - /** The Moment widget's tap: Today, at its root, until the app root has opened it. */ - private val todayRequests = kotlinx.coroutines.flow.MutableStateFlow(false) + /** Debug: what the screens are shown in place of the real state, from the debug extras. */ + private val previews = DebugPreviews() - /** A widget's strap status line (not paired, battery low): the Strap page, until the app root has opened it. */ - private val strapRequests = kotlinx.coroutines.flow.MutableStateFlow(false) + /** A Strap page action waiting on the Bluetooth answer, kept across a fold or a rotation. */ + private val pendingStrap: PendingStrapAction by viewModels() - /** Debug: the moment forced by the `moment` extra, null to follow the clock. */ - private val forcedMoment = kotlinx.coroutines.flow.MutableStateFlow(null) - - /** Debug: the strap state previewed by the `strap_state` extra, null for the real one. */ - private val strapPreview = kotlinx.coroutines.flow.MutableStateFlow(null) - - /** Debug: the data state previewed by the `data_state` extra, null for the real one. */ - private val dataPreview = kotlinx.coroutines.flow.MutableStateFlow(null) + /** What notifications, widgets, the tile and shortcuts asked to show, until the app root has shown it. */ + private val navRequests: NavigationRequests by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -116,7 +125,11 @@ class MainActivity : ComponentActivity() { navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), ) val container = (application as OpenStrapApp).container - setContent { OpenStrapTheme { AppRoot(container, metricRequests, todayRequests, strapRequests, forcedMoment, strapPreview, dataPreview) } } + setContent { + OpenStrapTheme { + AppRoot(container, navRequests, previews, pendingStrap) + } + } } override fun onStart() { @@ -132,63 +145,66 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() // Reconnect to a paired strap whenever the app comes to the front (sync follows on connect). - val granted = checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED - if (granted) (application as OpenStrapApp).container.strap.autoConnect() + if (BlePermissions.granted(this)) (application as OpenStrapApp).container.strap.autoConnect() // The association or battery setting may have changed in system settings meanwhile. (application as OpenStrapApp).container.companion.refresh() - handleDebugIntent() + handleDebugIntent(previews) showWorkoutFromIntent() } - override fun onNewIntent(intent: android.content.Intent) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - handleDebugIntent() + handleDebugIntent(previews) showWorkoutFromIntent() } /** - * The workout notification opens the live screen, even if it was tucked away; a shortcut or the tile, the ready - * screen; an auto-detected workout's notification, that workout; a health alert or body-signal notification, its - * metric's screen for its day. + * The workout notification opens the live screen, even if it was tucked away. A shortcut or the tile opens the + * ready screen, an auto-detected workout's notification that workout, and a health alert or body-signal + * notification its metric's screen for its day. */ private fun showWorkoutFromIntent() { // The Moment widget: Today as it is now, never a forced moment. if (intent.getStringExtra(EXTRA_MOMENT) == MOMENT_NOW) { intent.removeExtra(EXTRA_MOMENT) - todayRequests.value = true + navRequests.send(NavRequest.OpenToday) } if (intent.getBooleanExtra(EXTRA_OPEN_STRAP, false)) { intent.removeExtra(EXTRA_OPEN_STRAP) - strapRequests.value = true + navRequests.send(NavRequest.OpenStrap) + } + if (intent.getBooleanExtra(EXTRA_OPEN_RECAP, false)) { + intent.removeExtra(EXTRA_OPEN_RECAP) + navRequests.send(NavRequest.OpenRecap) } if (intent.getBooleanExtra(EXTRA_OPEN_UPDATES, false)) { intent.removeExtra(EXTRA_OPEN_UPDATES) - (application as OpenStrapApp).container.updater.openRequest.value = true + navRequests.send(NavRequest.OpenUpdates) } intent.getStringExtra(EXTRA_OPEN_METRIC)?.let { route -> val day = intent.getStringExtra(EXTRA_METRIC_DAY)?.let { runCatching { java.time.LocalDate.parse(it) }.getOrNull() } intent.removeExtra(EXTRA_OPEN_METRIC) intent.removeExtra(EXTRA_METRIC_DAY) - metricRequests.value = MetricRequest(route, day) + navRequests.send(NavRequest.OpenDetail(route, day)) } val open = intent.getLongExtra(EXTRA_OPEN_WORKOUT, -1) if (open >= 0) { intent.removeExtra(EXTRA_OPEN_WORKOUT) - (application as OpenStrapApp).container.workout.openRequest.value = open + navRequests.send(NavRequest.OpenWorkout(open)) } val ready = intent.getStringExtra(EXTRA_READY_SPORT)?.toIntOrNull() ?: intent.getIntExtra(EXTRA_READY_SPORT, -1) if (ready >= 0) { intent.removeExtra(EXTRA_READY_SPORT) - (application as OpenStrapApp).container.workout.readyRequest.value = ready + navRequests.send(NavRequest.OpenReady(ready)) } if (intent.getBooleanExtra(EXTRA_READY_PICK, false)) { intent.removeExtra(EXTRA_READY_PICK) - (application as OpenStrapApp).container.workout.readyPickRequest.value = true + navRequests.send(NavRequest.OpenChoose) } if (!intent.getBooleanExtra(EXTRA_SHOW_WORKOUT, false)) return intent.removeExtra(EXTRA_SHOW_WORKOUT) - (application as OpenStrapApp).container.workout.minimized.value = false + navRequests.send(NavRequest.ShowWorkout) } companion object { @@ -215,6 +231,9 @@ class MainActivity : ComponentActivity() { /** True opens the Strap page over the tab showing: a widget's status line while the strap isn't paired or is low. */ const val EXTRA_OPEN_STRAP = "open_strap" + /** True opens last week's recap over the tab showing: the weekly recap notification's tap. */ + const val EXTRA_OPEN_RECAP = "open_recap" + /** True opens the Updates page over the tab showing: an update notification's tap. */ const val EXTRA_OPEN_UPDATES = "open_updates" @@ -233,1315 +252,243 @@ class MainActivity : ComponentActivity() { /** Debug: the workout notification with sample figures, "running", "paused", "indoor", "end" or "clear". */ const val EXTRA_WORKOUT_NOTIFICATION = "workout_notification" } - - /** Debug: `am start -n org.openstrap.app/.MainActivity --ei dump_raw_days 7` dumps raw history to files/raw. */ - private fun handleDebugIntent() { - // Debug: `--ez demo_data true` fills an empty database with sample data (for the emulator), and stands in a - // strap when none is paired, so the screens show a paired phone. Pair with `simulate_hr` to have it connected. - if (BuildConfig.DEBUG && intent.getBooleanExtra("demo_data", false)) { - intent.removeExtra("demo_data") - val container = (application as OpenStrapApp).container - kotlinx.coroutines.MainScope().launch { - DemoData.fill(container.database.healthDao()) - if (container.settings.pairedDevice.first() == null) { - container.settings.savePairedDevice(org.openstrap.app.data.settings.PairedDevice("00:00:00:00:00:00", "00", "Helio Strap")) - } - } - } - // Debug: `--ei probe_endpoint 73 --es probe_hex 00` sends raw bytes to a channel endpoint and logs replies. - val probeEndpoint = intent.getIntExtra("probe_endpoint", -1) - if (BuildConfig.DEBUG && probeEndpoint >= 0) { - val hex = intent.getStringExtra("probe_hex").orEmpty() - intent.removeExtra("probe_endpoint") - val bytes = hex.chunked(2).map { it.toInt(16).toByte() }.toByteArray() - ((application as OpenStrapApp).container.strap as? org.openstrap.app.device.StrapManager)?.probe(probeEndpoint, bytes) - } - // Debug: `--ez strap_sessions false` stops phone workouts running a session on the strap (0x0019), `true` brings it back. - if (BuildConfig.DEBUG && intent.hasExtra("strap_sessions")) { - val on = intent.getBooleanExtra("strap_sessions", false) - intent.removeExtra("strap_sessions") - kotlinx.coroutines.MainScope().launch { (application as OpenStrapApp).container.settings.setStrapSessions(on) } - Toast.makeText(this, "Strap sessions ${if (on) "on" else "off"}", Toast.LENGTH_SHORT).show() - } - // Debug: `--es buzz AboveTarget` plays a cue's pattern on the strap (a Cue name, Stop or Offer), to feel it. - val buzz = intent.getStringExtra("buzz") - if (BuildConfig.DEBUG && buzz != null) { - intent.removeExtra("buzz") - val pattern = when (buzz) { - "Stop" -> org.openstrap.app.device.WorkoutSession.STOP_BUZZ - "Offer" -> org.openstrap.app.device.WorkoutSession.OFFER_BUZZ - else -> runCatching { org.openstrap.app.data.Cue.valueOf(buzz).pattern }.getOrNull() - } - val strap = (application as OpenStrapApp).container.strap - if (pattern != null) kotlinx.coroutines.MainScope().launch { - runCatching { strap.buzz(pattern) }.onFailure { Toast.makeText(this@MainActivity, "Buzz failed: ${it.message}", Toast.LENGTH_SHORT).show() } - } - } - // Debug: `--es simulate_hr 140x120,80x600 --ei simulate_steps 0 --ei simulate_battery 80` plays heart rate as a connected strap (emulator). - val simulate = intent.getStringExtra("simulate_hr") - if (BuildConfig.DEBUG && simulate != null) { - intent.removeExtra("simulate_hr") - val script = simulate.split(',').mapNotNull { p -> p.split('x').takeIf { it.size == 2 }?.let { (b, s) -> b.toIntOrNull()?.let { bpm -> s.toIntOrNull()?.let { bpm to it } } } } - ((application as OpenStrapApp).container.strap as? org.openstrap.app.device.StrapManager)?.simulate(script, intent.getIntExtra("simulate_steps", 0), intent.getIntExtra("simulate_battery", 80)) - } - // Debug: `--es moment Evening` forces Today's moment (a Moment name), so each can be checked on a phone; - // `--es moment auto` goes back to the clock. - val moment = intent.getStringExtra(EXTRA_MOMENT) - if (BuildConfig.DEBUG && moment != null && moment != MOMENT_NOW) { - intent.removeExtra(EXTRA_MOMENT) - if (moment.equals("auto", ignoreCase = true)) forcedMoment.value = null - else org.openstrap.app.data.Moment.entries.firstOrNull { it.name.equals(moment, ignoreCase = true) } - ?.let { forcedMoment.value = it } - ?: android.util.Log.w("OpenStrap", "moment: no moment called $moment") - } - // Debug: `--ei recovery_score 78` shows that score on the Recovery detail (its arc gauge) while Recovery is still - // learning; `--ei recovery_score -1` goes back to the real one. Nothing is written. - if (BuildConfig.DEBUG && intent.hasExtra("recovery_score")) { - val score = intent.getIntExtra("recovery_score", -1) - intent.removeExtra("recovery_score") - org.openstrap.app.ui.glow.debugRecoveryScore.value = score.takeIf { it in 0..100 } - } - // Debug: `--es strap_state stuck` shows a strap state on a phone whose strap is fine, so each can be checked; - // `--es strap_state auto` goes back to the real one. Only what the screens are shown changes: no Bluetooth, no - // sync, nothing written. - val strapState = intent.getStringExtra(EXTRA_STRAP_STATE) - if (BuildConfig.DEBUG && strapState != null) { - intent.removeExtra(EXTRA_STRAP_STATE) - if (strapState.equals("auto", ignoreCase = true)) strapPreview.value = null - else StrapPreview.of(strapState)?.let { strapPreview.value = it } - ?: android.util.Log.w("OpenStrap", "strap_state: no state called $strapState") - android.util.Log.i("OpenStrap", "strap_state: showing ${strapPreview.value ?: "the real state"}") - } - // Debug: `--es data_state nosleep` shows a data state (the pairing stage's steps, first nights, no sleep, a - // partial day, the first score) on a phone whose data is fine; `--es data_state auto` goes back to the real - // one. Only what the screens are shown changes: no Bluetooth, no scan, no sync, nothing written. - val dataState = intent.getStringExtra(EXTRA_DATA_STATE) - if (BuildConfig.DEBUG && dataState != null) { - intent.removeExtra(EXTRA_DATA_STATE) - if (dataState.equals("auto", ignoreCase = true)) dataPreview.value = null - else DataPreview.of(dataState)?.let { dataPreview.value = it } - ?: android.util.Log.w("OpenStrap", "data_state: no state called $dataState") - android.util.Log.i("OpenStrap", "data_state: showing ${dataPreview.value ?: "the real state"}") - } - // Debug: `--ez show_summary true` opens the summary right after a workout for the latest stored phone workout, - // with nothing live and nothing written. - if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SHOW_SUMMARY, false)) { - intent.removeExtra(EXTRA_SHOW_SUMMARY) - val container = (application as OpenStrapApp).container - kotlinx.coroutines.MainScope().launch { - val latest = container.database.healthDao().appWorkouts().first().filter { it.end != null }.maxByOrNull { it.start } - if (latest != null) container.workout.showSummary(latest.start) - else Toast.makeText(this@MainActivity, "No phone workout to show", Toast.LENGTH_SHORT).show() - } - } - // Debug: `--es workout_notification running` posts the workout notification with sample figures, built as a - // real workout's is: running, paused, indoor, end or clear. Nothing records and nothing is written. - val sample = intent.getStringExtra(EXTRA_WORKOUT_NOTIFICATION) - if (BuildConfig.DEBUG && sample != null) { - intent.removeExtra(EXTRA_WORKOUT_NOTIFICATION) - startService(Intent(this, StrapService::class.java).setAction(StrapService.ACTION_SAMPLE).putExtra(StrapService.EXTRA_SAMPLE, sample)) - } - val days = intent.getIntExtra("dump_raw_days", 0) - if (!BuildConfig.DEBUG || days <= 0) return - intent.removeExtra("dump_raw_days") - // Debug: `--es dump_types 5,6,70` limits the dump to these GDSP codes (even ones the strap doesn't advertise). - val types = intent.getStringExtra("dump_types")?.split(',')?.mapNotNull { it.trim().toIntOrNull() }?.toSet() - intent.removeExtra("dump_types") - android.util.Log.i("OpenStrap", "dump: requested $days days, types ${types ?: "all"}") - ((application as OpenStrapApp).container.strap as? org.openstrap.app.device.StrapManager)?.dumpRaw(days, types) - } } -private enum class Tab( - val label: String, - val icon: androidx.compose.ui.graphics.vector.ImageVector, - val selectedIcon: androidx.compose.ui.graphics.vector.ImageVector, - /** The capsule's colour while this tab shows (spec 3.2). */ - val tint: androidx.compose.ui.graphics.Color, -) { - Today("Today", AppIcons.HouseSimple, AppIcons.HouseSimpleFill, androidx.compose.ui.graphics.Color.White), - Recovery("Recovery", AppIcons.Plant, AppIcons.PlantFill, org.openstrap.app.ui.theme.MetricColors.readiness), - Sleep("Sleep", AppIcons.Moon, AppIcons.MoonFill, org.openstrap.app.ui.theme.MetricColors.sleepDeep), - Activity("Activity", AppIcons.SneakerMove, AppIcons.SneakerMoveFill, org.openstrap.app.ui.theme.MetricColors.steps), -} +/** An update is being installed on the Firmware page over the tab. */ +private fun firmwareInstalling(nav: AppNavState, firmwareVm: FirmwareViewModel): Boolean = + Page.Firmware in nav.pages && firmwareVm.step.value is FirmwareStep.Installing /** - * The pages that open over whichever tab is showing and close back to it (spec 3.3): the You page and what it opens, - * and the Strap page and what it opens. Shallowest first. + * Closes the pages over the tab, unless an update is being installed there, and says if any closed. Firmware starts + * again from the top once it closes. */ -private enum class Page( - /** The row on the You or Strap page that opens this page, marked while it's open beside the list on a wide screen. */ - val row: String? = null, -) { - You, Profile("Profile"), HealthConnect("Health Connect"), Backup("Backup"), Updates("Updates"), Diagnostics("Diagnostics"), - Strap, Alarms("Alarms"), PhoneAlerts("Phone alerts"), HealthAlerts("Health alerts"), Firmware("Firmware"), ZeppAccount("Zepp account"), +private fun closePages(nav: AppNavState, firmwareVm: FirmwareViewModel): Boolean { + val firmware = Page.Firmware in nav.pages + return nav.closePages(firmwareInstalling(nav, firmwareVm)).also { closed -> if (closed && firmware) firmwareVm.reset() } } -/** Keeps the open pages across process death, as their names ("Strap,Alarms"). */ -private val PagesSaver: androidx.compose.runtime.saveable.Saver, String> = androidx.compose.runtime.saveable.Saver( - save = { pages -> pages.joinToString(",") { it.name } }, - restore = { s -> s.split(',').mapNotNull { n -> Page.entries.firstOrNull { it.name == n } } }, -) - -/** What an `open_metric` link asked for: the screen and the day to show it for. */ -internal data class MetricRequest(val route: String, val day: java.time.LocalDate?) - -private val BLUETOOTH_PERMISSIONS = arrayOf( - Manifest.permission.BLUETOOTH_SCAN, - Manifest.permission.BLUETOOTH_CONNECT, - Manifest.permission.POST_NOTIFICATIONS, -) +/** + * A tap on the tab bar. Another tab keeps every stack. The tab showing scrolls to the top, then pops to its root (spec + * 3.3). A tap that closes pages over the tab does only that. + */ +private fun tapTab(t: Int, nav: AppNavState, firmwareVm: FirmwareViewModel, scroll: ScrollRequests) { + val closed = closePages(nav, firmwareVm) + when { + firmwareInstalling(nav, firmwareVm) -> Unit + t != nav.tab.current -> nav.select(Tab.entries[t]) + closed -> Unit + nav.reselect(Tab.entries[t]) == Reselect.ScrollToTop -> scroll.bump(t) + } +} -private fun epochNow() = System.currentTimeMillis() / 1000 +/** Wider than tall and at least this wide, the tabs stand down the left edge as a rail. */ +private val RailMinWidth = 600.dp -/** The first score's day before the settings store has answered: nothing is decided until it has. */ -private const val FIRST_SCORE_LOADING = Long.MIN_VALUE +/** Where the workout capsule floats: above the tab bar, or near the bottom edge without one. */ +private val CapsuleOverBar = 104.dp +private val CapsuleNoBar = 16.dp +/** + * The app: the tabs, the pages over them, the sheets and the workout screens, in that order. Back goes to what was + * composed last, so the order is the back order (wave 4 Decisions 1): the tabs, the pages over them, All metrics, add + * workout, the sport picker, the workout layer, the history badges, the Bluetooth dialog, then the glass popups. + */ +// AppRoot creates the view models and hands each part of the screen the ones it shows. +@Suppress("ViewModelForwarding", "ComposeViewModelForwarding") @Composable private fun AppRoot( container: AppContainer, - metricRequests: kotlinx.coroutines.flow.MutableStateFlow, - todayRequests: kotlinx.coroutines.flow.MutableStateFlow, - strapRequests: kotlinx.coroutines.flow.MutableStateFlow, - forcedMoment: kotlinx.coroutines.flow.StateFlow, - strapPreview: kotlinx.coroutines.flow.StateFlow, - dataPreviews: kotlinx.coroutines.flow.StateFlow, + navRequests: NavigationRequests, + previews: DebugPreviews, + pendingStrap: PendingStrapAction, + dayVm: DayViewModel = viewModel(factory = ViewModelFactories.day(container)), + deviceVm: DeviceViewModel = viewModel(factory = ViewModelFactories.device(container)), + firmwareVm: FirmwareViewModel = viewModel(factory = ViewModelFactories.firmware(container)), + workoutsVm: WorkoutsViewModel = viewModel(factory = ViewModelFactories.workouts(container)), + rootVm: AppRootViewModel = viewModel(factory = ViewModelFactories.root(container, workoutsVm.workouts)), ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val dayVm: DayViewModel = viewModel(factory = viewModelFactory { initializer { DayViewModel(container.health, container.strap.todaySteps, container.settings.profile) } }) - val deviceVm: DeviceViewModel = viewModel(factory = viewModelFactory { initializer { DeviceViewModel(container) } }) - - // Which tab shows and the one before it, for back; every tab keeps its own stack of details. - var nav by rememberSaveable(stateSaver = TabNav.Saver) { mutableStateOf(TabNav()) } - val tab = nav.current - val liveEnabled by container.strap.liveHeartRateEnabled.collectAsStateWithLifecycle() - val firmwareVm: FirmwareViewModel = viewModel(factory = viewModelFactory { initializer { FirmwareViewModel(container) } }) - // Pages over the tab showing (the You page and the Strap page, and what they open), deepest last. - var pages by rememberSaveable(stateSaver = PagesSaver) { mutableStateOf(emptyList()) } - // The All metrics sheet, from the grid button in the header: over the tab, never a place. - var allMetricsOpen by rememberSaveable { mutableStateOf(false) } - val workoutsVm: org.openstrap.app.ui.workout.WorkoutsViewModel = viewModel(factory = viewModelFactory { - initializer { org.openstrap.app.ui.workout.WorkoutsViewModel(container.database.healthDao(), container.settings.profile) } - }) - val workouts by workoutsVm.workouts.collectAsStateWithLifecycle() - val openWorkout by workoutsVm.open.collectAsStateWithLifecycle() - // Each tab's stack of details, deepest last; back pops one. Opening a detail never moves you to another tab. - var todayStack by rememberSaveable(stateSaver = Detail.StackSaver) { mutableStateOf(emptyList()) } - var activityStack by rememberSaveable(stateSaver = Detail.StackSaver) { mutableStateOf(emptyList()) } - var recoveryStack by rememberSaveable(stateSaver = Detail.StackSaver) { mutableStateOf(emptyList()) } - var sleepStack by rememberSaveable(stateSaver = Detail.StackSaver) { mutableStateOf(emptyList()) } - fun stackOf(t: Int): List = when (Tab.entries[t]) { - Tab.Today -> todayStack; Tab.Recovery -> recoveryStack; Tab.Sleep -> sleepStack; Tab.Activity -> activityStack - } - fun setStack(t: Int, stack: List) = when (Tab.entries[t]) { - Tab.Today -> todayStack = stack; Tab.Recovery -> recoveryStack = stack; Tab.Sleep -> sleepStack = stack; Tab.Activity -> activityStack = stack - } - // The All metrics Charge row asks the Recovery tab to scroll to its Charge card, once. - var showCharge by remember { mutableStateOf(false) } - // Each tap on the tab showing bumps its count, and its lists scroll to the top. - val scrollToTop = remember { androidx.compose.runtime.mutableStateListOf(*Array(Tab.entries.size) { 0 }) } - val history: org.openstrap.app.ui.dashboard.DailyHistory = { m, from, to -> container.health.daily(m, from, to) } - var editGoals by rememberSaveable { mutableStateOf(false) } - val goals by container.settings.goals.collectAsStateWithLifecycle(org.openstrap.app.data.settings.Goals()) - val todayHidden by container.settings.todayHiddenNames.collectAsStateWithLifecycle(null) - val todayPinned by container.settings.todayPinnedNames.collectAsStateWithLifecycle(null) - // Remembered, like the picker below: a new object each time the root recomposes (on every tab change) would - // recompose every tab. - val todayEdits = remember(todayHidden, todayPinned) { org.openstrap.app.ui.dashboard.TodayEdits.parse(todayHidden, todayPinned) } - val strapState by strapPreview.collectAsStateWithLifecycle() - val dataPreview by dataPreviews.collectAsStateWithLifecycle() + // Where the app is: the tab, the pages over it, each tab's details, the sheets and the workout screens. + val nav = rememberSaveable(saver = AppNavState.Saver) { AppNavState() } + val tab = nav.tab.current + val goals by rootVm.goals.collectAsStateWithLifecycle() + val strapPreview by previews.strap.collectAsStateWithLifecycle() + val dataPreview by previews.data.collectAsStateWithLifecycle() // The pairing steps before a strap is paired show with no strap, as `strap_state notpaired` does. - val preview = strapState ?: StrapPreview.NotPaired.takeIf { dataPreview?.unpaired == true } - val realRecovery by dayVm.recovery.collectAsStateWithLifecycle() - val realRecoveryFailure by dayVm.recoveryFailure.collectAsStateWithLifecycle() - // The debug preview of a failed Recovery shows the day as failed, with yesterday's score from the real result. - val previewFailure = if (preview == StrapPreview.RecoveryFail) realRecovery?.let { r -> - RecoveryFailure(r.date, r.readinessTrend.firstOrNull { it.first == r.date.minusDays(1) }?.second) - } else null - // The data previews show first nights or no sleep in the day and its Recovery; nothing else changes. - val recovery = if (previewFailure != null) null else realRecovery?.previewed(dataPreview) - val recoveryFailure = previewFailure ?: realRecoveryFailure - val healthspan by dayVm.healthspan.collectAsStateWithLifecycle() - val habitAgeSeen by container.settings.habitAgeSeenAsOf.collectAsStateWithLifecycle(null) - // Opening Habit Age marks the estimate seen, so Today stops offering it. - val seeHabitAge: () -> Unit = { healthspan?.result?.asOf?.let { a -> scope.launch { container.settings.setHabitAgeSeen(a) } } } - val profile by deviceVm.profile.collectAsStateWithLifecycle() - val realDay by dayVm.state.collectAsStateWithLifecycle() - val day = remember(realDay, dataPreview) { realDay.previewed(dataPreview) } - // Your usual steps by the hour, read once per day shown, off the main thread ([DayViewModel.usualSteps]). - val usualSteps by dayVm.usualSteps.collectAsStateWithLifecycle() - val paiHistory: org.openstrap.app.ui.glow.PaiHistory = remember(container) { { from, to -> container.health.dailyPai(from, to) } } - val energyHistory: org.openstrap.app.ui.glow.EnergyHistory = remember(container) { { from, to -> container.health.dailyEnergy(from, to) } } - val strapReadiness by remember(day.dayStart) { container.database.healthDao().strapReadiness(day.dayStart) }.collectAsStateWithLifecycle(null) - val checkIn by remember(day.date) { container.health.checkIn(day.date) }.collectAsStateWithLifecycle(null) - val daysWithData by dayVm.daysWithData.collectAsStateWithLifecycle() - val picker = remember(daysWithData, dayVm) { DayPicker(daysWithData, dayVm::select) } - val live by container.strap.liveHeartRate.collectAsStateWithLifecycle() - val realConnection by container.strap.connection.collectAsStateWithLifecycle() - val realSync by container.strap.sync.collectAsStateWithLifecycle() - val syncRecord by container.strap.syncRecord.collectAsStateWithLifecycle() - val message by deviceVm.message.collectAsStateWithLifecycle() + val preview = strapPreview ?: StrapPreview.NotPaired.takeIf { dataPreview?.unpaired == true } + val previewed = rememberPreviewedDay(dayVm, preview, dataPreview) + val day = previewed.day + val strapFacts by rootVm.strap.collectAsStateWithLifecycle() + val connected = strapFacts.connection is ConnectionState.Connected + val live by rootVm.liveHeartRate.collectAsStateWithLifecycle() + val liveEnabled by rootVm.liveEnabled.collectAsStateWithLifecycle() val workoutState by container.workout.state.collectAsStateWithLifecycle() val workoutMinimized by container.workout.minimized.collectAsStateWithLifecycle() - // The ready screen's sport while it is open, and whether it opened on the sport list ("All sports"). - var readySport by rememberSaveable { mutableStateOf(null) } - var readyPicking by rememberSaveable { mutableStateOf(false) } - val suggestions by workoutsVm.suggestions.collectAsStateWithLifecycle() - // Hidden while a workout records: the strap's history may show the one under way. - val suggestion = suggestions.firstOrNull().takeIf { workoutState !is org.openstrap.app.device.WorkoutState.Recording } - val offer by container.workout.offer.collectAsStateWithLifecycle() - // A stretch being saved as a workout waits here while its sport is chosen. - var savingSuggestion by remember { mutableStateOf(null) } - val firmwareInstalling: () -> Boolean = { Page.Firmware in pages && firmwareVm.step.value is org.openstrap.app.ui.firmware.FirmwareStep.Installing } - // Closes the pages over the tab, unless an update is being installed there; true if any closed. - val closePages: () -> Boolean = { - if (pages.isEmpty() || firmwareInstalling()) false - else { if (Page.Firmware in pages) firmwareVm.reset(); pages = emptyList(); true } + val running = workoutState as? WorkoutState.Recording + val forced by previews.moment.collectAsStateWithLifecycle() + val today = rememberTodayMoment( + day, previewed.recovery, previewed.recoveryFailure, running != null, forced, workoutsVm, onNewDay = dayVm::select, + ) + val strap = rememberStrapHeader(strapFacts, day, preview) + val shown = rememberShownDay(previewed, dayVm, rootVm, today.clockDate, strap.now, preview, dataPreview) + val histories = remember(container) { + Histories { m, from, to -> container.health.daily(m, from, to) } } - // 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(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 liveHeart = LiveHeart(live, liveEnabled, connected) { container.strap.setLiveHeartRate(it) } + val pairedKnown = strapFacts.pairedKnown && preview != StrapPreview.NotPaired + val scan by deviceVm.scan.collectAsStateWithLifecycle() + val pairing = rememberPairingStage(strapFacts.pairedKnown, scan.scanning, scan.found.isNotEmpty(), strapFacts.connection, dataPreview) + // Opening Habit Age marks the estimate seen, so Today stops offering it. + val seeHabitAge: () -> Unit = { shown.healthspan?.result?.asOf?.let(rootVm::seeHabitAge) } + val profile by deviceVm.profile.collectAsStateWithLifecycle() + DeviceMessages(deviceVm) + // The workout at a glance for the Today hero and the floating bar (workouts spec 2.10). Its State is passed down, + // not read here, so only those two recompose as it ticks. + val liveGlance = rootVm.liveGlance.collectAsStateWithLifecycle() + // The Today hero shows the workout: the floating bar stays away meanwhile. + var liveHeroShown by remember { mutableStateOf(false) } + val onGlanceAction = remember(container) { glanceAction(container.workout) } + // The All metrics Charge row asks the Recovery tab to scroll to its Charge card, once. + var showCharge by remember { mutableStateOf(false) } + // Each tap on the tab showing bumps its count, and its lists scroll to the top. + val scroll = remember { ScrollRequests() } + val firmwareInstalling: () -> Boolean = { firmwareInstalling(nav, firmwareVm) } + val closePages: () -> Boolean = { closePages(nav, firmwareVm) } + // The first page over the tab grows out of the header button tapped for it, if one was (spec 3.5), and the tab + // behind it sinks as it comes forward. + val growOrigin = remember { GrowOrigin() } + var growFrom by remember { mutableStateOf(null) } + val pageGrow = rememberGrowProgress(nav.pages.isNotEmpty()) val openPage: (Page) -> Unit = { p -> val tapped = growOrigin.take() - if (pages.isEmpty()) growFrom = tapped - if (pages.lastOrNull() != p) pages = pages + p - } - val closePage: () -> Unit = { pages = pages.dropLast(1) } - // A launcher shortcut or the Quick Settings tile asked for the ready screen. - val readyRequest by container.workout.readyRequest.collectAsStateWithLifecycle() - LaunchedEffect(readyRequest) { - readyRequest?.let { sport -> - if (workoutState !is org.openstrap.app.device.WorkoutState.Recording) { readySport = sport; readyPicking = false } - container.workout.readyRequest.value = null - } - } - // The Start widget's All sports: Ready on the sport picker, as the Activity tab's All sports opens it. - val readyPickRequest by container.workout.readyPickRequest.collectAsStateWithLifecycle() - LaunchedEffect(readyPickRequest) { - if (readyPickRequest) { - if (workoutState !is org.openstrap.app.device.WorkoutState.Recording) { - readySport = readySport ?: org.openstrap.app.ui.glow.topSports(workouts).first().sportType - readyPicking = true - } - container.workout.readyPickRequest.value = false - } - } - // The Moment widget: Today at its root, on today, over whatever showed. - val todayRequest by todayRequests.collectAsStateWithLifecycle() - LaunchedEffect(todayRequest) { - if (todayRequest) { - closePages() - allMetricsOpen = false - if (workoutState !is org.openstrap.app.device.WorkoutState.Recording) readySport = null - todayStack = emptyList() - dayVm.select(java.time.LocalDate.now()) - nav = nav.select(Tab.Today.ordinal) - todayRequests.value = false - } - } - // A widget's strap status line: the Strap page, over the tab showing, as the header's strap chip opens it. - val strapRequest by strapRequests.collectAsStateWithLifecycle() - LaunchedEffect(strapRequest) { - if (strapRequest) { - allMetricsOpen = false - if (pages.lastOrNull() != Page.Strap && (pages.isEmpty() || closePages())) openPage(Page.Strap) - strapRequests.value = false - } - } - // An update notification: the Updates page, over the You page as its row opens it. - val updatesRequest by container.updater.openRequest.collectAsStateWithLifecycle() - LaunchedEffect(updatesRequest) { - if (updatesRequest) { - allMetricsOpen = false - if (pages.lastOrNull() != Page.Updates && (pages.isEmpty() || closePages())) pages = listOf(Page.You, Page.Updates) - container.updater.openRequest.value = false - } - } - // An auto-detected workout's notification: that workout, on the Activity tab, its home. - val openRequest by container.workout.openRequest.collectAsStateWithLifecycle() - LaunchedEffect(openRequest) { - openRequest?.let { start -> - closePages() - activityStack = listOf(Detail.Workout(start)) - nav = nav.select(Tab.Activity.ordinal) - container.workout.openRequest.value = null - } - } - // A health alert or body-signal notification: its metric's screen, for the alert's day, over the tab showing. - val metricRequest by metricRequests.collectAsStateWithLifecycle() - LaunchedEffect(metricRequest) { - metricRequest?.let { r -> - Detail.fromRoute(r.route)?.let { detail -> - closePages() - allMetricsOpen = false - r.day?.let(dayVm::select) - if (stackOf(tab).lastOrNull() != detail) setStack(tab, stackOf(tab) + detail) - } - metricRequests.value = null - } - } - // The day a workout is being added to after the fact. - var addingOn by rememberSaveable { mutableStateOf(null) } - - // When the strap was last heard from, for the header chip: kept fresh while connected, then left where it stopped. - // Without that in this run, the last synced reading or the last sync stand in. - var seenConnected by rememberSaveable { mutableStateOf(null) } - val connected = realConnection is ConnectionState.Connected - LaunchedEffect(connected) { - if (connected) while (true) { seenConnected = epochNow(); kotlinx.coroutines.delay(30_000) } - } - val realLastSeen = listOfNotNull( - seenConnected, - (realSync as? org.openstrap.app.device.SyncState.Done)?.atEpochSeconds, - day.heartRate.lastOrNull()?.x?.takeIf { day.date == java.time.LocalDate.now() }, - ).maxOrNull() - // The chip's "18 min ago" moves on by itself. - val now by androidx.compose.runtime.produceState(epochNow()) { while (true) { kotlinx.coroutines.delay(30_000); value = epochNow() } } - // What the screens are shown of the strap: the real state, or a debug preview's (nothing else changes). - // Paired until the store says otherwise, so a paired strap never flashes the pairing hero or "Not paired" on start. - val realPairedKnown by remember { container.settings.pairedDevice.map { it != null } }.collectAsStateWithLifecycle(true) - val strapView = StrapView(realPairedKnown, realConnection, realSync, syncRecord, realLastSeen).previewed(preview, now) - val connection = strapView.connection - val sync = strapView.sync - val lastSeen = strapView.lastSeen - // Right after the process starts the service is still reconnecting, so the chip waits before saying "18 min ago". - val sinceStart = if (preview != null) Long.MAX_VALUE else (android.os.SystemClock.uptimeMillis() - android.os.Process.getStartUptimeMillis()) / 1000 - val chip = strapChip(strapView.paired, connection, sync, lastSeen, now, sinceStart) - - // The moment (spec 4.1), on a clock that ticks each minute only while the app is in view. - val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle - val clock by androidx.compose.runtime.produceState(java.time.Instant.now(), lifecycle) { - lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) { - while (true) { - value = java.time.Instant.now() - kotlinx.coroutines.delay(60_000 - System.currentTimeMillis() % 60_000) - } - } - } - val zone = java.time.ZoneId.systemDefault() - val clockDate = clock.atZone(zone).toLocalDate() - // Open across midnight on today, the day moves on with the clock, so night carries on past 00:00. - var shownToday by remember { mutableStateOf(clockDate) } - LaunchedEffect(clockDate) { - if (clockDate != shownToday) { - if (day.date == shownToday) dayVm.select(clockDate) - shownToday = clockDate - } - } - // Your usual workout time comes from workouts you started, not walks the strap noticed by itself. - val workoutStarts = remember(workouts) { workouts.filter { it.startedByYou }.map { it.workout.start } } - val lastWorkout = remember(workouts) { workouts.maxByOrNull { it.end } } - val recording = workoutState is org.openstrap.app.device.WorkoutState.Recording - // The moment is the clock's, whichever day is shown: browsing yesterday at night is still night (the mood follows - // it), and only Today's layout goes back to waking for a past day. Today's wake and usual bedtime are kept from - // when today was last loaded, so a past day doesn't lose them. Until today's recovery has loaded once, the - // bedtime isn't known and the fallback would show Evening for a second before the real moment, so there's no - // moment until then: Today's plain title, the day's mood. - // A Recovery that failed counts as loaded: the moment shouldn't wait on it. - val todayLoaded = day.date == clockDate && (recovery?.date == clockDate || recoveryFailure?.date == clockDate) - val freshClockFacts = if (todayLoaded) day.sleep?.end to recovery?.usualBedtime else null - var keptClockFacts by remember(clockDate) { mutableStateOf?>(null) } - androidx.compose.runtime.SideEffect { if (freshClockFacts != null && freshClockFacts != keptClockFacts) keptClockFacts = freshClockFacts } - val clockFacts = freshClockFacts ?: keptClockFacts - val usualBedtime = clockFacts?.second - val clockMoment = remember(clock.epochSecond / 60, clockDate, recording, lastWorkout?.end, clockFacts, workoutStarts) { - clockFacts?.let { (sleepEnd, bedtime) -> - org.openstrap.app.data.MomentResolver.resolve(clock, zone, clockDate, recording, lastWorkout?.end, sleepEnd, bedtime, workoutStarts) - } - } - val forced by forcedMoment.collectAsStateWithLifecycle() - val moment = forced ?: clockMoment - // What Today is laid out for: the moment today, the waking layout for a past day. - val todayMoment = org.openstrap.app.ui.dashboard.layoutMoment(moment, day.date == clockDate) - val pairedKnown = realPairedKnown && preview != StrapPreview.NotPaired - LaunchedEffect(forced, clockMoment) { - forced?.let { android.util.Log.i("OpenStrap", "moment: forced $it, the clock says $clockMoment") } - } - - // The pairing stage (E3) from the real pairing: the Strap page's scan before a strap is paired, then the - // connection. Once paired it stays on Today until the strap first connects, and holds Connected a moment. - val scan by deviceVm.scan.collectAsStateWithLifecycle() - var firstConnect by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(realPairedKnown) { if (!realPairedKnown) firstConnect = true } - val realStep = if (!realPairedKnown || firstConnect) pairStep(realPairedKnown, scan.scanning, scan.found.isNotEmpty(), realConnection) else null - LaunchedEffect(realStep == PairStep.Connected) { - if (realStep == PairStep.Connected) { kotlinx.coroutines.delay(PAIRED_HOLD_MS); firstConnect = false } - } - val pairing = dataPreview?.pair ?: realStep - - // How much of the day the strap was worn, for the Partial day tag on the cards it lowers (spec 4.3). - val dayIsToday = day.date == clockDate - val wear = day.wear(recovery, dayIsToday) - .previewed(dataPreview, (((if (dayIsToday) now else day.dayEnd + 1) - day.dayStart) / 60).toInt()) - - // The first real score is marked once (E4): its day is kept, and never written while a preview shows. - val firstScoreDay by container.settings.firstScoreDay.collectAsStateWithLifecycle(FIRST_SCORE_LOADING) - val dayRecovery = recovery?.takeIf { it.date == day.date } - val first = if (firstScoreDay == FIRST_SCORE_LOADING) FirstScore(false) else firstScore( - dayRecovery?.readiness?.score, day.date, dayIsToday, dayRecovery?.readiness?.phase == Recovery.Phase.Learning, - dayRecovery?.let { earliestScore(it.readinessTrend, day.date) }, firstScoreDay?.let(LocalDate::ofEpochDay), + if (nav.pages.isEmpty()) growFrom = tapped + nav.openPage(p) + } + NavRequestsEffect( + navRequests, nav, + NavRequestHooks( + recording = { container.workout.state.value is WorkoutState.Recording }, + firmwareInstalling = firmwareInstalling, + firmwareClosed = firmwareVm::reset, + selectDay = dayVm::select, + today = { dayVm.select(LocalDate.now()) }, + strapOpened = { growFrom = growOrigin.take() }, + showWorkout = { container.workout.minimized.value = false }, + ), ) - LaunchedEffect(first.remember, preview, dataPreview) { - if (preview == null && dataPreview == null) first.remember?.let { container.settings.setFirstScoreDay(it.toEpochDay()) } - } - val showFirstScore = first.show || dataPreview == DataPreview.FirstScoreShown - - LaunchedEffect(message) { - message?.let { Toast.makeText(context, it, Toast.LENGTH_LONG).show(); deviceVm.clearMessage() } - } - - // Bluetooth permission gate: remember what to run once granted. - var pending by remember { mutableStateOf<(() -> Unit)?>(null) } - val btLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> - val ok = result[Manifest.permission.BLUETOOTH_SCAN] == true && result[Manifest.permission.BLUETOOTH_CONNECT] == true - if (ok) pending?.invoke() else Toast.makeText(context, "Bluetooth permission is required.", Toast.LENGTH_LONG).show() - pending = null - } - val requestBluetooth: (() -> Unit) -> Unit = { then -> pending = then; btLauncher.launch(BLUETOOTH_PERMISSIONS) } - // Location for a GPS workout: asked only when GPS is turned on; the workout starts either way (steps without it). - var afterLocation by remember { mutableStateOf<(() -> Unit)?>(null) } - val locationLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> - if (result.values.none { it }) Toast.makeText(context, "Without location, distance comes from the strap's steps.", Toast.LENGTH_LONG).show() - afterLocation?.invoke() - afterLocation = null - } - val requestLocation: (() -> Unit) -> Unit = { then -> - afterLocation = then - locationLauncher.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) - } - - val csvLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri -> - uri?.let(deviceVm::exportCsv) - } - // The write permissions are asked for the manual copy and for turning auto sync on; this says which, and survives - // the activity being recreated behind the permission screen. - var hcForAuto by androidx.compose.runtime.saveable.rememberSaveable { mutableStateOf(false) } - val hcLauncher = rememberLauncherForActivityResult(PermissionController.createRequestPermissionResultContract()) { granted -> - val auto = hcForAuto - hcForAuto = false - scope.launch { - // The needed ones may have been granted before, outside what this screen returns; the calorie ones may stay off. - val ok = granted.containsAll(container.healthConnect.permissions) || runCatching { container.healthConnect.hasPermissions() }.getOrDefault(false) - if (!ok) Toast.makeText(context, "Health Connect permissions were not granted.", Toast.LENGTH_LONG).show() - else if (auto) deviceVm.setAutoHealthConnect(true) else deviceVm.exportHealthConnect() - } - } - // Automatic backup's folder picker (a cancel leaves it as it was), and Restore, opening on that folder or Download. - val backupFolderLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> - uri?.let(deviceVm::chooseBackupFolder) - } - val backupRestoreLauncher = rememberLauncherForActivityResult(org.openstrap.app.ui.device.PickBackup()) { uri -> - uri?.let(deviceVm::importCsv) - } - val hcReadLauncher = rememberLauncherForActivityResult(PermissionController.createRequestPermissionResultContract()) { granted -> - if (granted.any { it in container.healthConnect.readPermissions }) deviceVm.importHealthConnect() - else Toast.makeText(context, "Health Connect permissions were not granted.", Toast.LENGTH_LONG).show() - } - val importHealthConnect: () -> Unit = { - if (!container.healthConnect.isAvailable()) { - Toast.makeText(context, "Health Connect isn't available on this phone.", Toast.LENGTH_LONG).show() - } else scope.launch { - if (container.healthConnect.hasReadPermissions()) deviceVm.importHealthConnect() - else hcReadLauncher.launch(container.healthConnect.readPermissions) - } - } - // The manual copy, or turning auto sync on ([forAuto]): with the write permissions, asked for first when missing. - val writeHealthConnect: (Boolean) -> Unit = { forAuto -> - if (!container.healthConnect.isAvailable()) { - Toast.makeText(context, "Health Connect isn't available on this phone.", Toast.LENGTH_LONG).show() - } else scope.launch { - // The calorie permissions are optional, but asked for again until granted: the launcher goes on with the - // ones it needs. - if (container.healthConnect.hasRequestedPermissions()) { - if (forAuto) deviceVm.setAutoHealthConnect(true) else deviceVm.exportHealthConnect() - } else { - hcForAuto = forAuto - hcLauncher.launch(container.healthConnect.requestedPermissions) - } - } - } - val exportHealthConnect: () -> Unit = { writeHealthConnect(false) } - val autoHealthConnect: (Boolean) -> Unit = { on -> if (on) writeHealthConnect(true) else deviceVm.setAutoHealthConnect(false) } - - // Swiping left and right moves between tabs. [nav] owns the tab and the pager follows it: a tap animates the - // pager there, and only a swipe the user made, once it settles, is another way to pick the tab. The pager - // settling from its own animation (or one cut short by the next tap) never writes back. - val pager = rememberPagerState(initialPage = tab) { Tab.entries.size } - val swiped = remember { java.util.concurrent.atomic.AtomicBoolean(false) } - LaunchedEffect(tab) { - if (pager.currentPage != tab) { - swiped.set(false) - pager.animateScrollToPage(tab, animationSpec = tween(Motion.SWIFT, easing = Motion.easing)) - } - } - LaunchedEffect(pager) { - launch { pager.interactionSource.interactions.collect { if (it is androidx.compose.foundation.interaction.DragInteraction.Start) swiped.set(true) } } - snapshotFlow { pager.settledPage }.collect { nav = nav.settled(it, swiped.get()) } - } - // A detail screen open on this tab keeps the swipe for itself (and for its back gesture). - val currentDetail = stackOf(tab).lastOrNull() - LaunchedEffect(currentDetail) { - workoutsVm.open((currentDetail as? Detail.Workout)?.start) - // A screen opened or closed: the next tap on the tab scrolls before it pops. - nav = nav.moved() + // Starting a workout closes Ready and Choose. [saved] is what Ready opens on next time for the sport, and Choose's + // Start on your usual saves nothing new. + val gates = rememberPermissionGates(pendingStrap, { WorkoutLocation.granted(container.app) }) { (plan, saved) -> + container.workout.start( + plan.sportType, plan.askToEnd, plan.target, plan.useGps, plan.keepRoute, plan.rest, plan.setsTarget, + logSets = plan.logSets, structure = plan.structure, + ) + saved?.let { s -> rootVm.saveSportSettings(plan.sportType, s) } + nav.workoutStarted() } - val subScreenOpen = currentDetail != null || pages.isNotEmpty() - // Back closes what's open first (those screens handle it, registered later so they win). From a tab's root it - // goes to the tab before, then to Today; on Today looking at a past day, back first returns to today. Only - // Today's back leaves the app. - val today = java.time.LocalDate.now() - androidx.activity.compose.BackHandler(enabled = !subScreenOpen && (tab != Tab.Today.ordinal || day.date != today)) { - if (tab == Tab.Today.ordinal) dayVm.select(today) else nav.back()?.let { nav = it } + val pageHooks = PageHooks( + container, dayVm, deviceVm, firmwareVm, workoutsVm, rootVm, + rememberHealthConnectActions(rootVm, deviceVm), rememberBackupActions(deviceVm), + ) + // Back closes what's open first (those screens handle it, registered later so they win). From a tab's root it goes + // to the tab before, then to Today. On Today looking at a past day, back first returns to today. Only Today's back + // leaves the app. + val subScreenOpen = nav.stack(nav.current).isNotEmpty() || nav.pages.isNotEmpty() + val now = LocalDate.now() + BackHandler(enabled = !subScreenOpen && (tab != Tab.Today.ordinal || day.date != now)) { + if (nav.back(firmwareInstalling(), running != null) == BackResult.AtHome) dayVm.select(now) } // Back handlers on tabs that aren't showing (they stay composed, stacks and all) answer to a dispatcher nobody // presses, so back only ever closes what's on screen. val idleBack = remember { - object : androidx.navigationevent.NavigationEventDispatcherOwner { - override val navigationEventDispatcher = androidx.navigationevent.NavigationEventDispatcher() - } - } - // The pages are what the glass nav bar blurs as they scroll under it. - val navGlass = dev.chrisbanes.haze.rememberHazeState() - // Popups are drawn in the app's own window over a blur of the pages, like the nav bar. - val glassHost = remember { org.openstrap.app.ui.components.GlassHost() } - // Half-open on a table, the fold level: heart rate fills the standing top half, the app carries on below the fold. - // During a workout the top half is the workout's heart card instead, and the controls stay below. - val activity = androidx.activity.compose.LocalActivity.current - val windowLayout by remember(activity) { - activity?.let { androidx.window.layout.WindowInfoTracker.getOrCreate(it).windowLayoutInfo(it) } ?: kotlinx.coroutines.flow.emptyFlow() - }.collectAsStateWithLifecycle(null) - val halfOpen = windowLayout?.displayFeatures?.filterIsInstance() - ?.filter { it.state == androidx.window.layout.FoldingFeature.State.HALF_OPENED }.orEmpty() - val hinge = halfOpen.firstOrNull { it.orientation == androidx.window.layout.FoldingFeature.Orientation.HORIZONTAL } - // Stood up like a book, the fold upright: only the live workout splits, heart card left, controls right. - val bookHinge = halfOpen.firstOrNull { it.orientation == androidx.window.layout.FoldingFeature.Orientation.VERTICAL } - val workoutOpen = workoutState as? org.openstrap.app.device.WorkoutState.Recording - val workoutOnTop = workoutOpen?.takeIf { hinge != null && !workoutMinimized } - CompositionLocalProvider( - org.openstrap.app.ui.dashboard.LocalGoals provides goals, - org.openstrap.app.ui.components.LocalGlassHost provides glassHost, - org.openstrap.app.ui.components.LocalGrowOrigin provides growOrigin, - ) { - androidx.compose.foundation.layout.Column(androidx.compose.ui.Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - if (hinge != null) { - val density = androidx.compose.ui.platform.LocalDensity.current - val top = androidx.compose.ui.Modifier.height(with(density) { hinge.bounds.top.toDp() }) - if (workoutOnTop != null) { - val recent by container.workout.recent.collectAsStateWithLifecycle() - val zoneSeconds by container.workout.zoneSeconds.collectAsStateWithLifecycle() - val kcal by container.workout.kcal.collectAsStateWithLifecycle() - org.openstrap.app.ui.glow.GlowWorkoutGlance(workoutOnTop, live, recent, zoneSeconds, realConnection, top, kcal) - } else org.openstrap.app.ui.glow.GlowTabletopHeart( - day, live, liveEnabled.takeIf { realConnection is ConnectionState.Connected }, { container.strap.setLiveHeartRate(it) }, top, - ) - androidx.compose.foundation.layout.Spacer(androidx.compose.ui.Modifier.height(with(density) { hinge.bounds.height().toDp() })) - } - // Below the fold the status bar is far away, so the pages don't make room for it. - androidx.compose.foundation.layout.BoxWithConstraints( - androidx.compose.ui.Modifier.weight(1f).fillMaxWidth() - .let { if (hinge != null) it.consumeWindowInsets(androidx.compose.foundation.layout.WindowInsets.statusBars) else it }, - ) { - // Wider than tall (a phone on its side, an unfolded foldable turned sideways): the tabs stand down the - // left edge, so the pages keep their height. - val rail = maxWidth > maxHeight && maxWidth >= 600.dp - val navItems = Tab.entries.map { NavItem(it.label, it.icon, it.selectedIcon, it.tint) } - // A detail covering the tab is a screen stepped into: no tab bar, only its back button (spec 3.2). Wide - // enough for the detail to open beside the tab, the bar stays with the tab it belongs to. - val showBar = !rail && !(currentDetail != null && maxWidth < SideBySideWidth) - // Another tab keeps every stack. The tab showing scrolls to the top, then pops to its root (spec 3.3). A tap - // that closes pages over the tab does only that. - val selectTab: (Int) -> Unit = { t -> - val closed = closePages() - when { - firmwareInstalling() -> Unit - t != nav.current -> nav = nav.select(t) - closed -> Unit - else -> { - val (next, action) = nav.reselect(stackOf(t).size) - nav = next - when (action) { - Reselect.ScrollToTop -> scrollToTop[t] = scrollToTop[t] + 1 - Reselect.PopToRoot -> setStack(t, emptyList()) - } - } - } - } - Scaffold( - bottomBar = { - androidx.compose.animation.AnimatedVisibility( - showBar, - enter = androidx.compose.animation.slideInVertically { it } + androidx.compose.animation.fadeIn(), - exit = androidx.compose.animation.slideOutVertically { it } + androidx.compose.animation.fadeOut(), - ) { FloatingNavBar(navItems, tab, selectTab, glass = navGlass) } - }, - containerColor = MaterialTheme.colorScheme.background, - ) { padding -> - if (editGoals) { - // The calorie goal your last four weeks suggest, read as the sheet opens (calories spec 3.4). - val suggestedCalories by produceState(null) { value = runCatching { container.health.suggestedCalorieGoal() }.getOrNull() } - org.openstrap.app.ui.device.GoalsSheet(goals, suggestedCalories = suggestedCalories, onSave = { g -> - scope.launch { - container.settings.saveGoals(g) - // The Steps widget's goal, and the evening sentence's on Moment (widgets spec 7). - org.openstrap.app.device.WidgetUpdater.changed(container.app, setOf(org.openstrap.app.widget.WidgetKind.Steps, org.openstrap.app.widget.WidgetKind.Moment)) - } - editGoals = false - }, onDismiss = { editGoals = false }) - } - val updateWaiting by container.updater.waiting.collectAsStateWithLifecycle() - // The header on every tab: the moment on Today (a past day's name for a past day), the tab's name elsewhere. - val header: @Composable (Tab) -> Unit = { t -> - MomentHeader( - if (t != Tab.Today) t.label else todayMoment?.let { m -> - // During a workout the header is the sport under way, after one the sport just done. - val sport = if (m == org.openstrap.app.data.Moment.During) (workoutState as? org.openstrap.app.device.WorkoutState.Recording)?.workout?.sportType else lastWorkout?.workout?.sportType - todayTitle(m, day.date, sport) - } ?: org.openstrap.app.ui.dashboard.dayTitle(day.date), day.date, chip, dayVm::previousDay, dayVm::nextDay, picker, - onOpenYou = { openPage(Page.You) }, onOpenStrap = { openPage(Page.Strap) }, - // All metrics is hidden for now (spec 3.1). Passing { allMetricsOpen = true } brings the button - // back; the sheet opens over whatever shows, and no tab hears of it (spec 3.2). - onOpenAllMetrics = null, - updateWaiting = updateWaiting, - ) - } - // What a tab's stack shows on top: the same screens on every tab, each pushing onto the stack it's on. - val detailPage: @Composable (Int, List) -> Unit = { t, stack -> - val pop: () -> Unit = { setStack(t, stack.dropLast(1)) } - val push: (Detail) -> Unit = { setStack(t, stack + it) } - when (val detail = stack.last()) { - // A switcher pick takes the detail's place; anything else it opens goes on top. - is Detail.OfMetric -> MetricDetail( - detail.metric, detail.night, day, history = history, - liveHeartRate = live, liveEnabled = liveEnabled, canUseLive = realConnection is ConnectionState.Connected, - onLiveToggle = { container.strap.setLiveHeartRate(it) }, - onOpen = push, onSwitch = { setStack(t, stack.dropLast(1) + it) }, onSelectDay = dayVm::select, - contentPadding = padding, onBack = pop, - ) - is Detail.OfScore -> org.openstrap.app.ui.glow.GlowScoreDetailScreen( - detail.score, day, recovery, strapReadiness?.takeIf { it.day == day.dayStart }?.score, history, - onOpen = push, onSwitch = { setStack(t, stack.dropLast(1) + it) }, onSelectDay = dayVm::select, - contentPadding = padding, onBack = pop, - workouts = workouts, paiHistory = paiHistory, onEditProfile = { openPage(Page.Profile) }, energyHistory = energyHistory, - onSetNapTimes = dayVm::setNapTimes, - ) - Detail.Week -> org.openstrap.app.ui.glow.WeekDetail( - org.openstrap.app.ui.glow.ActivityDetailContext( - day, recovery?.takeIf { it.date == day.date }, workouts, paiHistory, history, onOpen = push, onSwitch = {}, - onSelectDay = dayVm::select, onEditProfile = { openPage(Page.Profile) }, contentPadding = padding, onBack = pop, + object : NavigationEventDispatcherOwner { + override val navigationEventDispatcher = NavigationEventDispatcher() + } + } + // The pages are what the glass nav bar blurs as they scroll under it, and popups are drawn in the app's own window + // over a blur of the pages, like the nav bar. + val navGlass = rememberHazeState() + val glassHost = remember { GlassHost() } + // Half open on a table the top half is heart rate, or the workout's heart card with the controls below. Stood up + // like a book, only the live workout splits. + val folds = rememberFolds() + val hinge = folds.table + CompositionLocalProvider(LocalGoals provides goals, LocalGlassHost provides glassHost, LocalGrowOrigin provides growOrigin) { + Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + if (hinge != null) { + TabletopTop(hinge, container, running?.takeIf { !workoutMinimized }, day, live, liveEnabled, strapFacts.connection) + } + // Below the fold the status bar is far away, so the pages don't make room for it. + BoxWithConstraints( + Modifier.weight(1f).fillMaxWidth().let { if (hinge != null) it.consumeWindowInsets(WindowInsets.statusBars) else it }, + ) { + // Wider than tall (a phone on its side, an unfolded foldable turned sideways): the tabs stand down the left + // edge, so the pages keep their height. + val rail = maxWidth > maxHeight && maxWidth >= RailMinWidth + val navItems = remember { tabItems() } + // A detail covering the tab is a screen stepped into: no tab bar, only its back button (spec 3.2). Wide + // enough for the detail to open beside the tab, the bar stays with the tab it belongs to. + var barHidden by remember { mutableStateOf(false) } + val detailOpen = nav.stack(nav.current).isNotEmpty() + val showBar = !rail && !barHidden && !(detailOpen && maxWidth < SideBySideWidth) + val selectTab: (Int) -> Unit = { t -> tapTab(t, nav, firmwareVm, scroll) } + Scaffold( + bottomBar = { + // Qualified: inside the Column, the plain name would pick its ColumnScope overload. + androidx.compose.animation.AnimatedVisibility( + showBar, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + ) { FloatingNavBar(navItems, tab, selectTab, glass = navGlass) } + }, + containerColor = MaterialTheme.colorScheme.background, + ) { padding -> + CompositionLocalProvider(LocalHideTabBar provides { barHidden = it }) { + GoalsSheetHost(nav, rootVm) + TabsPager( + nav, shown, histories, + TodayHooks( + TodayStrap( + paired = pairedKnown, pairing = pairing, + stopped = syncStopped(pairedKnown, strap.view.connection, strap.view.sync), record = strap.view.record, + canSync = connected, staleAfter = preview?.staleAfter ?: STALE_AFTER_S, + ), + LiveHeroHooks({ liveGlance.value }, onGlanceAction, { liveHeroShown = it }), + today.layout, today.usualBedtime, running, onOpenRunning = { container.workout.minimized.value = false }, ), - ) - is Detail.Workout -> WorkoutDetail( - openWorkout?.takeIf { it.workout.start == detail.start }, workouts, padding, onBack = pop, - onDelete = { container.workout.discard(detail.start); pop() }, - workoutsVm = workoutsVm, session = container.workout, - bmrPerMinute = org.openstrap.app.data.EnergyExpenditure.bmrPerMinute(profile, profile.age()), - recovery = recovery, energyHistory = energyHistory, - // Its day's Calories: the day shown moves to it, as a strip pick does. - onOpenDay = { d -> dayVm.select(d); push(Detail.OfScore(org.openstrap.app.ui.dashboard.Score.Energy)) }, - ) - Detail.AllWorkouts -> org.openstrap.app.ui.glow.GlowWorkoutsScreen( - workouts, padding, onOpen = { push(Detail.Workout(it)) }, onBack = pop, - ) - Detail.Healthspan -> org.openstrap.app.ui.glow.GlowHealthspanScreen(healthspan, padding, onBack = pop) - // The Recovery and Sleep tabs' screens, opened over another tab from a card about them. - Detail.Recovery -> { - androidx.activity.compose.BackHandler(onBack = pop) - org.openstrap.app.ui.glow.GlowRecoveryScreen( - day, recovery, history, - header = { org.openstrap.app.ui.glow.PageHeader("Recovery", pop, subtitle = org.openstrap.app.ui.glow.dayName(day.date)) }, - contentPadding = padding, onOpenMetric = { push(Detail.OfMetric(it)) }, - onOpenScore = { push(Detail.OfScore(it)) }, strapScore = strapReadiness?.takeIf { it.day == day.dayStart }?.score, - healthspan = healthspan, onOpenHealthspan = { seeHabitAge(); push(Detail.Healthspan) }, onOpenProfile = { openPage(Page.Profile) }, - recoveryFailure = recoveryFailure, onRetryRecovery = dayVm::retryRecovery, - wear = wear, firstScore = showFirstScore, - ) - } - Detail.Sleep -> { - androidx.activity.compose.BackHandler(onBack = pop) - org.openstrap.app.ui.glow.GlowSleepScreen( - day, header = { org.openstrap.app.ui.glow.PageHeader("Sleep", pop, subtitle = org.openstrap.app.ui.glow.dayName(day.date)) }, - contentPadding = padding, onOpen = push, recovery = recovery, - ) - } - } - } - // 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) - .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. - org.openstrap.app.ui.glow.LocalGlowPagePaused provides remember(pager, page) { { pager.isScrollInProgress || pager.settledPage != page } }, - org.openstrap.app.ui.dashboard.LocalScrollToTop provides scrollToTop[page], - *(if (page == tab) emptyArray() else arrayOf(androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner provides idleBack)), - ) { - val pullToSync: @androidx.compose.runtime.Composable (@androidx.compose.runtime.Composable () -> Unit) -> Unit = { screen -> - org.openstrap.app.ui.components.PullToSync(realSync, realConnection is ConnectionState.Connected, container.strap::syncNow) { screen() } - } - val t = Tab.entries[page] - // A tab's root with its stack opening over it (or beside it, when wide). - ListDetail( - stackOf(page), isOpen = { it.isNotEmpty() }, keys = { s -> s.map(Detail::keyOf) }, minWidth = SideBySideWidth, - detail = { stack -> if (stack.isNotEmpty()) detailPage(page, stack) }, - ) { - pullToSync { - when (t) { - // The moment's mood lights Today only (spec 2.1 Moods); the Sleep tab sets its own. - Tab.Today -> androidx.compose.runtime.CompositionLocalProvider( - org.openstrap.app.ui.glow.LocalGlowMood provides (todayMoment?.let { org.openstrap.app.ui.dashboard.momentMood(it) } ?: org.openstrap.app.ui.glow.GlowMood.Day), - ) { org.openstrap.app.ui.glow.GlowTodayScreen( - day, live, header = { header(Tab.Today) }, contentPadding = padding, moment = todayMoment, - // Cards about other tabs open those screens here, on Today; none switches tab. - onOpenMetric = { todayStack = listOf(Detail.OfMetric(it)) }, - onOpen = { todayStack = listOf(it) }, usualSteps = usualSteps, - recovery = recovery, - strapReadiness = strapReadiness, - checkIn = checkIn?.feeling, - onCheckIn = { v -> scope.launch { container.health.saveCheckIn(day.date, v) } }, - onOpenRecovery = { todayStack = listOf(Detail.Recovery) }, - workouts = workouts, - onOpenWorkout = { todayStack = listOf(Detail.Workout(it)) }, - onOpenHealthspan = { seeHabitAge(); todayStack = listOf(Detail.Healthspan) }, - healthspan = healthspan, - habitAgeSeenAsOf = habitAgeSeen, - onOpenProfile = { openPage(Page.Profile) }, - history = history, - edits = todayEdits, - onEditsChange = { e -> scope.launch { container.settings.saveTodayEdits(e.hidden.map { it.name }, e.pinned.map { it.name }) } }, - suggestion = suggestion, - onSaveSuggestion = { savingSuggestion = suggestion }, - onDismissSuggestion = { suggestion?.let { container.workout.dismissSuggestion(it.start, it.end) } }, - // The same way in as Activity's start card: the ready screen for the sport, or its sport list. - onStartSport = { readySport = it; readyPicking = false }, - onOtherSport = { readySport = readySport ?: org.openstrap.app.ui.glow.topSports(workouts).first().sportType; readyPicking = true }, - offer = offer, - onRecordOffer = { container.workout.recordOffer() }, - onDeclineOffer = { container.workout.declineOffer() }, - running = workoutState as? org.openstrap.app.device.WorkoutState.Recording, - onOpenRunning = { container.workout.minimized.value = false }, - onResumeRunning = container.workout::resume, - heroLightAngle = todayMoment?.let { org.openstrap.app.ui.dashboard.heroLightAngle(it) }, - strap = org.openstrap.app.ui.glow.TodayStrap( - paired = pairedKnown, pairing = pairing, stopped = syncStopped(pairedKnown, connection, sync), record = strapView.record, - canSync = realConnection is ConnectionState.Connected, - staleAfter = preview?.staleAfter ?: org.openstrap.app.ui.dashboard.STALE_AFTER_S, - ), - recoveryFailure = recoveryFailure, - onRetryRecovery = dayVm::retryRecovery, - onPair = { openPage(Page.Strap) }, - onSyncNow = container.strap::syncNow, - usualBedtime = usualBedtime, - wear = wear, firstScore = showFirstScore, - ) } - Tab.Recovery -> org.openstrap.app.ui.glow.GlowRecoveryScreen( - day, recovery, history, header = { header(Tab.Recovery) }, contentPadding = padding, - onOpenMetric = { recoveryStack = listOf(Detail.OfMetric(it)) }, - onOpenScore = { recoveryStack = listOf(Detail.OfScore(it)) }, - strapScore = strapReadiness?.takeIf { it.day == day.dayStart }?.score, - healthspan = healthspan, onOpenHealthspan = { seeHabitAge(); recoveryStack = listOf(Detail.Healthspan) }, - onOpenProfile = { openPage(Page.Profile) }, - showCharge = showCharge, onChargeShown = { showCharge = false }, - recoveryFailure = recoveryFailure, onRetryRecovery = dayVm::retryRecovery, - wear = wear, firstScore = showFirstScore, - ) - Tab.Sleep -> org.openstrap.app.ui.glow.GlowSleepScreen( - day, header = { header(Tab.Sleep) }, contentPadding = padding, - onOpen = { sleepStack = listOf(it) }, - recovery = recovery, - ) - Tab.Activity -> org.openstrap.app.ui.glow.GlowActivityScreen( - day, recovery, workouts, paiHistory, - header = { header(Tab.Activity) }, contentPadding = padding, - onOpenMetric = { activityStack = listOf(Detail.OfMetric(it)) }, - onOpen = { activityStack = listOf(it) }, usualSteps = usualSteps, - onOpenWorkout = { activityStack = listOf(Detail.Workout(it)) }, - onAllWorkouts = { activityStack = listOf(Detail.AllWorkouts) }, - onEditProfile = { openPage(Page.Profile) }, - onStartWorkout = { readySport = it; readyPicking = false }, - suggestion = suggestion, - onSaveSuggestion = { savingSuggestion = suggestion }, - onDismissSuggestion = { suggestion?.let { container.workout.dismissSuggestion(it.start, it.end) } }, - onAllSports = { readySport = readySport ?: org.openstrap.app.ui.glow.topSports(workouts).first().sportType; readyPicking = true }, - onAddWorkout = { addingOn = day.date }, - liveHeartRate = live, history = history, wear = wear, - ) - } - } - } - } } - // The pages over the tab: the You page and the Strap page, and what they open. They grow out of the - // button that opened them and shrink back into it, drawn as they were while they go; meanwhile back and - // taps belong to the tab again. - val keptPages = remember { arrayOf(pages) } - if (pages.isNotEmpty()) keptPages[0] = pages - val tabBack = androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner.current - 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), - ) { - CompositionLocalProvider(androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner provides (if (closing || tabBack == null) idleBack else tabBack)) { - org.openstrap.app.ui.components.OledSurfaces { - OverPages( - pages.ifEmpty { keptPages[0] }, container, deviceVm, firmwareVm, profile, padding, requestBluetooth, lastSeen, strapView.takeIf { preview != null }, - open = openPage, close = closePage, - // A row on a wide screen's list: its page beside the list, in place of the one there, unless - // an update is being installed there. - openBeside = { p -> if (!firmwareInstalling()) pages = org.openstrap.app.ui.dashboard.openBeside(pages, p) }, - closeAll = { closePages() }, - youPage = { open, selected, onBack -> - val log by container.strap.log.collectAsStateWithLifecycle() - val hcAuto by deviceVm.healthConnectAuto.collectAsStateWithLifecycle() - val backup by deviceVm.backup.collectAsStateWithLifecycle() - val updates by container.updater.state.collectAsStateWithLifecycle() - val updateWaiting by container.updater.waiting.collectAsStateWithLifecycle() - // Permissions can be taken back in Health Connect while away: the row says so on return. - androidx.lifecycle.compose.LifecycleResumeEffect(Unit) { deviceVm.checkAutoHealthConnect(); onPauseOrDispose { } } - org.openstrap.app.ui.device.YouPage( - profile, goals, org.openstrap.app.data.Recovery.sleepNeed(profile.age(), profile.sleepNeedMinutes), log.size, padding, - onProfile = { open(Page.Profile) }, - onGoals = { editGoals = true }, - healthConnectAuto = hcAuto, - onHealthConnect = { open(Page.HealthConnect) }, - backup = backup, - onBackup = { open(Page.Backup) }, - updates = updates, - installedVersion = container.updater.installed, - updateWaiting = updateWaiting, - onUpdates = { open(Page.Updates) }, - onDiagnostics = { open(Page.Diagnostics) }, - onSampleData = if (org.openstrap.app.data.SampleData.AVAILABLE) ({ deviceVm.loadSampleData() }) else null, - onBack = onBack, - selected = selected?.row, + container, dayVm, workoutsVm, rootVm, liveHeart, padding, rail, pageGrow, navGlass, idleBack, scroll, + showCharge, onChargeConsume = { showCharge = false }, + header = HeaderHooks(day, today, running, strap.chip, dayVm, rootVm, openPage), + detail = { t, d -> + DetailHost( + t, d, nav, shown, histories, container, dayVm, workoutsVm, liveHeart, profile, padding, + onOpenProfile = { openPage(Page.Profile) }, seeHabitAge = seeHabitAge, ) }, - dataPage = { shown, onBack -> - when (shown) { - Page.HealthConnect -> { - val hcAuto by deviceVm.healthConnectAuto.collectAsStateWithLifecycle() - // As on the You page: the switch says so when permissions were taken back meanwhile. - androidx.lifecycle.compose.LifecycleResumeEffect(Unit) { deviceVm.checkAutoHealthConnect(); onPauseOrDispose { } } - org.openstrap.app.ui.device.HealthConnectPage( - hcAuto, padding, - onAuto = autoHealthConnect, - onCopyNow = exportHealthConnect, - onRestore = importHealthConnect, - onOpen = { - if (!org.openstrap.app.ui.device.openHealthConnect(context)) { - Toast.makeText(context, "Health Connect isn't available on this phone.", Toast.LENGTH_LONG).show() - } - }, - onBack = onBack, - ) - } - Page.Backup -> { - val backup by deviceVm.backup.collectAsStateWithLifecycle() - val backingUp by deviceVm.backingUp.collectAsStateWithLifecycle() - val folder = backup.folder?.let(android.net.Uri::parse) - org.openstrap.app.ui.device.BackupPage( - backup, backingUp, padding, - onTurnOn = { backupFolderLauncher.launch(org.openstrap.app.ui.device.BackupCopy.DOCUMENTS) }, - onTurnOff = { deviceVm.turnOffBackup() }, - onFolder = { backupFolderLauncher.launch(folder) }, - onBackUpNow = { deviceVm.backUpNow() }, - onRestore = { backupRestoreLauncher.launch(folder) }, - onSaveCopy = { csvLauncher.launch("openstrap-export.zip") }, - onBack = onBack, - ) - } - Page.Updates -> { - val updater = container.updater - val updates by updater.state.collectAsStateWithLifecycle() - val auto by container.settings.updateAuto.collectAsStateWithLifecycle(true) - val workoutNow by container.workout.state.collectAsStateWithLifecycle() - // Seen: the dots on the avatar and the Updates row go, and again for a release found while it's open. - LaunchedEffect(org.openstrap.app.update.Releases.newer(updates.releases, updater.installed).firstOrNull()?.tag) { updater.markSeen() } - // Back from Android's "Install unknown apps" switch: on, the install goes ahead. - var waiting by remember { mutableStateOf(null) } - val allowLauncher = rememberLauncherForActivityResult(androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult()) { - waiting?.let { r -> if (updater.canInstall()) updater.install(r) } - waiting = null - } - val uri = androidx.compose.ui.platform.LocalUriHandler.current - org.openstrap.app.ui.device.UpdatesPage( - updates, updater.installed, auto, workoutNow is org.openstrap.app.device.WorkoutState.Recording, padding, - onInstall = { r -> - when { - r.apk == null -> uri.openUri(r.page) - updater.canInstall() -> updater.install(r) - else -> { waiting = r; allowLauncher.launch(updater.allowInstalls) } - } - }, - onCheck = updater::checkNow, - onAuto = updater::setAuto, - onGitHub = { uri.openUri(org.openstrap.app.update.Releases.PAGE) }, - onBack = { updater.dismissFailure(); onBack() }, - ) - } - else -> Unit - } - }, + onOpenPage = openPage, seeHabitAge = seeHabitAge, + ) + PagesOverTab( + nav, pageHooks, gates.requestBluetooth, strap, preview != null, padding, rail, growFrom, pageGrow, + navGlass, idleBack, openPage, closePages, firmwareInstalling, ) } - } - } } - } - if (rail) org.openstrap.app.ui.components.FloatingNavRail( - navItems, tab, selectTab, androidx.compose.ui.Modifier.align(androidx.compose.ui.Alignment.CenterStart), - glass = navGlass, - ) - // A map, not a card: rows whose home is a tab go there, at its root and top (Charge to its card); the rest open - // their detail on the tab showing. The day is shared, so the tab shows the same one. The sheet closes first. - if (allMetricsOpen) org.openstrap.app.ui.glow.AllMetricsSheet( - org.openstrap.app.ui.glow.metricIndex(day, recovery, healthspan, workouts, strapScore = strapReadiness?.takeIf { it.day == day.dayStart }?.score), day.date, - onDismiss = { allMetricsOpen = false }, - onOpen = { target -> - allMetricsOpen = false - closePages() - when (target) { - is org.openstrap.app.ui.glow.IndexTarget.Open -> - if (stackOf(tab).lastOrNull() != target.detail) setStack(tab, stackOf(tab) + target.detail) - is org.openstrap.app.ui.glow.IndexTarget.Home -> { - val home = when (target.tab) { - org.openstrap.app.ui.glow.HomeTab.Recovery -> Tab.Recovery - org.openstrap.app.ui.glow.HomeTab.Sleep -> Tab.Sleep - }.ordinal - setStack(home, emptyList()) - nav = nav.select(home) - if (target.charge) showCharge = true else scrollToTop[home] = scrollToTop[home] + 1 - } - } - }, - ) - addingOn?.let { date -> - androidx.compose.foundation.layout.Box(androidx.compose.ui.Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - val series by androidx.compose.runtime.produceState>?>(null, date) { value = workoutsVm.dayMinuteHeartRate(date) } - val maxHr by workoutsVm.maxHeartRate.collectAsStateWithLifecycle() - val (recent, sides) = pickerSports(container, workoutsVm) - org.openstrap.app.ui.glow.GlowAddWorkoutScreen( - date, series, maxHr ?: 190, androidx.compose.foundation.layout.WindowInsets.navigationBars.asPaddingValues(), - onSave = { from, to, sport -> container.workout.saveFromHistory(from, to, sport); addingOn = null }, - onBack = { addingOn = null }, - recentSports = recent, sides = sides, - ) - } - } - savingSuggestion?.let { s -> - androidx.compose.foundation.layout.Box(androidx.compose.ui.Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { - val (recent, sides) = pickerSports(container, workoutsVm) - org.openstrap.app.ui.glow.GlowSportPickerScreen( - androidx.compose.foundation.layout.WindowInsets.navigationBars.asPaddingValues(), - onPick = { sport -> container.workout.saveFromHistory(s.start, s.end, sport); savingSuggestion = null }, - onBack = { savingSuggestion = null }, - recentSports = recent, sides = sides, - ) - } - } - WorkoutLayer( - container, workoutState, workoutMinimized, readySport, readyPicking, live, realConnection, workoutsVm, requestBluetooth, requestLocation, - onReadySport = { readySport = it }, - onCloseReady = { readySport = null; readyPicking = false }, - capsuleBottom = if (showBar) 104.dp else 16.dp, - tabletop = hinge != null, bookHinge = bookHinge, - ) - org.openstrap.app.ui.components.GlassOverlay(glassHost, navGlass) - } - } - } -} - -/** - * The top page over the tab, sliding in and out like a stack; each closes back to the one under it. On a wide screen - * (spec 2.4 Unfolded) the You and Strap pages are a list with the page a row opens beside it, as a tab with its - * detail: the list on the left with that row marked, the page in a pane on the right ([openBeside] puts it there, - * in place of the one open). Back closes the pane, or goes back within it; the list's own back closes it all - * ([closeAll]). A page opened on its own (Profile from a card) is one centred column. - */ -@Composable -private fun OverPages( - pages: List, - container: AppContainer, - deviceVm: DeviceViewModel, - firmwareVm: FirmwareViewModel, - profile: org.openstrap.app.data.settings.UserProfile, - padding: androidx.compose.foundation.layout.PaddingValues, - requestBluetooth: (() -> Unit) -> Unit, - lastSeen: Long?, - /** Debug: a previewed strap state for the Strap page's card. */ - preview: StrapView?, - open: (Page) -> Unit, - close: () -> Unit, - openBeside: (Page) -> Unit, - closeAll: () -> Unit, - /** The You page, built where its launchers and the Goals editor live: how its rows open, the row marked, its back. */ - youPage: @Composable (open: (Page) -> Unit, selected: Page?, onBack: () -> Unit) -> Unit, - /** The Health Connect and Backup pages, built there too for the same reason: the page shown and its back. */ - dataPage: @Composable (shown: Page, onBack: () -> Unit) -> Unit, -) { - // Read from the strap when first needed (the Strap page's rows show what it holds), and again on each page that edits it. - val needsAlarms = pages.any { it == Page.Strap || it == Page.Alarms || it == Page.HealthAlerts } - val alarmsVm: org.openstrap.app.ui.alarms.AlarmsViewModel? = if (needsAlarms) viewModel( - key = "alarms", factory = viewModelFactory { initializer { org.openstrap.app.ui.alarms.AlarmsViewModel(container.strap) } }, - ) else null - // One page. [list] is set for the You or Strap page as the list beside a pane: its rows open there, the row whose - // page is open is marked, and its back closes everything. - val page: @Composable (Page?, List?) -> Unit = page@{ shown, list -> - val rowOpens: (Page) -> Unit = if (list != null) openBeside else open - val selected = list?.getOrNull(1) - val back = if (list != null) closeAll else close - when (shown) { - null -> Unit - Page.You -> youPage(rowOpens, selected, back) - Page.Strap -> { - val alarms = alarmsVm ?: return@page - val alarmsState by alarms.state.collectAsStateWithLifecycle() - val account by firmwareVm.account.collectAsStateWithLifecycle() - val connection by container.strap.connection.collectAsStateWithLifecycle() - // Nothing read yet (the strap wasn't there when it was asked): ask again once it's connected. - LaunchedEffect(connection is ConnectionState.Connected) { - if (connection is ConnectionState.Connected && alarmsState.caps == null && !alarmsState.busy) alarms.refresh() - } - DeviceScreen( - deviceVm, padding, requestBluetooth, container.settings, alarmsState, account != null, lastSeen, - onAlarms = { rowOpens(Page.Alarms) }, - onPhoneAlerts = { rowOpens(Page.PhoneAlerts) }, - onHealthAlerts = { rowOpens(Page.HealthAlerts) }, - onFirmware = { rowOpens(Page.Firmware) }, - onZeppAccount = { rowOpens(Page.ZeppAccount) }, - onBack = back, - preview = preview, - selected = selected?.row, - ) - } - Page.Alarms -> { - val alarms = alarmsVm ?: return@page - LaunchedEffect(Unit) { alarms.refresh() } - val alarmsState by alarms.state.collectAsStateWithLifecycle() - org.openstrap.app.ui.alarms.AlarmsScreen(alarms, alarmsState, padding, onBack = close) } - Page.HealthAlerts -> { - val alarms = alarmsVm ?: return@page - LaunchedEffect(Unit) { alarms.refresh() } - val alarmsState by alarms.state.collectAsStateWithLifecycle() - org.openstrap.app.ui.alarms.HealthAlertsScreen(alarms, alarmsState, padding, onBack = close) - } - Page.PhoneAlerts -> org.openstrap.app.ui.alarms.PhoneAlertsScreen(container.phoneAlerts, container.settings, padding, onBack = close) - Page.Firmware -> FirmwareScreen(firmwareVm, padding, onBack = close, onZeppAccount = { open(Page.ZeppAccount) }) - Page.ZeppAccount -> org.openstrap.app.ui.firmware.ZeppAccountScreen(firmwareVm, padding, onBack = close) - Page.HealthConnect, Page.Backup, Page.Updates -> dataPage(shown, close) - Page.Profile -> org.openstrap.app.ui.device.ProfileScreen(profile, padding, onSave = { deviceVm.saveProfile(it); close() }, onBack = close) - Page.Diagnostics -> { - val log by container.strap.log.collectAsStateWithLifecycle() - org.openstrap.app.ui.device.DiagnosticsScreen(log, padding, onBack = close) - } - } - } - androidx.compose.foundation.layout.BoxWithConstraints(androidx.compose.ui.Modifier.fillMaxSize()) { - val root = pages.firstOrNull() - if (maxWidth >= org.openstrap.app.ui.components.SidePaneWidth && (root == Page.You || root == Page.Strap)) { - ListDetail( - pages.drop(1), isOpen = { it.isNotEmpty() }, keys = { s -> s.map { it.name } }, - minWidth = org.openstrap.app.ui.components.SidePaneWidth, - closedMaxWidth = org.openstrap.app.ui.glow.GlowLayout.PageMaxWidth, - detail = { stack -> page(stack.lastOrNull(), null) }, - ) { page(root, pages) } - } else org.openstrap.app.ui.components.PushContent(pages, depth = { it.size }) { stack -> page(stack.lastOrNull(), null) } - } -} - -/** - * What the sport picker needs (spec 6.3): your last four sports for its Recent row, newest first and only those you - * started, and the Outdoor or Indoor side last chosen per sport on Ready. - */ -@Composable -private fun pickerSports(container: AppContainer, workoutsVm: org.openstrap.app.ui.workout.WorkoutsViewModel): Pair, Map> { - val workouts by workoutsVm.workouts.collectAsStateWithLifecycle() - val sides by container.settings.workoutSides.collectAsStateWithLifecycle(emptyMap()) - val recent = remember(workouts) { - org.openstrap.app.data.Workouts.recentSports(workouts.filter { it.startedByYou }.sortedByDescending { it.workout.start }.map { it.workout.sportType }) - } - return recent to sides -} - -/** - * The workout screens over the whole app: the ready screen, the live screen (or, tucked away, a capsule above the tab - * bar) and the summary once it ends. - */ -@Composable -private fun androidx.compose.foundation.layout.BoxScope.WorkoutLayer( - container: AppContainer, - state: org.openstrap.app.device.WorkoutState, - minimized: Boolean, - readySport: Int?, - readyPicking: Boolean, - live: Int?, - connection: ConnectionState, - workoutsVm: org.openstrap.app.ui.workout.WorkoutsViewModel, - requestBluetooth: (() -> Unit) -> Unit, - requestLocation: (() -> Unit) -> Unit, - onReadySport: (Int) -> Unit, - onCloseReady: () -> Unit, - capsuleBottom: androidx.compose.ui.unit.Dp, - tabletop: Boolean = false, - bookHinge: androidx.window.layout.FoldingFeature? = null, -) { - val session = container.workout - // Read here, ahead of a workout, so the live screen usually opens on the right view at once. - val liveView by container.settings.workoutLiveView.collectAsStateWithLifecycle(null) - val kcal by session.kcal.collectAsStateWithLifecycle() - val profile by container.settings.profile.collectAsStateWithLifecycle(null) - val viewScope = rememberCoroutineScope() - val padding = androidx.compose.foundation.layout.WindowInsets.navigationBars.asPaddingValues() - val full = androidx.compose.ui.Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background) - when { - // Minimizing morphs the live screen into the capsule above the tab bar, and tapping the capsule back. - state is org.openstrap.app.device.WorkoutState.Recording -> org.openstrap.app.ui.glow.WorkoutMorph( - minimized, capsuleBottom + padding.calculateBottomPadding(), - full = { - val recent by session.recent.collectAsStateWithLifecycle() - val stats by session.stats.collectAsStateWithLifecycle() - val zoneSeconds by session.zoneSeconds.collectAsStateWithLifecycle() - val route by session.route.collectAsStateWithLifecycle() - val history by session.history.collectAsStateWithLifecycle() - // Folded, the heart card has the standing half: above the fold (drawn with the tabletop heart) or, - // stood up like a book, the left half here. - val liveScreen: @Composable (androidx.compose.ui.Modifier) -> Unit = { modifier -> - androidx.compose.foundation.layout.Box(modifier) { - org.openstrap.app.ui.glow.GlowWorkoutLiveScreen( - state, live, recent, stats, zoneSeconds, connection, padding, - onPause = session::pause, onResume = session::resume, onEnd = session::end, - onMinimize = { session.minimized.value = true }, - onEndAtPrompt = session::endAtPrompt, onKeepGoing = session::keepGoing, - route = route, history = history, - onStartSet = session::startSet, onSetDone = session::setDone, onExtendRest = session::extendRest, - onLap = session::lap, - split = tabletop || bookHinge != null, - liveView = liveView, onLiveView = { v -> viewScope.launch { container.settings.setWorkoutLiveView(v) } }, - kcal = kcal, - ) - } - } - if (bookHinge != null) { - val density = androidx.compose.ui.platform.LocalDensity.current - androidx.compose.foundation.layout.Row(androidx.compose.ui.Modifier.fillMaxSize()) { - org.openstrap.app.ui.glow.GlowWorkoutGlance( - state, live, recent, zoneSeconds, connection, - androidx.compose.ui.Modifier.width(with(density) { bookHinge.bounds.left.toDp() }).fillMaxHeight(), kcal, - ) - androidx.compose.foundation.layout.Spacer(androidx.compose.ui.Modifier.width(with(density) { bookHinge.bounds.width().toDp() })) - liveScreen(androidx.compose.ui.Modifier.weight(1f).fillMaxHeight()) - } - } else liveScreen(androidx.compose.ui.Modifier.fillMaxSize()) - }, - capsule = { place -> - org.openstrap.app.ui.glow.WorkoutCapsule( - state, live, connection is ConnectionState.Connected, onClick = { session.minimized.value = false }, - onPause = session::pause, onResume = session::resume, modifier = place, kcal = kcal, - ) - }, - ) - state is org.openstrap.app.device.WorkoutState.Ended -> androidx.compose.foundation.layout.Box(full) { - // A debug preview of a stored workout writes nothing just by opening, so no effect is worked out for it. - val summary by remember(state) { workoutsVm.phoneSummary(state.start, backfill = !state.preview) }.collectAsStateWithLifecycle(null) - val calmDown by session.calmDown.collectAsStateWithLifecycle() - val route by session.route.collectAsStateWithLifecycle() - val workouts by workoutsVm.workouts.collectAsStateWithLifecycle() - val usualDrop by produceState(null, state.start) { value = workoutsVm.usualDrop(state.start) } - GlowWorkoutSummaryScreen( - summary, calmDown.takeIf { !state.preview }, if (state.preview) emptyList() else route, workouts, usualDrop, padding, - onDone = session::dismissSummary, onDelete = { session.discard(state.start) }, - onTrim = { from, to -> session.trim(state.start, from, to) }, onUndoTrim = { session.undoTrim(state.start) }, - ) - } - } - // The ready screen comes and goes like any other screen; a workout starting replaces it at once. - val recording = state is org.openstrap.app.device.WorkoutState.Recording || state is org.openstrap.app.device.WorkoutState.Ended - org.openstrap.app.ui.glow.PushOverlay(readySport.takeIf { !recording }, cut = recording) { sport -> - androidx.compose.foundation.layout.Box(full) { - val (recentSports, sides) = pickerSports(container, workoutsVm) - org.openstrap.app.ui.glow.GlowWorkoutReadyScreen( - sport, readyPicking, connection, live, padding, - recentSports = recentSports, sides = sides, - kcalTargets = profile?.let { org.openstrap.app.data.EnergyExpenditure.bmrPerMinute(it, it.age()) } != null, - onSide = { s, indoor -> - viewScope.launch { - container.settings.setWorkoutSide(s, indoor) - // The Start widget names the side Ready opens on and says whether GPS is on (widgets spec 7). - org.openstrap.app.device.WidgetUpdater.changed(container.app, setOf(org.openstrap.app.widget.WidgetKind.Start)) - } - }, - onTurnOnLocation = requestLocation, - onSport = onReadySport, - onStart = { sport, askToEnd, target, useGps, keepRoute, rest, setsTarget -> - val start = { session.start(sport, askToEnd, target, useGps, keepRoute, rest, setsTarget); onCloseReady() } - val granted = container.app.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == android.content.pm.PackageManager.PERMISSION_GRANTED - val withBluetooth = { if (granted) start() else requestBluetooth(start) } - if (useGps && !org.openstrap.app.device.WorkoutLocation.granted(container.app)) requestLocation(withBluetooth) else withBluetooth() - }, - onBack = onCloseReady, + if (rail) FloatingNavRail(navItems, tab, selectTab, Modifier.align(Alignment.CenterStart), glass = navGlass) + AllMetricsHost(nav, shown, workoutsVm, scroll, closePages, onShowCharge = { showCharge = true }) + AddWorkoutHost(nav, container, workoutsVm, rootVm) + SaveStretchHost(nav, container, workoutsVm, rootVm) + WorkoutLayer( + container, nav, workoutState, workoutMinimized, workoutsVm, rootVm, + WorkoutLayerHooks( + live, strapFacts.connection, gates.startPlan, gates.requestLocation, + capsuleBottom = if (showBar) CapsuleOverBar else CapsuleNoBar, tabletop = hinge != null, bookHinge = folds.book, + ), + glass = navGlass, glance = { liveGlance.value }, onGlanceAction = onGlanceAction, + capsuleHidden = liveHeroShown && nav.atTodayRoot, ) + HistoryBadgesHost(nav, rootVm, workoutState) + BluetoothBlockedDialog(gates) + GlassOverlay(glassHost, navGlass) } + } } } - -/** A metric's detail. */ -@androidx.compose.runtime.Composable -private fun MetricDetail( - metric: org.openstrap.app.ui.dashboard.Metric, - night: Boolean, - state: org.openstrap.app.ui.dashboard.DayUiState, - history: org.openstrap.app.ui.dashboard.DailyHistory, - liveHeartRate: Int?, - liveEnabled: Boolean, - canUseLive: Boolean, - onLiveToggle: (Boolean) -> Unit, - onOpen: (Detail) -> Unit, - onSwitch: (Detail) -> Unit, - onSelectDay: (java.time.LocalDate) -> Unit, - contentPadding: androidx.compose.foundation.layout.PaddingValues, - onBack: () -> Unit, -) = org.openstrap.app.ui.glow.GlowMetricDetailScreen(metric, night, state, history, liveHeartRate, liveEnabled, canUseLive, onLiveToggle, onOpen, onSwitch, onSelectDay, contentPadding, onBack) - -/** A workout's detail: one page for the strap's and the phone's ([org.openstrap.app.ui.glow.GlowWorkoutDetailScreen]). */ -@androidx.compose.runtime.Composable -private fun WorkoutDetail( - a: org.openstrap.app.data.WorkoutAnalysis?, - workouts: List, - contentPadding: androidx.compose.foundation.layout.PaddingValues, - onBack: () -> Unit, - onDelete: () -> Unit, - workoutsVm: org.openstrap.app.ui.workout.WorkoutsViewModel, - session: org.openstrap.app.device.WorkoutSession, - bmrPerMinute: Float?, - recovery: org.openstrap.app.data.RecoveryResult?, - energyHistory: org.openstrap.app.ui.glow.EnergyHistory, - onOpenDay: (java.time.LocalDate) -> Unit, -) { - // OpenStrap's own recordings also get their summary (route, laps, sets, the recording for trim) and are deleted - // through the session; the strap's are deleted and remembered, so the next sync doesn't bring them back. - val start = a?.workout?.start - val fromPhone = a?.fromPhone == true - val summary by remember(start, fromPhone) { - if (start != null && fromPhone) workoutsVm.phoneSummary(start) else kotlinx.coroutines.flow.flowOf(null) - }.collectAsStateWithLifecycle(null) - val usualDrop by produceState(null, start, fromPhone) { value = start?.takeIf { !fromPhone }?.let { workoutsVm.usualDrop(it) } } - // The workout's day: its active kcal as just worked out when it's the day shown, else as stored. - val date = a?.let { java.time.Instant.ofEpochSecond(it.shownStart).atZone(java.time.ZoneId.systemDefault()).toLocalDate() } - val stored by remember(date, energyHistory) { - date?.let { d -> energyHistory(d, d) } ?: kotlinx.coroutines.flow.flowOf(emptyMap()) - }.collectAsStateWithLifecycle(emptyMap()) - val dayActive = recovery?.takeIf { it.date == date }?.energy?.activeKcal ?: date?.let { stored[it]?.activeKcal } - org.openstrap.app.ui.glow.GlowWorkoutDetailScreen( - a, summary, workouts, usualDrop, contentPadding, onBack, - onDelete = if (fromPhone) onDelete else ({ start?.let(workoutsVm::deleteStrapWorkout); onBack() }), - onTrim = { from, to -> start?.let { session.trim(it, from, to) } }, onUndoTrim = { start?.let(session::undoTrim) }, - bmrPerMinute = bmrPerMinute, dayActiveKcal = dayActive, onOpenDay = date?.let { d -> { onOpenDay(d) } }, - ) -} diff --git a/app/src/main/kotlin/org/openstrap/app/NavRequests.kt b/app/src/main/kotlin/org/openstrap/app/NavRequests.kt new file mode 100644 index 00000000..6c02a4fa --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/NavRequests.kt @@ -0,0 +1,55 @@ +package org.openstrap.app + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import java.time.LocalDate + +/** + * Something outside the screens asks the app to show: a notification, a widget, the tile or a launcher shortcut. The + * activity reads it from the intent's extras and the app root applies it (audit A7). + */ +sealed interface NavRequest { + /** The ready screen for [sport]: a launcher shortcut, the Quick Settings tile, the Start widget, the service. */ + data class OpenReady(val sport: Int) : NavRequest + + /** Choose with every sport: the Start widget's All sports. */ + data object OpenChoose : NavRequest + + /** The strap workout starting at [start] on the Activity tab: an auto-detected workout's notification. */ + data class OpenWorkout(val start: Long) : NavRequest + + /** The screen [route] names ([org.openstrap.app.ui.dashboard.Detail.fromRoute]), for [day] when it is set. */ + data class OpenDetail(val route: String, val day: LocalDate?) : NavRequest + + /** Today at its root, on today: the Moment widget. */ + data object OpenToday : NavRequest + + /** The Strap page over the tab showing: a widget's strap status line. */ + data object OpenStrap : NavRequest + + /** Last week's recap: the weekly recap notification. */ + data object OpenRecap : NavRequest + + /** The Updates page: an update notification. */ + data object OpenUpdates : NavRequest + + /** The live workout screen, even when it was tucked away: the workout notification, the tile, the Start widget. */ + data object ShowWorkout : NavRequest +} + +/** + * The requests on their way to the app root. It is the activity's, so a request sent before the first frame waits for + * it, and one sent just before a fold or a rotation outlives the old activity. Each is received once. + */ +class NavigationRequests : ViewModel() { + private val channel = Channel(Channel.UNLIMITED) + + /** Every request in the order sent, each received once. */ + val requests: Flow = channel.receiveAsFlow() + + fun send(request: NavRequest) { + channel.trySend(request) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/OpenStrapApp.kt b/app/src/main/kotlin/org/openstrap/app/OpenStrapApp.kt index 5428e496..103a92a0 100644 --- a/app/src/main/kotlin/org/openstrap/app/OpenStrapApp.kt +++ b/app/src/main/kotlin/org/openstrap/app/OpenStrapApp.kt @@ -1,12 +1,17 @@ package org.openstrap.app -import android.Manifest import android.app.Application -import android.content.pm.PackageManager +import android.util.Log +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import org.openstrap.app.ble.BleScanner import org.openstrap.app.data.HealthRepository import org.openstrap.app.data.db.OpenStrapDatabase import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.device.BlePermissions import org.openstrap.app.device.CompanionLink import org.openstrap.app.device.PhoneAlerts import org.openstrap.app.device.StrapController @@ -16,20 +21,63 @@ import org.openstrap.app.health.HealthConnectExporter /** Hand-wired dependencies; small enough not to need a DI framework. */ class AppContainer(val app: Application) { + /** + * The one scope for work that lives as long as the app. With a [SupervisorJob], an exception nobody caught goes to + * the thread's default handler, which kills the process, so this handler logs it instead (audit C3). Classes get it + * passed in rather than building their own. + */ + // This is where the dispatcher is injected from, so there is nothing to inject it into. + @Suppress("InjectDispatcher") + val appScope = CoroutineScope( + SupervisorJob() + Dispatchers.Default + CoroutineExceptionHandler { _, e -> Log.e("OpenStrap", "app scope: uncaught $e", e) }, + ) val database = OpenStrapDatabase.create(app) val settings = SettingsStore(app) val health = HealthRepository(database.healthDao()) + /** The max heart rate every zone goes by: `maxHeartRates.current` to follow it, `maxHeartRates.now()` to read it once. */ + val maxHeartRates = org.openstrap.app.data.MaxHeartRates(database.healthDao(), settings.profile) + val rewards = org.openstrap.app.data.RewardsKeeper(database.healthDao(), settings, maxHeartRates) val scanner = BleScanner(app) val csv = CsvExporter(database.healthDao()) val healthConnect = HealthConnectExporter(app, database.healthDao()) - val strap: StrapController = StrapManager(app, settings, database.healthDao()) + val strap: StrapController = StrapManager(app, settings, database.healthDao(), scope = appScope) val companion = CompanionLink(app, settings) val phoneAlerts = PhoneAlerts(app, settings, strap) - val autoHealthConnect = org.openstrap.app.health.HealthConnectAutoSync(app, settings, healthConnect, strap) + val autoHealthConnect = org.openstrap.app.health.HealthConnectAutoSync(app, settings, healthConnect, strap, scope = appScope) val workout = org.openstrap.app.device.WorkoutSession( - app, database.healthDao(), settings, strap, callRinging = { phoneAlerts.callRinging }, onSaved = autoHealthConnect::workoutSaved, + app, database.healthDao(), settings, strap, maxHeartRates, callRinging = { phoneAlerts.callRinging }, onSaved = autoHealthConnect::workoutSaved, + scope = appScope, + ) + val updater = org.openstrap.app.update.AppUpdater( + app, settings, recording = { workout.state.value is org.openstrap.app.device.WorkoutState.Recording }, scope = appScope, + ) + /** Save a copy, Restore and automatic backup's zip. What a restore couldn't do goes to Diagnostics. */ + val backups: org.openstrap.app.data.BackupRepository = + org.openstrap.app.export.CsvBackups(app, database, settings, csv, log = { (strap as? StrapManager)?.log(it) }) + /** Today's steps with the strap's live counter on top: one anchor, so Today and the widgets count alike (audit A5). */ + val liveSteps = org.openstrap.app.data.LiveSteps() + /** Each day as Today and the tabs show it, read and resolved. */ + val days: org.openstrap.app.data.DayRepository = org.openstrap.app.data.RoomDayRepository(health, liveSteps, strap.todaySteps) + /** The workouts, analysed, for the lists, pages and summaries. */ + val workouts: org.openstrap.app.data.WorkoutRepository = + org.openstrap.app.data.RoomWorkoutRepository(database.healthDao(), settings.profile, maxHeartRates) + + init { + // The Recovery cache follows the data from here on. + health.start(appScope, HealthRepository.recoveryInputs(database)) + } + /** The home screen widgets' refreshes. Built last: it watches the strap, the workout and settings. */ + val widgets = org.openstrap.app.widget.WidgetUpdater( + app, appScope, settings, strap, workout, + org.openstrap.app.widget.WidgetSnapshot( + health, database.healthDao(), settings, liveSteps, + syncing = { strap.sync.value is org.openstrap.app.device.SyncState.Running }, + todaySteps = { strap.todaySteps.value }, + workoutState = { workout.state.value }, + location = { org.openstrap.app.device.locationAvailable(app) }, + placed = { org.openstrap.app.widget.placedWidgets(app) }, + ), ) - val updater = org.openstrap.app.update.AppUpdater(app, settings, recording = { workout.state.value is org.openstrap.app.device.WorkoutState.Recording }) } class OpenStrapApp : Application(), androidx.work.Configuration.Provider { @@ -42,18 +90,25 @@ class OpenStrapApp : Application(), androidx.work.Configuration.Provider { override fun onCreate() { super.onCreate() + // The phone's 12 or 24 hour clock, before anything writes a time, and from then on as it changes. + PhoneClock.follow(this) container = AppContainer(this) + // Your heart rate zones, kept in use for everything that reads a zone, a restored backup's included. + container.appScope.launch { + container.settings.heartZones.collect { org.openstrap.app.data.HeartZones.use(it) } + } container.companion.refresh() // A workout still recording when the process died carries on (the service restarts it too). container.workout.restore() // Android restarts the process on its own (companion presence, a reboot): reconnect without the screen. // A no-op when nothing is paired, a connection is already up, or the user tapped Disconnect. - if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) container.strap.backgroundConnect() - // Widgets turn the day over at midnight; alarms don't survive a reboot or an update, so set it on each start. - org.openstrap.app.device.WidgetUpdater.scheduleMidnight(this) - // Android 15 and later: the widget picker's previews, from the real layouts (spec 6.4). - org.openstrap.app.device.WidgetUpdater.publishPreviews(this) + if (BlePermissions.granted(this)) container.strap.backgroundConnect() + // The widgets watch what they show from now on, turn the day over at midnight (alarms don't survive a reboot or + // an update, so it's set on each start) and, on Android 15 and later, give the picker their previews (spec 6.4). + container.widgets.start() // The daily check for a new OpenStrap, unless it was turned off on the Updates page. container.updater.start() + // Looks for the first morning of the week, for the weekly recap (workouts spec 3.4). + org.openstrap.app.device.WeeklyRecap.schedule(this) } } diff --git a/app/src/main/kotlin/org/openstrap/app/PendingAction.kt b/app/src/main/kotlin/org/openstrap/app/PendingAction.kt new file mode 100644 index 00000000..83895d49 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/PendingAction.kt @@ -0,0 +1,83 @@ +package org.openstrap.app + +import androidx.compose.runtime.saveable.Saver +import androidx.lifecycle.ViewModel +import org.openstrap.app.data.SportSettings +import org.openstrap.app.data.Strength +import org.openstrap.app.data.WorkoutPlan +import org.openstrap.app.data.WorkoutTarget + +/** + * What to do once a permission screen or a system setting answers. It is kept as data, not a closure, so it + * survives the activity being recreated behind that screen: a fold, a rotation, or the process dying while + * Settings is open. The answer then comes back to a new composition, which runs the action again (audit E2). + */ +internal sealed interface PendingAction { + /** Start [plan] once location (for GPS) and Bluetooth have been asked for. [saved] is what Ready keeps for the sport. */ + data class StartPlan(val plan: WorkoutPlan, val saved: SportSettings?) : PendingAction + + /** + * Install the release tagged [tag] once "Install unknown apps" is on. [answered]: back from that setting, so the + * install goes ahead as soon as the release is known again (after process death the list loads a moment later). + */ + data class Install(val tag: String, val answered: Boolean = false) : PendingAction + + companion object { + private const val START_PLAN = "plan" + private const val INSTALL = "install" + + /** + * For `rememberSaveable`: an action as a list of strings, ints, booleans and nulls, which a saved instance + * state bundle can hold. Anything unreadable restores as nothing pending. + */ + val Saver: Saver = Saver( + save = { action -> action?.let(::encode) }, + restore = { saved -> (saved as? List<*>)?.let(::decode) }, + ) + + private fun encode(action: PendingAction): ArrayList = when (action) { + is StartPlan -> with(action.plan) { + arrayListOf( + START_PLAN, sportType, askToEnd, target?.code, useGps, keepRoute, rest?.code, setsTarget, logSets, structure, + action.saved?.encode(), + ) + } + is Install -> arrayListOf(INSTALL, action.tag, action.answered) + } + + private fun decode(list: List<*>): PendingAction? { + // Read in the order [encode] wrote them, so the fields need no positions of their own. + val fields = list.drop(1).iterator() + fun next(): Any? = if (fields.hasNext()) fields.next() else null + return when (list.firstOrNull()) { + START_PLAN -> (next() as? Int)?.let { sport -> + StartPlan( + WorkoutPlan( + sportType = sport, + askToEnd = next() as? Boolean ?: true, + target = WorkoutTarget.parse(next() as? String), + useGps = next() as? Boolean ?: false, + keepRoute = next() as? Boolean ?: false, + rest = Strength.Rest.parse(next() as? String), + setsTarget = next() as? Int, + logSets = next() as? Boolean ?: false, + structure = next() as? String, + ), + (next() as? String)?.let(SportSettings::parse), + ) + } + INSTALL -> (next() as? String)?.let { tag -> Install(tag, next() as? Boolean ?: false) } + else -> null + } + } + } +} + +/** + * A Strap page action waiting on the Bluetooth answer. The page hands over a closure (scan, connect, pair), which + * can't be saved, but a view model outlives a fold or a rotation, and the closures only reach the device view model, + * which lives as long. After process death it is gone, and the reconnect on resume covers a connect. + */ +internal class PendingStrapAction : ViewModel() { + var action: (() -> Unit)? = null +} diff --git a/app/src/main/kotlin/org/openstrap/app/PhoneClock.kt b/app/src/main/kotlin/org/openstrap/app/PhoneClock.kt new file mode 100644 index 00000000..3322136a --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/PhoneClock.kt @@ -0,0 +1,34 @@ +package org.openstrap.app + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.text.format.DateFormat +import androidx.core.content.ContextCompat +import org.openstrap.app.presentation.ClockSetting + +/** + * Keeps [ClockSetting] on the phone's 12 or 24 hour setting, so every time the app writes follows it. Android tells a + * running app when the setting changes with [Intent.ACTION_TIME_CHANGED], the broadcast a clock change sends too, and + * the widgets are drawn again then, since their times were written in the old form. + */ +internal object PhoneClock { + /** Reads the setting now. */ + fun read(context: Context) { + ClockSetting.use24Hour = DateFormat.is24HourFormat(context) + } + + /** Reads the setting and follows it while the process lives. Called once, from the app's start. */ + fun follow(context: Context) { + read(context) + ContextCompat.registerReceiver(context, changed, IntentFilter(Intent.ACTION_TIME_CHANGED), ContextCompat.RECEIVER_NOT_EXPORTED) + } + + private val changed = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + read(context) + (context.applicationContext as? OpenStrapApp)?.container?.widgets?.changedAll() + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ble/BleScanner.kt b/app/src/main/kotlin/org/openstrap/app/ble/BleScanner.kt index 7714913e..095cb41f 100644 --- a/app/src/main/kotlin/org/openstrap/app/ble/BleScanner.kt +++ b/app/src/main/kotlin/org/openstrap/app/ble/BleScanner.kt @@ -16,7 +16,7 @@ data class FoundDevice(val address: String, val name: String, val rssi: Int) val HELIO_STRAP_NAMES = listOf("Amazfit Helio Strap", "Stuttgart") class BleScanner(private val context: Context) { - /** Emits Helio Straps as they're seen. Requires BLUETOOTH_SCAN. */ + /** Emits Helio Straps as they're seen. Requires BLUETOOTH_SCAN (see BlePermissions). */ @SuppressLint("MissingPermission") fun scan(): Flow = callbackFlow { val scanner = context.getSystemService(BluetoothManager::class.java).adapter?.bluetoothLeScanner diff --git a/app/src/main/kotlin/org/openstrap/app/ble/GattClient.kt b/app/src/main/kotlin/org/openstrap/app/ble/GattClient.kt index 562382d7..c80382e0 100644 --- a/app/src/main/kotlin/org/openstrap/app/ble/GattClient.kt +++ b/app/src/main/kotlin/org/openstrap/app/ble/GattClient.kt @@ -11,6 +11,7 @@ import android.bluetooth.BluetoothProfile import android.bluetooth.BluetoothStatusCodes import android.content.Context import android.os.Build +import android.util.Log import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow @@ -19,10 +20,23 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull import java.util.UUID +import java.util.concurrent.atomic.AtomicInteger -class GattException(message: String) : Exception(message) +open class GattException(message: String) : Exception(message) + +/** + * A GATT operation got no callback in time. A plain exception, not `withTimeout`'s cancellation, so a caller that + * rethrows cancellations still sees the failure and can close the client and reconnect (audit C1). + */ +class GattTimeoutException(message: String) : GattException(message) + +/** + * Retrying can't help: the address is malformed or the app lacks the Bluetooth permission. The connect should end + * for good instead of looping on reconnects (audit E4). + */ +class GattUnusableException(message: String, cause: Throwable) : Exception(message, cause) /** A characteristic notification. */ class Notification(val characteristic: UUID, val value: ByteArray) @@ -32,13 +46,21 @@ class Notification(val characteristic: UUID, val value: ByteArray) * operation at a time, so every operation is serialized through [opLock] and completes via the callback. */ @SuppressLint("MissingPermission") -class GattClient(private val context: Context, private val address: String) { +class GattClient( + private val context: Context, + private val address: String, + private val log: (String) -> Unit = { Log.w("OpenStrap", it) }, +) { - private var gatt: BluetoothGatt? = null + // Written by ops and close(), read by the binder thread's callbacks. + @Volatile private var gatt: BluetoothGatt? = null private val opLock = Mutex() - private var pending: CompletableDeferred? = null - /** Set while our own MTU request is the pending op; the stack or the strap can also start an exchange. */ - @Volatile private var awaitingMtu = false + @Volatile private var pending: Pending? = null + + /** The op waiting for its callback. A callback of another kind, or for another characteristic, isn't its answer. */ + private class Pending(val kind: Op, val uuid: UUID?, val result: CompletableDeferred = CompletableDeferred()) + + private enum class Op { CONNECT, DISCOVER, MTU, WRITE, READ, DESCRIPTOR } private val _connected = MutableStateFlow(false) val connected: StateFlow = _connected @@ -47,36 +69,50 @@ class GattClient(private val context: Context, private val address: String) { extraBufferCapacity = 512, onBufferOverflow = BufferOverflow.SUSPEND, ) val notifications: SharedFlow = _notifications + private val droppedCount = AtomicInteger() - var mtu: Int = 23 + @Volatile var mtu: Int = 23 private set private val callback = object : BluetoothGattCallback() { override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { _connected.value = true - complete(Unit) + complete(Op.CONNECT, null, Unit) } else { _connected.value = false - fail("Disconnected (status $status)") + // The link is gone, so whatever was pending can't be answered. + failAny("Disconnected (status $status)") } } override fun onServicesDiscovered(g: BluetoothGatt, status: Int) = - if (status == BluetoothGatt.GATT_SUCCESS) complete(Unit) else fail("Service discovery failed ($status)") + if (status == BluetoothGatt.GATT_SUCCESS) { + complete(Op.DISCOVER, null, Unit) + } else { + fail(Op.DISCOVER, null, "Service discovery failed ($status)") + } override fun onMtuChanged(g: BluetoothGatt, newMtu: Int, status: Int) { if (status == BluetoothGatt.GATT_SUCCESS) mtu = newMtu // A refused MTU request isn't fatal; we keep the default. An exchange we didn't ask for must not // complete whatever else is pending. - if (awaitingMtu) complete(Unit) + complete(Op.MTU, null, Unit) } override fun onCharacteristicWrite(g: BluetoothGatt, c: BluetoothGattCharacteristic, status: Int) = - if (status == BluetoothGatt.GATT_SUCCESS) complete(Unit) else fail("Write to ${c.uuid} failed ($status)") + if (status == BluetoothGatt.GATT_SUCCESS) { + complete(Op.WRITE, c.uuid, Unit) + } else { + fail(Op.WRITE, c.uuid, "Write to ${c.uuid} failed ($status)") + } override fun onCharacteristicRead(g: BluetoothGatt, c: BluetoothGattCharacteristic, value: ByteArray, status: Int) = - if (status == BluetoothGatt.GATT_SUCCESS) complete(value) else fail("Read of ${c.uuid} failed ($status)") + if (status == BluetoothGatt.GATT_SUCCESS) { + complete(Op.READ, c.uuid, value) + } else { + fail(Op.READ, c.uuid, "Read of ${c.uuid} failed ($status)") + } @Deprecated("Pre-Android 13 callback") override fun onCharacteristicRead(g: BluetoothGatt, c: BluetoothGattCharacteristic, status: Int) { @@ -84,38 +120,59 @@ class GattClient(private val context: Context, private val address: String) { onCharacteristicRead(g, c, c.value ?: ByteArray(0), status) } - override fun onDescriptorWrite(g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int) = - if (status == BluetoothGatt.GATT_SUCCESS) complete(Unit) else fail("Descriptor write failed ($status)") + override fun onDescriptorWrite(g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int) { + val uuid = d.characteristic?.uuid + if (status == BluetoothGatt.GATT_SUCCESS) { + complete(Op.DESCRIPTOR, uuid, Unit) + } else { + fail(Op.DESCRIPTOR, uuid, "Descriptor write failed ($status)") + } + } override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic, value: ByteArray) { - _notifications.tryEmit(Notification(c.uuid, value)) + if (!_notifications.tryEmit(Notification(c.uuid, value))) dropped(c.uuid) } @Deprecated("Pre-Android 13 callback") override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic) { + // The characteristic's value is overwritten by the next notification, so it's copied before it leaves. @Suppress("DEPRECATION") - _notifications.tryEmit(Notification(c.uuid, c.value?.copyOf() ?: ByteArray(0))) + onCharacteristicChanged(g, c, c.value?.copyOf() ?: ByteArray(0)) } } + /** tryEmit never suspends, so a full buffer drops the chunk. Logged sparingly: a stuck collector drops many. */ + private fun dropped(uuid: UUID) { + val n = droppedCount.incrementAndGet() + if (n == 1 || n % DROP_LOG_EVERY == 0) log("gatt: notification buffer full, dropped $n so far (last from $uuid)") + } + private fun Throwable.isDeadBinder(): Boolean = generateSequence(this) { it.cause }.any { it is android.os.DeadObjectException } /** Called when Bluetooth turns off: no callback reports the link's end then either. */ fun lost() { _connected.value = false - fail("Bluetooth turned off") + failAny("Bluetooth turned off") } - private fun complete(value: Any?) { pending?.complete(value) } - private fun fail(message: String) { pending?.completeExceptionally(GattException(message)) } + private fun matching(kind: Op, uuid: UUID?): Pending? = + pending?.takeIf { it.kind == kind && (uuid == null || it.uuid == null || it.uuid == uuid) } + private fun complete(kind: Op, uuid: UUID?, value: Any?) { matching(kind, uuid)?.result?.complete(value) } + private fun fail(kind: Op, uuid: UUID?, message: String) { matching(kind, uuid)?.result?.completeExceptionally(GattException(message)) } + private fun failAny(message: String) { pending?.result?.completeExceptionally(GattException(message)) } - private suspend fun op(timeoutMs: Long = 10_000, start: () -> Boolean): T = opLock.withLock { - val d = CompletableDeferred() - pending = d + /** The open handle, or a [GattException] once [close] has run, instead of an NPE from a field nulled meanwhile. */ + private fun requireGatt(): BluetoothGatt = gatt ?: throw GattException("Closed") + + private suspend fun op(kind: Op, uuid: UUID? = null, timeoutMs: Long = 10_000, start: () -> Boolean): T = opLock.withLock { + val p = Pending(kind, uuid) + pending = p try { val started = try { start() + } catch (e: SecurityException) { + throw GattUnusableException("No Bluetooth permission", e) } catch (e: RuntimeException) { // The phone's Bluetooth service died (a stack restart): this handle is dead and no disconnect callback // will ever come, so report the link lost here and let the owner reconnect with a fresh one. @@ -124,8 +181,10 @@ class GattClient(private val context: Context, private val address: String) { throw GattException("Bluetooth restarted on the phone") } if (!started) throw GattException("GATT operation could not be started") + val result = withTimeoutOrNull(timeoutMs) { p.result.await() } + ?: throw GattTimeoutException("No answer to ${kind.name.lowercase()}${uuid?.let { " of $it" }.orEmpty()} in $timeoutMs ms") @Suppress("UNCHECKED_CAST") - withTimeout(timeoutMs) { d.await() } as T + result as T } finally { pending = null } @@ -133,9 +192,13 @@ class GattClient(private val context: Context, private val address: String) { /** [refreshCache] drops Android's cached service table first; needed after a firmware update. */ suspend fun connect(refreshCache: Boolean = false) { - val device: BluetoothDevice = context.getSystemService(BluetoothManager::class.java).adapter.getRemoteDevice(address) - op(timeoutMs = 30_000) { - gatt = if (Build.VERSION.SDK_INT >= 37) { + val device: BluetoothDevice = try { + context.getSystemService(BluetoothManager::class.java).adapter.getRemoteDevice(address) + } catch (e: IllegalArgumentException) { + throw GattUnusableException("Not a Bluetooth address: $address", e) + } + op(Op.CONNECT, timeoutMs = 30_000) { + val g = if (Build.VERSION.SDK_INT >= 37) { // Android 17's settings form. Automatic MTU stays off, as the older call leaves it, and callbacks run // on the binder thread like before. val settings = android.bluetooth.BluetoothGattConnectionSettings.Builder() @@ -148,26 +211,21 @@ class GattClient(private val context: Context, private val address: String) { @Suppress("DEPRECATION") device.connectGatt(context, false, callback, BluetoothDevice.TRANSPORT_LE) } - gatt != null + gatt = g + g != null } - gatt!!.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) + requireGatt().requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) if (refreshCache) { // Hidden API, but the only way to force re-discovery; harmless if it fails. - runCatching { BluetoothGatt::class.java.getMethod("refresh").invoke(gatt) } + val g = requireGatt() + runCatching { BluetoothGatt::class.java.getMethod("refresh").invoke(g) } kotlinx.coroutines.delay(500) } - op { gatt!!.discoverServices() } + op(Op.DISCOVER) { requireGatt().discoverServices() } } suspend fun requestMtu(size: Int): Int { - try { - op { - awaitingMtu = true - gatt!!.requestMtu(size) - } - } finally { - awaitingMtu = false - } + op(Op.MTU) { requireGatt().requestMtu(size) } return mtu } @@ -179,17 +237,17 @@ class GattClient(private val context: Context, private val address: String) { suspend fun enableNotifications(service: UUID, uuid: UUID) { val c = characteristic(service, uuid) - gatt!!.setCharacteristicNotification(c, true) + requireGatt().setCharacteristicNotification(c, true) val cccd = c.getDescriptor(CCCD) ?: throw GattException("No CCCD on $uuid") val value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE - op { + op(Op.DESCRIPTOR, uuid) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - gatt!!.writeDescriptor(cccd, value) == BluetoothStatusCodes.SUCCESS + requireGatt().writeDescriptor(cccd, value) == BluetoothStatusCodes.SUCCESS } else { @Suppress("DEPRECATION") cccd.value = value @Suppress("DEPRECATION") - gatt!!.writeDescriptor(cccd) + requireGatt().writeDescriptor(cccd) } } } @@ -197,34 +255,36 @@ class GattClient(private val context: Context, private val address: String) { suspend fun write(service: UUID, uuid: UUID, value: ByteArray, withResponse: Boolean = false) { val c = characteristic(service, uuid) val type = if (withResponse) BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT else BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE - op { + op(Op.WRITE, uuid) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - gatt!!.writeCharacteristic(c, value, type) == BluetoothStatusCodes.SUCCESS + requireGatt().writeCharacteristic(c, value, type) == BluetoothStatusCodes.SUCCESS } else { @Suppress("DEPRECATION") c.writeType = type @Suppress("DEPRECATION") c.value = value @Suppress("DEPRECATION") - gatt!!.writeCharacteristic(c) + requireGatt().writeCharacteristic(c) } } } suspend fun read(service: UUID, uuid: UUID): ByteArray { val c = characteristic(service, uuid) - return op { gatt!!.readCharacteristic(c) } + return op(Op.READ, uuid) { requireGatt().readCharacteristic(c) } } fun close() { - runCatching { gatt?.disconnect() } - runCatching { gatt?.close() } + val g = gatt gatt = null + runCatching { g?.disconnect() } + runCatching { g?.close() } _connected.value = false - fail("Closed") + failAny("Closed") } companion object { val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + private const val DROP_LOG_EVERY = 100 } } diff --git a/app/src/main/kotlin/org/openstrap/app/data/Activity.kt b/app/src/main/kotlin/org/openstrap/app/data/Activity.kt index 6e7cd544..37cd1b9f 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/Activity.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/Activity.kt @@ -1,6 +1,5 @@ package org.openstrap.app.data -import kotlin.math.abs /** * Steps, distance and moderate-to-vigorous activity (docs/algorithms/evidence-review.md, "Energy and activity"). @@ -23,8 +22,6 @@ object Activity { const val HR_RUN_MINUTES = 10 /** WHO 2020: 150 min of moderate a week, a vigorous minute counting double. */ const val MVPA_WEEKLY_TARGET = 150 - /** Flag a divergence between the strap's daily counter and the summed minutes above this fraction. */ - const val STEP_DIVERGENCE = 0.05f data class Mvpa( val moderateMinutes: Int, @@ -74,13 +71,6 @@ object Activity { return Mvpa(moderate, vigorous, fromHr) } - /** True when the strap's daily counter and the summed minutes differ by more than [STEP_DIVERGENCE]. */ - fun stepsDiverge(strapTotal: Int?, minuteSum: Int): Boolean { - if (strapTotal == null || strapTotal <= 0 && minuteSum <= 0) return false - val ref = maxOf(strapTotal, minuteSum).toFloat() - return abs(strapTotal - minuteSum) / ref > STEP_DIVERGENCE - } - /** * Step length in metres for a minute at [cadence] steps/min and height [heightCm]: 0.414·H below 120 spm, rising * linearly to 0.45·H at 130 and above. An estimate; distance is always shown with "≈". diff --git a/app/src/main/kotlin/org/openstrap/app/data/BackupRepository.kt b/app/src/main/kotlin/org/openstrap/app/data/BackupRepository.kt new file mode 100644 index 00000000..521f334d --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/BackupRepository.kt @@ -0,0 +1,38 @@ +package org.openstrap.app.data + +import android.net.Uri +import java.io.OutputStream + +/** + * Save a copy and Restore: every table and the settings as one zip, at a place the user picked. The view model turns a + * [BackupResult] into what the screen says, so nothing here knows the copy. + */ +interface BackupRepository { + /** The whole zip, records and settings, written to [to]. */ + suspend fun exportCsv(to: Uri): BackupResult + + /** A zip from [exportCsv] or automatic backup, restored into what this phone is missing. Safe to run twice. */ + suspend fun importCsv(from: Uri): BackupResult + + /** The settings alone, as the zip's settings file. */ + suspend fun exportSettings(to: Uri): BackupResult + + /** Settings from [exportSettings], restored where this phone hasn't set them. */ + suspend fun importSettings(from: Uri): BackupResult + + /** + * The zip [exportCsv] writes, into [out], throwing on failure. For automatic backup, which writes through a temp + * file and names the failure itself. + */ + suspend fun writeZip(out: OutputStream) +} + +sealed interface BackupResult { + data object Saved : BackupResult + + /** [total] records came back, [settings] says whether any setting did, and [skipped] rows couldn't be read. */ + data class Restored(val total: Int, val settings: Boolean, val skipped: Int) : BackupResult + + /** What went wrong, for the view model to word and to log. */ + data class Failed(val error: Throwable) : BackupResult +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/Catching.kt b/app/src/main/kotlin/org/openstrap/app/data/Catching.kt new file mode 100644 index 00000000..5bbb31d8 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/Catching.kt @@ -0,0 +1,27 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlin.coroutines.cancellation.CancellationException + +/** + * [runCatching] for suspend code. `runCatching` turns a cancellation into a failure, so a cancelled request shows an + * error, a cleared view model sets a Failed step and a cancelled loop keeps going. This rethrows a + * [CancellationException] once the calling coroutine is no longer active, so cancellation still unwinds. + * + * It asks the coroutine, not the exception's type. A `withTimeout` that expires inside [block] throws a + * `TimeoutCancellationException`, which is a [CancellationException] too, while the caller is still active. That one + * is a timeout, not a cancellation, so it comes back as a failure. Rethrowing every [CancellationException] would let + * a timeout escape as if the caller were cancelled and leave its state where it was (audit C1 and C2). + */ +// Catching everything is the point: it is runCatching with cancellation kept. +@Suppress("TooGenericExceptionCaught") +suspend inline fun catching(block: () -> T): Result = + try { + Result.success(block()) + } catch (e: CancellationException) { + if (!currentCoroutineContext().isActive) throw e + Result.failure(e) + } catch (e: Throwable) { + Result.failure(e) + } diff --git a/app/src/main/kotlin/org/openstrap/app/data/Coaching.kt b/app/src/main/kotlin/org/openstrap/app/data/Coaching.kt index 32698b25..27b7122c 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/Coaching.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/Coaching.kt @@ -1,15 +1,15 @@ package org.openstrap.app.data /** - * What a workout aims for, chosen on the ready screen: a zone or a range of zones (0..4, inclusive), a time, or active - * kcal. Stored on the workout as [code] ("z:1-2", "t:2700", "k:300"). + * What a workout aims for, chosen on the ready screen: a zone or a range of zones (0..4, inclusive), a time, active + * kcal or a distance. Stored on the workout as [code] ("z:1-2", "t:2700", "k:300", "d:5000"). */ sealed interface WorkoutTarget { val code: String data class Zones(val low: Int, val high: Int) : WorkoutTarget { override val code get() = "z:$low-$high" - val label: String get() = if (low == high) "Zone ${low + 1}" else "Zone ${low + 1}–${high + 1}" + val label: String get() = if (low == high) "Zone ${low + 1}" else "Zone ${low + 1} to ${high + 1}" } data class Time(val seconds: Long) : WorkoutTarget { @@ -23,6 +23,17 @@ sealed interface WorkoutTarget { val label: String get() = "$kcal kcal" } + /** Metres: from GPS, or the strap's steps without it, as the workout counts its distance. */ + data class Distance(val meters: Int) : WorkoutTarget { + override val code get() = "d:$meters" + /** "5 km", "21.1 km", "800 m". */ + val label: String get() = when { + meters < 1000 -> "$meters m" + meters % 1000 == 0 -> "${meters / 1000} km" + else -> String.format(java.util.Locale.ROOT, "%.1f km", meters / 1000f) + } + } + companion object { fun parse(code: String?): WorkoutTarget? { code ?: return null @@ -31,6 +42,7 @@ sealed interface WorkoutTarget { "z" -> v.split('-').mapNotNull { it.toIntOrNull() }.takeIf { it.size == 2 }?.let { (a, b) -> Zones(a, b) } "t" -> v.toLongOrNull()?.let(::Time) "k" -> v.toIntOrNull()?.takeIf { it > 0 }?.let(::Kcal) + "d" -> v.toIntOrNull()?.takeIf { it > 0 }?.let(::Distance) else -> null } } @@ -53,12 +65,12 @@ enum class Cue(val pattern: List) { BelowTarget(listOf(700)), /** Back in the target: one short pulse. */ BackInTarget(listOf(200)), - /** Halfway through a time target: two short pulses. */ - Halfway(listOf(150, 150, 150)), - /** Time or kcal target reached: long, short, long. */ - GoalReached(listOf(500, 200, 200, 200, 500)), - /** Strength: rest is over, start the next set. Two medium pulses. */ - RestOver(listOf(400, 150, 400)), + /** Halfway through a time or distance goal: one long ([WorkoutBuzz.LONG]). */ + Halfway(WorkoutBuzz.LONG), + /** A time, distance or kcal goal reached: three short ([WorkoutBuzz.GOAL]). */ + GoalReached(WorkoutBuzz.GOAL), + /** Strength: rest is over, start the next set. Two short, as work starting is everywhere ([WorkoutBuzz.WORK_STARTS]). */ + RestOver(WorkoutBuzz.WORK_STARTS), } /** @@ -88,7 +100,7 @@ class Coach(private val target: WorkoutTarget?, reachedAt: Long? = null) { else -> null } is WorkoutTarget.Zones -> zone?.let { zoneCue(t, it, target) } - is WorkoutTarget.Kcal, null -> null + is WorkoutTarget.Kcal, is WorkoutTarget.Distance, null -> null } /** @@ -102,6 +114,19 @@ class Coach(private val target: WorkoutTarget?, reachedAt: Long? = null) { return Cue.GoalReached } + /** + * The distance so far, [meters] at [t]: the halfway cue, then the goal cue the first time it reaches a distance + * target, each once. Distance only grows, so a pause holds it where it is. + */ + fun distance(t: Long, meters: Float): Cue? { + if (target !is WorkoutTarget.Distance || goalDone) return null + return when { + meters >= target.meters -> { goalDone = true; halfwayDone = true; reachedAt = t; Cue.GoalReached } + !halfwayDone && meters * 2 >= target.meters -> { halfwayDone = true; Cue.Halfway } + else -> null + } + } + private fun zoneCue(t: Long, zone: Int, target: WorkoutTarget.Zones): Cue? { val side = when { zone > target.high -> 1; zone < target.low -> -1; else -> 0 } if (side != pendingSide) { pendingSide = side; pendingSince = t; return null } diff --git a/app/src/main/kotlin/org/openstrap/app/data/DailyEnergyHistory.kt b/app/src/main/kotlin/org/openstrap/app/data/DailyEnergyHistory.kt index c5d5d199..9f7b9229 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/DailyEnergyHistory.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/DailyEnergyHistory.kt @@ -40,10 +40,10 @@ class DailyEnergyHistory(private val dao: HealthDao) { val engine = RecoveryEngine(dao) val maxHr = engine.maxHeartRate(profile, today, zone) val vo2 = HashMap() + val firstDay = Weeks.firstDay() var written = 0 for (d in days) { - val monday = d.with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) - val v = vo2.getOrPut(monday) { runCatching { engine.vo2max(profile, d, zone) }.getOrNull() } + val v = vo2.getOrPut(Weeks.start(d, firstDay)) { runCatching { engine.vo2max(profile, d, zone, firstDay) }.getOrNull() } if (write(engine, d, profile, maxHr, v, zone, now)) written++ } return written @@ -99,8 +99,9 @@ class DailyEnergyHistory(private val dao: HealthDao) { /** * What in the profile moves a day's energy: weight, height, age, sex and a tested max HR. When it differs from - * the one the rows were written with, every row is written again. + * the one the rows were written with, every row is written again. Built from a string, whose hash is the same + * in every process, as it is stored and compared after the app restarts. An enum's own hash is not. */ - fun profileKey(p: UserProfile): Long = listOf(p.weightKg, p.heightCm, p.age(), p.sex, p.maxHeartRate).hashCode().toLong() + fun profileKey(p: UserProfile): Long = "${p.weightKg}|${p.heightCm}|${p.age()}|${p.sex?.name}|${p.maxHeartRate}".hashCode().toLong() } } diff --git a/app/src/main/kotlin/org/openstrap/app/data/DayRepository.kt b/app/src/main/kotlin/org/openstrap/app/data/DayRepository.kt new file mode 100644 index 00000000..ce48a947 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/DayRepository.kt @@ -0,0 +1,155 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.flow.Flow +import org.openstrap.app.data.db.ActivityMinute +import org.openstrap.app.data.db.BreathRate +import org.openstrap.app.data.db.DailyEnergyRow +import org.openstrap.app.data.db.DailyPai +import org.openstrap.app.data.db.HealthAlertRow +import org.openstrap.app.data.db.HeartRateSample +import org.openstrap.app.data.db.HrvSample +import org.openstrap.app.data.db.HrvSdnnSample +import org.openstrap.app.data.db.OsaEventRow +import org.openstrap.app.data.db.OsaResultRow +import org.openstrap.app.data.db.PaiSample +import org.openstrap.app.data.db.SkinTemperature +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepSpo2Sample +import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.data.db.Spo2Sample +import org.openstrap.app.data.db.StressSample +import org.openstrap.app.data.settings.UserProfile +import java.time.LocalDate + +/** + * Everything one day shows, read and resolved: which sleep is the main one, the naps, the baselines, the week's trends + * and the nights. The screens turn it into what they draw, so nothing here knows about charts. + */ +// One function for each thing a day reads or changes, as the screens use them, rather than a few that take a mode. +@Suppress("TooManyFunctions") +interface DayRepository { + /** The day [date] as it changes. Only [isToday] adds the strap's live step count. */ + fun day(date: LocalDate, isToday: Boolean): Flow + + /** Days the calendar lets you pick. */ + fun daysWithData(): Flow> + + /** Each local day's steps hour by hour, from [from] to [to], keyed by date, read once. */ + suspend fun hourlySteps(from: LocalDate, to: LocalDate): Map> + + /** Readiness and charge for [date], through the cache in [HealthRepository.recovery]. Heavy. */ + suspend fun recovery(date: LocalDate, profile: UserProfile): RecoveryResult + + /** The weekly Healthspan estimate shown on [date], or why it's withheld. Heavy. */ + suspend fun healthspan(date: LocalDate, profile: UserProfile): Healthspan.Outcome + + /** Emits whenever data the scores are worked out from changes. */ + fun changes(): Flow + + /** Each day's Heart points from [from] to [to], keyed by local date. */ + fun dailyPai(from: LocalDate, to: LocalDate): Flow> + + /** Each day's stored energy from [from] to [to], keyed by local date. */ + fun dailyEnergy(from: LocalDate, to: LocalDate): Flow> + + /** Sleep and naps deleted in the last [SleepEdits.KEEP_DAYS], newest first. */ + fun deletedSleep(): Flow> + + /** Keeps [start] to [end] as the times of the strap's sleep or nap [strap]. */ + suspend fun setSleepTimes(strap: SleepSession, start: Long, end: Long) + + /** Deletes the strap's sleep or nap [strap]. [restoreSleep] brings it back. */ + suspend fun deleteSleep(strap: SleepSession) + + /** Brings back the strap's deleted sleep or nap [strap], as it was. */ + suspend fun restoreSleep(strap: SleepSession) +} + +/** One day's value of a weekly trend, null where the day has no data. */ +data class DayValue(val date: LocalDate, val value: Float?) + +/** Averages over the 7 days before the selected day, null where there's no data. */ +data class Baselines( + val restingHr: Float? = null, + val hrv: Float? = null, + val spo2: Float? = null, + val stress: Float? = null, + val skinTemperature: Float? = null, + val breathRate: Float? = null, +) + +/** One night's main sleep and the vitals averaged over it. */ +data class SleepNight( + val date: LocalDate, + val session: SleepSession?, + val hrv: Float? = null, + val skinTemperature: Float? = null, + val spo2: Float? = null, + val breathing: Float? = null, +) { + val asleepSeconds: Long? get() = session?.let(SleepHistory::asleepSeconds) +} + +/** A day's sleep, resolved: the night's main sleep ([SleepHistory.mainSleep]), the day's naps and the edits around them. */ +data class SleepDay( + /** The main sleep as shown, carrying the strap's own score and stage totals when it reported them. */ + val main: SleepSession? = null, + /** The night's stages, clipped to [main]'s times. */ + val mainStages: List = emptyList(), + val naps: List = emptyList(), + /** Each nap's stages, keyed by the nap's start as shown. */ + val napStages: Map> = emptyMap(), + /** The strap's score for the night before. */ + val previousScore: Int? = null, + /** Each nap as the strap saw it, keyed by the nap's start as shown. */ + val napsAsDetected: Map = emptyMap(), + /** The main sleep as the strap saw it. */ + val mainAsDetected: SleepSession? = null, + /** The strap's whole stages for a main sleep the user moved, else empty. */ + val strapStages: List = emptyList(), + /** The night's main sleep the user deleted, when there's no other ([main] is null then), for its Restore. */ + val deletedMain: SleepEdits.Resolved? = null, + /** Every sleep and nap as shown around the day, from the night before to tonight. */ + val neighbours: List = emptyList(), +) + +/** One day read and resolved by a [DayRepository]. Rows are the database's own, so the screens choose how to draw them. */ +data class DayData( + val date: LocalDate, + val isToday: Boolean = false, + val day: DayWindow = DayWindow.of(date), + val night: DayWindow = DayWindow.nightEndingOn(date), + /** The day's activity minutes, for the steps by hour and the heart rate series. */ + val minutes: List = emptyList(), + val heartRate: List = emptyList(), + /** Steps shown: today, the strap's live count when it's ahead of the synced minutes ([LiveSteps]). */ + val steps: Int = 0, + /** The 7 days ending on [date], oldest first: the strap's resting heart rate, else our own estimate. */ + val restingTrend: List = emptyList(), + /** For each of [restingTrend], whether it's our own estimate. */ + val restingEstimated: List = emptyList(), + val hrvTrend: List = emptyList(), + val sleep: SleepDay = SleepDay(), + /** The [SleepHistory.NIGHTS] nights ending on [date], oldest first. */ + val nights: List = emptyList(), + /** Heart rate across the night window. */ + val nightHeartRate: List = emptyList(), + /** HRV and skin temperature read during the night's main sleep. */ + val sleepHrv: List = emptyList(), + val sleepSkin: List = emptyList(), + val spo2: List = emptyList(), + val stress: List = emptyList(), + val hrv: List = emptyList(), + val skin: List = emptyList(), + val breath: List = emptyList(), + val pai: PaiSample? = null, + val baselines: Baselines = Baselines(), + val sleepSpo2: List = emptyList(), + val hrvSdnn: List = emptyList(), + val osaResult: OsaResultRow? = null, + val osaEvents: List = emptyList(), + val dailyMaxHeartRate: Int? = null, + val distanceMeters: Int? = null, + val activeCalories: Int? = null, + val alerts: List = emptyList(), +) diff --git a/app/src/main/kotlin/org/openstrap/app/data/Days.kt b/app/src/main/kotlin/org/openstrap/app/data/Days.kt new file mode 100644 index 00000000..c0e7d975 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/Days.kt @@ -0,0 +1,27 @@ +package org.openstrap.app.data + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * A local day's bounds in epoch seconds. A day runs from its local midnight to the next one, which is 23 or 25 hours + * on when the clocks change, so it's never `start + 86400`. Where a clock change skips midnight, the day starts at the + * first moment it has. + */ +object Days { + /** The local date [epoch] falls on in [zone]. */ + fun date(epoch: Long, zone: ZoneId): LocalDate = Instant.ofEpochSecond(epoch).atZone(zone).toLocalDate() + + /** The local midnight that starts [date]. */ + fun start(date: LocalDate, zone: ZoneId): Long = date.atStartOfDay(zone).toEpochSecond() + + /** The local midnight after [date], the first second not in it. */ + fun end(date: LocalDate, zone: ZoneId): Long = start(date.plusDays(1), zone) + + /** The local midnight that starts the day [epoch] falls in. */ + fun start(epoch: Long, zone: ZoneId): Long = start(date(epoch, zone), zone) + + /** The local midnight after the day [epoch] falls in, the first second not in it. */ + fun end(epoch: Long, zone: ZoneId): Long = end(date(epoch, zone), zone) +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/DetailRoutes.kt b/app/src/main/kotlin/org/openstrap/app/data/DetailRoutes.kt new file mode 100644 index 00000000..f1c67757 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/DetailRoutes.kt @@ -0,0 +1,12 @@ +package org.openstrap.app.data + +/** + * The routes an `open_metric` intent names a detail by, for code below the screens: the widgets build their taps from + * these, and the app reads them back into its details. + */ +object DetailRoutes { + const val STEPS = "Steps" + const val RECOVERY = "Recovery" + const val SLEEP = "Sleep" + const val SLEEP_SCORE = "SleepScore" +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/Games.kt b/app/src/main/kotlin/org/openstrap/app/data/Games.kt new file mode 100644 index 00000000..c55a03c6 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/Games.kt @@ -0,0 +1,43 @@ +package org.openstrap.app.data + +/** + * Games and sports played with the phone in a bag (workouts spec 2.7): how hard it has been, from the time in each + * zone, and the hard efforts in it. Pure, like [Strength]. + */ +object Games { + /** A spell in zone 5 counts as a hard effort from this long. */ + const val HARD_EFFORT_S = 10L + + /** + * Effort 1 to 5 from [zoneSeconds], the seconds in each zone so far: the time-weighted average zone, rounded, at + * least 1. Null with no time in any zone yet. + */ + fun effort(zoneSeconds: IntArray): Int? { + val total = zoneSeconds.sum() + if (total <= 0) return null + val mean = zoneSeconds.withIndex().sumOf { (z, s) -> (z + 1) * s.toDouble() } / total + return Math.round(mean).toInt().coerceIn(1, Workouts.ZONES) + } + + /** + * Spells of at least [HARD_EFFORT_S] in zone 5 in [readings] (time, bpm, ascending): one effort per spell, however + * long. A gap of more than a few seconds between readings ends a spell. + */ + fun hardEfforts(readings: List>, maxHr: Int): Int { + var count = 0 + var since: Long? = null + var last: Long? = null + var counted = false + for ((t, bpm) in readings) { + val top = WorkoutRecorder.liveZone(bpm, maxHr) == Workouts.ZONES - 1 + val gap = last != null && t - last > WorkoutRecorder.MAX_STEP_S + if (!top || gap) { since = null; counted = false } + if (top) { + if (since == null) since = t + if (!counted && t - since >= HARD_EFFORT_S) { count++; counted = true } + } + last = t + } + return count + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/HealthRepository.kt b/app/src/main/kotlin/org/openstrap/app/data/HealthRepository.kt index ed2ca50d..bcdce674 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/HealthRepository.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/HealthRepository.kt @@ -1,9 +1,13 @@ package org.openstrap.app.data +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.openstrap.app.data.db.ActivityExtendedMinute import org.openstrap.app.data.db.ActivityMinute import org.openstrap.app.data.db.DailyAggregate @@ -19,6 +23,7 @@ import org.openstrap.app.data.db.CheckInRow import org.openstrap.app.data.db.HealthDao import org.openstrap.app.data.db.HeartRateSample import org.openstrap.app.data.db.HrvSample +import org.openstrap.app.data.db.OpenStrapDatabase import org.openstrap.app.data.db.PaiSample import org.openstrap.app.data.db.RestingHeartRate import org.openstrap.app.data.db.SkinTemperature @@ -26,37 +31,108 @@ import org.openstrap.app.data.db.SleepSession import org.openstrap.app.data.db.SleepStage import org.openstrap.app.data.db.Spo2Sample import org.openstrap.app.data.db.StressSample +import org.openstrap.app.data.settings.UserProfile import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import java.util.concurrent.atomic.AtomicLong /** A [from, to] window in epoch seconds covering one local calendar day. */ data class DayWindow(val from: Long, val to: Long) { companion object { fun of(date: LocalDate, zone: ZoneId = ZoneId.systemDefault()): DayWindow { - val from = date.atStartOfDay(zone).toEpochSecond() - val to = date.plusDays(1).atStartOfDay(zone).toEpochSecond() - 1 - return DayWindow(from, to) + return DayWindow(Days.start(date, zone), Days.end(date, zone) - 1) } + /** Where one night's sleep ends and the next begins, on the clock. */ + private const val NIGHT_SPLIT_HOUR = 18 + /** Sleep that *ended* on [date]: from 18:00 the previous evening until 18:00 on [date]. */ fun nightEndingOn(date: LocalDate, zone: ZoneId = ZoneId.systemDefault()): DayWindow { - val to = date.atTime(18, 0).atZone(zone).toEpochSecond() - return DayWindow(to - 24 * 3600, to) + // From the evening before by the clock, not 24 hours back, which is an hour off when the clocks change. + fun split(d: LocalDate) = d.atTime(NIGHT_SPLIT_HOUR, 0).atZone(zone).toEpochSecond() + return DayWindow(split(date.minusDays(1)), split(date)) } } } -class HealthRepository(private val dao: HealthDao) { +class HealthRepository( + private val dao: HealthDao, + /** The clock Recovery is worked out at, in epoch seconds. Tests pass one they move by hand. */ + private val nowSeconds: () -> Long = { System.currentTimeMillis() / 1000 }, +) { private val recoveryEngine = RecoveryEngine(dao) private val healthspanEngine = HealthspanEngine(dao) + /** The one reader of sleep for everything shown ([SleepSource]). */ + val sleep = SleepSource(dao) + + /** + * A Recovery worked out, the data version it was worked out from, and the clock minute when the day's Charge run + * still reached the present ([liveMinute]). + */ + private class CachedRecovery(val version: Long, val minute: Long?, val result: RecoveryResult) + + /** + * Recovery by day and profile, so Today and the widgets work a day out once per data version, not once each. The + * profile is part of the key because age, weight and the sleep need change the result. The oldest goes past + * [RECOVERY_CACHE_SIZE]. Only read and written under [recoveryLock]. + */ + private val recoveries = LinkedHashMap, CachedRecovery>() + private val recoveryLock = Mutex() + + /** Moves whenever a table Recovery reads changes, so a cached Recovery is never older than the data under it. */ + private val dataVersion = AtomicLong() + + /** How many times the inputs given to [start] have emitted, the first emission included. */ + @get:androidx.annotation.VisibleForTesting + internal val recoveryVersion: Long get() = dataVersion.get() + + /** False until [start]: with nothing following the data, every Recovery is worked out afresh. */ + @Volatile private var caching = false + + /** + * Follows [inputs] for as long as [scope] lives, so the Recovery cache knows when new data lands: a sync, a + * restore, a sleep edit or a trim. [inputs] is [recoveryInputs] in the app, which watches every table Recovery + * reads, not only those [changes] watches. Called once, from the app's container. + */ + fun start(scope: CoroutineScope, inputs: Flow<*>) { + caching = true + scope.launch { inputs.collect { dataVersion.incrementAndGet() } } + } /** * Readiness and charge for [date]; heavy, so call it off the main thread. Also logs the night's own inputs next to * the strap's ([NightMetricsRow]) for the calibration review, and keeps the day's energy ([DailyEnergyHistory]). + * Once [start]ed, a result is kept until the data changes, and a kept one writes nothing: its rows were written + * when it was first worked out. While the day's Charge still runs up to the present, it is kept for the minute + * only. A failure is never kept, so asking again works it out again. + */ + suspend fun recovery(date: LocalDate, profile: UserProfile): RecoveryResult { + val now = nowSeconds() + if (!caching) return computeRecovery(date, profile, now) + val key = date to profile + val version = dataVersion.get() + val minute = liveMinute(date, now) + return recoveryLock.withLock { recoveries[key]?.takeIf { it.version == version && it.minute == minute }?.result } + ?: computeRecovery(date, profile, now).also { r -> + recoveryLock.withLock { + recoveries.remove(key) + recoveries[key] = CachedRecovery(version, minute, r) + while (recoveries.size > RECOVERY_CACHE_SIZE) recoveries.remove(recoveries.keys.first()) + } + } + } + + /** + * The clock minute at [now] when [date]'s Charge run ends at the present rather than at its lookahead + * ([RecoveryEngine.LOOKAHEAD_SECONDS]): today, and yesterday until the lookahead has passed. Null for older days, + * whose result changes only with the data. */ - suspend fun recovery(date: LocalDate, profile: org.openstrap.app.data.settings.UserProfile): RecoveryResult { - val r = recoveryEngine.compute(date, profile) + private fun liveMinute(date: LocalDate, now: Long): Long? = + (now / MINUTE_S).takeIf { now / MINUTE_S * MINUTE_S + MINUTE_S < DayWindow.of(date).to + 1 + RecoveryEngine.LOOKAHEAD_SECONDS } + + private suspend fun computeRecovery(date: LocalDate, profile: UserProfile, now: Long): RecoveryResult { + val r = recoveryEngine.compute(date, profile, now = now) r.tonight?.let { logNight(it) } r.energy?.let { dao.insertDailyEnergy(listOf(DailyEnergyHistory.row(DayWindow.of(date).from, it, System.currentTimeMillis() / 1000))) } return r @@ -66,7 +142,7 @@ class HealthRepository(private val dao: HealthDao) { suspend fun usualBedtime(date: LocalDate): java.time.LocalTime? = recoveryEngine.usualBedtime(date) /** The weekly Healthspan estimate shown on [date] (made the Sunday on or before it), or why it's withheld; heavy. */ - suspend fun healthspan(date: LocalDate, profile: org.openstrap.app.data.settings.UserProfile): Healthspan.Outcome = + suspend fun healthspan(date: LocalDate, profile: UserProfile): Healthspan.Outcome = healthspanEngine.compute(date, profile) private suspend fun logNight(n: Recovery.Night) { @@ -91,25 +167,58 @@ class HealthRepository(private val dao: HealthDao) { */ fun changes(): Flow = kotlinx.coroutines.flow.combine( dao.latestHeartRateTime(), dao.latestStressTime(), dao.latestSleepEnd(), dao.workoutCountFlow().distinctUntilChanged(), - dao.napEdits().distinctUntilChanged(), + dao.sleepEdits().distinctUntilChanged(), ) { _, _, _, _, _ -> } - /** Local days that have any heart rate, steps or sleep, for the calendar. */ - fun daysWithData(zone: ZoneId = ZoneId.systemDefault()): Flow> = dao.hoursWithData().map { hours -> - hours.mapTo(HashSet()) { Instant.ofEpochSecond(it * 3600).atZone(zone).toLocalDate() } + /** + * Local days that have any heart rate, steps or sleep, for the calendar. Only the last [RECENT_DATA_DAYS] are + * watched, where syncs write: a query over all of every table ran again on each sync batch. The older days are read + * once, and again only when older data comes in ([HealthDao.earliestDataMark]), as a restore brings. + */ + fun daysWithData(zone: ZoneId = ZoneId.systemDefault(), now: Long = System.currentTimeMillis() / 1000): Flow> { + val split = (now / 3600 - RECENT_DATA_DAYS * 24L) * 3600 + val older = dao.earliestDataMark().distinctUntilChanged().map { dao.hoursWithDataOnce(Long.MIN_VALUE, split - 1) } + return combine(older, dao.hoursWithData(split, Long.MAX_VALUE)) { old, recent -> + (old + recent).mapTo(HashSet()) { Instant.ofEpochSecond(it * 3600).atZone(zone).toLocalDate() } + }.distinctUntilChanged() } fun heartRate(w: DayWindow): Flow> = dao.heartRate(w.from, w.to) fun activity(w: DayWindow): Flow> = dao.activity(w.from, w.to) - fun sleepSessions(w: DayWindow): Flow> = dao.sleepSessions(w.from, w.to) - fun sleepStages(sessionStart: Long): Flow> = dao.sleepStages(sessionStart) - fun napEdits(): Flow> = dao.napEdits() + /** Sleep and naps as shown, ending in [w]: with the user's times, deleted ones left out ([SleepSource]). */ + fun sleepSessions(w: DayWindow): Flow> = sleep.sessions(w.from, w.to) + /** Each strap session ending in [w] with its edit, deleted ones too, for the screens that change them. */ + fun sleepResolved(w: DayWindow): Flow> = sleep.resolved(w.from, w.to) + /** [resolved] as [sleepSessions] shows them. */ + suspend fun sleepShown(resolved: List): List = sleep.shown(resolved) + /** [r]'s stages as shown. */ + fun sleepStages(r: SleepEdits.Resolved): Flow> = sleep.stages(r) + /** The strap's own stages for its session [strap], unclipped. */ + fun strapStages(strap: SleepSession): Flow> = sleep.strapStages(strap) + fun sleepEdits(): Flow> = dao.sleepEdits() + + /** Keeps [start] to [end] as the strap's session [strap]'s times; its own times again drop the edit. */ + suspend fun setSleepTimes(strap: SleepSession, start: Long, end: Long) { + val current = sleep.editOf(strap) + val e = SleepEdits.edit(strap, start, end, current, System.currentTimeMillis() / 1000) + if (e == null) dao.deleteSleepEdit(current?.strapStart ?: strap.start) else dao.upsertSleepEdit(e) + } + + /** Deletes the strap's session [strap], keeping any times the user gave it, so [restoreSleep] brings back exactly that. */ + suspend fun deleteSleep(strap: SleepSession) { + val current = sleep.editOf(strap) + dao.upsertSleepEdit(SleepEdits.delete(strap, current, System.currentTimeMillis() / 1000)) + } - /** Keeps [start] to [end] as the strap's nap [strap]'s times; its own times again drop the edit. */ - suspend fun setNapTimes(strap: SleepSession, start: Long, end: Long) { - val e = NapTimes.edit(strap, start, end) - if (e == null) dao.deleteNapEdit(strap.start) else dao.upsertNapEdit(e) + /** Brings back the strap's deleted session [strap], with the times it had: Undo and Restore. */ + suspend fun restoreSleep(strap: SleepSession) { + val current = sleep.editOf(strap) ?: return + val restored = SleepEdits.restore(current, System.currentTimeMillis() / 1000) + if (restored == null) dao.deleteSleepEdit(current.strapStart) else dao.upsertSleepEdit(restored) } + + /** Sleep and naps deleted in the last [SleepEdits.KEEP_DAYS], newest first, for You, Your data, Deleted sleep. */ + fun deletedSleep(): Flow> = sleep.deleted { System.currentTimeMillis() / 1000 } fun spo2(w: DayWindow): Flow> = dao.spo2(w.from, w.to) /** * The day's stress readings that count as daily stress: those outside the exclusion around workouts @@ -211,6 +320,32 @@ class HealthRepository(private val dao: HealthDao) { val rows = dao.dailyEnergyOnce(DayWindow.of(today.minusDays(days), zone).from, DayWindow.of(today.minusDays(1), zone).from) return org.openstrap.app.data.settings.CalorieGoal.suggested(rows.filter { !it.partial }.map { it.activeKcal }) } + + companion object { + /** The days [daysWithData] watches as they change: well past how far back a strap's history sync reaches. */ + const val RECENT_DATA_DAYS = 14 + + /** Recoveries kept: today, yesterday and a few days browsed. */ + const val RECOVERY_CACHE_SIZE = 8 + + private const val MINUTE_S = 60L + + /** + * Every table [RecoveryEngine.compute] reads, directly or through [SleepSource]. Not the two it writes + * (`daily_energy` and `night_metrics`), or each compute would throw away its own result. + */ + val RECOVERY_TABLES = arrayOf( + "activity_minute", "heart_rate", "stress", "hrv", "skin_temperature", "breath_rate", "resting_hr", "pai", + "sleep_spo2", "osa_result", "strap_readiness", "wear_event", "daily_max_hr", "workout", "workout_hr", + "app_workout", "app_workout_hr", "sleep_session", "sleep_edit", "sleep_stage", + ) + + // Built once, at app start, so the spread's copy of the table names costs nothing. + /** Emits once at first and again whenever one of [RECOVERY_TABLES] changes, for [start]. */ + @Suppress("SpreadOperator") + fun recoveryInputs(database: OpenStrapDatabase): Flow = + database.invalidationTracker.createFlow(*RECOVERY_TABLES).map { } + } } /** Sampled metrics that have a per-day series. */ diff --git a/app/src/main/kotlin/org/openstrap/app/data/Healthspan.kt b/app/src/main/kotlin/org/openstrap/app/data/Healthspan.kt index 4f940155..013f0f3b 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/Healthspan.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/Healthspan.kt @@ -42,7 +42,11 @@ object Healthspan { const val STEP_DAY_WORN_MINUTES = 10 * 60 /** VO2max needs this many workout estimates in [VO2_WINDOW_DAYS]. */ const val MIN_VO2_WORKOUTS = 3 - /** The strap's sport types for strength work ([Workouts.sportName]): 52 Strength training, 50 Core training. */ + /** + * The strap's sport types for strength work ([Workouts.sportName]): 52 Strength training, 50 Core training. A + * deliberate subset of [SportTypes.STRENGTH], as docs/algorithms/healthspan.md documents, so Habit Age counts + * only dedicated strength sessions. + */ val STRENGTH_SPORTS = setOf(50, 52) // ------------------------------------------------------------------------------------------------ diff --git a/app/src/main/kotlin/org/openstrap/app/data/HealthspanEngine.kt b/app/src/main/kotlin/org/openstrap/app/data/HealthspanEngine.kt index b63e2b1f..12e28254 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/HealthspanEngine.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/HealthspanEngine.kt @@ -14,10 +14,15 @@ import java.time.temporal.TemporalAdjusters * thread. The last result is kept until a sync adds data to its window. */ class HealthspanEngine(private val dao: HealthDao) { + /** Sleep as shown, with the user's edits ([SleepEdits]). */ + private val sleepSource = SleepSource(dao) private data class Key(val asOf: Long, val profile: UserProfile, val fingerprint: Long) private var cached: Pair? = null - /** The Sunday on or before [date]: the day the estimate it shows was made. */ + /** + * The Sunday on or before [date]: the day the estimate it shows was made. Sunday whatever the locale, not + * [Weeks], because the weekly schedule follows WHOOP's. + */ fun sundayOf(date: LocalDate): LocalDate = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)) suspend fun compute(date: LocalDate, profile: UserProfile, zone: ZoneId = ZoneId.systemDefault()): Healthspan.Outcome { @@ -50,6 +55,32 @@ class HealthspanEngine(private val dao: HealthDao) { return DayWindow.of(d.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)), zone).from } + /** + * Each of [nights]' heart rate by the minute, by its start: read a week of nights at a time and sliced, rather than + * two reads a night, while no read holds more than a week of minutes. + */ + private suspend fun nightHeartRates(nights: Collection): Map>> { + val out = HashMap>>() + val sorted = nights.sortedBy { it.start } + var i = 0 + while (i < sorted.size) { + val first = sorted[i].start + var j = i + while (j + 1 < sorted.size && sorted[j + 1].end - first <= HR_READ_SPAN_S) j++ + val group = sorted.subList(i, j + 1) + val to = group.maxOf { it.end } + val hr = dao.heartRateOnce(first, to) + val activity = dao.activityOnce(first, to) + for (s in group) { + val night = hr.within(s.start, s.end) { it.timestamp } + val series = Summaries.heartRateSeries(night, activity.within(s.start, s.end) { it.timestamp }) + out[s.start] = Summaries.bucketAverage(series, 60) + } + i = j + 1 + } + return out + } + /** Every input over [from, asOf), keyed as [Healthspan.Daily] wants. */ private suspend fun daily(sunday: LocalDate, from: Long, asOf: Long, zone: ZoneId): Healthspan.Daily { val to = asOf - 1 @@ -76,7 +107,8 @@ class HealthspanEngine(private val dao: HealthDao) { }.toMap() // Main sleeps: the longest non-nap session per morning, as Recovery picks them. - val sessions = dao.sleepSessionsOnce(from, to) + val resolved = sleepSource.resolvedOnce(from, to) + val sessions = sleepSource.shown(resolved) val mains = sessions.filter { !it.nap } .groupBy { RecoveryEngine.morningOf(it, zone) } .filterKeys { it in from until asOf } @@ -85,19 +117,21 @@ class HealthspanEngine(private val dao: HealthDao) { // Resting HR: the logged lowest 30-minute mean, else worked out from the night's heart rate the same way. val logged = dao.nightMetricsOnce(from, to).associateBy { it.day } - val restingHr = mains.mapNotNull { (morning, s) -> - if (s.end - s.start < Recovery.MIN_VALID_SLEEP_MINUTES * 60L) return@mapNotNull null + val valid = mains.filterValues { s -> s.end - s.start >= Recovery.MIN_VALID_SLEEP_MINUTES * 60L } + val nightHr = nightHeartRates(valid.filterKeys { logged[it]?.rhrLowest30 == null }.values) + val restingHr = valid.mapNotNull { (morning, s) -> val row = logged[morning] val rhr = if (row?.rhrLowest30 != null) row.rhrLowest30.takeIf { row.hrMinutes >= Recovery.MIN_HR_MINUTES } else { - val hr = Summaries.bucketAverage(Summaries.heartRateSeries(dao.heartRateOnce(s.start, s.end), dao.activityOnce(s.start, s.end)), 60) - .filter { it.second in Recovery.HR_MIN_BPM..Recovery.HR_MAX_BPM } + val hr = nightHr[s.start].orEmpty().filter { it.second in Recovery.HR_MIN_BPM..Recovery.HR_MAX_BPM } if (hr.size < Recovery.MIN_HR_MINUTES) null else Recovery.lowestAverage(hr, 30, Recovery.RHR_WINDOW_MIN_MINUTES)?.second } rhr?.let { morning to it } }.toMap() // Sleep regularity: one SRI per week, each over 8 noon-to-noon grid days ending at a Saturday noon. - val stages = sessions.flatMap { s -> dao.sleepStagesOnce(s.start).map { Triple(it.start, it.end, it.stage) } } + // Each session's stages from its resolved edit, which is at hand, so finding its edit again isn't a read per night. + val stages = resolved.filter { it.shown != null } + .flatMap { r -> sleepSource.stagesOnce(r).map { Triple(it.start, it.end, it.stage) } } val weeks = (Healthspan.WINDOW_DAYS - 1) / 7 val weeklySri = (0 until weeks).mapNotNull { k -> val end = sunday.minusDays(7L * k + 1) @@ -123,4 +157,9 @@ class HealthspanEngine(private val dao: HealthDao) { strength = strength, vo2max = vo2, firstDay = worn.firstOrNull()?.let { it * 60 } ?: mains.keys.minOrNull(), ) } + + private companion object { + /** The most a heart rate read for the nights spans: a week. */ + const val HR_READ_SPAN_S = 7 * 86_400L + } } diff --git a/app/src/main/kotlin/org/openstrap/app/data/HeartRate.kt b/app/src/main/kotlin/org/openstrap/app/data/HeartRate.kt new file mode 100644 index 00000000..cdcde11d --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/HeartRate.kt @@ -0,0 +1,21 @@ +package org.openstrap.app.data + +/** + * The heart rates the app believes (decision 9 in `Units`): 30 to 230 bpm, in every + * chart, summary, export, engine and workout. Outside it a reading is a sensor artefact, and one range means a reading + * counts everywhere or nowhere. + */ +object HeartRate { + const val MIN_BPM = 30 + const val MAX_BPM = 230 + + val VALID: IntRange = MIN_BPM..MAX_BPM + + /** + * [VALID] as SQL, to follow a column in a Room query: `"... AND bpm " + HeartRate.SQL_VALID`. A constant, so + * annotations can use it. + */ + const val SQL_VALID = "BETWEEN $MIN_BPM AND $MAX_BPM" + + fun isValid(bpm: Int): Boolean = bpm in VALID +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/HeartZones.kt b/app/src/main/kotlin/org/openstrap/app/data/HeartZones.kt new file mode 100644 index 00000000..9b0ae824 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/HeartZones.kt @@ -0,0 +1,110 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.math.ceil + +/** + * Your heart rate zones, one set for every sport (the Heart rate zones page): where each of the five starts, as a + * percent of max heart rate ([starts], ascending), so they follow max HR as it's learned or set. Zone 5 runs on up. + * + * Everything that reads a zone ([Workouts.zoneOf], [WorkoutRecorder.liveZone] and [WorkoutRecorder.zonePosition], so + * zone goals, live zones, zone time and the route's colours) reads [current], which the app keeps to what's stored + * ([org.openstrap.app.data.settings.SettingsStore.heartZones]). Stored as [encode]; [STANDARD] until moved. + */ +data class HeartZones(val starts: List) { + init { + require(starts.size == Workouts.ZONES) { "five zones" } + } + + /** Zone 0..4 for [bpm] at [maxHr]; null under zone 1's start. */ + fun zoneOf(bpm: Int, maxHr: Int): Int? { + val pct = bpm * 100f / maxHr + return starts.indexOfLast { pct >= it - EPS }.takeIf { it >= 0 } + } + + /** Where [bpm] sits along the five zones, 0..1: zone 1's start at the left, each zone a fifth, max at the right. */ + fun position(bpm: Int, maxHr: Int): Float { + val z = zoneOf(bpm, maxHr) ?: return 0f + val pct = bpm * 100f / maxHr + val to = starts.getOrNull(z + 1) ?: 100f + val within = if (to > starts[z]) ((pct - starts[z]) / (to - starts[z])).coerceIn(0f, 1f) else 1f + return ((z + within) / Workouts.ZONES).coerceIn(0f, 1f) + } + + /** The lowest bpm in [zone] at [maxHr]. */ + fun startBpm(zone: Int, maxHr: Int): Int = ceil(starts[zone] * maxHr / 100f - EPS).toInt() + + /** The highest bpm in [zone] at [maxHr]; null for zone 5, which runs on up. */ + fun endBpm(zone: Int, maxHr: Int): Int? = if (zone == LAST) null else startBpm(zone + 1, maxHr) - 1 + + /** + * [zone] starting at [bpm] instead, at [maxHr]: kept [MIN_WIDTH_BPM] clear of the zones either side, zone 1 no + * lower than [MIN_START_PCT] of max and zone 5 under max. The zone below ends where it starts. + */ + fun withStart(zone: Int, bpm: Int, maxHr: Int): HeartZones { + val low = if (zone == 0) ceil(MIN_START_PCT * maxHr / 100f).toInt() else startBpm(zone - 1, maxHr) + MIN_WIDTH_BPM + val high = if (zone == LAST) maxHr - 1 else startBpm(zone + 1, maxHr) - MIN_WIDTH_BPM + if (low > high) return this + val b = bpm.coerceIn(low, high) + return HeartZones(starts.toMutableList().also { it[zone] = b * 100f / maxHr }) + } + + /** [zone] ending at [bpm] instead: the next zone starts one higher. Zone 5 has no end. */ + fun withEnd(zone: Int, bpm: Int, maxHr: Int): HeartZones = if (zone == LAST) this else withStart(zone + 1, bpm + 1, maxHr) + + val standard: Boolean get() = this == STANDARD + + /** "50.0,60.0,70.0,80.0,90.0". */ + fun encode(): String = starts.joinToString(",") + + companion object { + /** 50, 60, 70, 80 and 90 % of max, as the app has always used. */ + val STANDARD = HeartZones(listOf(50f, 60f, 70f, 80f, 90f)) + + /** A zone is at least this many bpm wide. */ + const val MIN_WIDTH_BPM = 2 + + /** Zone 1 starts no lower than this percent of max. */ + const val MIN_START_PCT = 30f + + private const val LAST = Workouts.ZONES - 1 + + /** Float slack, so a start set from a whole bpm reads back as that bpm. */ + private const val EPS = 1e-3f + + /** [text] as written by [encode]: five rising percents of max under 100; null for anything else. */ + fun parse(text: String?): HeartZones? { + val v = text?.split(',')?.map { it.trim().toFloatOrNull() ?: return null } ?: return null + if (v.size != Workouts.ZONES || v.first() < MIN_START_PCT || v.last() >= 100f) return null + if (v.zipWithNext().any { (a, b) -> b <= a }) return null + return HeartZones(v) + } + + private val live = MutableStateFlow(STANDARD) + + /** The zones in use now, everywhere. */ + val current: StateFlow = live + + /** Makes [zones] the ones in use ([current]). */ + fun use(zones: HeartZones) { + live.value = zones + } + } +} + +/** Max heart rate as the Heart rate zones page shows it: the [bpm], where it comes from, and the day it was [learnedOn]. */ +data class MaxHeartRate(val bpm: Int, val source: Source, val learnedOn: java.time.LocalDate? = null) { + enum class Source { + /** You set it yourself ([org.openstrap.app.data.settings.UserProfile.maxHeartRate]). */ + Set, + /** Your workouts went higher than the estimate, on two days that agree ([Recovery.observedPeak]). */ + Learned, + /** Tanaka's estimate from your age. */ + Age, + /** The strap's own value, from the Zepp profile. */ + Strap, + /** Nothing to go on: 190. */ + Guess, + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/History.kt b/app/src/main/kotlin/org/openstrap/app/data/History.kt index c827ef1d..57106db5 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/History.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/History.kt @@ -2,7 +2,6 @@ package org.openstrap.app.data import java.time.DayOfWeek import java.time.LocalDate -import java.time.temporal.TemporalAdjusters import kotlin.math.sqrt /** Pure helpers for week, month and year views of a daily series; free of Android types so they're unit-testable. */ @@ -38,14 +37,15 @@ object History { } /** - * Averages daily stats into weeks starting on Monday, for the year view; each week keeps its lowest and highest - * day. The week's date is its Monday. + * Averages daily stats into weeks starting on [firstDay] ([Weeks]), for the year view. Each week keeps its lowest + * and highest day. The week's date is its first day. */ - fun weekly(stats: List): List = - stats.groupBy { it.date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) } + fun weekly(stats: List, firstDay: DayOfWeek = Weeks.firstDay()): List = + stats.groupBy { Weeks.start(it.date, firstDay) } .toSortedMap() - .map { (monday, days) -> - DayStat(monday, days.map { it.value }.average().toFloat(), days.minOf { it.value }, days.maxOf { it.value }, days.sumOf { it.count }) + .map { (first, days) -> + val mean = days.map { it.value }.average().toFloat() + DayStat(first, mean, days.minOf { it.value }, days.maxOf { it.value }, days.sumOf { it.count }) } /** Average, lowest and highest of the daily values in [from]..[to]; null when there are none. */ diff --git a/app/src/main/kotlin/org/openstrap/app/data/LiveSteps.kt b/app/src/main/kotlin/org/openstrap/app/data/LiveSteps.kt index fbb5f905..b19eb170 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/LiveSteps.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/LiveSteps.kt @@ -1,6 +1,7 @@ package org.openstrap.app.data import org.openstrap.protocol.services.StepTotals +import java.time.LocalDate /** * Today's step count from synced minutes plus the strap's live counter. @@ -9,19 +10,36 @@ import org.openstrap.protocol.services.StepTotals * the live total carries a large constant offset (11,368 steps with 1,836 m), while its increments stay right. * Then the counter is anchored to the synced history: each time the history total changes, the live reading * at that moment is remembered, and only the steps counted since are added on top. + * + * The anchor is state, so the app keeps one instance, built once at start, that Today and the + * widgets share: a fresh one would show only the history total until the next sync (audit A5). */ class LiveSteps { + private var day: LocalDate? = null private var anchoredHistory = -1 private var liveAtAnchor = 0 - fun today(history: Int, live: StepTotals?): Int { - if (live == null) return history - if (live.isPlausible) return maxOf(history, live.steps) - // Re-anchor on new synced data, and when the counter goes backwards (midnight reset or strap restart). - if (history != anchoredHistory || live.steps < liveAtAnchor) { - anchoredHistory = history - liveAtAnchor = live.steps + /** [date]'s steps: a new day starts a new anchor, so yesterday's never carries over. */ + @Synchronized + fun today(date: LocalDate, history: Int, live: StepTotals?): Int { + if (date != day) { + day = date + anchoredHistory = -1 + liveAtAnchor = 0 + } + return count(history, live) + } + + private fun count(history: Int, live: StepTotals?): Int = when { + live == null -> history + live.isPlausible -> maxOf(history, live.steps) + else -> { + // Re-anchor on new synced data, and when the counter goes backwards (midnight reset or strap restart). + if (history != anchoredHistory || live.steps < liveAtAnchor) { + anchoredHistory = history + liveAtAnchor = live.steps + } + history + (live.steps - liveAtAnchor) } - return history + (live.steps - liveAtAnchor) } } diff --git a/app/src/main/kotlin/org/openstrap/app/data/MaxHeartRates.kt b/app/src/main/kotlin/org/openstrap/app/data/MaxHeartRates.kt new file mode 100644 index 00000000..f23b58ff --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/MaxHeartRates.kt @@ -0,0 +1,44 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.withContext +import org.openstrap.app.data.db.HealthDao +import org.openstrap.app.data.settings.UserProfile + +/** + * The one max heart rate the zones go by, for the workouts list and Ready, a recording workout and the Heart rate + * zones page alike: [RecoveryEngine.maxHeartRateExplained]'s rule, so a tested value wins, else your age (or the + * strap's value, or 190) raised by peaks over the 90-day load window. [current] is worked out again on each profile + * change while anything reads it, so the 90-day read runs once for all of them. [now] reads it afresh where the old + * readers did (a workout starting, the zones page opening) and hands what it found on to [current]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MaxHeartRates( + private val dao: HealthDao, + private val profile: Flow, + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) { + // No replay: a fresh read from before a profile change mustn't come back over the newer one. + private val fresh = MutableSharedFlow(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + /** Max heart rate as of now, null until it's first worked out. */ + val current: StateFlow = merge(profile.mapLatest { work(it) }, fresh) + .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + + /** Max heart rate worked out now from the stored profile, for a suspend reader that needs it once. */ + suspend fun now(): MaxHeartRate = work(profile.first()).also { fresh.tryEmit(it) } + + private suspend fun work(p: UserProfile): MaxHeartRate = withContext(Dispatchers.Default) { RecoveryEngine(dao).maxHeartRateExplained(p) } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/MomentLead.kt b/app/src/main/kotlin/org/openstrap/app/data/MomentLead.kt index 0cccb674..c3df595f 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/MomentLead.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/MomentLead.kt @@ -1,7 +1,5 @@ package org.openstrap.app.data -import org.openstrap.app.widget.WidgetKind - /** What the Moment widget leads with (widgets spec 3.1): its number and word, or what stands in for them. */ enum class MomentLead { /** Recovery's score and status word, the waking lead. */ diff --git a/app/src/main/kotlin/org/openstrap/app/data/MomentResolver.kt b/app/src/main/kotlin/org/openstrap/app/data/MomentResolver.kt index 59bd2a9e..bb42fa70 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/MomentResolver.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/MomentResolver.kt @@ -183,9 +183,7 @@ object MomentResolver { recent.size >= USUAL_WORKOUT_MIN -> recent else -> return null } - val minutes = pick.map { it.toLocalTime().toSecondOfDay() / 60 }.sorted() - val median = (minutes[(minutes.size - 1) / 2] + minutes[minutes.size / 2]) / 2 - return LocalTime.ofSecondOfDay(median * 60L) + return Stats.median(pick.map { it.toLocalTime().toSecondOfDay() / 60 })?.let { LocalTime.ofSecondOfDay(it.toInt() * 60L) } } /** Within [WINDOW_MINUTES] either side of [usual], the end minute left out, across midnight too. */ @@ -200,7 +198,7 @@ object MomentResolver { /** * What [MomentResolver] reads, kept together for the Moment widget: it renders from these and schedules its next - * update from the same ones ([org.openstrap.app.device.WidgetUpdater.scheduleMoment]). + * update from the same ones ([org.openstrap.app.widget.WidgetUpdater.scheduleMoment]). */ data class MomentInputs( val day: LocalDate, diff --git a/app/src/main/kotlin/org/openstrap/app/data/NapTimes.kt b/app/src/main/kotlin/org/openstrap/app/data/NapTimes.kt deleted file mode 100644 index afd1adba..00000000 --- a/app/src/main/kotlin/org/openstrap/app/data/NapTimes.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.openstrap.app.data - -import java.time.Instant -import java.time.ZoneId -import kotlin.math.abs -import org.openstrap.app.data.db.NapEdit -import org.openstrap.app.data.db.SleepSession -import org.openstrap.app.data.db.SleepStage - -/** - * The user's own start and end for a nap the strap detected ([NapEdit]). The strap's row and stages stay as synced, - * keyed by its start; everything that counts a nap reads it through [apply], and its stages through [clip]. - */ -object NapTimes { - /** A nap shorter than this isn't kept: the strap itself doesn't report one shorter. */ - const val MIN_SECONDS = 5 * 60L - /** Longer than this it's no nap. */ - const val MAX_SECONDS = 4 * 3600L - /** How far either end may move from what the strap saw. */ - const val MAX_SHIFT_SECONDS = 3 * 3600L - - /** [s] with the user's times when it's a nap they changed; anything else as it is. */ - fun apply(s: SleepSession, edits: Map): SleepSession { - if (!s.nap) return s - val e = edits[s.start] ?: return s - return s.copy(start = e.start, end = e.end) - } - - fun applyAll(sessions: List, edits: List): List { - if (edits.isEmpty()) return sessions - val byStart = edits.associateBy { it.strapStart } - return sessions.map { apply(it, byStart) } - } - - /** The strap's [stages] for a nap, trimmed to [start] and [end]; stages outside go. */ - fun clip(stages: List, start: Long, end: Long): List = stages.mapNotNull { - val from = maxOf(it.start, start) - val to = minOf(it.end, end) - if (to <= from) null else it.copy(start = from, end = to) - } - - /** Why [start] to [end] can't be saved for the strap's nap [strap], or null when it can. */ - fun problem(strap: SleepSession, start: Long, end: Long, now: Long): String? = when { - end <= start -> "Waking up has to come after falling asleep." - end - start < MIN_SECONDS -> "A nap needs at least 5 minutes." - end - start > MAX_SECONDS -> "A nap can be 4 hours at most." - end > now -> "That's later than now." - start < strap.start - MAX_SHIFT_SECONDS || end > strap.end + MAX_SHIFT_SECONDS -> "Keep it within 3 hours of what the strap saw." - else -> null - } - - /** - * The moment at [minuteOfDay] (after local midnight) closest to [near]: a clock picked for a nap's end is read - * on the nap's own day, or the next or previous one when the nap crosses midnight. - */ - fun atNear(minuteOfDay: Int, near: Long, zone: ZoneId): Long { - val day = Instant.ofEpochSecond(near).atZone(zone).toLocalDate() - return (-1L..1L).map { day.plusDays(it).atStartOfDay(zone).plusMinutes(minuteOfDay.toLong()).toEpochSecond() } - .minBy { abs(it - near) } - } - - /** The row to save for [strap], or null when the times are the strap's own again (the edit goes then). */ - fun edit(strap: SleepSession, start: Long, end: Long): NapEdit? = - if (start == strap.start && end == strap.end) null else NapEdit(strap.start, start, end) -} diff --git a/app/src/main/kotlin/org/openstrap/app/data/Phases.kt b/app/src/main/kotlin/org/openstrap/app/data/Phases.kt new file mode 100644 index 00000000..cf99d103 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/Phases.kt @@ -0,0 +1,198 @@ +package org.openstrap.app.data + +import org.openstrap.app.data.db.AppWorkoutRound + +/** + * A structured workout's rounds (workouts spec 2.5, 4), stored as [code] on the workout + * ([org.openstrap.app.data.db.AppWorkout.structure]): + * + * - [Timed]: "t:20/10x8", [work] seconds on, [rest] off, [rounds] times (Tabata, 40 / 20). + * - [EveryMinute]: "e:60x12", a round starts every [period] seconds; you tap Done and rest for what's left of it. + * - [Reps]: "r:400m/90x6" or "r:120s/60x8", run reps of a distance or a time with rest by time. + * + * A trailing ":w" warms up first, open until you start the rounds. + */ +sealed interface Structure { + val rounds: Int + val warmUp: Boolean + val code: String + + /** The same rounds, warming up first ([on]) or not. */ + fun warm(on: Boolean): Structure + + data class Timed(val work: Long, val rest: Long, override val rounds: Int, override val warmUp: Boolean = false) : Structure { + override val code get() = "t:$work/${rest}x$rounds" + if (warmUp) ":w" else "" + override fun warm(on: Boolean) = copy(warmUp = on) + } + + data class EveryMinute(val period: Long, override val rounds: Int, override val warmUp: Boolean = false) : Structure { + override val code get() = "e:${period}x$rounds" + if (warmUp) ":w" else "" + override fun warm(on: Boolean) = copy(warmUp = on) + } + + /** A rep is [meters] of running, or [seconds] when there's no distance to count (the treadmill side). */ + data class Reps(val meters: Int?, val seconds: Long?, val rest: Long, override val rounds: Int, override val warmUp: Boolean = false) : Structure { + override val code get() = "r:" + (meters?.let { "${it}m" } ?: "${seconds}s") + "/${rest}x$rounds" + if (warmUp) ":w" else "" + override fun warm(on: Boolean) = copy(warmUp = on) + } + + /** + * A game's periods (workouts spec 2.7): [rounds] periods of [length] seconds with a [rest] break between, "p:2x2700/900". + * Half time can come early (a tap), and the strap buzzes at the end of each period and break. + */ + data class Periods(override val rounds: Int, val length: Long, val rest: Long) : Structure { + override val warmUp get() = false + override val code get() = "p:${rounds}x$length/$rest" + override fun warm(on: Boolean) = this + } + + companion object { + private val PERIODS = Regex("""p:(\d+)x(\d+)/(\d+)""") + private val TIMED = Regex("""t:(\d+)/(\d+)x(\d+)""") + private val EVERY = Regex("""e:(\d+)x(\d+)""") + private val REPS = Regex("""r:(\d+)([ms])/(\d+)x(\d+)""") + + /** [code] as written by [Structure.code]; null for none, or anything unreadable. */ + fun parse(code: String?): Structure? { + code ?: return null + PERIODS.matchEntire(code)?.destructured?.let { (n, l, r) -> + return Periods(n.toInt(), l.toLong(), r.toLong()).takeIf { it.rounds > 0 && it.length > 0 } + } + val warm = code.endsWith(":w") + val body = code.removeSuffix(":w") + TIMED.matchEntire(body)?.destructured?.let { (w, r, n) -> + return Timed(w.toLong(), r.toLong(), n.toInt(), warm).takeIf { it.work > 0 && it.rounds > 0 } + } + EVERY.matchEntire(body)?.destructured?.let { (p, n) -> return EveryMinute(p.toLong(), n.toInt(), warm).takeIf { it.period > 0 && it.rounds > 0 } } + REPS.matchEntire(body)?.destructured?.let { (v, unit, r, n) -> + val reps = if (unit == "m") Reps(v.toInt(), null, r.toLong(), n.toInt(), warm) else Reps(null, v.toLong(), r.toLong(), n.toInt(), warm) + return reps.takeIf { it.rounds > 0 && (it.meters ?: it.seconds?.toInt() ?: 0) > 0 } + } + return null + } + + /** Whether [s] fits a workout of [sport]: timed rounds for HIIT, reps for a run. */ + fun fits(s: Structure, sport: Int): Boolean = when (s) { + is Reps -> sport in SportTypes.RUNS + is Periods -> Workouts.family(sport) == Workouts.Family.Games + else -> Workouts.family(sport) == Workouts.Family.Intervals + } + + /** A game's Halves and Quarters on Ready, before you change their length and break. */ + val HALVES = Periods(2, 40 * 60, 10 * 60) + val QUARTERS = Periods(4, 12 * 60, 2 * 60) + + /** HIIT's presets (workouts spec 2.5): Tabata, 40 / 20 and Every minute. Free has none. */ + val TABATA = Timed(20, 10, 8) + val FORTY_TWENTY = Timed(40, 20, 10) + val EVERY_MINUTE = EveryMinute(60, 12) + } +} + +/** Where a structured workout is: warming up, working round [round] (from 0), resting after it, or done. */ +sealed interface Phase { + data object WarmUp : Phase + data class Work(val round: Int, val start: Long, val endsAt: Long?) : Phase + data class Rest(val round: Int, val start: Long, val endsAt: Long) : Phase + data object Done : Phase +} + +/** A buzz the phases ask for at a switch ([WorkoutBuzz]). */ +enum class Switch { WorkStarts, RestStarts, Finished } + +/** + * The rounds as a state machine (workouts spec 6), pure so the same rules run with the screen off and are tested. + * Its state is the rounds recorded so far ([AppWorkoutRound], one row a round: work start, work end, rest end), so a + * workout picked up after the process died carries on where it was. Times are epoch seconds; [active] turns one into + * the workout's active seconds, so a pause doesn't eat into a work or rest. + */ +object Phases { + /** The phase [rounds] are in under [s], [extra] seconds added to the rest under way (+10 s). */ + fun phase(s: Structure, rounds: List, active: (Long) -> Long, extra: Long = 0): Phase { + val last = rounds.lastOrNull() ?: return if (s.warmUp) Phase.WarmUp else Phase.Work(0, 0, workEnd(s, 0, null)) + return when { + last.workEnd == null -> Phase.Work(last.index, last.workStart, workEnd(s, last.workStart, active)) + last.restEnd == null -> Phase.Rest(last.index, last.workEnd, restEnd(s, last, active) + extra) + last.index + 1 >= s.rounds -> Phase.Done + else -> Phase.Work(last.index + 1, last.restEnd, null) + } + } + + /** When round work begun at [start] ends by the clock, in active seconds; null when it ends on a tap or a distance. */ + private fun workEnd(s: Structure, start: Long, active: ((Long) -> Long)?): Long? = when (s) { + is Structure.Timed -> (active?.invoke(start) ?: 0) + s.work + is Structure.EveryMinute -> null + is Structure.Reps -> s.seconds?.let { (active?.invoke(start) ?: 0) + it } + is Structure.Periods -> (active?.invoke(start) ?: 0) + s.length + } + + /** When the rest after [r] ends, in active seconds. Every minute rests for what's left of the round's minute. */ + private fun restEnd(s: Structure, r: AppWorkoutRound, active: (Long) -> Long): Long = when (s) { + is Structure.Timed -> active(r.workEnd!!) + s.rest + is Structure.EveryMinute -> active(r.workStart) + s.period + is Structure.Reps -> active(r.workEnd!!) + s.rest + is Structure.Periods -> active(r.workEnd!!) + s.rest + } + + /** What changed at a tick or a tap: the rounds to store (whole list) and the buzz for the switch, if one happened. */ + data class Step(val rounds: List, val switch: Switch?) + + /** + * The clock moved to [now]: a work or rest that is over ends, and the next begins. [meters] is the distance since a + * time, for run reps (null without one); [peak] the highest heart rate between two times, kept with each round. + * The first round starts at the first tick unless the workout warms up first. + */ + fun tick( + s: Structure, rounds: List, workoutStart: Long, now: Long, active: (Long) -> Long, + meters: (from: Long) -> Float? = { null }, peak: (Long, Long) -> Int? = { _, _ -> null }, extra: Long = 0, + ): Step { + if (rounds.isEmpty()) return if (s.warmUp) Step(rounds, null) else Step(listOf(AppWorkoutRound(workoutStart, 0, now)), null) + return when (val p = phase(s, rounds, active, extra)) { + is Phase.Work -> { + if (p.start == 0L || rounds.last().workEnd != null) return Step(rounds + AppWorkoutRound(workoutStart, p.round, now), Switch.WorkStarts) + val byClock = p.endsAt != null && active(now) >= p.endsAt + val byDistance = s is Structure.Reps && s.meters != null && (meters(p.start) ?: 0f) >= s.meters + // Every minute: the minute ran out before Done, so the next round starts with no rest. + val minuteUp = s is Structure.EveryMinute && active(now) >= active(p.start) + s.period + when { + minuteUp -> endWork(s, rounds, now, peak, restToo = true) + byClock || byDistance -> endWork(s, rounds, now, peak) + else -> Step(rounds, null) + } + } + is Phase.Rest -> if (active(now) >= p.endsAt) endRest(s, rounds, now) else Step(rounds, null) + Phase.WarmUp, Phase.Done -> Step(rounds, null) + } + } + + /** Skip, Done, or Start the rounds: whatever is under way ends now and the next phase begins. */ + fun skip(s: Structure, rounds: List, workoutStart: Long, now: Long, active: (Long) -> Long, peak: (Long, Long) -> Int? = { _, _ -> null }): Step = + when (val p = phase(s, rounds, active)) { + Phase.WarmUp -> Step(listOf(AppWorkoutRound(workoutStart, 0, now)), Switch.WorkStarts) + is Phase.Work -> if (rounds.lastOrNull()?.workEnd == null && rounds.isNotEmpty()) endWork(s, rounds, now, peak) + else Step(rounds + AppWorkoutRound(workoutStart, p.round, now), Switch.WorkStarts) + is Phase.Rest -> endRest(s, rounds, now) + Phase.Done -> Step(rounds, null) + } + + private fun endWork(s: Structure, rounds: List, now: Long, peak: (Long, Long) -> Int?, restToo: Boolean = false): Step { + val r = rounds.last() + val ended = r.copy(workEnd = now, restEnd = if (restToo) now else null, peakBpm = peak(r.workStart, now)) + val done = rounds.dropLast(1) + ended + // The last round has no rest after it: its work ending is the end. + if (r.index + 1 >= s.rounds) return Step(done.closeLast(now), Switch.Finished) + return if (restToo) Step(done + AppWorkoutRound(r.workoutStart, r.index + 1, now), Switch.WorkStarts) else Step(done, Switch.RestStarts) + } + + private fun endRest(s: Structure, rounds: List, now: Long): Step { + val r = rounds.last() + val closed = rounds.dropLast(1) + r.copy(restEnd = now) + if (r.index + 1 >= s.rounds) return Step(closed, Switch.Finished) + return Step(closed + AppWorkoutRound(r.workoutStart, r.index + 1, now), Switch.WorkStarts) + } + + private fun List.closeLast(now: Long) = dropLast(1) + last().let { it.copy(restEnd = it.restEnd ?: now) } + + /** Rounds whose work has ended, for "rounds done". */ + fun roundsDone(rounds: List): Int = rounds.count { it.workEnd != null } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/Recovery.kt b/app/src/main/kotlin/org/openstrap/app/data/Recovery.kt index 42f3dd30..b74a4344 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/Recovery.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/Recovery.kt @@ -143,8 +143,8 @@ object Recovery { fun stressThreshold(calm: List>, dayOf: (Long) -> Long): StressThreshold { val days = calm.map { dayOf(it.first) }.distinct().size if (days < MIN_STRESS_DAYS || calm.size < MIN_STRESS_READINGS) return StressThreshold(STRESS_FROM, null, days) - val sorted = calm.map { it.second }.sorted() - val median = (sorted[(sorted.size - 1) / 2] + sorted[sorted.size / 2]) / 2f + // Never null here: there are at least MIN_STRESS_READINGS. + val median = Stats.median(calm.map { it.second }) ?: 0f val value = Math.round(median + STRESS_MARGIN).coerceIn(STRESS_FROM_MIN, STRESS_FROM_MAX) return StressThreshold(value, Math.round(median), days) } @@ -224,6 +224,9 @@ object Recovery { /** Valid nights of history a baseline looks back over (published windows run 28-60). */ const val BASELINE_NIGHTS = 60 + /** The max heart rates a tested value may be: anything outside is taken as a typo and left out. */ + val TESTED_MAX_RANGE = 120..230 + enum class Phase { Withheld, Learning, Scored, Calibrated } fun phase(validNights: Int): Phase = when { @@ -262,7 +265,34 @@ object Recovery { RecoveryIndex("Recovery index", 0.03f), } - data class Contributor(val factor: Factor, val score: Int, val detail: String) + data class Contributor(val factor: Factor, val score: Int, val detail: Detail) + + /** The numbers behind a contributor's line. Presentation writes the line (`RecoveryCopy.detail`). */ + sealed interface Detail { + /** Tonight's HRV and how far it sits from your usual, in percent. */ + data class Hrv(val ms: Int, val percentVsUsual: Int) : Detail + + /** Tonight's resting heart rate and its change against your usual. */ + data class RestingHr(val bpm: Int, val vsUsual: Float) : Detail + + data class Asleep(val minutes: Int) : Detail + + /** Sleep debt over the last 14 nights, 0 when there is none worth naming. */ + data class SleepDebt(val minutes: Int) : Detail + + /** How long before waking the night's lowest heart rate came. */ + data class LowestHeartRate(val minutesBeforeWaking: Int) : Detail + + /** Skin temperature against your usual, in °C. */ + data class Temperature(val vsUsual: Float) : Detail + + /** Breaths a minute against your usual. */ + data class Breathing(val vsUsual: Float) : Detail + + data class BloodOxygen(val percent: Float, val vsUsual: Float) : Detail + + data class Load(val yesterday: Int, val usual: Int) : Detail + } /** Where the 7-night HRV sits against the normal range, centre ± [HRV_RANGE_SCALE]·scale (the trials' band). */ enum class HrvRange { Below, Within, Above } @@ -399,7 +429,7 @@ object Recovery { val zNight = hrvBase.z(ln) val z = if (hrv7 != null) HRV_NIGHT_WEIGHT * zNight + HRV_WEEK_WEIGHT * hrvBase.z(hrv7) else zNight val pct = ((exp(ln - hrvBase.centre.toDouble()) - 1) * 100).toInt() - out += Contributor(Factor.HrvBalance, scoreFromZ(deadZone(z)), "${tonight.hrv!!.toInt()} ms, ${signed(pct)}% vs usual") + out += Contributor(Factor.HrvBalance, scoreFromZ(deadZone(z)), Detail.Hrv(tonight.hrv!!.toInt(), pct)) } // Resting heart rate: the night's lowest 30-minute average against baseline; lower is better. @@ -407,7 +437,7 @@ object Recovery { val rhr = tonight.validRestingHr if (scored && rhrBase != null && rhr != null) { val delta = rhr - rhrBase.centre - out += Contributor(Factor.RestingHr, scoreFromZ(deadZone(-rhrBase.z(rhr))), "${rhr.toInt()} bpm, ${signed(delta, 0)} vs usual") + out += Contributor(Factor.RestingHr, scoreFromZ(deadZone(-rhrBase.z(rhr))), Detail.RestingHr(rhr.toInt(), delta)) } // The strap's own values: a fallback for display when ours failed the validity rule, and a quality check. @@ -424,13 +454,12 @@ object Recovery { } // Last night's sleep: the strap's own score when there is one, else ours. - out += Contributor(Factor.Sleep, tonight.strapScore ?: sleepScore(tonight, sleepNeed), formatMinutes(tonight.asleepMinutes) + " asleep") + out += Contributor(Factor.Sleep, tonight.strapScore ?: sleepScore(tonight, sleepNeed), Detail.Asleep(tonight.asleepMinutes)) // Sleep balance: debt built over the last 14 nights, against need (naps count). sleepDebtMinutes(earlier + tonight, tonight.day, sleepNeed)?.let { debt -> val score = (100 - SLEEP_DEBT_POINTS_PER_HOUR * debt / 60f).toInt().coerceIn(0, 100) - val detail = if (debt < 3f) "No sleep debt" else "${formatMinutes(debt.toInt())} of sleep debt" - out += Contributor(Factor.SleepBalance, score, detail) + out += Contributor(Factor.SleepBalance, score, Detail.SleepDebt(if (debt < SLEEP_DEBT_NAMED_FROM) 0 else debt.toInt())) } // Recovery index: the night's lowest heart rate should come in its first half (Oura). @@ -438,7 +467,7 @@ object Recovery { val p = (tonight.nadirTime - tonight.start).toFloat() / (tonight.end - tonight.start) val score = if (p <= 0.5f) 100 else (100 - 120 * (p - 0.5f)).toInt().coerceIn(40, 100) val after = ((tonight.end - tonight.nadirTime) / 60).toInt() - out += Contributor(Factor.RecoveryIndex, score, "Lowest heart rate ${formatMinutes(after)} before waking") + out += Contributor(Factor.RecoveryIndex, score, Detail.LowestHeartRate(after)) } // Body temperature: distance from the usual nightly skin temperature, either direction, through the dead zone. @@ -446,7 +475,7 @@ object Recovery { tonight.skinTemperature?.let { t -> if (tempBase != null) { val dev = t - tempBase.centre - out += Contributor(Factor.Temperature, scoreFromZ(-abs(deadZone(tempBase.z(t)))), "${signed(dev, 1)} °C vs usual") + out += Contributor(Factor.Temperature, scoreFromZ(-abs(deadZone(tempBase.z(t)))), Detail.Temperature(dev)) } } @@ -455,7 +484,7 @@ object Recovery { tonight.breathRate?.let { b -> if (breathBase != null) { val dev = b - breathBase.centre - out += Contributor(Factor.Breathing, scoreFromZ(-abs(deadZone(breathBase.z(b)))), "${signed(dev, 1)} /min vs usual") + out += Contributor(Factor.Breathing, scoreFromZ(-abs(deadZone(breathBase.z(b)))), Detail.Breathing(dev)) } } @@ -465,7 +494,7 @@ object Recovery { if (spo2Base != null) { val dev = o - spo2Base.centre val score = (100 - SPO2_POINTS_PER_PERCENT * (-dev - SPO2_DEAD_BAND).coerceAtLeast(0f)).toInt().coerceIn(0, 100) - out += Contributor(Factor.BloodOxygen, score, "${"%.1f".format(o)} %, ${signed(dev, 1)} vs usual") + out += Contributor(Factor.BloodOxygen, score, Detail.BloodOxygen(o, dev)) } } @@ -475,7 +504,7 @@ object Recovery { val yesterday = dailyLoad.last() val ratio = yesterday / max(chronic, MIN_CHRONIC_LOAD) val score = if (ratio <= 1.5f) 100 else (100 - 25 * (ratio - 1.5f)).toInt().coerceIn(30, 100) - out += Contributor(Factor.PreviousDayLoad, score, "Load ${yesterday.toInt()}, usual ${chronic.toInt()}") + out += Contributor(Factor.PreviousDayLoad, score, Detail.Load(yesterday.toInt(), chronic.toInt())) } val alert = body.state == BodySignals.State.Alert @@ -560,9 +589,12 @@ object Recovery { private const val DST_SLACK = 2 * 3600L /** Readiness points lost per hour of average nightly shortfall (1 h short ≈ 75, 2 h ≈ 50). */ const val SLEEP_DEBT_POINTS_PER_HOUR = 25 + + /** Minutes of sleep debt under this read as none: a few minutes short isn't debt worth naming. */ + private const val SLEEP_DEBT_NAMED_FROM = 3f const val RED_FLAG = 40 const val RED_FLAG_HEADROOM = 30 - /** Where OpenStrap's "Take it steady" (Fair) band starts; [org.openstrap.app.ui.dashboard.Narrative.ratingFor] uses it. */ + /** Where OpenStrap's "Take it steady" (Fair) band starts, which `Narrative.ratingFor` uses. */ const val FAIR_FROM = 55 /** One below the Fair band, so an Alert day always rates in the lowest band ("Take it easy"). */ const val ALERT_CAP = FAIR_FROM - 1 @@ -817,7 +849,7 @@ object Recovery { * lowering is not), by at most [MAX_HR_RAISE] bpm. */ fun maxHeartRate(age: Int?, strapMax: Int? = null, observedPeak: Int? = null, tested: Int? = null): Float { - if (tested != null && tested in 120..230) return tested.toFloat() + if (tested != null && tested in TESTED_MAX_RANGE) return tested.toFloat() val estimate = when { age != null && age in 10..100 -> 208f - 0.7f * age strapMax != null && strapMax in 140..225 -> strapMax.toFloat() @@ -859,12 +891,33 @@ object Recovery { * [ARTEFACT_JUMP_BPM] of the filtered minute maximum. A strap value on a day without one has nothing to * corroborate it and is dropped, so the strap's unfiltered figure can't carry a spike in. */ - fun dailyMaxima(minuteMax: Map, strapMax: List>): List { + fun dailyMaxima(minuteMax: Map, strapMax: List>): List = dailyMaximaByDay(minuteMax, strapMax).values.toList() + + /** [dailyMaxima] by day, for saying when max HR was learned ([observedPeakDay]). */ + fun dailyMaximaByDay(minuteMax: Map, strapMax: List>): Map { val strap = strapMax.filter { it.second in HR_MIN_BPM..HR_MAX_BPM }.groupBy({ it.first }, { it.second }) return minuteMax.mapNotNull { (day, m) -> m ?: return@mapNotNull null - maxOf(m, strap[day].orEmpty().filter { abs(it - m) <= ARTEFACT_JUMP_BPM }.maxOrNull() ?: m) - } + day to maxOf(m, strap[day].orEmpty().filter { abs(it - m) <= ARTEFACT_JUMP_BPM }.maxOrNull() ?: m) + }.toMap() + } + + /** The day [observedPeak] comes from: the latest of the days whose maximum is that peak. Null with no peak. */ + fun > observedPeakDay(maxima: Map): K? { + val peak = observedPeak(maxima.values.toList()) ?: return null + return maxima.filterValues { it == peak }.keys.maxOrNull() + } + + /** + * Where [maxHeartRate] comes from, for the same inputs: your own value, a peak that raised the estimate, your age, + * the strap's value, or the 190 used when there's nothing. + */ + fun maxHeartRateSource(age: Int?, strapMax: Int? = null, observedPeak: Int? = null, tested: Int? = null): MaxHeartRate.Source = when { + tested != null && tested in TESTED_MAX_RANGE -> MaxHeartRate.Source.Set + observedPeak != null && observedPeak > maxHeartRate(age, strapMax) -> MaxHeartRate.Source.Learned + age != null && age in 10..100 -> MaxHeartRate.Source.Age + strapMax != null && strapMax in 140..225 -> MaxHeartRate.Source.Strap + else -> MaxHeartRate.Source.Guess } /** @@ -1371,7 +1424,7 @@ object Recovery { return out } - private fun List.median(): Float? = if (isEmpty()) null else sorted().let { (it[(it.size - 1) / 2] + it[it.size / 2]) / 2 } + private fun List.median(): Float? = Stats.median(this) private const val EPOC_THRESHOLD = 5f /** Charge's EPOC-like debt, and a workout's EPOC at rest ([WorkoutEffect]), are multiplied by this each minute (half-life ≈ 20 min). */ @@ -1431,21 +1484,17 @@ object Recovery { return out } - /** Gates for a nightly sample: RMSSD outside 5-200 ms or heart rate outside 30-220 bpm is treated as artefact. */ + /** + * Gates for a nightly sample: RMSSD outside 5-200 ms or heart rate outside [HeartRate.VALID] is treated as + * artefact. + */ const val HRV_MIN_MS = 5 const val HRV_MAX_MS = 200 - const val HR_MIN_BPM = 30 - const val HR_MAX_BPM = 220 + const val HR_MIN_BPM = HeartRate.MIN_BPM + const val HR_MAX_BPM = HeartRate.MAX_BPM /** The [p]-th percentile (0-1) of [values] by linear interpolation; null when empty. */ - fun percentile(values: List, p: Float): Float? { - if (values.isEmpty()) return null - val sorted = values.sorted() - val pos = p.coerceIn(0f, 1f) * (sorted.size - 1) - val lo = pos.toInt() - val hi = min(lo + 1, sorted.size - 1) - return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo) - } + fun percentile(values: List, p: Float): Float? = Stats.quantile(values, p) /** Lowest rolling-[windowMinutes] average in [series], with the time at its centre. */ fun lowestAverage(series: List>, windowMinutes: Int, minCount: Int = windowMinutes / 2): Pair? { @@ -1464,16 +1513,4 @@ object Recovery { } return best } - - private fun signed(v: Int) = if (v > 0) "+$v" else if (v < 0) "−${-v}" else "±0" - /** "+0.4", "−3", or "±0" when it rounds to zero (never "-0"). */ - private fun signed(v: Float, decimals: Int): String { - val text = "%.${decimals}f".format(abs(v)) - return when { - text.trim('0', '.').isEmpty() -> "±$text" - v > 0 -> "+$text" - else -> "−$text" - } - } - fun formatMinutes(m: Int) = if (m >= 60) "${m / 60}h ${"%02d".format(m % 60)}m" else "${m}m" } diff --git a/app/src/main/kotlin/org/openstrap/app/data/RecoveryEngine.kt b/app/src/main/kotlin/org/openstrap/app/data/RecoveryEngine.kt index ca2b3215..7d8123ef 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/RecoveryEngine.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/RecoveryEngine.kt @@ -6,6 +6,7 @@ import org.openstrap.app.data.settings.UserProfile import java.time.Instant import java.time.LocalDate import java.time.ZoneId +import kotlin.math.roundToInt /** Everything the Charge screens show for one day. */ data class RecoveryResult( @@ -62,6 +63,8 @@ data class RecoveryResult( /** Loads what [Recovery] needs from the database and runs it. All work happens off the main thread by the caller. */ class RecoveryEngine(private val dao: HealthDao) { + /** Sleep as shown, with the user's edits ([SleepEdits]). */ + private val sleepSource = SleepSource(dao) /** * [RecoveryResult.usualBedtime] for [date] without the whole model: the same main sleeps over the same nights @@ -70,7 +73,7 @@ class RecoveryEngine(private val dao: HealthDao) { suspend fun usualBedtime(date: LocalDate, zone: ZoneId = ZoneId.systemDefault()): java.time.LocalTime? { val day = DayWindow.of(date, zone) val from = DayWindow.of(date.minusDays(Recovery.BASELINE_NIGHTS.toLong() + 1), zone).from - val mains = mainsByMorning(dao.sleepSessionsOnce(from, day.to), zone) + val mains = mainsByMorning(sleepSource.sessionsOnce(from, day.to), zone) return usualBedtime(mains.filterKeys { it <= day.from }.values, date, zone) } @@ -81,11 +84,30 @@ class RecoveryEngine(private val dao: HealthDao) { // Load history (CTL, Strain's "vs your usual", max HR) reaches further back than the nightly baselines. val loadFrom = DayWindow.of(date.minusDays(LOAD_DAYS.toLong()), zone).from - val activityAll = dao.activityOnce(loadFrom, to) - val hrAll = minuteHeartRate(loadFrom, to, activityAll) + val midnight = day.from + // Charge: start a week and a day early, so the starting guess has washed out through the first night and the + // week's wake values are there for a restart after a night with the strap off ([Recovery.charge]). + val simFrom = midnight - (Recovery.WAKE_HISTORY + 1) * 86_400L + // For a past day, run on past its end (as far as there is data) so an evening with the strap off can be seen to + // last the night, which makes it a gap rather than a drift ([Recovery.charge]). The window is still the day. + val simTo = minOf(day.to + 1 + LOOKAHEAD_SECONDS, now / 60 * 60 + 60) + // Personal stress threshold from calm readings: awake, still, at resting heart rate and away from workouts. + val calmFrom = DayWindow.of(date.minusDays(Recovery.STRESS_BASELINE_DAYS.toLong() - 1), zone).from + // The per-minute tables are read once each, over every span below, and sliced for each use: the load history, + // the Charge run with its lookahead, the calm readings and the activity weeks all overlap. + val readTo = maxOf(to, simTo) + val activityRead = dao.activityOnce(loadFrom, readTo) + val hrRead = dao.heartRateOnce(loadFrom, readTo) + val stressRead = dao.stressOnce(minOf(simFrom, calmFrom), readTo) + val strapWorkouts = dao.workoutsOnce(loadFrom, to) + val activityAll = activityRead.within(loadFrom, to) { it.timestamp } + val hrAll = minuteHeartRate(hrRead.within(loadFrom, to) { it.timestamp }, activityAll) val hr = hrAll.filter { it.first >= from } // Naps count with the user's own times when they changed them. - val sessions = NapTimes.applyAll(dao.sleepSessionsOnce(from, to), dao.napEditsOnce()) + val sessions = sleepSource.sessionsOnce(from, to) + // The nights, the charge, the regularity grid and tonight's sleep health all read stages: each session's once. + val stageReads = HashMap>() + suspend fun stagesOf(s: org.openstrap.app.data.db.SleepSession) = stageReads.getOrPut(s) { sleepSource.stagesOnce(s) } val hrv = dao.hrvOnce(from, to) val skin = dao.skinTemperatureOnce(from, to) val breath = dao.breathRateOnce(from, to) @@ -94,16 +116,16 @@ class RecoveryEngine(private val dao: HealthDao) { val sleepSpo2 = dao.sleepSpo2Once(from, to) // A night's summary uses the sleep result's day rule: the date of belongTime + 2 h. val osaByMorning = dao.osaResultsOnce(from - 86_400, to).associateBy { - Instant.ofEpochSecond(it.belongTime + 2 * 3600).atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() + Days.start(it.belongTime + 2 * 3600, zone) } val mains = mainsByMorning(sessions, zone) val strapNights = dao.strapReadinessOnce(from, to).associateBy { it.day } // Naps count toward a day's total sleep for the debt; a nap belongs to the day it started. - val napsByDay = sessions.filter { it.nap }.groupBy { Instant.ofEpochSecond(it.start).atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() } + val napsByDay = sessions.filter { it.nap }.groupBy { Days.start(it.start, zone) } .mapValues { (_, v) -> v.sumOf { asleepMinutes(it) } } val nights = mains.map { (morning, s) -> - val stages = dao.sleepStagesOnce(s.start).map { Triple(it.start, it.end, it.stage) } + val stages = stagesOf(s).map { Triple(it.start, it.end, it.stage) } night(morning, s, hr, hrv, skin, breath, sleepSpo2, osaByMorning[morning]).copy( strapHrv = strapNights[morning]?.sleepHrv?.toFloat(), strapRestingHr = strapNights[morning]?.sleepRestingHr?.toFloat(), @@ -118,16 +140,17 @@ class RecoveryEngine(private val dao: HealthDao) { nights.mapNotNull { n -> n.restingHr?.let { n.day to it } }.toMap(), day.from, ) ?: 60f - val age = profile.age() + // The age the scored day had, so a past day keeps the max HR and sleep need it had then. + val age = profile.age(date) fun dayOf(t: Long) = Instant.ofEpochSecond(t).atZone(zone).toLocalDate() - val maxHr = maxHeartRate(profile, hrAll, pai?.maxHeartRate, loadFrom, to, zone) + val maxHr = maxHeartRate(profile, age, hrAll, pai?.maxHeartRate, loadFrom, to, zone) val female = profile.sex == UserProfile.Sex.Female // Daily training load for the days before the selected one, skipping days the strap was mostly off. Workouts // (phone and strap) count every minute, on the workout's own heart rate; outside them only moving minutes do. val sleepRanges = sessions.map { it.start..it.end } val asleep = { t: Long -> sleepRanges.any { t in it } } - val workoutHr = workoutHeartRates(loadFrom, to, now) + val workoutHr = workoutHeartRates(loadFrom, to, now, strapWorkouts) val workoutRanges = workoutHr.map { it.first } val loadHr = Recovery.withWorkoutMinutes(hrAll, workoutHr.map { it.second }) val stepsByMinute = activityAll.associate { it.timestamp / 60 * 60 to it.steps } @@ -152,7 +175,6 @@ class RecoveryEngine(private val dao: HealthDao) { val todaySplit = dailyLoad(hrByDay[date].orEmpty()) val todayLoad = todaySplit.total - val midnight = day.from val tonight = nights.lastOrNull { it.day == midnight } val sleepNeed = Recovery.sleepNeed(age, profile.sleepNeedMinutes) val readiness = Recovery.readiness(tonight, nights, loads, causes, sleepNeed) @@ -162,28 +184,23 @@ class RecoveryEngine(private val dao: HealthDao) { d to (if (back == 0) readiness.score else Recovery.readiness(nights.lastOrNull { it.day == m }, nights, loadsBefore(loadDays, m), causes, sleepNeed).score) } - // Charge: start a week and a day early, so the starting guess has washed out through the first night and the - // week's wake values are there for a restart after a night with the strap off ([Recovery.charge]). - val simFrom = midnight - (Recovery.WAKE_HISTORY + 1) * 86_400L - // For a past day, run on past its end (as far as there is data) so an evening with the strap off can be seen to - // last the night, which makes it a gap rather than a drift ([Recovery.charge]); the window is still the day. - val simTo = minOf(day.to + 1 + LOOKAHEAD_SECONDS, now / 60 * 60 + 60) val stages = sessions.filter { it.end > simFrom && it.start < simTo } - .flatMap { s -> dao.sleepStagesOnce(s.start).map { Triple(it.start, it.end, it.stage) } } - val stress = dao.stressOnce(simFrom, simTo).map { it.timestamp to it.value } - val simActivity = dao.activityOnce(simFrom, simTo) + .flatMap { s -> stagesOf(s).map { Triple(it.start, it.end, it.stage) } } + val stress = stressRead.within(simFrom, simTo) { it.timestamp }.map { it.timestamp to it.value } + val simActivity = activityRead.within(simFrom, simTo) { it.timestamp } val steps = simActivity.associate { it.timestamp / 60 * 60 to it.steps } val offWrist = simActivity.filter { Recovery.offWrist(it.kind) }.mapTo(HashSet()) { it.timestamp / 60 * 60 } - // Personal stress threshold from calm readings: awake, still, at resting heart rate and away from workouts. - val calmFrom = DayWindow.of(date.minusDays(Recovery.STRESS_BASELINE_DAYS.toLong() - 1), zone).from // Minutes in or just after a workout are left out ([Recovery.stressExcluded]). - val calmSteps = dao.activityOnce(calmFrom, to).associate { it.timestamp / 60 * 60 to it.steps } + val calmSteps = activityRead.within(calmFrom, to) { it.timestamp }.associate { it.timestamp / 60 * 60 to it.steps } val stressExcluded = Recovery.stressExcluded(workoutRanges) - val calm = Recovery.calmStress(dao.stressOnce(calmFrom, to).map { it.timestamp to it.value }, hr.toMap(), calmSteps, restingHr, maxHr, stressExcluded, asleep) + val calmReadings = stressRead.within(calmFrom, to) { it.timestamp }.map { it.timestamp to it.value } + val calm = Recovery.calmStress(calmReadings, hr.toMap(), calmSteps, restingHr, maxHr, stressExcluded, asleep) val stressThreshold = Recovery.stressThreshold(calm) { Instant.ofEpochSecond(it).atZone(zone).toLocalDate().toEpochDay() } // Heart rate after the day (the lookahead) only says when the strap went back on. - val hrAhead = if (simTo > day.to + 1) minuteHeartRate(day.to + 1, simTo) else emptyList() + val hrAhead = if (simTo > day.to + 1) { + minuteHeartRate(hrRead.within(day.to + 1, simTo) { it.timestamp }, activityRead.within(day.to + 1, simTo) { it.timestamp }) + } else emptyList() val simHr = hr.filter { it.first in simFrom until simTo } + hrAhead.filter { it.first > day.to } // The strap's live wear pushes, where the app was connected to hear them; elsewhere the heart-rate gap rule stands. val wear = dao.wearEventsOnce(simFrom, simTo).map { it.timestamp to it.worn } @@ -203,8 +220,9 @@ class RecoveryEngine(private val dao: HealthDao) { // Activity: MVPA from cadence, then heart rate, over the 7 days ending on the selected one. Worked out for the // 14 days ending on it, the same way each day, so the week story can tell last week's days. val weekFrom = DayWindow.of(date.minusDays(ACTIVITY_DAYS - 1L), zone).from - val workouts = dao.workoutsOnce(weekFrom, to) - val minuteSteps = dao.activityOnce(weekFrom, to).filter { it.steps in 0..250 }.associate { it.timestamp / 60 * 60 to it.steps } + val workouts = strapWorkouts.filter { it.end >= weekFrom } + val minuteSteps = activityRead.within(weekFrom, to) { it.timestamp }.filter { it.steps in 0..250 } + .associate { it.timestamp / 60 * 60 to it.steps } val weekHr = hr.filter { it.first >= weekFrom }.toMap() val activityDays = (ACTIVITY_DAYS - 1 downTo 0).map { back -> val w = DayWindow.of(date.minusDays(back.toLong()), zone) @@ -229,14 +247,20 @@ class RecoveryEngine(private val dao: HealthDao) { byDay = dayMvpa.mapIndexed { i, m -> date.minusDays(ACTIVITY_DAYS - 1L - i) to m }.toMap(), ) - // VO2max: recomputed weekly, as of the latest Monday, from the nightly resting HR. - val monday = date.with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + // VO2max: recomputed weekly, as of the first day of the week ([Weeks]), from the nightly resting HR. + val weekStart = Weeks.start(date) val (vo2, vo2Withheld) = Vo2max.estimate( - profile, profile.age(monday), nights.mapNotNull { n -> n.validRestingHr?.let { n.day to it } }.toMap(), - DayWindow.of(monday, zone).from, + profile, profile.age(weekStart), nights.mapNotNull { n -> n.validRestingHr?.let { n.day to it } }.toMap(), + DayWindow.of(weekStart, zone).from, ) - val energy = energy(date, profile, restingHr, maxHr, vo2?.value?.toFloat(), zone, now) + val energy = energy( + date, profile, restingHr, maxHr, vo2?.value?.toFloat(), zone, now, + DayReads( + activityRead.within(day.from, day.to) { it.timestamp }, hrRead.within(day.from, day.to) { it.timestamp }, + sessions.filter { it.end in day.from..day.to }, strapWorkouts.filter { it.end >= day.from && it.start <= day.to }, + ), + ) val loadTrend = if (loads.size >= 7) Recovery.fitnessFatigue(loads).last() else null val chronic = loadTrend?.ctl @@ -245,7 +269,7 @@ class RecoveryEngine(private val dao: HealthDao) { val gridStart = date.minusDays(SleepRegularity.SRI_DAYS.toLong() + 1).atTime(SleepRegularity.DAY_START_HOUR, 0).atZone(zone).toEpochSecond() val gridEnd = date.atTime(SleepRegularity.DAY_START_HOUR, 0).atZone(zone).toEpochSecond() val gridStages = sessions.filter { it.end > gridStart && it.start < gridEnd } - .flatMap { s -> dao.sleepStagesOnce(s.start).map { Triple(it.start, it.end, it.stage) } } + .flatMap { s -> stagesOf(s).map { Triple(it.start, it.end, it.stage) } } val wornMinutes = hr.mapTo(HashSet()) { it.first / 60 * 60 } val grids = (0..SleepRegularity.SRI_DAYS).map { k -> val noon = date.minusDays(SleepRegularity.SRI_DAYS.toLong() + 1 - k).atTime(SleepRegularity.DAY_START_HOUR, 0).atZone(zone).toEpochSecond() @@ -257,7 +281,7 @@ class RecoveryEngine(private val dao: HealthDao) { val regularity = SleepRegularity.result(grids, midpoints) val sleepHealth = tonight?.let { n -> val main = mains[midnight] - val stagesTonight = main?.let { dao.sleepStagesOnce(it.start).filter { st -> st.stage != org.openstrap.app.data.db.SleepStage.AWAKE } }.orEmpty() + val stagesTonight = main?.let { stagesOf(it).filter { st -> st.stage != org.openstrap.app.data.db.SleepStage.AWAKE } }.orEmpty() val period = stagesTonight.takeIf { it.isNotEmpty() }?.let { ((it.maxOf { st -> st.end } - it.minOf { st -> st.start }) / 60).toInt() } val mid = Instant.ofEpochSecond((n.start + n.end) / 2).atZone(zone).toLocalTime().toSecondOfDay() / 60 SleepRegularity.health(n.asleepMinutes, n.wasoMinutes, period, regularity, age, mid) @@ -277,15 +301,13 @@ class RecoveryEngine(private val dao: HealthDao) { * the tested value, else Tanaka or the strap's PAI value, raised by observed peaks over [LOAD_DAYS]. */ suspend fun maxHeartRate(profile: UserProfile, date: LocalDate = LocalDate.now(), zone: ZoneId = ZoneId.systemDefault()): Float { - val to = DayWindow.of(date, zone).to - val loadFrom = DayWindow.of(date.minusDays(LOAD_DAYS.toLong()), zone).from - // Same PAI window as compute: the latest record within the nightly baseline span. - val paiFrom = DayWindow.of(date.minusDays(Recovery.BASELINE_NIGHTS.toLong() + 1), zone).from - val strapMax = dao.paiOnce(paiFrom, to).lastOrNull()?.maxHeartRate + val (loadFrom, to, strapMax) = maxHeartRateWindow(date, zone) // A tested value wins outright, so it doesn't need 90 days of heart rate read back. val tested = profile.maxHeartRate - if (tested != null) Recovery.maxHeartRate(profile.age(), strapMax, tested = tested).let { if (it == tested.toFloat()) return it } - return maxHeartRate(profile, minuteHeartRate(loadFrom, to), strapMax, loadFrom, to, zone) + if (tested != null) { + Recovery.maxHeartRate(profile.age(date), strapMax, tested = tested).let { if (it == tested.toFloat()) return it } + } + return maxHeartRate(profile, profile.age(date), minuteHeartRate(loadFrom, to), strapMax, loadFrom, to, zone) } /** @@ -300,12 +322,30 @@ class RecoveryEngine(private val dao: HealthDao) { zone: ZoneId = ZoneId.systemDefault(), now: Long = System.currentTimeMillis() / 1000, ): EnergyExpenditure.Day? { val day = DayWindow.of(date, zone) - val age = profile.age() - val activity = dao.activityOnce(day.from, day.to) - val minuteSteps = activity.filter { it.steps in 0..250 }.associate { it.timestamp / 60 * 60 to it.steps } - val minuteHr = minuteHeartRate(day.from, day.to, activity).toMap() - val sessions = NapTimes.applyAll(dao.sleepSessionsOnce(day.from, day.to), dao.napEditsOnce()) - val strap = dao.workoutsOnce(day.from, day.to) + val reads = DayReads( + dao.activityOnce(day.from, day.to), dao.heartRateOnce(day.from, day.to), sleepSource.sessionsOnce(day.from, day.to), + dao.workoutsOnce(day.from, day.to), + ) + return energy(date, profile, restingHr, maxHr, vo2max, zone, now, reads) + } + + /** What [energy] reads of its day, so [compute] can hand over what it read already. */ + private class DayReads( + val activity: List, + val heartRate: List, + val sessions: List, + val strapWorkouts: List, + ) + + private suspend fun energy( + date: LocalDate, profile: UserProfile, restingHr: Float, maxHr: Float, vo2max: Float?, zone: ZoneId, now: Long, reads: DayReads, + ): EnergyExpenditure.Day? { + val day = DayWindow.of(date, zone) + val age = profile.age(date) + val minuteSteps = reads.activity.filter { it.steps in 0..250 }.associate { it.timestamp / 60 * 60 to it.steps } + val minuteHr = minuteHeartRate(reads.heartRate, reads.activity).toMap() + val sessions = reads.sessions + val strap = reads.strapWorkouts val phone = HashMap() for (w in dao.appWorkoutsOnce(day.from, day.to)) { phoneWorkoutKcal(w, profile, age, restingHr, maxHr, vo2max, now)?.forEach { if (it.t in day.from..day.to) phone[it.t] = it.kcal } @@ -343,24 +383,34 @@ class RecoveryEngine(private val dao: HealthDao) { } /** - * VO2max as of [date] by the rule [compute] uses ([RecoveryResult.vo2max]): Jurca as of the latest Monday, on - * the nightly resting HR of the two weeks before it. Null when withheld. + * VO2max as of [date] by the rule [compute] uses ([RecoveryResult.vo2max]): Jurca as of the first day of the + * week ([Weeks]), on the nightly resting HR of the two weeks before it. Null when withheld. */ - suspend fun vo2max(profile: UserProfile, date: LocalDate, zone: ZoneId = ZoneId.systemDefault()): Float? { - val monday = date.with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) - val from = DayWindow.of(monday.minusDays(Vo2max.RHR_LOOKBACK_DAYS + 1L), zone).from - val to = DayWindow.of(monday, zone).to + suspend fun vo2max( + profile: UserProfile, + date: LocalDate, + zone: ZoneId = ZoneId.systemDefault(), + firstDay: java.time.DayOfWeek = Weeks.firstDay(), + ): Float? { + val weekStart = Weeks.start(date, firstDay) + val from = DayWindow.of(weekStart.minusDays(Vo2max.RHR_LOOKBACK_DAYS + 1L), zone).from + val to = DayWindow.of(weekStart, zone).to val hr = minuteHeartRate(from, to) val hrv = dao.hrvOnce(from, to) - val nights = mainsByMorning(dao.sleepSessionsOnce(from, to), zone).mapNotNull { (morning, s) -> + val nights = mainsByMorning(sleepSource.sessionsOnce(from, to), zone).mapNotNull { (morning, s) -> night(morning, s, hr, hrv, emptyList(), emptyList(), emptyList(), null).validRestingHr?.let { morning to it } }.toMap() - return Vo2max.estimate(profile, profile.age(monday), nights, DayWindow.of(monday, zone).from).first?.value?.toFloat() + return Vo2max.estimate(profile, profile.age(weekStart), nights, DayWindow.of(weekStart, zone).from).first?.value?.toFloat() } /** Live heart rate is stored several times a minute; the model works in minutes. */ - private suspend fun minuteHeartRate(from: Long, to: Long, activity: List? = null): List> = - Summaries.bucketAverage(Summaries.heartRateSeries(dao.heartRateOnce(from, to), activity ?: dao.activityOnce(from, to)), 60) + private suspend fun minuteHeartRate(from: Long, to: Long): List> = + minuteHeartRate(dao.heartRateOnce(from, to), dao.activityOnce(from, to)) + + private fun minuteHeartRate( + hr: List, activity: List, + ): List> = + Summaries.bucketAverage(Summaries.heartRateSeries(hr, activity), 60) /** * Every workout overlapping [from]..[to], phone and strap, as (its time, its own heart rate per minute, @@ -369,28 +419,64 @@ class RecoveryEngine(private val dao: HealthDao) { * strap's 3-minute recovery recording after it is left out. Each workout's samples are reduced to minutes as soon * as they're read, so 90 days of per-second recordings are never all held at once. */ - private suspend fun workoutHeartRates(from: Long, to: Long, now: Long): List>> { + private suspend fun workoutHeartRates( + from: Long, to: Long, now: Long, strapWorkouts: List, + ): List>> { val phone = dao.appWorkoutsOnce(from, to).flatMap { w -> val ranges = Recovery.activeRanges(w, now).filter { !it.isEmpty() } if (ranges.isEmpty()) return@flatMap emptyList() val samples = dao.appWorkoutHeartRateOnce(w.start).map { it.timestamp to it.bpm } ranges.map { it to Recovery.workoutMinutes(it, samples) } } - val strap = dao.workoutsOnce(from, to).mapNotNull { w -> + val strap = strapWorkouts.mapNotNull { w -> val range = w.start until w.end if (range.isEmpty()) null else range to Recovery.workoutMinutes(range, dao.workoutHeartRateOnce(w.start).map { it.timestamp to it.bpm }) } return phone + strap } - private suspend fun maxHeartRate(profile: UserProfile, hrAll: List>, strapMax: Int?, from: Long, to: Long, zone: ZoneId): Float { - // Each day's highest real heart rate (filtered per-minute HR, and the strap's daily exercise maximum when the - // minutes back it up); two separate days have to agree before it raises max HR, so one artefact can't. + /** + * What both max heart rates read: the load history's start and the day's end, and the strap's own max from the + * latest PAI record within the nightly baseline span (the same window [compute] reads it from). + */ + private suspend fun maxHeartRateWindow(date: LocalDate, zone: ZoneId): Triple { + val to = DayWindow.of(date, zone).to + val loadFrom = DayWindow.of(date.minusDays(LOAD_DAYS.toLong()), zone).from + val paiFrom = DayWindow.of(date.minusDays(Recovery.BASELINE_NIGHTS.toLong() + 1), zone).from + return Triple(loadFrom, to, dao.paiOnce(paiFrom, to).lastOrNull()?.maxHeartRate) + } + + private suspend fun maxHeartRate( + profile: UserProfile, age: Int?, hrAll: List>, strapMax: Int?, from: Long, to: Long, zone: ZoneId, + ): Float { + val peak = Recovery.observedPeak(dailyMaxima(hrAll, from, to, zone).values.toList()) + return Recovery.maxHeartRate(age, strapMax, peak, profile.maxHeartRate) + } + + /** + * Each day's highest real heart rate (filtered per-minute HR, and the strap's daily exercise maximum when the + * minutes back it up); two separate days have to agree before it raises max HR, so one artefact can't. + */ + private suspend fun dailyMaxima(hrAll: List>, from: Long, to: Long, zone: ZoneId): Map { fun dayOf(t: Long) = Instant.ofEpochSecond(t).atZone(zone).toLocalDate() val minuteMax = hrAll.groupBy { dayOf(it.first) }.mapValues { (_, m) -> Recovery.filteredMax(m) } val strapDaily = dao.dailyMaxHrOnce(from, to).map { dayOf(it.day) to it.bpm } - val observedPeak = Recovery.observedPeak(Recovery.dailyMaxima(minuteMax, strapDaily)) - return Recovery.maxHeartRate(profile.age(), strapMax, observedPeak, profile.maxHeartRate) + return Recovery.dailyMaximaByDay(minuteMax, strapDaily) + } + + /** + * Max HR as [maxHeartRate] works it out, with where it comes from and, when your workouts raised it, the day + * they did: for the Heart rate zones page. + */ + suspend fun maxHeartRateExplained(profile: UserProfile, date: LocalDate = LocalDate.now(), zone: ZoneId = ZoneId.systemDefault()): MaxHeartRate { + val (loadFrom, to, strapMax) = maxHeartRateWindow(date, zone) + val tested = profile.maxHeartRate?.takeIf { it in Recovery.TESTED_MAX_RANGE } + if (tested != null) return MaxHeartRate(tested, MaxHeartRate.Source.Set) + val maxima = dailyMaxima(minuteHeartRate(loadFrom, to), loadFrom, to, zone) + val peak = Recovery.observedPeak(maxima.values.toList()) + val bpm = Recovery.maxHeartRate(profile.age(date), strapMax, peak).roundToInt() + val source = Recovery.maxHeartRateSource(profile.age(date), strapMax, peak) + return MaxHeartRate(bpm, source, Recovery.observedPeakDay(maxima).takeIf { source == MaxHeartRate.Source.Learned }) } private fun night( @@ -474,7 +560,7 @@ class RecoveryEngine(private val dao: HealthDao) { /** Local midnight of the morning a sleep belongs to. */ fun morningOf(s: SleepSession, zone: ZoneId): Long = - if (s.day > 0) s.day else Instant.ofEpochSecond(s.end).atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() + if (s.day > 0) s.day else Days.start(s.end, zone) /** * The daily loads a morning's readiness saw: those of [loadDays] (day start to load, oldest first) before @@ -502,5 +588,24 @@ class RecoveryEngine(private val dao: HealthDao) { } } +/** + * The rows of this list, sorted by [time], that fall in [from, to] with both ends in, as a `BETWEEN` read of that span + * would return them. + */ +internal fun List.within(from: Long, to: Long, time: (T) -> Long): List { + fun firstAtOrAfter(t: Long): Int { + var lo = 0 + var hi = size + while (lo < hi) { + val mid = (lo + hi) ushr 1 + if (time(get(mid)) < t) lo = mid + 1 else hi = mid + } + return lo + } + val a = firstAtOrAfter(from) + val b = if (to == Long.MAX_VALUE) size else firstAtOrAfter(to + 1) + return if (b <= a) emptyList() else subList(a, b) +} + private fun List.mean(): Float? = if (isEmpty()) null else average().toFloat() -private fun List.median(): Float? = if (isEmpty()) null else sorted().let { (it[(it.size - 1) / 2] + it[it.size / 2]) / 2 } +private fun List.median(): Float? = Stats.median(this) diff --git a/app/src/main/kotlin/org/openstrap/app/data/Rewards.kt b/app/src/main/kotlin/org/openstrap/app/data/Rewards.kt new file mode 100644 index 00000000..1db03b5c --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/Rewards.kt @@ -0,0 +1,382 @@ +package org.openstrap.app.data + +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import org.openstrap.app.data.db.AppWorkoutSet +import org.openstrap.app.data.db.BadgeRow +import org.openstrap.app.data.db.PersonalBest +import org.openstrap.app.data.db.WeekResult + +/** + * The weekly goal, personal bests and badges (workouts spec 3, requests spec 7), worked out from the workouts and days + * alone so they can be rebuilt at any time: on a new workout, a trim or a delete, and for past history on first launch. + * Pure, like [Recovery]. What's stored ([WeekResult], [PersonalBest], [BadgeRow]) is only a cache of this, except + * two things history can't say: the goal a past week had, and badges once earned, which are never taken back. + */ +object Rewards { + /** A workout counts toward the weekly goal from this long ([WorkoutSuggestions.MIN_MINUTES]). */ + const val MIN_SECONDS = WorkoutSuggestions.MIN_MINUTES * 60L + + /** At most this many workouts a day count. */ + const val PER_DAY = 2 + + const val DEFAULT_GOAL = 3 + val GOALS = 1..14 + + /** The fastest stretches kept for each GPS run: 1 km, 5 km, 10 km and the half marathon. */ + val STRETCHES = listOf(1000, 5000, 10000, 21097) + + /** + * The fastest [meters] of a run, in [seconds] of moving time, starting at [start] (epoch seconds), so a trim can + * tell whether it's still inside. Stored as "5000:1601:1761800000", comma separated ([AppWorkout.stretches]). + */ + data class Stretch(val meters: Int, val seconds: Long, val start: Long) { + val code: String get() = "$meters:$seconds:$start" + + companion object { + fun parse(code: String?): List = code.orEmpty().split(',').mapNotNull { part -> + val (m, s, t) = part.split(':').takeIf { it.size == 3 } ?: return@mapNotNull null + Stretch(m.toIntOrNull() ?: return@mapNotNull null, s.toLongOrNull() ?: return@mapNotNull null, t.toLongOrNull() ?: return@mapNotNull null) + } + + fun format(stretches: List): String? = stretches.takeIf { it.isNotEmpty() }?.joinToString(",") { it.code } + } + } + + /** + * The fastest stretch of each of [STRETCHES] along [points] ([Route.track], pauses left out), in moving time. The + * time over the shortest run of fixes covering the distance is scaled down to the distance exactly. + */ + fun stretches(points: List, pauses: List = emptyList()): List { + val track = Route.track(points) + if (track.size < 2) return emptyList() + // Distance and moving time from the first fix to each one. + val dist = DoubleArray(track.size) + val time = LongArray(track.size) + for (i in 1 until track.size) { + val a = track[i - 1] + val b = track[i] + val paused = pauses.any { it.first < b.t && it.last > a.t } + dist[i] = dist[i - 1] + if (paused) 0.0 else Route.meters(a, b) + time[i] = time[i - 1] + if (paused) 0 else b.t - a.t + } + return STRETCHES.mapNotNull { d -> + if (dist.last() < d) return@mapNotNull null + var best: Stretch? = null + var i = 0 + for (j in 1 until track.size) { + while (i + 1 < j && dist[j] - dist[i + 1] >= d) i++ + val covered = dist[j] - dist[i] + if (covered < d) continue + val secs = Math.round((time[j] - time[i]) * d / covered) + if (best == null || secs < best.seconds) best = Stretch(d, secs, track[i].t) + } + best + } + } + + /** A workout as the rewards read it. */ + data class Done( + val start: Long, + val shownStart: Long = start, + val end: Long, + val sport: Int, + val activeSeconds: Long, + /** You started it, or kept it when it was noticed for you ([WorkoutAnalysis.startedByYou]). */ + val startedByYou: Boolean = true, + /** Metres from GPS only; null without. */ + val gpsMeters: Float? = null, + /** Metres from GPS or steps, for the distance in total. */ + val meters: Float? = null, + val stretches: List = emptyList(), + val structure: String? = null, + val sets: List = emptyList(), + /** Active kcal, for the weekly recap; null when not worked out. */ + val activeKcal: Float? = null, + ) { + /** Counts toward the weekly goal (workouts spec 3.1). */ + val counts: Boolean get() = startedByYou && activeSeconds >= MIN_SECONDS + + /** A run measured by GPS. */ + val gpsRun: Boolean get() = sport in SportTypes.RUNS && gpsMeters != null && gpsMeters > 0f + + /** Its stretches still inside it after a trim. */ + val stretchesInside: List get() = stretches.filter { it.start >= shownStart && it.start + it.seconds <= end } + } + + /** A day's steps. */ + data class Day(val date: LocalDate, val steps: Int) + + /* -------------------------------------------- Weekly goal -------------------------------------------- */ + + /** The first day of [date]'s week, weeks starting on [firstDay] ([Weeks.start]). */ + fun weekStart(date: LocalDate, firstDay: DayOfWeek): LocalDate = Weeks.start(date, firstDay) + + /** + * The workouts that count, in order, each with its week: at most [PER_DAY] a day, the first of the day by start. + */ + fun counted(workouts: List, firstDay: DayOfWeek, zone: ZoneId): List> = + workouts.filter { it.counts }.sortedBy { it.shownStart } + .groupBy { day(it.shownStart, zone) }.values.flatMap { it.take(PER_DAY) } + .sortedBy { it.shownStart } + .map { weekStart(day(it.shownStart, zone), firstDay) to it } + + private fun day(t: Long, zone: ZoneId) = Instant.ofEpochSecond(t).atZone(zone).toLocalDate() + + /** + * Every week from the first that counted a workout up to [today]'s, against its goal: a past week keeps the goal + * [kept] says it had, this week and any week never stored take [goal] (workouts spec 3.1). Weeks with nothing + * counted are left out unless stored or this week. + */ + fun weeks(workouts: List, kept: List, goal: Int, today: LocalDate, firstDay: DayOfWeek, zone: ZoneId): List { + val thisWeek = weekStart(today, firstDay) + val counts = counted(workouts, firstDay, zone).groupingBy { it.first }.eachCount() + val goals = kept.associate { it.weekStart to it.goal } + val weeks = (counts.keys + kept.map { LocalDate.ofEpochDay(it.weekStart) } + thisWeek).filter { it <= thisWeek }.toSortedSet() + return weeks.map { w -> + val g = if (w == thisWeek) goal else goals[w.toEpochDay()] ?: goal + val n = counts[w] ?: 0 + WeekResult(w.toEpochDay(), g, n, n >= g) + } + } + + /** + * Weeks in a row with the goal met, up to [thisWeek]: counted from this week when it's met, else from the week + * before, so a week still under way never breaks the run. + */ + fun inARow(weeks: List, thisWeek: LocalDate): Int { + val met = weeks.filter { it.met }.map { it.weekStart }.toSet() + var w = if (thisWeek.toEpochDay() in met) thisWeek else thisWeek.minusWeeks(1) + var n = 0 + while (w.toEpochDay() in met) { n++; w = w.minusWeeks(1) } + return n + } + + /** The most weeks in a row the goal was ever met. */ + fun bestRun(weeks: List): Int { + val met = weeks.filter { it.met }.map { it.weekStart }.sorted() + var best = 0 + var run = 0 + var last: Long? = null + for (w in met) { + run = if (last != null && w - last == 7L) run + 1 else 1 + best = maxOf(best, run) + last = w + } + return best + } + + /** This week's count and goal, and whether [w] is the workout that met it ([counted] with and without it). */ + data class WeekLine(val count: Int, val goal: Int, val metByThis: Boolean) + + fun weekLine(w: Done, workouts: List, goal: Int, firstDay: DayOfWeek, zone: ZoneId): WeekLine { + val week = weekStart(day(w.shownStart, zone), firstDay) + fun count(list: List) = counted(list, firstDay, zone).count { it.first == week } + val with = count(workouts.filter { it.start != w.start } + w) + val without = count(workouts.filter { it.start != w.start }) + return WeekLine(with, goal, with >= goal && without < goal) + } + + /* -------------------------------------------- Personal bests -------------------------------------------- */ + + /** The kinds of personal best (workouts spec 3.2); a lift's is [lift] of the exercise. */ + object Kind { + const val LONGEST_RUN = "longest_run" + const val LONGEST_WORKOUT = "longest_workout" + const val MOST_STEPS = "most_steps" + private const val FASTEST = "fastest_" + private const val LIFT = "lift:" + + fun fastest(meters: Int) = "$FASTEST$meters" + fun fastestMeters(kind: String): Int? = kind.removePrefix(FASTEST).takeIf { kind.startsWith(FASTEST) }?.toIntOrNull() + fun lift(exercise: String) = LIFT + exercise.trim() + fun liftName(kind: String): String? = kind.removePrefix(LIFT).takeIf { kind.startsWith(LIFT) } + } + + /** + * Every personal best in [workouts] and [days] (workouts spec 3.2): the fastest stretches and the longest of GPS + * runs only, the longest workout you started, the heaviest logged set of each exercise, the day with the most + * steps. A tie keeps the first. + */ + fun bests(workouts: List, days: List, zone: ZoneId): List { + val out = mutableListOf() + val ordered = workouts.sortedBy { it.shownStart } + val runs = ordered.filter { it.gpsRun } + for (d in STRETCHES) { + runs.mapNotNull { r -> r.stretchesInside.firstOrNull { it.meters == d }?.let { r to it } } + .minByOrNull { it.second.seconds } + ?.let { (r, s) -> out += PersonalBest(Kind.fastest(d), s.seconds.toDouble(), r.shownStart, r.start) } + } + runs.maxByOrNull { it.gpsMeters!! }?.let { out += PersonalBest(Kind.LONGEST_RUN, it.gpsMeters!!.toDouble(), it.shownStart, it.start) } + ordered.filter { it.startedByYou && it.activeSeconds > 0 }.maxByOrNull { it.activeSeconds } + ?.let { out += PersonalBest(Kind.LONGEST_WORKOUT, it.activeSeconds.toDouble(), it.shownStart, it.start) } + ordered.flatMap { w -> w.sets.filter { it.end != null && it.weightKg != null && (it.reps ?: 0) > 0 && !it.exercise.isNullOrBlank() }.map { w to it } } + .groupBy { it.second.exercise!!.trim().lowercase() }.values + .forEach { sets -> + val (w, s) = sets.maxByOrNull { it.second.weightKg!! }!! + out += PersonalBest(Kind.lift(s.exercise!!), s.weightKg!!.toDouble(), w.shownStart, w.start) + } + days.filter { it.steps > 0 }.maxByOrNull { it.steps } + ?.let { out += PersonalBest(Kind.MOST_STEPS, it.steps.toDouble(), it.date.atStartOfDay(zone).toEpochSecond(), null) } + return out + } + + /** A best [w] set, and the one it beat. */ + data class NewBest(val best: PersonalBest, val before: PersonalBest) + + /** + * The bests [w] set that beat one from before it (workouts spec 3.4): the first time something is measured is a + * record, not a new best. + */ + fun newBests(w: Done, workouts: List, days: List, zone: ZoneId): List { + val others = workouts.filter { it.start != w.start } + // A lift's kind is spelled as its heaviest set was ([bests] groups them ignoring case), so match ignoring it too. + val before = bests(others, emptyList(), zone).associateBy { it.kind.lowercase() } + return bests(others + w, emptyList(), zone).filter { it.workoutStart == w.start } + .mapNotNull { b -> before[b.kind.lowercase()]?.let { NewBest(b, it) } } + } + + /* -------------------------------------------- Badges -------------------------------------------- */ + + enum class Group { Firsts, Milestones, Weekly, Kinds } + + /** A badge: its [id], [title], [group], the [short] text on its art, and for a milestone how much it [needs]. */ + data class Badge(val id: String, val title: String, val group: Group, val short: String, val needs: Long? = null, val unit: String = "") + + /** About 24 (workouts spec 3.3): the requests spec 7.1 set with weekly goal badges for streaks, and a first per kind. */ + val BADGES: List = listOf( + Badge("first_workout", "First workout", Group.Firsts, "1"), + Badge("first_10k_steps", "First 10,000 step day", Group.Firsts, "10K", 10_000, "steps"), + Badge("workouts_10", "10 workouts", Group.Milestones, "10", 10, "workouts"), + Badge("workouts_50", "50 workouts", Group.Milestones, "50", 50, "workouts"), + Badge("workouts_100", "100 workouts", Group.Milestones, "100", 100, "workouts"), + Badge("workouts_500", "500 workouts", Group.Milestones, "500", 500, "workouts"), + Badge("km_100", "100 km in all", Group.Milestones, "100", 100, "km"), + Badge("km_500", "500 km in all", Group.Milestones, "500", 500, "km"), + Badge("km_1000", "1,000 km in all", Group.Milestones, "1000", 1000, "km"), + Badge("steps_1m", "A million steps", Group.Milestones, "1M", 1_000_000, "steps"), + Badge("goal_first", "First weekly goal met", Group.Weekly, "✓", 1, "weeks"), + Badge("goal_4", "4 weeks in a row", Group.Weekly, "4", 4, "weeks"), + Badge("goal_12", "12 weeks in a row", Group.Weekly, "12", 12, "weeks"), + Badge("goal_26", "26 weeks in a row", Group.Weekly, "26", 26, "weeks"), + Badge("goal_52", "52 weeks in a row", Group.Weekly, "52", 52, "weeks"), + Badge("first_run", "First run", Group.Kinds, "Run"), + Badge("first_5k", "First 5 km run", Group.Kinds, "5K", 5000, "m"), + Badge("first_10k", "First 10 km run", Group.Kinds, "10K", 10_000, "m"), + Badge("first_indoor", "First indoor workout", Group.Kinds, "In"), + Badge("first_log", "First logged strength session", Group.Kinds, "Kg"), + Badge("first_tabata", "First Tabata", Group.Kinds, "Tab"), + Badge("first_reps", "First run intervals", Group.Kinds, "Reps"), + Badge("first_mind", "First yoga or calm session", Group.Kinds, "Om"), + Badge("first_game", "First game", Group.Kinds, "Play"), + ) + + fun badge(id: String): Badge? = BADGES.firstOrNull { it.id == id } + + /** + * The badges of one count, by id ("km_", "workouts_", "goal_"), with what each needs: earning and progress read + * these, so a badge's award and its progress bar can't drift apart. + */ + private fun thresholds(prefix: String): List> = BADGES.filter { it.id.startsWith(prefix) }.mapNotNull { b -> b.needs?.let { b.id to it } } + + private fun needs(id: String): Long = badge(id)!!.needs!! + + /** + * The goal of the week a badge [row] was earned in, from [weeks]: the first weekly goal badge shows it ("3/3"). + * Null for a badge a day earned, or a week not stored. + */ + fun goalAt(row: BadgeRow, weeks: List, firstDay: DayOfWeek, zone: ZoneId): Int? { + if (row.workoutStart == null) return null + val week = weekStart(day(row.earnedAt, zone), firstDay).toEpochDay() + return weeks.firstOrNull { it.weekStart == week }?.goal + } + + /** What a badge was earned by: the time, and the workout or the day. */ + private data class Earn(val at: Long, val workout: Long? = null, val day: Long? = null) + + /** + * Every badge [workouts], [days] and [weeks] have earned, each when it was first earned, with the workout or day + * that earned it (requests spec 7.2). A week's badge is earned by the workout that met its goal. + */ + fun badges(workouts: List, days: List, weeks: List, firstDay: DayOfWeek, zone: ZoneId): List { + val earned = linkedMapOf() + fun earn(id: String, e: Earn) { earned.putIfAbsent(id, e) } + val mine = workouts.filter { it.startedByYou }.sortedBy { it.shownStart } + // The distance in all counts every workout you started; the firsts and the workout counts only those that + // count toward the weekly goal, so a mis-tap of a minute earns nothing. + var km = 0.0 + var counted = 0 + mine.forEach { w -> + val e = Earn(w.end, w.start) + km += (w.meters ?: 0f) / 1000.0 + thresholds("km_").forEach { (id, n) -> if (km >= n) earn(id, e) } + if (!w.counts) return@forEach + counted++ + earn("first_workout", e) + thresholds("workouts_").forEach { (id, n) -> if (counted >= n) earn(id, e) } + val family = Workouts.family(w.sport) + val structure = Structure.parse(w.structure) + if (w.sport in SportTypes.RUNS) earn("first_run", e) + if (w.gpsRun && w.gpsMeters!! >= needs("first_5k")) earn("first_5k", e) + if (w.gpsRun && w.gpsMeters!! >= needs("first_10k")) earn("first_10k", e) + if (family == Workouts.Family.IndoorCardio && w.sport != Workouts.GENERIC_ACTIVITY) earn("first_indoor", e) + if (family == Workouts.Family.Strength && w.sets.any { it.reps != null && it.weightKg != null }) earn("first_log", e) + if (structure is Structure.Timed && structure.work == Structure.TABATA.work && structure.rest == Structure.TABATA.rest) earn("first_tabata", e) + if (structure is Structure.Reps) earn("first_reps", e) + if (family == Workouts.Family.MindBody) earn("first_mind", e) + if (family == Workouts.Family.Games) earn("first_game", e) + } + var steps = 0L + days.sortedBy { it.date }.forEach { d -> + val e = Earn(d.date.plusDays(1).atStartOfDay(zone).toEpochSecond() - 1, day = d.date.toEpochDay()) + if (d.steps >= needs("first_10k_steps")) earn("first_10k_steps", e) + steps += d.steps + if (steps >= needs("steps_1m")) earn("steps_1m", e) + } + // Each met week, by the workout that met it, and the run of weeks it extends. + val goals = weeks.associate { it.weekStart to it.goal } + val byWeek = counted(workouts, firstDay, zone).groupBy { it.first.toEpochDay() } + var run = 0 + var last: Long? = null + for (w in weeks.filter { it.met }.sortedBy { it.weekStart }) { + val goal = goals[w.weekStart] ?: continue + val meeting = byWeek[w.weekStart]?.getOrNull(goal - 1)?.second ?: continue + run = if (last != null && w.weekStart - last == 7L) run + 1 else 1 + last = w.weekStart + val e = Earn(meeting.end, meeting.start) + // goal_first needs one week, so every met week earns it. + thresholds("goal_").forEach { (id, n) -> if (run >= n) earn(id, e) } + } + return BADGES.mapNotNull { b -> earned[b.id]?.let { BadgeRow(b.id, it.at, it.workout, it.day) } } + } + + /** + * The badges [w] earned (workouts spec 2.8): those it earns that weren't earned without it, less any already + * [stored] from another workout or day, since badges are never taken back. + */ + fun newBadges(w: Done, workouts: List, days: List, weeks: List, stored: List, firstDay: DayOfWeek, zone: ZoneId): List { + val others = workouts.filter { it.start != w.start } + val with = badges(others + w, days, weeks, firstDay, zone).filter { it.workoutStart == w.start }.map { it.id } + val without = badges(others, days, weeks, firstDay, zone).map { it.id }.toSet() + val before = stored.filter { it.workoutStart != w.start }.map { it.id }.toSet() + return with.filter { it !in without && it !in before }.mapNotNull(::badge) + } + + /** How far toward a locked badge: what you have and what it needs, in its unit; null for one with no count. */ + fun progress(b: Badge, workouts: List, days: List, weeks: List, thisWeek: LocalDate): Pair? { + val need = b.needs ?: return null + val mine = workouts.filter { it.startedByYou } + val have = when { + b.id.startsWith("workouts_") -> mine.count { it.counts }.toLong() + b.id.startsWith("km_") -> (mine.sumOf { (it.meters ?: 0f).toDouble() } / 1000).toLong() + b.id == "steps_1m" -> days.sumOf { it.steps.toLong() } + b.id == "first_10k_steps" -> days.maxOfOrNull { it.steps.toLong() } ?: 0 + b.id.startsWith("goal_") -> inARow(weeks, thisWeek).toLong() + b.id == "first_5k" || b.id == "first_10k" -> mine.filter { it.gpsRun }.maxOfOrNull { it.gpsMeters!!.toLong() } ?: 0 + else -> return null + } + return have.coerceAtMost(need) to need + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/RewardsKeeper.kt b/app/src/main/kotlin/org/openstrap/app/data/RewardsKeeper.kt new file mode 100644 index 00000000..039798ae --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/RewardsKeeper.kt @@ -0,0 +1,139 @@ +package org.openstrap.app.data + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.ZoneId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.openstrap.app.data.db.HealthDao +import org.openstrap.app.data.settings.SettingsStore + +/** + * Keeps the stored rewards ([Rewards]) in step with the workouts: works the weeks, bests and badges out again from + * everything whenever the list of workouts or the weekly goal changes (a new workout, a trim, a delete), and stores + * them. The first time, the badges past history earned wait to be shown once, all together. + */ +class RewardsKeeper( + private val dao: HealthDao, + private val settings: SettingsStore, + private val maxHeartRates: MaxHeartRates = MaxHeartRates(dao, settings.profile), +) { + /** Everything the rewards read: the workouts as [Rewards.Done], the days' steps, the weeks stored. */ + data class Facts(val workouts: List, val days: List, val firstDay: DayOfWeek, val zone: ZoneId) + + /** The locale's first day of the week (workouts spec 3.1, [Weeks]). */ + val firstDay: DayOfWeek get() = Weeks.firstDay() + + /** [workouts] (the merged list, `WorkoutsViewModel.workouts`) with what the phone kept of each. */ + suspend fun facts(workouts: List): Facts = withContext(Dispatchers.IO) { + val phone = dao.appWorkoutsAll().associateBy { it.start } + val sets = dao.setsAll().groupBy { it.workoutStart } + // One still recording isn't done yet. + val done = workouts.filter { it.end > it.workout.start && !(it.fromPhone && phone[it.workout.start]?.end == null) }.map { a -> + val p = phone[a.workout.start].takeIf { a.fromPhone } + Rewards.Done( + a.workout.start, a.shownStart, a.end, a.workout.sportType, a.activeSeconds, a.startedByYou, + gpsMeters = a.distanceMeters?.takeIf { !a.distanceFromSteps }, meters = a.distanceMeters, + stretches = Rewards.Stretch.parse(p?.stretches), structure = p?.structure, sets = sets[a.workout.start].orEmpty(), activeKcal = a.activeKcal, + ) + } + val zone = ZoneId.systemDefault() + Facts(done, days(zone), firstDay, zone) + } + + private class DaysRead(val stamp: org.openstrap.app.data.db.TableStamp, val zone: ZoneId, val at: Long, val days: List) + + @Volatile private var daysRead: DaysRead? = null + private val daysLock = kotlinx.coroutines.sync.Mutex() + + /** + * One [refresh] at a time: each reads what is stored, works out what changed and writes it, so two at once (the + * app root and the weekly recap) could each write against what the other was replacing, or both start the rewards. + */ + private val refreshLock = kotlinx.coroutines.sync.Mutex() + + /** + * Steps per day, for the bests and badges. Summing every minute ever recorded each time took a scan of the whole + * table, so the days are kept: when only newer minutes came since, just the last day kept and after are read + * again (its minutes are the ones a sync rewrites). Anything else (older minutes in, some gone, another zone, or + * [FULL_READ_S] gone by) reads them all. + */ + private suspend fun days(zone: ZoneId): List = daysLock.withLock { + val stamp = dao.activityStampOnce() + val now = System.currentTimeMillis() / 1000 + val kept = daysRead + val last = kept?.stamp?.latest + val onlyNewer = kept != null && last != null && kept.zone == zone && now - kept.at < FULL_READ_S && + stamp.rowCount == kept.stamp.rowCount + dao.activityCountAfterOnce(last) + val days = if (onlyNewer) { + val from = java.time.Instant.ofEpochSecond(last).atZone(zone).toLocalDate() + kept.days.filter { it.date < from } + readDays(from.atStartOfDay(zone).toEpochSecond()) + } else readDays(0) + daysRead = DaysRead(stamp, zone, if (onlyNewer) kept.at else now, days) + days + } + + private suspend fun readDays(from: Long) = dao.dailyStepsOnce(from, Long.MAX_VALUE / 2).mapNotNull { d -> + runCatching { Rewards.Day(LocalDate.parse(d.day), d.value.toInt()) }.getOrNull() + } + + /** The weeks and badges stored: past weeks' goals, and badges once earned. */ + suspend fun stored(): Pair, List> = + withContext(Dispatchers.IO) { dao.weekResultsOnce() to dao.badgesOnce() } + + /** Works everything out again from [workouts] against [goal] and stores it. */ + suspend fun refresh(workouts: List, goal: Int) = refreshLock.withLock { + // The list starts empty before it loads: only a database with no workouts at all starts the rewards empty. + if (workouts.isEmpty()) { + if (!settings.rewardsStarted.first() && !dao.anyWorkoutOnce()) settings.startRewards(historyBadges = false) + return@withLock + } + val f = facts(workouts) + withContext(Dispatchers.Default) { + val today = LocalDate.now(f.zone) + val kept = dao.weekResultsOnce() + val weeks = Rewards.weeks(f.workouts, kept, goal, today, f.firstDay, f.zone) + val bests = Rewards.bests(f.workouts, f.days, f.zone) + val badges = Rewards.badges(f.workouts, f.days, weeks, f.firstDay, f.zone) + val started = settings.rewardsStarted.first() + // Only what changed is written: every write sends the bests, badges and weeks to whatever shows them, + // which works the rewards out again. Together, so a refresh cut short leaves none of it half done. + val had = dao.badgesOnce().mapTo(HashSet()) { it.id } + dao.storeRewards( + weeks = weeks - kept.toSet(), + bests = bests.takeIf { it.toSet() != dao.personalBestsOnce().toSet() }, + badges = badges.filter { it.id !in had }, + ) + if (!started) settings.startRewards(historyBadges = badges.isNotEmpty()) + } + } + + /** + * The merged list as `WorkoutsViewModel.workouts` has it, read once: the strap's + * workouts and the phone's ([Workouts.listed]) against the shared max heart rate ([MaxHeartRates]). Without the + * list's zone seconds, which no reward reads. + */ + suspend fun listedOnce(): List = withContext(Dispatchers.IO) { + val maxHr = maxHeartRates.now().bpm + Workouts.listed( + dao.workouts().first(), dao.sportEffects().first(), dao.lastWorkoutHeartRates().first(), dao.appWorkouts().first(), + dao.setCounts().first().associate { it.workoutStart to it.sets }, maxHr, + ) + } + + /** + * [refresh] without the screen: from [listedOnce] against the weekly goal stored, less the workout starting at + * [recordingStart] (the one recording, as the app root leaves it out). The weekly recap runs it before it reads + * the weeks, so workouts synced while the app was closed are counted. + */ + suspend fun refreshNow(recordingStart: Long?) { + refresh(listedOnce().filter { it.workout.start != recordingStart }, settings.goals.first().workoutsPerWeek) + } + + private companion object { + /** The longest the steps per day are kept before every minute is read again. */ + const val FULL_READ_S = 6 * 3600L + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/RoomDayRepository.kt b/app/src/main/kotlin/org/openstrap/app/data/RoomDayRepository.kt new file mode 100644 index 00000000..d24864c3 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/RoomDayRepository.kt @@ -0,0 +1,250 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import org.openstrap.app.data.db.DailyEnergyRow +import org.openstrap.app.data.db.DailyPai +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.settings.UserProfile +import org.openstrap.protocol.services.StepTotals +import java.time.LocalDate +import java.time.ZoneId + +/** + * The [DayRepository] over Room, through [health]. [strapSteps] is the strap's own running step total, which today's + * steps add when it's ahead of the synced minutes, through the app's one [liveSteps], so Today and the widgets count alike. + */ +// Most of its functions are one-line overrides of the interface's, which detekt counts too. +@Suppress("TooManyFunctions") +@OptIn(ExperimentalCoroutinesApi::class) +class RoomDayRepository( + private val health: HealthRepository, + private val liveSteps: LiveSteps, + private val strapSteps: Flow = flowOf(null), +) : DayRepository { + override fun daysWithData(): Flow> = health.daysWithData() + override suspend fun hourlySteps(from: LocalDate, to: LocalDate): Map> = health.hourlySteps(from, to) + override suspend fun recovery(date: LocalDate, profile: UserProfile): RecoveryResult = health.recovery(date, profile) + override suspend fun healthspan(date: LocalDate, profile: UserProfile): Healthspan.Outcome = health.healthspan(date, profile) + override fun changes(): Flow = health.changes() + override fun dailyPai(from: LocalDate, to: LocalDate): Flow> = health.dailyPai(from, to) + override fun dailyEnergy(from: LocalDate, to: LocalDate): Flow> = health.dailyEnergy(from, to) + override fun deletedSleep(): Flow> = health.deletedSleep() + override suspend fun setSleepTimes(strap: SleepSession, start: Long, end: Long) = health.setSleepTimes(strap, start, end) + override suspend fun deleteSleep(strap: SleepSession) = health.deleteSleep(strap) + override suspend fun restoreSleep(strap: SleepSession) = health.restoreSleep(strap) + + override fun day(date: LocalDate, isToday: Boolean): Flow { + val zone = ZoneId.systemDefault() + val day = DayWindow.of(date) + val night = DayWindow.nightEndingOn(date) + + val activityAndHr = combine(health.activity(day), health.heartRate(day)) { act, hr -> act to hr } + val vitals = combine(health.spo2(day), health.stress(day), health.hrv(day)) { spo2, stress, hrv -> Triple(spo2, stress, hrv) } + val span = DayWindow(DayWindow.nightEndingOn(date.minusDays(SleepHistory.NIGHTS - 1L), zone).from, night.to) + val extras = combine(health.skinTemperature(day), health.breathRate(night), health.latestPai(day.to)) { skin, breath, pai -> + Triple(skin, breath, pai?.takeIf { it.timestamp >= day.from }) + } + val sleepAndExtras = combine(nights(date, night, span, zone), extras, baselines(date)) { s, e, b -> Triple(s, e, b) } + + // The strap's own running total covers steps whose minute records were already taken by another app. + val todayLive = if (isToday) strapSteps else flowOf(null) + val actAndLive = combine(activityAndHr, todayLive) { a, l -> a to l } + + return combine( + actAndLive, vitals, sleepAndExtras, weekly(date, zone), more(date, day, night), + ) { (ah, live), (spo2, stress, hrv), (sleepPart, extraParts, base), weeklyParts, moreParts -> + val (skin, breath, pai) = extraParts + val (act, hr) = ah + moreParts.copy( + isToday = isToday, + day = day, + night = night, + minutes = act, + heartRate = hr, + // Only today's count touches the shared anchor: another day's would move it off today. + steps = Summaries.totalSteps(act).let { total -> if (isToday) liveSteps.today(date, total, live) else total }, + restingTrend = weeklyParts.resting, + restingEstimated = weeklyParts.restingEstimated, + hrvTrend = weeklyParts.hrv, + sleep = sleepPart.sleep, + nights = sleepPart.nights, + nightHeartRate = sleepPart.nightHeartRate, + sleepHrv = sleepPart.sleepHrv, + sleepSkin = sleepPart.sleepSkin, + spo2 = spo2, + stress = stress, + hrv = hrv, + skin = skin, + breath = breath, + pai = pai, + baselines = base, + ) + } + } + + /** The night's oxygen and apnea readings, and the day's extended activity, highest heart rate and alerts. */ + private fun more(date: LocalDate, day: DayWindow, night: DayWindow): Flow = combine( + combine(health.sleepSpo2(night), health.hrvSdnn(day), health.osaEvents(night)) { a, b, c -> Triple(a, b, c) }, + health.osaResult(date), + health.dailyMaxHr(day), + health.activityExtended(day), + health.healthAlerts(day), + ) { (sleepSpo2, sdnn, events), osa, maxHr, extended, alerts -> + DayData( + date = date, + sleepSpo2 = sleepSpo2, + hrvSdnn = sdnn, + osaResult = osa, + osaEvents = events, + dailyMaxHeartRate = maxHr?.bpm, + distanceMeters = extended.takeIf { it.isNotEmpty() }?.sumOf { it.distanceCm.toLong() }?.let { (it / CM_PER_M).toInt() }, + activeCalories = extended.takeIf { it.isNotEmpty() }?.sumOf { it.calories }, + alerts = alerts, + ) + } + + /** + * The day's sleep ([sleep]), the [SleepHistory.NIGHTS] nights over [span] with the vitals averaged over each, and + * the night's own readings for the charts of the night-scoped details. + */ + private fun nights(date: LocalDate, night: DayWindow, span: DayWindow, zone: ZoneId): Flow = combine( + sleep(date, DayWindow.of(date), night, span), + combine(health.hrv(span), health.skinTemperature(span)) { h, s -> h to s }, + combine(health.sleepSpo2(span), health.breathRate(span)) { o, b -> o to b }, + health.heartRate(night), + ) { (parts, sessions), (hrv, skin), (spo2, breath), hr -> + val main = SleepHistory.mainSleeps(sessions, zone) + val list = (SleepHistory.NIGHTS - 1 downTo 0).map { ago -> + val d = date.minusDays(ago.toLong()) + val s = main[d] + // Averages over the sleep itself, not the whole night window. + fun during(samples: List, time: (T) -> Long, value: (T) -> Float): Float? = s?.let { w -> + samples.filter { time(it) in w.start..w.end }.map(value).takeIf { it.isNotEmpty() }?.average()?.toFloat() + } + SleepNight( + date = d, + session = s, + hrv = during(hrv, { it.timestamp }, { it.milliseconds.toFloat() }), + skinTemperature = during(skin, { it.timestamp }, { it.celsius }), + spo2 = during(spo2, { it.timestamp }, { it.percent.toFloat() }), + breathing = during(breath, { it.timestamp }, { it.perMinute.toFloat() }), + ) + } + val tonight = main[date] + DayData( + date = date, + sleep = parts, + nights = list, + nightHeartRate = hr, + sleepHrv = tonight?.let { w -> hrv.filter { it.timestamp in w.start..w.end } }.orEmpty(), + sleepSkin = tonight?.let { w -> skin.filter { it.timestamp in w.start..w.end } }.orEmpty(), + ) + } + + /** + * The night's main sleep, the day's naps and the edits around them, with the sessions shown over [span] for the + * strip of nights. One read for both, so a delete or a move changes both in the same state. + */ + private fun sleep(date: LocalDate, day: DayWindow, night: DayWindow, span: DayWindow): Flow>> { + // Sessions ending from the start of the night into early tomorrow, so tonight's evening naps are included too. + val nightBefore = DayWindow.nightEndingOn(date.minusDays(1)) + val sleepWindow = DayWindow(nightBefore.from, day.to + 12 * 3600) + return health.sleepResolved(DayWindow(minOf(span.from, sleepWindow.from), sleepWindow.to)).flatMapLatest { every -> + val spanSessions = health.sleepShown(every.filter { it.end in span.from..span.to }) + val all = every.filter { it.end in sleepWindow.from..sleepWindow.to } + val shown = all.filter { !it.deleted } + val main = SleepHistory.mainSleep(shown, night) + val before = SleepHistory.mainSleep(shown, nightBefore)?.shown + // With no main sleep, the one the user deleted, for the "No sleep recorded" card and its Restore. + val deleted = if (main != null) null else SleepHistory.mainSleep(all, night, deleted = true) + // Naps belong to the calendar day they started on, not the night window (which ends at 18:00). + // The strap's start decides the day, so moving a nap's times never moves it to another day. + val resolved = shown.filter { it.strap.nap && it.strap.start in day.from..day.to } + val naps = resolved.mapNotNull { it.shown } + // Stages stay under the strap's start, trimmed to the times shown. + // The main sleep's are read whole, so the chart can show what a move left out. + val strapStages = if (main == null) flowOf(emptyList()) else health.strapStages(main.strap) + val napStages = if (naps.isEmpty()) flowOf(emptyMap()) + else combine(resolved.map { health.sleepStages(it) }) { st -> naps.zip(st) { n, s -> n.start to s }.toMap() } + val asDetected = naps.zip(resolved) { n, r -> n.start to r.strap }.toMap() + combine(strapStages, napStages) { whole, ns -> + val st = if (main?.moved == true) SleepEdits.clip(whole, main.start, main.end) else whole + // A moved main sleep's totals and score come from its clipped stages, as everywhere else ([SleepSource]). + val session = main?.shown?.let { if (main.moved) SleepEdits.restage(it, main.strap, whole) else it } + SleepDay( + main = session, + mainStages = st, + naps = naps, + napStages = ns, + previousScore = before?.score, + napsAsDetected = asDetected, + mainAsDetected = main?.strap, + strapStages = if (main?.moved == true) whole else emptyList(), + deletedMain = deleted, + neighbours = shown.mapNotNull { it.shown }, + ) to spanSessions + } + } + } + + /** The 7 days ending on [date]: resting heart rate (the strap's, else our own estimate) and HRV. */ + private fun weekly(date: LocalDate, zone: ZoneId): Flow { + val week = DayWindow(DayWindow.of(date.minusDays(6)).from, DayWindow.of(date).to) + return combine( + health.restingHr(week.from, week.to), health.heartRate(week), health.activity(week), health.hrv(week), + ) { resting, hr, act, hrv -> + val days = (6 downTo 0).map { date.minusDays(it.toLong()) } + val restingTrend = days.map { d -> + val w = DayWindow.of(d, zone) + val stored = resting.firstOrNull { it.day in w.from..w.to }?.bpm + // Our own estimate (lowest 30-min average) only means something with most of a day's data. + val inDay = { t: Long -> t in w.from..w.to } + val series = Summaries.heartRateSeries(hr.filter { inDay(it.timestamp) }, act.filter { inDay(it.timestamp) }) + val estimated = if (series.size >= MIN_MINUTES_FOR_ESTIMATE) Summaries.estimateRestingHr(series) else null + DayValue(d, (stored ?: estimated)?.toFloat()) to (stored == null && estimated != null) + } + val hrvTrend = days.map { d -> + val w = DayWindow.of(d, zone) + val v = hrv.filter { it.timestamp in w.from..w.to }.map { it.milliseconds } + DayValue(d, if (v.isEmpty()) null else v.average().toFloat()) + } + WeeklyTrends(restingTrend.map { it.first }, restingTrend.map { it.second }, hrvTrend) + } + } + + /** Averages over the 7 days before [date]. */ + private fun baselines(date: LocalDate): Flow { + val previous = DayWindow(DayWindow.of(date.minusDays(7)).from, DayWindow.of(date.minusDays(1)).to) + return combine( + combine(health.restingHr(previous.from, previous.to), health.hrv(previous), health.spo2(previous)) { r, h, s -> + Triple(r, h, s) + }, + combine(health.stress(previous), health.skinTemperature(previous), health.breathRate(previous)) { st, sk, br -> + Triple(st, sk, br) + }, + ) { (resting, hrv, spo2), (stress, skin, breath) -> + fun List.mean() = if (isEmpty()) null else map { it.toFloat() }.average().toFloat() + Baselines( + restingHr = resting.map { it.bpm }.mean(), + hrv = hrv.map { it.milliseconds }.mean(), + spo2 = spo2.map { it.percent }.mean(), + stress = stress.map { it.value }.mean(), + skinTemperature = skin.map { it.celsius }.mean(), + breathRate = breath.map { it.perMinute }.mean(), + ) + } + } + + private class WeeklyTrends(val resting: List, val restingEstimated: List, val hrv: List) + + private companion object { + /** Minutes of heart-rate data a day needs before we estimate its resting HR ourselves. */ + const val MIN_MINUTES_FOR_ESTIMATE = 6 * 60 + + const val CM_PER_M = 100 + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/RoomWorkoutRepository.kt b/app/src/main/kotlin/org/openstrap/app/data/RoomWorkoutRepository.kt new file mode 100644 index 00000000..cb2798db --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/RoomWorkoutRepository.kt @@ -0,0 +1,203 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.HealthDao +import org.openstrap.app.data.db.SportEffectRow +import org.openstrap.app.data.settings.UserProfile +import java.time.LocalDate + +/** + * The [WorkoutRepository] over Room. Nothing here picks a thread: the callers' flowOn and withContext do, as the rows + * are heavy to work through. + */ +// Most of its functions are overrides of the interface's, which detekt counts too. +@Suppress("TooManyFunctions") +@OptIn(ExperimentalCoroutinesApi::class) +class RoomWorkoutRepository( + private val dao: HealthDao, + private val profile: Flow, + maxHeartRates: MaxHeartRates, +) : WorkoutRepository { + /** + * Zones fall back to the app's one max-HR rule (tested value, Tanaka, strap, raised by observed peaks) when a + * workout has no sport effect of its own. The app's shared one, so the 90-day read behind it runs once per profile + * change for every screen. + */ + private val learnedMaxHr: Flow = maxHeartRates.current.filterNotNull().map { it.bpm }.distinctUntilChanged() + + /** [learnedMaxHr], and again whenever your zones move ([HeartZones.current]), so what reads zones reads them afresh. */ + private val fallbackMaxHr: Flow = combine(learnedMaxHr, HeartZones.current) { maxHr, _ -> maxHr } + + override fun maxHeartRate(): Flow = learnedMaxHr + + override fun effects(): Flow> = dao.sportEffects() + + /** + * Each listed workout's zone seconds, by (from the phone, start), worked out from its whole HR stream as its page + * does, so the list and the page agree. Only for workouts whose row, effect, max HR or stream, or your zones, + * changed ([ZoneCache], one per collection). Empty at first, so the list shows before its zones fill in. + */ + private fun listZones(): Flow, List>> = flow { + val zoneCache = ZoneCache>() + emitAll( + combine( + dao.workouts(), dao.sportEffects(), dao.workoutHeartRateMarks(), + combine(dao.appWorkouts(), dao.appWorkoutHeartRateMarks()) { phone, marks -> phone to marks }, + combine(learnedMaxHr, HeartZones.current, ::Pair), + ) { ws, effects, marks, (phone, phoneMarks), (maxHr, zones) -> + val strapMark = marks.associateBy { it.workoutStart } + val phoneMark = phoneMarks.associateBy { it.workoutStart } + val strapByStart = ws.associateBy { it.start } + val phoneByStart = phone.associateBy { it.start } + val strapStamps = ws.associate { w -> + (false to w.start) to listOf(w, Workouts.effectOf(w, effects), maxHr, zones, strapMark[w.start]) + } + val stamps = strapStamps + phone.associate { w -> (true to w.start) to listOf(w, maxHr, zones, phoneMark[w.start]) } + val analyse: suspend (Pair) -> List = { (fromPhone, start) -> + val a = if (fromPhone) WorkoutRecorder.analyse(phoneByStart.getValue(start), dao.appWorkoutHeartRateOnce(start), maxHr) + else Workouts.analyse(strapByStart.getValue(start), dao.workoutHeartRateOnce(start), effects, maxHr) + a.zoneSeconds + } + stamps to analyse + }.mapLatest { (stamps, analyse) -> zoneCache.zones(stamps, analyse) }, + ) + }.onStart { emit(emptyMap()) } + + /** + * List rows use the stored end, and the detail screen works out the real one from the HR stream. A phone strength + * workout carries its count of finished sets. + */ + override fun workouts(): Flow> = combine( + combine( + dao.workouts(), dao.sportEffects(), dao.lastWorkoutHeartRates(), + combine(dao.appWorkouts(), dao.setCounts()) { phone, sets -> phone to sets.associate { it.workoutStart to it.sets } }, + fallbackMaxHr, + ) { ws, effects, last, (phone, sets), maxHr -> Workouts.listed(ws, effects, last, phone, sets, maxHr) }, + listZones(), + ) { list, zones -> list.map { a -> zones[a.fromPhone to a.workout.start]?.let { a.copy(zoneSeconds = it) } ?: a } } + + override suspend fun dayMinuteHeartRate(date: LocalDate): List> { + val w = DayWindow.of(date) + return Summaries.bucketAverage(Summaries.heartRateSeries(dao.heartRateOnce(w.from, w.to), dao.activityOnce(w.from, w.to)), MINUTE_S) + } + + /** Sleep, saved workouts and stretches answered "Not a workout" never count. */ + override fun suggestions(from: Long, now: () -> Long): Flow> = combine( + combine(dao.heartRate(from, Long.MAX_VALUE), dao.activity(from, Long.MAX_VALUE)) { hr, minutes -> + Summaries.bucketAverage(Summaries.heartRateSeries(hr, minutes), MINUTE_S) + }, + combine( + dao.workouts(), dao.appWorkouts(), dao.dismissedWorkouts(from), SleepSource(dao).sessions(from, Long.MAX_VALUE), + ) { strap, phone, dismissed, sleep -> + strap.map { it.start..it.end } + phone.map { it.start..(it.recordedEnd ?: it.end ?: it.start) } + + dismissed.map { it.start..it.end } + sleep.map { it.start..it.end } + }, + fallbackMaxHr, + ) { series, busy, maxHr -> Triple(series, busy, maxHr) } + .mapLatest { (series, busy, maxHr) -> + val at = now() + val resting = dao.restingHrOnce(at - 14 * 86_400L, at).lastOrNull()?.bpm ?: 60 + WorkoutSuggestions.find(series, resting, maxHr, busy) + } + + override fun open(start: Long): Flow = dao.appWorkout(start).flatMapLatest { phone -> + if (phone != null) phoneAnalysis(start) + else combine(dao.workout(start), dao.workoutHeartRate(start), dao.sportEffects(), fallbackMaxHr) { w, hr, effects, maxHr -> + w?.let { Workouts.analyse(it, hr, effects, maxHr) } + } + } + + /** A phone workout with every second of it, updating as gap filling adds minutes. */ + private fun phoneAnalysis(start: Long): Flow = + combine(dao.appWorkout(start), dao.appWorkoutHeartRate(start), fallbackMaxHr) { w, hr, maxHr -> + w?.let { WorkoutRecorder.analyse(it, hr, maxHr) } + } + + /** Its analysis, everything recorded (for trimming), and the strap's own record of it when there is one. */ + override fun phoneSummary(start: Long): Flow = + combine( + dao.appWorkout(start), dao.appWorkoutHeartRate(start), fallbackMaxHr, + combine(dao.route(start), dao.sets(start), dao.rounds(start)) { r, s, n -> Triple(r, s, n) }, + ) { w, hr, maxHr, extra -> + Triple(w, hr, maxHr) to extra + } + .mapLatest { (main, extra) -> + val (routeRows, sets, rounds) = extra + val (w, hr, maxHr) = main + w ?: return@mapLatest null + val a = WorkoutRecorder.analyse(w, hr, maxHr) + // The strap's own records of this workout ([Workouts.sameWorkout]), which the lists leave out. + val effects = dao.sportEffects().first() + val strap = Workouts.strapTwins(dao.workoutsOnce(w.from, w.end ?: a.end), w.from..(w.end ?: a.end)) + .map { s -> Workouts.analyse(s, dao.workoutHeartRate(s.start).first(), effects, maxHr) } + val route = routeRows.map { it.point }.filter { it.t in w.from..(w.end ?: Long.MAX_VALUE) } + val below = Strength.Rest.parse(w.rest)?.belowBpm + val live = hr.filter { !it.fromHistory }.map { it.timestamp to it.bpm } + WorkoutSummaryData( + a, WorkoutRecorder.expand(hr), w.start, w.recordedEnd ?: w.end ?: a.end, w.endReason, + route, live, w.distanceMeters, w.distanceFromSteps, sets, strap, + w.lapMarks.takeIf { it.isNotEmpty() }?.let { marks -> + WorkoutRecorder.laps(w.from, w.end ?: a.end, marks, live, route, w.pauseRanges) + }.orEmpty(), + w.pauseRanges, w.effectFromStrap, + Structure.parse(w.structure), rounds, below, + below?.let { restBefore(w, it) }, + ) + } + + /** Saves nothing for a workout that already has its effect ([WorkoutEffectEngine.backfill]). */ + override suspend fun backfillEffect(start: Long) { + val w = dao.appWorkoutOnce(start) ?: return + WorkoutEffectEngine(dao).backfill(w, profile.first()) + } + + /** + * How fast rest brought the heart rate under [below] in the last strength session before [w] with sets, in the + * fortnight before ([WorkoutSummaryData.restRecovery]), or null without one. + */ + private suspend fun restBefore(w: AppWorkout, below: Int): Long? { + val last = dao.appWorkoutsOnce(w.start - 14 * 86_400L, w.start - 1) + .filter { it.end != null && it.start < w.start && it.sportType in SportTypes.STRENGTH } + .maxByOrNull { it.start } ?: return null + val readings = dao.appWorkoutHeartRateOnce(last.start).map { it.timestamp to it.bpm } + return WorkoutSummaryData.restRecovery(dao.setsOnce(last.start), readings, below) + } + + /** From the strap's own workouts, whose recording runs on after the end. Phone workouts keep no drop yet. */ + override suspend fun recoveryDrops(before: Long, count: Int): List { + val maxHr = fallbackMaxHr.first() + val effects = dao.sportEffects().first() + return dao.workouts().first() + .filter { it.start < before && it.start >= before - USUAL_DROP_LOOKBACK_S } + .sortedByDescending { it.start } + .take(count) + .mapNotNull { w -> + Workouts.analyse(w, dao.workoutHeartRateOnce(w.start), effects, maxHr).takeIf { it.recoveryIsMeaningful }?.recovery60 + } + } + + override suspend fun deleteStrapWorkout(start: Long, now: Long) { + dao.deleteStrapWorkout(start, now) + } + + private companion object { + /** Heart rate a minute at a time. */ + const val MINUTE_S = 60L + + /** Drops older than this say little about your fitness now. */ + const val USUAL_DROP_LOOKBACK_S = 180 * 86_400L + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/Route.kt b/app/src/main/kotlin/org/openstrap/app/data/Route.kt index 63041f66..65453df0 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/Route.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/Route.kt @@ -83,11 +83,16 @@ object Route { * §5.1); a stretch of standing still ([still]) is left as its first fix, so a phone's wander adds nothing. */ fun track(points: List): List { + // A live workout's route is one list per fix, read by the screen, the map, the splits and the glance alike: + // the last one's track is kept, so a fix is smoothed once, not once for each of them. + lastTrack?.let { (from, track) -> if (from === points) return track } val smoothed = smooth(clean(points)) val still = still(smoothed) - return smoothed.filterIndexed { i, _ -> !still[i] || i == 0 || !still[i - 1] } + return smoothed.filterIndexed { i, _ -> !still[i] || i == 0 || !still[i - 1] }.also { lastTrack = points to it } } + @Volatile private var lastTrack: Pair, List>? = null + /** * Process noise (white acceleration, m/s^1.5) when fixes carry Doppler velocity: Zepp's q (native 02 §3.7), free * enough to follow a runner's corners, since the velocity measurement keeps it from chasing position noise. @@ -295,6 +300,13 @@ object Route { /** How far two fixes can lie apart from GPS error alone. */ private fun noise(a: Point, b: Point): Double = maxOf(MIN_MOVE_M, (a.accuracy + b.accuracy).toDouble()) + /** Least spread for a route to be worth a map: standing still gives a dot, and the map would zoom out to the world. */ + const val MAPPABLE_M = 25.0 + + /** Whether [points] spread at least [MAPPABLE_M] from the first one, so a map of them shows somewhere. */ + fun mappable(points: List): Boolean = + points.size >= 2 && points.any { meters(points[0], it) >= MAPPABLE_M } + /** Metres covered by [points] along [track], leaving out stretches that overlap [pauses]. */ fun distance(points: List, pauses: List = emptyList()): Double = track(points).zipWithNext().sumOf { (a, b) -> if (paused(a, b, pauses)) 0.0 else meters(a, b) } @@ -330,23 +342,45 @@ object Route { return out } + /** A rise counts toward the climb only once it is this many metres above the last low: GPS height is noisy. */ + const val CLIMB_STEP_M = 4.0 + + /** + * Metres climbed along [points]: the rises in GPS altitude, each counted once it clears [CLIMB_STEP_M] above the + * last low, from fixes good enough to count ([MAX_ACCURACY_M]). Null without heights. + */ + fun climb(points: List): Double? { + val heights = points.filter { it.accuracy <= MAX_ACCURACY_M }.mapNotNull { it.altitude } + if (heights.size < 2) return null + var total = 0.0 + var low = heights.first() + var high = low + for (h in heights) { + if (h > high) high = h + if (high - low >= CLIMB_STEP_M && h <= high - CLIMB_STEP_M) { + // Past a peak: the rise to it is climbed, and the next one starts from here. + total += high - low + low = h + high = h + } else if (h < low) { + low = h + high = h + } + } + if (high - low >= CLIMB_STEP_M) total += high - low + return total + } + /** Seconds per kilometre over the last [windowS] seconds of [points], or null with too little to say. */ fun currentPace(points: List, windowS: Long = 30): Double? { val clean = track(points) val last = clean.lastOrNull() ?: return null - val recent = clean.filter { it.t >= last.t - windowS } + val recent = clean.takeLastWhile { it.t >= last.t - windowS } val m = recent.zipWithNext().sumOf { (a, b) -> meters(a, b) } val s = recent.last().t - recent.first().t return if (m < 10 || s <= 0) null else s / (m / 1000) } - /** "5′42″" per kilometre, or null with no pace to show. */ - fun formatPace(secondsPerKm: Double?): String? { - if (secondsPerKm == null || secondsPerKm <= 0 || secondsPerKm > 3600) return null - val s = Math.round(secondsPerKm) - return "%d′%02d″".format(s / 60, s % 60) - } - /** * The route cut into stretches of one zone each, for drawing it in zone colours: each fix takes the zone of the * nearest heart rate within [MAX_HR_GAP_S] (time, bpm, ascending), else none (−1, drawn grey). diff --git a/app/src/main/kotlin/org/openstrap/app/data/SleepEdits.kt b/app/src/main/kotlin/org/openstrap/app/data/SleepEdits.kt new file mode 100644 index 00000000..242194c8 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/SleepEdits.kt @@ -0,0 +1,231 @@ +package org.openstrap.app.data + +import java.time.Instant +import java.time.ZoneId +import kotlin.math.abs +import org.openstrap.app.data.db.SleepEdit +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepStage + +/** + * The user's own times for a sleep or nap the strap detected, or its removal ([SleepEdit]). The strap's rows and + * stages stay as synced, keyed by its start; everything that reads sleep goes through [apply] (by way of + * [SleepSource]), and a session's stages through [clip]. Main sleep and naps share every rule but their lengths. + */ +object SleepEdits { + /** A nap shorter than this isn't kept: the strap itself doesn't report one shorter. */ + const val NAP_MIN_SECONDS = 5 * 60L + /** Longer than this it's no nap. */ + const val NAP_MAX_SECONDS = 4 * 3600L + const val SLEEP_MIN_SECONDS = 3600L + const val SLEEP_MAX_SECONDS = 16 * 3600L + /** How far either end may move from what the strap saw. */ + const val MAX_SHIFT_SECONDS = 3 * 3600L + /** A resync can move the strap's start a little; an edit still finds its session this close, of the same kind. */ + const val MATCH_SECONDS = 15 * 60L + /** Both ends moved less than this in all keeps the strap's own score. */ + const val KEEP_SCORE_SECONDS = 15 * 60L + /** Days a deleted session can be restored; after them it goes for good ([expired]). */ + const val KEEP_DAYS = 7 + + /** The strap's session [strap] with its [edit], when it has one. */ + data class Resolved(val strap: SleepSession, val edit: SleepEdit?) { + val deleted: Boolean get() = edit?.deleted == true + val moved: Boolean get() = edit?.moved == true + + /** The session as shown, or null when deleted. */ + val shown: SleepSession? get() = when { + edit == null -> strap + edit.deleted -> null + edit.moved -> moved(strap, edit.start!!, edit.end!!) + else -> strap + } + + /** The times to show: the user's when moved, else the strap's; also for a deleted one. */ + val start: Long get() = edit?.start ?: strap.start + val end: Long get() = edit?.end ?: strap.end + } + + /** + * Each of [sessions] with its edit: the one keyed by its exact start first, else an unused one of the same kind + * that's within [MATCH_SECONDS], the nearest. An edit goes to one session at most, and never across kinds. + */ + fun resolve(sessions: List, edits: List): List { + if (edits.isEmpty()) return sessions.map { Resolved(it, null) } + val byStart = edits.associateBy { it.strapStart } + val exact = sessions.associateWith { s -> byStart[s.start]?.takeIf { it.nap == s.nap } } + val used = exact.values.filterNotNull().mapTo(HashSet()) { it.strapStart } + return sessions.map { s -> + val e = exact[s] ?: edits.filter { it.strapStart !in used && it.nap == s.nap && abs(it.strapStart - s.start) <= MATCH_SECONDS } + .minByOrNull { abs(it.strapStart - s.start) } + ?.also { used += it.strapStart } + Resolved(s, e) + } + } + + /** [sessions] as shown: deleted ones left out, moved ones at the user's times ([moved]). */ + fun apply(sessions: List, edits: List): List = + if (edits.isEmpty()) sessions else resolve(sessions, edits).mapNotNull { it.shown } + + /** + * [strap] at [start] to [end]. Its stage totals are the strap's for its own span, so they go (the time asleep is + * then the span, until [restage] works them out from the clipped stages). Its score stays when the move is small + * ([keepsScore]); a main sleep's otherwise moves from the strap's by what the span changes ([anchored]), a nap's + * goes. + */ + fun moved(strap: SleepSession, start: Long, end: Long): SleepSession { + val s = strap.copy(start = start, end = end, deepMinutes = null, lightMinutes = null, remMinutes = null, awakeMinutes = null, wakeCount = null) + // Without stages both sides are their spans, so only the change in length counts. + return s.copy(score = score(strap, s, Part(((end - start) / 60).toInt(), null), Part(((strap.end - strap.start) / 60).toInt(), null))) + } + + /** + * A moved [shown] with its totals worked out from the strap's [stages] for its whole session, [clip]ped here to + * the user's times: what [moved] left out. [strap] is the strap's own session, for the score rule. + */ + fun restage(shown: SleepSession, strap: SleepSession, stages: List): SleepSession { + if (stages.isEmpty()) return shown + val clipped = clip(stages, shown.start, shown.end) + fun minutes(st: List, stage: Int) = (st.filter { it.stage == stage }.sumOf { it.end - it.start } / 60).toInt() + fun part(st: List) = Part( + listOf(SleepStage.DEEP, SleepStage.LIGHT, SleepStage.REM).sumOf { minutes(st, it) }, + Recovery.wasoMinutes(st.map { Triple(it.start, it.end, it.stage) }), + ) + return shown.copy( + deepMinutes = minutes(clipped, SleepStage.DEEP), lightMinutes = minutes(clipped, SleepStage.LIGHT), + remMinutes = minutes(clipped, SleepStage.REM), awakeMinutes = minutes(clipped, SleepStage.AWAKE), + score = score(strap, shown, part(clipped), part(stages)), + ) + } + + /** Minutes asleep and awake after sleep onset (null without stages): what OpenStrap's own score reads. */ + private data class Part(val asleep: Int, val waso: Int?) + + /** + * The strap's score when [shown] keeps it ([keepsScore]), else [anchored] to it for a main sleep the strap scored + * ([user] the shown span, [own] the strap's); a nap has none. + */ + private fun score(strap: SleepSession, shown: SleepSession, user: Part, own: Part): Int? = when { + keepsScore(strap, shown.start, shown.end) -> strap.score + strap.nap || strap.score == null -> null + else -> anchored( + strap.score, + Recovery.sleepScore(Recovery.Night(shown.day, shown.start, shown.end, user.asleep, wasoMinutes = user.waso)), + Recovery.sleepScore(Recovery.Night(strap.day, strap.start, strap.end, own.asleep, wasoMinutes = own.waso)), + (shown.end - shown.start) - (strap.end - strap.start), + ) + } + + /** + * The strap's [strapScore] moved by what OpenStrap's own score says the new times change ([ownShown] less + * [ownStrap]), so the strap's judgement of the night stays the anchor. A shorter night ([lengthChange], seconds, + * below 0) never scores higher than the strap's, nor a longer one lower, whatever the clipped stages do to its + * wake after sleep onset. 0 to 100. + */ + fun anchored(strapScore: Int, ownShown: Int, ownStrap: Int, lengthChange: Long): Int { + val moved = (strapScore + ownShown - ownStrap).coerceIn(0, 100) + return when { + lengthChange < 0 -> minOf(moved, strapScore) + lengthChange > 0 -> maxOf(moved, strapScore) + else -> moved + } + } + + /** True when [start] to [end] is close enough to [strap]'s own times to keep its score. */ + fun keepsScore(strap: SleepSession, start: Long, end: Long): Boolean = + abs(start - strap.start) + abs(end - strap.end) < KEEP_SCORE_SECONDS + + /** The strap's [stages] for a session, trimmed to [start] and [end]; stages outside go. */ + fun clip(stages: List, start: Long, end: Long): List = stages.mapNotNull { + val from = maxOf(it.start, start) + val to = minOf(it.end, end) + if (to <= from) null else it.copy(start = from, end = to) + } + + /** Why an edit can't be saved ([check]). [nap] says which kind's limits it broke, or ran into. */ + sealed interface Problem { + data object WakeBeforeSleep : Problem + data class TooShort(val nap: Boolean) : Problem + data class TooLong(val nap: Boolean) : Problem + data object Future : Problem + data object TooFarFromStrap : Problem + data class RunsInto(val nap: Boolean, val start: Long) : Problem + } + + /** + * Why [start] to [end] can't be saved for the strap's session [strap], or null when it can. [others] are the + * other sleep and naps as shown, which it mustn't run into. + */ + fun check(strap: SleepSession, start: Long, end: Long, now: Long, others: List = emptyList()): Problem? { + val nap = strap.nap + val overlap = others.filter { start < it.end && end > it.start }.minByOrNull { it.start } + return when { + end <= start -> Problem.WakeBeforeSleep + end - start < if (nap) NAP_MIN_SECONDS else SLEEP_MIN_SECONDS -> Problem.TooShort(nap) + end - start > if (nap) NAP_MAX_SECONDS else SLEEP_MAX_SECONDS -> Problem.TooLong(nap) + end > now -> Problem.Future + start < strap.start - MAX_SHIFT_SECONDS || end > strap.end + MAX_SHIFT_SECONDS -> Problem.TooFarFromStrap + overlap != null -> Problem.RunsInto(overlap.nap, overlap.start) + else -> null + } + } + + /** + * The moment at [minuteOfDay] (after local midnight) closest to [near]: a clock picked for a session's end is read + * on its own day, or the next or previous one when it crosses midnight. + */ + fun atNear(minuteOfDay: Int, near: Long, zone: ZoneId): Long { + val day = Instant.ofEpochSecond(near).atZone(zone).toLocalDate() + return (-1L..1L).map { day.plusDays(it).atStartOfDay(zone).plusMinutes(minuteOfDay.toLong()).toEpochSecond() } + .minBy { abs(it - near) } + } + + /** + * The row to save for [strap] moved to [start] and [end], given its [current] edit: null when the times are the + * strap's own again and it isn't deleted (the edit goes then). It keeps the current edit's key, which a resync may + * have left a few minutes off the strap's start. + */ + fun edit(strap: SleepSession, start: Long, end: Long, current: SleepEdit?, now: Long): SleepEdit? { + val own = start == strap.start && end == strap.end + val deleted = current?.deleted == true + if (own && !deleted) return null + return SleepEdit(current?.strapStart ?: strap.start, strap.nap, start.takeUnless { own }, end.takeUnless { own }, deleted, now) + } + + /** [strap] deleted, keeping any times the user had given it, so a restore brings them back too. */ + fun delete(strap: SleepSession, current: SleepEdit?, now: Long): SleepEdit = + SleepEdit(current?.strapStart ?: strap.start, strap.nap, current?.start, current?.end, deleted = true, editedAt = now) + + /** [edit] restored: its times stay; null when it had none (the edit goes then). */ + fun restore(edit: SleepEdit, now: Long): SleepEdit? = if (edit.moved) edit.copy(deleted = false, editedAt = now) else null + + /** True for a delete older than [KEEP_DAYS] at [now], whose session goes for good. */ + fun expired(edit: SleepEdit, now: Long): Boolean = edit.deleted && now - edit.editedAt >= KEEP_DAYS * 86_400L + + /** Whole days left at [now] before a deleted [edit]'s session goes for good, at least 1 while it's still there. */ + fun daysLeft(edit: SleepEdit, now: Long): Int = + ((KEEP_DAYS * 86_400L - (now - edit.editedAt) + 86_399) / 86_400).toInt().coerceIn(1, KEEP_DAYS) + + /** A nap's heart rate this close to the hour before it reads as sitting still, not sleep ([stillHeartRate]). */ + const val STILL_BPM = 5f + + /** + * The nap's average heart rate when it looks like sitting still rather than a nap (edit spec 4.5): within + * [STILL_BPM] of the hour before it, and no deep sleep. [heartRate] is (time, bpm) readings around the nap; null + * when it doesn't look like that, or there's too little to tell. Nothing is removed by it, the nap only says so. + */ + fun stillHeartRate(heartRate: List>, start: Long, end: Long, deepSeconds: Long): Int? { + if (deepSeconds > 0) return null + val during = heartRate.filter { it.first in start..end }.map { it.second } + val before = heartRate.filter { it.first in start - 3600 until start }.map { it.second } + if (during.isEmpty() || before.isEmpty()) return null + val avg = during.average().toFloat() + return if (abs(avg - before.average().toFloat()) <= STILL_BPM) Math.round(avg) else null + } + + /** + * What's left of a delete [edit] once its session has gone for good: a marker with no times, so a later resync of + * that sleep from the strap is dropped again instead of coming back. + */ + fun marker(edit: SleepEdit): SleepEdit = edit.copy(start = null, end = null) +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/SleepHistory.kt b/app/src/main/kotlin/org/openstrap/app/data/SleepHistory.kt index b25272ca..17ba4380 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/SleepHistory.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/SleepHistory.kt @@ -15,14 +15,21 @@ object SleepHistory { /** Nights the Sleep screen's week looks back over. */ const val WEEK_NIGHTS = 7 - /** The night a sleep belongs to: the day it ended on, counting until 18:00 (see DayWindow.nightEndingOn). */ - fun nightOf(end: Long, zone: ZoneId): LocalDate = Instant.ofEpochSecond(end + 6 * 3600).atZone(zone).toLocalDate() + /** Nights of sleep loaded for the week view and the in-sleep vitals' usual values. */ + const val NIGHTS = 14 - /** The main sleep of each night: the longest session that isn't a nap. */ + /** + * The night a sleep ending at [end] belongs to: the day it ended on, the rule recovery counts by + * ([RecoveryEngine.morningOf]). + */ + fun nightOf(end: Long, zone: ZoneId): LocalDate = Days.date(end, zone) + + /** The night [s] belongs to: the day the strap gave it, else the day it ended on ([RecoveryEngine.morningOf]). */ + fun nightOf(s: SleepSession, zone: ZoneId): LocalDate = Days.date(RecoveryEngine.morningOf(s, zone), zone) + + /** The main sleep of each night: the longest session that isn't a nap, as recovery picks it ([RecoveryEngine.mainsByMorning]). */ fun mainSleeps(sessions: List, zone: ZoneId): Map = - sessions.filter { !it.nap } - .groupBy { nightOf(it.end, zone) } - .mapValues { (_, s) -> s.maxBy { it.end - it.start } } + RecoveryEngine.mainsByMorning(sessions, zone).mapKeys { (morning, _) -> Days.date(morning, zone) } /** * The main sleep of the night in [night] (a [DayWindow.nightEndingOn]): the longest session that isn't a nap and @@ -32,21 +39,29 @@ object SleepHistory { sessions.filter { !it.nap && it.end in night.from..night.to }.maxByOrNull { it.end - it.start } /** - * Time asleep on the week's other nights, for the longest or shortest rule ([org.openstrap.app.ui.dashboard.TabCopy.night]): + * [mainSleep] over the strap's sessions with their edits, by the times shown, so Today and the widgets pick the + * same night. The deleted ones are left out, unless [deleted] asks for the main sleep among them instead, which is + * what the No sleep recorded card offers to restore. + */ + fun mainSleep(resolved: List, night: DayWindow, deleted: Boolean = false): SleepEdits.Resolved? = + resolved.filter { it.deleted == deleted && !it.strap.nap && it.end in night.from..night.to }.maxByOrNull { it.end - it.start } + + /** + * Time asleep on the week's other nights, for the longest or shortest rule (`TabCopy.night`): * the [WEEK_NIGHTS] nights ending on [date] ([nights] by [mainSleeps]) without [date] itself, those with a sleep. */ fun weekOthers(nights: Map, date: LocalDate): List = (WEEK_NIGHTS - 1 downTo 1).mapNotNull { nights[date.minusDays(it.toLong())] }.map(::asleepSeconds) - /** Time asleep from the strap's stage totals, or the whole session when it didn't send them. */ - fun asleepSeconds(s: SleepSession): Long { - val staged = listOfNotNull(s.deepMinutes, s.lightMinutes, s.remMinutes) - return if (staged.isEmpty()) s.end - s.start else staged.sum() * 60L - } + /** + * Time asleep from the strap's stage totals, or the time in bed less awake when it didn't send them: the rule + * recovery counts by ([RecoveryEngine.asleepMinutes]). + */ + fun asleepSeconds(s: SleepSession): Long = RecoveryEngine.asleepMinutes(s) * 60L /** * The average fall-asleep and wake-up time over the nights up to [end]. [bedtime] and [wake] are average minutes - * past midnight (bedtime can be past 24 h · 60 when it's after midnight; use [clockMinutes] to print it). + * past midnight (bedtime can be past 24 times 60 when it's after midnight, and [clockMinutes] prints it). */ data class UsualTimes(val bedtime: Int, val wake: Int, val nights: Int) diff --git a/app/src/main/kotlin/org/openstrap/app/data/SleepSource.kt b/app/src/main/kotlin/org/openstrap/app/data/SleepSource.kt new file mode 100644 index 00000000..c23b9907 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/SleepSource.kt @@ -0,0 +1,136 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import org.openstrap.app.data.db.HealthDao +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepStage + +/** + * Sleep as shown: the strap's sessions through the user's edits ([SleepEdits]), deleted ones left out, moved ones at + * the user's times with their stages clipped and their totals worked out again ([SleepEdits.restage]). The only way + * the app reads sleep for display or maths, so every screen, widget and model counts the same sleep: apart from the + * sync that writes them and the backup that copies them as they are, nothing reads the DAO's sleep sessions or stages + * directly (SleepReadersTest checks). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SleepSource(private val dao: HealthDao) { + /** + * Each strap session with its edit, deleted ones too, whose end as shown falls in [from, to]. The strap's are read + * from further out, since a moved end can be up to [SleepEdits.MAX_SHIFT_SECONDS] from the strap's. + */ + suspend fun resolvedOnce(from: Long, to: Long): List = + resolvedIn(dao.sleepSessionsOnce(widen(from, -1), widen(to, 1)), dao.sleepEditsOnce(), from, to) + + fun resolved(from: Long, to: Long): Flow> = + combine(dao.sleepSessions(widen(from, -1), widen(to, 1)), dao.sleepEdits()) { s, e -> resolvedIn(s, e, from, to) } + + /** Sessions as shown whose end falls in [from, to], by start. */ + suspend fun sessionsOnce(from: Long, to: Long): List = shown(resolvedOnce(from, to)) + + fun sessions(from: Long, to: Long): Flow> = resolved(from, to).mapLatest { shown(it) } + + /** The stages of a session as shown ([sessionsOnce]): the strap's, clipped to its times. */ + suspend fun stagesOnce(session: SleepSession): List { + val strap = movedFrom(session) ?: return dao.sleepStagesOnce(session.start) + return SleepEdits.clip(dao.sleepStagesOnce(strap), session.start, session.end) + } + + /** The stages of [r] as shown: the strap's, clipped to the user's times when moved. */ + suspend fun stagesOnce(r: SleepEdits.Resolved): List = + dao.sleepStagesOnce(r.strap.start).let { if (r.moved) SleepEdits.clip(it, r.start, r.end) else it } + + /** The stages of [r] as shown, as they change: the strap's, clipped to the user's times when moved. */ + fun stages(r: SleepEdits.Resolved): Flow> = + dao.sleepStages(r.strap.start).map { if (r.moved) SleepEdits.clip(it, r.start, r.end) else it } + + /** The strap's own stages for its session [strap], unclipped: for showing what a move left out. */ + fun strapStages(strap: SleepSession): Flow> = dao.sleepStages(strap.start) + + /** The stages of a session as shown ([sessions]), as they change. */ + fun stages(session: SleepSession): Flow> = + kotlinx.coroutines.flow.flow { emit(movedFrom(session)) }.flatMapLatest { strap -> + if (strap == null) dao.sleepStages(session.start) else dao.sleepStages(strap).map { SleepEdits.clip(it, session.start, session.end) } + } + + /** + * True when the night [from, to] (a [DayWindow.nightEndingOn]) has no main sleep because the user deleted it, so + * what shows it says so instead of asking whether the strap was on. + */ + suspend fun nightDeleted(from: Long, to: Long): Boolean { + val mains = resolvedOnce(from, to).filter { !it.strap.nap } + return mains.isNotEmpty() && mains.all { it.deleted } + } + + /** + * The sleep and naps deleted less than [SleepEdits.KEEP_DAYS] before [now] whose sessions are still there, newest + * delete first: the Deleted sleep list. + */ + suspend fun deletedOnce(now: Long): List { + val edits = dao.sleepEditsOnce() + return edits.filter { it.deleted && !SleepEdits.expired(it, now) }.sortedByDescending { it.editedAt }.mapNotNull { e -> strapOf(e, edits) } + } + + fun deleted(now: () -> Long): Flow> = dao.sleepEdits().mapLatest { deletedOnce(now()) } + + /** + * Removes for good the session and stages of every delete older than [SleepEdits.KEEP_DAYS] at [now], and keeps + * its edit as a marker without times ([SleepEdits.marker]), so a resync of that sleep is dropped again. Run after + * every sync, which also takes away a resynced copy. Returns the sessions removed. + */ + suspend fun purgeExpired(now: Long): Int { + val edits = dao.sleepEditsOnce() + val expired = edits.filter { SleepEdits.expired(it, now) } + val starts = expired.mapNotNull { strapOf(it, edits)?.strap?.start }.distinct() + val markers = expired.filter { it.moved }.map { SleepEdits.marker(it) } + // One transaction: a kill half way must not leave a session without its stages, or an edit still moved. + if (starts.isNotEmpty() || markers.isNotEmpty()) dao.purgeSleepEdits(starts, markers) + return starts.size + } + + /** + * [strap]'s edit, resolved among the sessions near it as [resolved] resolves it: alone, a session with no edit of + * its own would take a neighbour's within [SleepEdits.MATCH_SECONDS], and saving over it would change that one. + */ + suspend fun editOf(strap: SleepSession): org.openstrap.app.data.db.SleepEdit? { + val near = dao.sleepSessionsStartingOnce(strap.start - 2 * SleepEdits.MATCH_SECONDS, strap.start + 2 * SleepEdits.MATCH_SECONDS) + .filter { it.start != strap.start } + strap + return SleepEdits.resolve(near.sortedBy { it.start }, dao.sleepEditsOnce()).first { it.strap.start == strap.start }.edit + } + + /** The strap's session [edit] resolves to among [edits], when it's still there. */ + private suspend fun strapOf(edit: org.openstrap.app.data.db.SleepEdit, edits: List): SleepEdits.Resolved? { + val near = dao.sleepSessionsStartingOnce(edit.strapStart - SleepEdits.MATCH_SECONDS, edit.strapStart + SleepEdits.MATCH_SECONDS) + return SleepEdits.resolve(near, edits).firstOrNull { it.edit == edit } + } + + /** [resolved] as shown ([sessions]): deleted ones left out, moved ones with their totals from their clipped stages. */ + suspend fun shown(resolved: List): List = resolved.mapNotNull { r -> + val s = r.shown ?: return@mapNotNull null + if (!r.moved) s else SleepEdits.restage(s, r.strap, dao.sleepStagesOnce(r.strap.start)) + } + + /** + * The strap's start for a session as shown that the user moved: the strap session the moved edit with exactly its + * times resolves to (a resync may have left the edit's key a few minutes off). Null when it isn't moved. + */ + private suspend fun movedFrom(session: SleepSession): Long? { + val edits = dao.sleepEditsOnce() + val e = edits.firstOrNull { !it.deleted && it.nap == session.nap && it.start == session.start && it.end == session.end } ?: return null + return strapOf(e, edits)?.strap?.start ?: e.strapStart + } + + private fun resolvedIn(sessions: List, edits: List, from: Long, to: Long) = + SleepEdits.resolve(sessions, edits).filter { it.end in from..to } + + /** [t] moved by [SleepEdits.MAX_SHIFT_SECONDS] in [sign]'s direction, without running past Long's ends. */ + private fun widen(t: Long, sign: Int): Long = when { + sign > 0 && t > Long.MAX_VALUE - SleepEdits.MAX_SHIFT_SECONDS -> Long.MAX_VALUE + sign < 0 && t < Long.MIN_VALUE + SleepEdits.MAX_SHIFT_SECONDS -> Long.MIN_VALUE + else -> t + sign * SleepEdits.MAX_SHIFT_SECONDS + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/SportTypes.kt b/app/src/main/kotlin/org/openstrap/app/data/SportTypes.kt new file mode 100644 index 00000000..7ea31294 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/SportTypes.kt @@ -0,0 +1,46 @@ +package org.openstrap.app.data + +/** + * Groups of Zepp's sport type ids ([Workouts.sportName] names each one), declared once so the engines, screens and + * icons agree on what counts as a run or strength work. + */ +object SportTypes { + const val OUTDOOR_RUN = 1 + const val TREADMILL = 2 + const val WALK = 3 + const val OUTDOOR_CYCLING = 4 + const val POOL_SWIM = 6 + const val OPEN_WATER_SWIM = 7 + const val INDOOR_CYCLING = 8 + const val TRAIL_RUN = 11 + const val HIKING = 15 + const val ROWING_MACHINE = 23 + const val INDOOR_FITNESS = 24 + const val INDOOR_WALK = 40 + const val CORE_TRAINING = 50 + const val STRENGTH_TRAINING = 52 + const val SPINNING = 143 + const val DOG_WALKING = 215 + const val HYROX_TRAINING = 222 + const val MOUNTAIN_HIKE = 224 + const val RUCKING = 225 + const val HYBRID_TRAINING = 226 + + /** Runs, where distance comes with a pace. */ + val RUNS: Set = setOf(OUTDOOR_RUN, TREADMILL, TRAIL_RUN) + + /** Everything done on foot, where steps can stand in for distance. */ + val ON_FOOT: Set = RUNS + setOf(WALK, HIKING, INDOOR_WALK, DOG_WALKING, MOUNTAIN_HIKE, RUCKING) + + /** On foot but not running: walks, hikes and rucking, where distance alone says it. */ + val WALKS: Set = ON_FOOT - RUNS + + /** Hikes, a kind of walk with a noun and an icon of their own. */ + val HIKES: Set = setOf(HIKING, MOUNTAIN_HIKE) + + val RIDES: Set = setOf(OUTDOOR_CYCLING, INDOOR_CYCLING, SPINNING) + val SWIMS: Set = setOf(POOL_SWIM, OPEN_WATER_SWIM) + + /** Sports counted in sets rather than distance ([Strength]). */ + val STRENGTH: Set = setOf(INDOOR_FITNESS, CORE_TRAINING, STRENGTH_TRAINING, HYROX_TRAINING, HYBRID_TRAINING) +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/Stats.kt b/app/src/main/kotlin/org/openstrap/app/data/Stats.kt new file mode 100644 index 00000000..6aaad1ef --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/Stats.kt @@ -0,0 +1,33 @@ +package org.openstrap.app.data + +/** Order statistics the engines share, so a median is one median everywhere. */ +object Stats { + /** The median of [values]: the middle one, or the mean of the two middle ones. Null when empty. */ + fun median(values: List): Float? = + if (values.isEmpty()) null else values.sorted().let { (it[(it.size - 1) / 2] + it[it.size / 2]) / 2f } + + /** [median] of whole numbers, which may fall on a half. */ + @JvmName("medianOfInts") + fun median(values: List): Float? = median(values.map { it.toFloat() }) + + /** + * The upper of the two middle values for an even count, the middle one otherwise. For a reading that must be one + * that was taken, such as a heart rate at a point in time. + */ + fun upperMiddle(values: List): Int? = if (values.isEmpty()) null else values.sorted()[values.size / 2] + + /** The [p] quantile (0 to 1, clamped) of [values] by linear interpolation between neighbours, null when empty. */ + fun quantile(values: List, p: Float): Float? { + if (values.isEmpty()) return null + return quantileOfSorted(values.sorted().map { it.toDouble() }, p.toDouble()).toFloat() + } + + /** [quantile] of values already sorted ascending, which must not be empty. */ + fun quantileOfSorted(sorted: List, p: Double): Double { + require(sorted.isNotEmpty()) { "no values" } + val pos = p.coerceIn(0.0, 1.0) * (sorted.size - 1) + val lo = pos.toInt() + val hi = minOf(lo + 1, sorted.lastIndex) + return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/Strength.kt b/app/src/main/kotlin/org/openstrap/app/data/Strength.kt index 96cff3f8..a738e053 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/Strength.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/Strength.kt @@ -7,9 +7,6 @@ package org.openstrap.app.data * the time limit always applies. */ object Strength { - /** Sports counted in sets rather than distance. */ - val SPORTS = setOf(24, 50, 52, 222, 226) - /** How rest works for a workout, chosen on the ready screen; stored as [code] ("r:90:110"). */ data class Rest(val seconds: Long, val belowBpm: Int?) { val code: String get() = "r:$seconds:${belowBpm ?: 0}" @@ -23,9 +20,74 @@ object Strength { } val TIMES = listOf(60L, 90L, 120L, 180L) val LEVELS = listOf(null, 100, 110, 120) + val RANGE_S = 30L..300L + } + } + + /** Ready's sets stepper; below its first is open, no plan. */ + val SETS = 1..10 + + /** The exercise list's built in lifts (workouts spec 11: a short list plus typed names), after your own. */ + val COMMON_LIFTS = listOf( + "Bench press", "Squat", "Deadlift", "Overhead press", "Barbell row", "Pull up", "Lat pulldown", "Romanian deadlift", + "Hip thrust", "Leg press", "Lunge", "Dumbbell curl", "Triceps extension", "Push up", "Dips", "Plank", + ) + + /** The exercise list: yours, last used first ([used]), then the built in lifts you haven't used. */ + fun exerciseList(used: List): List = used + COMMON_LIFTS.filter { c -> used.none { it.equals(c, ignoreCase = true) } } + + /** Without anything to fill the log card from: 10 reps and 20 kg. */ + const val DEFAULT_REPS = 10 + const val DEFAULT_KG = 20f + + /** The log card's reps and kg steppers. */ + val REPS = 1..100 + const val KG_STEP = 2.5f + const val KG_MAX = 500f + + fun stepReps(reps: Int, by: Int): Int = (reps + by).coerceIn(REPS) + fun stepKg(kg: Float, by: Int): Float = (kg + by * KG_STEP).coerceIn(0f, KG_MAX) + + /** + * What the log card opens on for [exercise] (workouts spec 2.4): the last set of it logged in this workout ([sets]), + * else what was last logged for it ([lastReps], [lastKg] from the exercise list), else [DEFAULT_REPS] and + * [DEFAULT_KG]. + */ + fun prefill(exercise: String?, sets: List, lastReps: Int?, lastKg: Float?): Pair { + val here = sets.lastOrNull { it.reps != null && it.exercise.equals(exercise, ignoreCase = true) } + return (here?.reps ?: lastReps ?: DEFAULT_REPS) to (here?.weightKg ?: lastKg ?: DEFAULT_KG) + } + + /** Kilograms lifted: reps times weight over the sets logged with both; null when none was. */ + fun kgLifted(sets: List): Float? { + val logged = sets.filter { it.reps != null && it.weightKg != null } + return if (logged.isEmpty()) null else logged.sumOf { it.reps!! * it.weightKg!!.toDouble() }.toFloat() + } + + /** + * The highest heart rate of [readings] (time, bpm, oldest first, as every workout's are) from [from] to [to]; null + * without any. Found by a binary search and a scan of just that stretch: it's asked once per set row and round. + */ + fun peak(readings: List>, from: Long, to: Long): Int? { + var lo = 0 + var hi = readings.size + while (lo < hi) { + val mid = (lo + hi) ushr 1 + if (readings[mid].first < from) lo = mid + 1 else hi = mid } + var best: Int? = null + var i = lo + while (i < readings.size && readings[i].first <= to) { + val bpm = readings[i].second + if (best == null || bpm > best) best = bpm + i++ + } + return best } + /** "60", or "62.5" kg. */ + fun kgText(kg: Float): String = if (kg % 1f == 0f) kg.toInt().toString() else String.format(java.util.Locale.ROOT, "%.1f", kg) + /** * Whether a rest that began at [restStart] (and may have been extended to [restUntil]) is over at [now]: its time * is up, or heart rate has stayed below the rule's level for [HR_HOLD_S] seconds after [MIN_REST_S] of rest. diff --git a/app/src/main/kotlin/org/openstrap/app/data/Summaries.kt b/app/src/main/kotlin/org/openstrap/app/data/Summaries.kt index 02e90399..43cb0481 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/Summaries.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/Summaries.kt @@ -3,21 +3,30 @@ package org.openstrap.app.data import org.openstrap.app.data.db.ActivityMinute import org.openstrap.app.data.db.HeartRateSample import org.openstrap.app.data.db.SleepStage +import java.time.Instant +import java.time.ZoneId /** Pure aggregation helpers for the dashboard; kept free of Android types so they're unit-testable. */ object Summaries { fun totalSteps(minutes: List): Int = minutes.sumOf { it.steps } - /** 24 buckets of steps for the day starting at [dayStart]. */ - fun hourlySteps(minutes: List, dayStart: Long): List { - val buckets = FloatArray(24) + /** + * 24 buckets of steps for the day starting at [dayStart], one for each hour on the clock in [zone]. A day the + * clocks change on has 23 or 25 hours, so a minute goes by its local hour rather than by hours since midnight: + * the hour that repeats shares its bucket, and the one skipped stays empty. + */ + fun hourlySteps(minutes: List, dayStart: Long, zone: ZoneId = ZoneId.systemDefault()): List { + val buckets = FloatArray(HOURS) + val dayEnd = Days.end(dayStart, zone) for (m in minutes) { - val hour = ((m.timestamp - dayStart) / 3600).toInt() - if (hour in 0..23) buckets[hour] += m.steps.toFloat() + if (m.timestamp < dayStart || m.timestamp >= dayEnd) continue + buckets[Instant.ofEpochSecond(m.timestamp).atZone(zone).hour] += m.steps.toFloat() } return buckets.toList() } + private const val HOURS = 24 + /** * The day's running total at the end of each hour from its [hourly] steps, the Steps card's line: null for the * hours after [upTo] (the hour now, on today), every hour on a past day ([upTo] null). @@ -33,11 +42,29 @@ object Summaries { /** Merges heart rate from dedicated HR samples and per-minute activity, preferring HR samples. */ fun heartRateSeries(hr: List, minutes: List): List> { val byTime = sortedMapOf() - for (m in minutes) if (m.heartRate != null && m.heartRate in 30..230) byTime[m.timestamp] = m.heartRate - for (s in hr) if (s.bpm in 30..230) byTime[s.timestamp] = s.bpm + for (m in minutes) if (m.heartRate != null && m.heartRate in HeartRate.VALID) byTime[m.timestamp] = m.heartRate + for (s in hr) if (s.bpm in HeartRate.VALID) byTime[s.timestamp] = s.bpm return byTime.map { it.key to it.value } } + /** + * [items] averaged into [n] buckets, each into the one [index] gives it (those outside left out); an empty bucket + * is null, a gap in the chart's line. Every chart's evenly spaced line is made here. + */ + inline fun bucketMeans(items: Iterable, n: Int, index: (T) -> Int, value: (T) -> Float): List { + val sums = FloatArray(n) + val counts = IntArray(n) + for (item in items) { + val i = index(item) + if (i in 0 until n) { sums[i] += value(item); counts[i]++ } + } + return List(n) { if (counts[it] == 0) null else sums[it] / counts[it] } + } + + /** [items] averaged into [seconds]-wide buckets across [range] by their [time], at least two ([bucketMeans]). */ + inline fun evenly(items: Iterable, range: LongRange, seconds: Long, time: (T) -> Long, value: (T) -> Float): List = + bucketMeans(items, ((range.last - range.first + 1) / seconds).toInt().coerceAtLeast(2), { ((time(it) - range.first) / seconds).toInt() }, value) + /** Averages a series into fixed buckets (e.g. 5 minutes) for a calmer chart line. */ fun bucketAverage(series: List>, bucketSeconds: Long = 300): List> = series.groupBy { it.first / bucketSeconds * bucketSeconds } diff --git a/app/src/main/kotlin/org/openstrap/app/data/Today.kt b/app/src/main/kotlin/org/openstrap/app/data/Today.kt new file mode 100644 index 00000000..01b332cf --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/Today.kt @@ -0,0 +1,60 @@ +package org.openstrap.app.data + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import java.time.Clock +import java.time.LocalDate + +/** + * Today's date as it moves, for screens left open past midnight (audit C10). A date read once when a view model is + * made stays on the day it was made, so the day shown and today's live steps never roll over. + */ +object Today { + private const val MINUTE_MS = 60_000L + + /** The date by [clock] now. */ + fun on(clock: Clock): LocalDate = LocalDate.now(clock) + + /** + * Today by [clock], at once and again at each new minute and on each of [changes], only when the date moved. + * [clock] is asked afresh every time, so the default follows a change of time zone too. + */ + fun dates(clock: () -> Clock = { Clock.systemDefaultZone() }, changes: Flow = emptyFlow()): Flow { + val minutes = flow { + while (true) { + emit(Unit) + delay(MINUTE_MS - Math.floorMod(clock().millis(), MINUTE_MS)) + } + } + return merge(minutes, changes).map { on(clock()) }.distinctUntilChanged() + } + + /** The phone's clock, time zone or date was changed, so today may be another day without waiting for the minute. */ + fun changes(context: Context): Flow = callbackFlow { + val receiver = object : BroadcastReceiver() { + override fun onReceive(receiver: Context, intent: Intent) { + trySend(Unit) + } + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_TIME_CHANGED) + addAction(Intent.ACTION_TIMEZONE_CHANGED) + addAction(Intent.ACTION_DATE_CHANGED) + } + // System broadcasts only, so nothing outside the system can send it. + ContextCompat.registerReceiver(context.applicationContext, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED) + awaitClose { context.applicationContext.unregisterReceiver(receiver) } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/UsualSteps.kt b/app/src/main/kotlin/org/openstrap/app/data/UsualSteps.kt index d51176e1..1cc9aeda 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/UsualSteps.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/UsualSteps.kt @@ -21,7 +21,7 @@ object UsualSteps { /** By this time of day you've usually walked fewer steps than this, too few for a comparison to mean much. */ const val MIN_USUAL = 500f - /** Within this share of your usual counts as about usual: the band Today uses for Load ([org.openstrap.app.ui.dashboard.TodayCopy.LOAD_BAND]). */ + /** Within this share of your usual counts as about usual: the band Today uses for Load (`TodayCopy.LOAD_BAND`). */ const val BAND = 0.25f /** @@ -56,9 +56,5 @@ object UsualSteps { else -> Pace.About } - private fun median(values: List): Float { - val s = values.sorted() - val n = s.size - return if (n % 2 == 1) s[n / 2] else (s[n / 2 - 1] + s[n / 2]) / 2f - } + private fun median(values: List): Float = Stats.median(values) ?: 0f } diff --git a/app/src/main/kotlin/org/openstrap/app/data/Vo2max.kt b/app/src/main/kotlin/org/openstrap/app/data/Vo2max.kt index 1074d7af..ceb32114 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/Vo2max.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/Vo2max.kt @@ -65,13 +65,8 @@ object Vo2max { ) /** Why there is no estimate, in the order checked. */ - enum class Withheld(val reason: String) { - Age("Needs an age between 20 and 79."), - Sex("Needs your sex: the equation differs for women and men."), - Body("Needs a weight and height entered in the last 90 days."), - Activity("Answer the activity question (asked every 6 months)."), - Medication("Not shown while taking beta-blockers or during pregnancy: resting heart rate doesn't reflect fitness then."), - RestingHr("Needs at least 5 nights of resting heart rate in the last 2 weeks."), + enum class Withheld { + Age, Sex, Body, Activity, Medication, RestingHr } /** @@ -115,13 +110,7 @@ object Vo2max { * interpolation or a distribution assumption. */ object Friend { - enum class Band(val label: String) { - Below30("Below the 30th percentile"), - P30to50("30th–50th percentile"), - P50to70("50th–70th percentile"), - P70to90("70th–90th percentile"), - Top10("Top 10 %"), - } + enum class Band { Below30, P30to50, P50to70, P70to90, Top10 } /** The percentiles in each row, ascending. */ val PERCENTILES = intArrayOf(10, 20, 30, 40, 50, 60, 70, 80, 90) @@ -187,10 +176,6 @@ object Vo2max { return (centre - FITNESS_AGE_SPREAD).coerceAtLeast(FITNESS_AGE_MIN)..(centre + FITNESS_AGE_SPREAD).coerceAtMost(FITNESS_AGE_MAX) } - /** "typical of a woman aged about 35–55". */ - fun fitnessAgeText(range: IntRange, sex: UserProfile.Sex): String = - "typical of a ${if (sex == UserProfile.Sex.Male) "man" else "woman"} aged about ${range.first}–${range.last}" - const val FITNESS_AGE_SPREAD = 10 const val FITNESS_AGE_MIN = 20 const val FITNESS_AGE_MAX = 80 diff --git a/app/src/main/kotlin/org/openstrap/app/data/Weeks.kt b/app/src/main/kotlin/org/openstrap/app/data/Weeks.kt new file mode 100644 index 00000000..9190f196 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/Weeks.kt @@ -0,0 +1,30 @@ +package org.openstrap.app.data + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.TemporalAdjusters +import java.time.temporal.WeekFields +import java.util.Locale + +/** + * What "a week" is everywhere in the app (decision 7 in presentation's `Units`): it starts on the phone's locale's + * first day, Monday on most phones outside the Americas and Sunday in the US. Rewards, history, recovery, Today, the + * charts and the widgets all ask here, so "this week" is the same week on every screen. It lives in data because the + * data package groups by week too. + */ +object Weeks { + const val DAYS = 7 + + /** The first day of the week in [locale]. */ + fun firstDay(locale: Locale = Locale.getDefault()): DayOfWeek = WeekFields.of(locale).firstDayOfWeek + + /** The first day of [date]'s week: [date] itself when it's [firstDay]. */ + fun start(date: LocalDate, firstDay: DayOfWeek = firstDay()): LocalDate = date.with(TemporalAdjusters.previousOrSame(firstDay)) + + /** The last day of [date]'s week. */ + fun end(date: LocalDate, firstDay: DayOfWeek = firstDay()): LocalDate = start(date, firstDay).plusDays(DAYS - 1L) + + /** The seven days of [date]'s week, first to last. */ + fun days(date: LocalDate, firstDay: DayOfWeek = firstDay()): List = + start(date, firstDay).let { s -> List(DAYS) { s.plusDays(it.toLong()) } } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/WidgetKind.kt b/app/src/main/kotlin/org/openstrap/app/data/WidgetKind.kt new file mode 100644 index 00000000..e9700e47 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/WidgetKind.kt @@ -0,0 +1,7 @@ +package org.openstrap.app.data + +/** + * The five home screen widgets (widgets spec 2). It lives in data, not widget, so the Moment lead rule ([momentLead]) + * can name which widgets are placed without data depending on the widget package. + */ +enum class WidgetKind { Moment, Sleep, Steps, Recovery, Start } diff --git a/app/src/main/kotlin/org/openstrap/app/data/WorkoutBuzz.kt b/app/src/main/kotlin/org/openstrap/app/data/WorkoutBuzz.kt new file mode 100644 index 00000000..77341013 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/WorkoutBuzz.kt @@ -0,0 +1,31 @@ +package org.openstrap.app.data + +/** + * Every workout buzz on the strap by name (workouts spec 5), so each kind of workout says the same thing the same + * way. A pattern is the motor's on and off times in milliseconds, alternating, starting on. + * + * Needs a hardware check that they can be told apart on the wrist, with the phone in a bag. + */ +object WorkoutBuzz { + /** Rest over, work starts: two short. */ + val WORK_STARTS = listOf(150, 150, 150) + + /** Work over, rest starts: one short. */ + val REST_STARTS = listOf(150) + + /** Halfway, or a period over: one long. */ + val LONG = listOf(700) + + /** Goal reached, or the workout's length over: three short. */ + val GOAL = listOf(150, 150, 150, 150, 150) + + /** Mind and body's end: one soft, the shortest the strap does. */ + val SOFT = listOf(60) + + /** The buzz for a switch between phases; a [game]'s period ending is one long, as is its last. */ + fun of(s: Switch, game: Boolean = false): List = when (s) { + Switch.WorkStarts -> WORK_STARTS + Switch.RestStarts -> if (game) LONG else REST_STARTS + Switch.Finished -> if (game) LONG else GOAL + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/WorkoutEffect.kt b/app/src/main/kotlin/org/openstrap/app/data/WorkoutEffect.kt index 71df8dea..dd6d92d6 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/WorkoutEffect.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/WorkoutEffect.kt @@ -43,7 +43,7 @@ object WorkoutEffect { val values = byEpoch[k] val bpm = if (values != null) { held = 0 - values.sorted()[values.size / 2].also { last = it } + Stats.upperMiddle(values)?.also { last = it } } else if (last != null && held < HOLD_EPOCHS) { held++ last @@ -272,12 +272,7 @@ object WorkoutEffect { return sqrt(x.sumOf { (it - m) * (it - m) } / x.size) } - private fun quantile(sorted: List, p: Double): Double { - val pos = p * (sorted.size - 1) - val lo = floor(pos).toInt() - val hi = minOf(lo + 1, sorted.lastIndex) - return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo) - } + private fun quantile(sorted: List, p: Double): Double = Stats.quantileOfSorted(sorted, p) /* ---------------------------------------------- Together ---------------------------------------------- */ diff --git a/app/src/main/kotlin/org/openstrap/app/data/WorkoutEffectEngine.kt b/app/src/main/kotlin/org/openstrap/app/data/WorkoutEffectEngine.kt index fdc859e7..f67d2fa4 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/WorkoutEffectEngine.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/WorkoutEffectEngine.kt @@ -45,7 +45,8 @@ class WorkoutEffectEngine(private val dao: HealthDao) { restingHr, maxHr, load28, trend, carriedMinutes(w), ) // kcal from scratch over the range as it now stands, so the saved total isn't fitted to the old one. - val kcal = runCatching { engine.phoneWorkoutKcal(w.copy(totalKcal = null), profile, profile.age(date), restingHr, maxHr, engine.vo2max(profile, date, zone), end, hr) } + val kcal = catching { engine.phoneWorkoutKcal(w.copy(totalKcal = null), profile, profile.age(date), restingHr, maxHr, engine.vo2max(profile, date, zone), end, hr) } + .onFailure { android.util.Log.w(TAG, "workout effect: kcal for ${w.start} not worked out: $it") } .getOrNull() val modelled = WorkoutRecorder.withKcal(w, kcal).copy( epoc = r.epoc, aerobicTrainingEffect = r.aerobicTe, recoveryMinutes = r.recoveryMinutes, @@ -112,7 +113,9 @@ class WorkoutEffectEngine(private val dao: HealthDao) { for (w in dao.appWorkoutsAll().filter { it.end != null }.sortedBy { it.start }) { val hr = dao.appWorkoutHeartRateOnce(w.start) if (hr.isEmpty()) continue - val e = runCatching { withEffect(w, hr, profile) }.getOrNull() ?: continue + val e = catching { withEffect(w, hr, profile) } + .onFailure { android.util.Log.w(TAG, "workout effect: ${w.start} not worked out again: $it") } + .getOrNull() ?: continue if (e.epoc == null) continue dao.updateAppWorkoutEffect(w.start, e.epoc, e.aerobicTrainingEffect, e.recoveryMinutes, e.vo2max, e.anaerobicTrainingEffect, e.effectFromStrap, e.ownRecoveryMinutes) n++ @@ -153,12 +156,13 @@ class WorkoutEffectEngine(private val dao: HealthDao) { return (phone + strap).maxOfOrNull { (end, minutes) -> minutes - ((w.from - end) / 60).toInt() }?.coerceAtLeast(0) ?: 0 } - private fun dayStart(t: Long, zone: ZoneId) = Instant.ofEpochSecond(t).atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() + private fun dayStart(t: Long, zone: ZoneId) = Days.start(t, zone) private companion object { const val DAY = 86_400L /** Fitness takes 42 days to build (Recovery.CTL_DAYS); the activity class uses the last 28 of them. */ const val TREND_DAYS = Recovery.CTL_DAYS const val DEFAULT_RESTING_HR = 60f + const val TAG = "OpenStrap" } } diff --git a/app/src/main/kotlin/org/openstrap/app/data/WorkoutRecorder.kt b/app/src/main/kotlin/org/openstrap/app/data/WorkoutRecorder.kt index 7adcbe11..3c0cb7ee 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/WorkoutRecorder.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/WorkoutRecorder.kt @@ -14,16 +14,13 @@ import org.openstrap.app.data.settings.UserProfile */ object WorkoutRecorder { /** - * The zone shown live, 0..4: below 60 % of max is zone 1 (anything slower is easy), then 10 % steps, as + * The zone shown live, 0..4: below zone 2 is zone 1 (anything slower is easy), then your zones, as * [Workouts.zoneOf]. Null for no reading. */ fun liveZone(bpm: Int?, maxHr: Int): Int? = bpm?.let { Workouts.zoneOf(it, maxHr) ?: 0 } - /** Where [bpm] sits along the five zones, 0..1: 50 % of max at the left, each zone a fifth. */ - fun zonePosition(bpm: Int, maxHr: Int): Float { - val pct = bpm * 100f / maxHr - return ((pct - 50f) / 50f).coerceIn(0f, 1f) - } + /** Where [bpm] sits along the five zones ([HeartZones.position]), 0..1: zone 1's start at the left, each zone a fifth. */ + fun zonePosition(bpm: Int, maxHr: Int): Float = HeartZones.current.value.position(bpm, maxHr) /** Seconds in each live zone over [samples] (time, bpm, ascending); a gap counts up to [MAX_STEP_S]. */ fun liveZoneSeconds(samples: List>, maxHr: Int): IntArray { @@ -35,7 +32,7 @@ object WorkoutRecorder { const val MAX_STEP_S = 5L /** Live readings outside this range are the sensor settling or off the skin, not a heart rate. */ - fun isValid(bpm: Int) = bpm in 30..240 + fun isValid(bpm: Int) = HeartRate.isValid(bpm) /** A history minute is drawn as samples this far apart, so charts and zone time treat it like live data. */ private const val HISTORY_STEP_S = 5L diff --git a/app/src/main/kotlin/org/openstrap/app/data/WorkoutRepository.kt b/app/src/main/kotlin/org/openstrap/app/data/WorkoutRepository.kt new file mode 100644 index 00000000..da90e55b --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/WorkoutRepository.kt @@ -0,0 +1,103 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.flow.Flow +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.AppWorkoutRound +import org.openstrap.app.data.db.AppWorkoutSet +import org.openstrap.app.data.db.SportEffectRow +import org.openstrap.app.data.db.WorkoutHeartRate +import java.time.LocalDate + +/** The strap's workouts and the phone's, analysed, and what their pages and summaries read. */ +interface WorkoutRepository { + /** + * The strap's workouts and the phone's, newest first, each with its zones from its whole heart rate stream. Zones + * come in after the list, so the list shows before they're worked out. + */ + fun workouts(): Flow> + + /** The strap's sport-effect records, newest last. */ + fun effects(): Flow> + + /** The max heart rate zones are shares of ([MaxHeartRates]). */ + fun maxHeartRate(): Flow + + /** The workout starting at [start], the phone's or the strap's, as it changes, or null when there's none. */ + fun open(start: Long): Flow + + /** The phone workout [start]'s summary. Reading it never writes: [backfillEffect] does that on its own. */ + fun phoneSummary(start: Long): Flow + + /** Workouts nobody started ([WorkoutSuggestions]) since [from], with [now] read afresh each time. */ + fun suggestions(from: Long, now: () -> Long): Flow> + + /** [date]'s heart rate a minute at a time, for adding a workout after the fact. */ + suspend fun dayMinuteHeartRate(date: LocalDate): List> + + /** + * The heart rate drop in the first minute after each of the latest [count] strap workouts before [before], newest + * first, from those ended from a real effort ([WorkoutAnalysis.recoveryIsMeaningful]). + */ + suspend fun recoveryDrops(before: Long, count: Int): List + + /** Works out and saves the effect of phone workout [start] when it ended before effects were worked out. */ + suspend fun backfillEffect(start: Long) + + /** Deletes a strap workout and keeps it from coming back with a later sync. */ + suspend fun deleteStrapWorkout(start: Long, now: Long) +} + +/** + * What a phone workout's summary shows: [analysis] over its shown range, [recorded] every sample from + * [recordedStart] to [recordedEnd] (for the trim chart). + */ +data class WorkoutSummaryData( + val analysis: WorkoutAnalysis, + val recorded: List, + val recordedStart: Long, + val recordedEnd: Long, + val endReason: Int?, + /** The route, when it was kept, else empty. */ + val route: List = emptyList(), + /** Every live reading in the shown range, for colouring the route. */ + val history: List> = emptyList(), + val distanceMeters: Float? = null, + val distanceFromSteps: Boolean = false, + /** Strength: each set and the rest after it. */ + val sets: List = emptyList(), + /** + * The strap's own records of the same workout, by start, when it kept any (a strap session, or its + * auto-detection), more than one when a long workout held several. + */ + val strapWorkouts: List = emptyList(), + /** Laps, when any were marked. */ + val laps: List = emptyList(), + /** Finished pauses, which no split counts. */ + val pauses: List = emptyList(), + /** Its effect figures are the strap's own ([AppWorkout.effectFromStrap]). */ + val effectFromStrap: Boolean = false, + /** Its rounds or periods ([AppWorkout.structure]) and each one recorded. */ + val structure: Structure? = null, + val rounds: List = emptyList(), + /** Strength: the heart rate rest ended under, when rest ended on heart rate ([Strength.Rest]). */ + val restBelow: Int? = null, + /** Strength: how long the heart rate took to fall under [restBelow] in the last session before ([restRecovery]). */ + val restBefore: Long? = null, +) { + companion object { + /** + * How long the heart rate took to fall under [below] after each set, on average over the rests where it did: + * from the set's end to the first reading under the level before the rest ended. Null without a level or such + * a rest. + */ + fun restRecovery(sets: List, readings: List>, below: Int?): Long? { + below ?: return null + val times = sets.mapNotNull { s -> + val end = s.end ?: return@mapNotNull null + val until = s.restEnd ?: return@mapNotNull null + readings.firstOrNull { it.first in end..until && it.second < below }?.let { it.first - end } + } + return if (times.isEmpty()) null else times.sum() / times.size + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/WorkoutSetup.kt b/app/src/main/kotlin/org/openstrap/app/data/WorkoutSetup.kt new file mode 100644 index 00000000..76ae28b0 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/WorkoutSetup.kt @@ -0,0 +1,202 @@ +package org.openstrap.app.data + +import org.openstrap.app.data.db.SavedWorkout + +/** + * What Ready was set to the last time a sport was started (workouts spec 2.2, 4): Ready opens on it, and Start on + * Choose's usual card starts with it, skipping Ready. Kept per sport by its tile ([Workouts.tileType]), so a run and + * a treadmill share theirs; which side was last chosen is kept apart ([Workouts.Sides]). + * + * Stored as one line per sport ([encode]): "g=d:5000|a=1|p=1|k=0". Anything unreadable falls back to the default. + */ +data class SportSettings( + /** The goal ([WorkoutTarget.code]); null for none ("Just run"). */ + val goal: String? = null, + /** "Ask to end when I stop". */ + val askToEnd: Boolean = true, + /** + * GPS where it's offered. No longer turned off anywhere: Ready's where row picks an indoor side instead, so GPS is + * on wherever it's offered ([WorkoutSetup.plan]). Kept so older lines still read. + */ + val gps: Boolean = true, + /** Saving the GPS route for the map: on unless turned off. */ + val keepRoute: Boolean = true, + /** Strength: how rest ends ([Strength.Rest.code]) and the sets planned, null for open. */ + val rest: String? = null, + val sets: Int? = null, + /** Strength: a card after each set for its reps and weight. */ + val log: Boolean = false, + /** Intervals and games: rounds or periods ([AppWorkout.structure][org.openstrap.app.data.db.AppWorkout]); null for open. */ + val structure: String? = null, +) { + fun encode(): String = listOfNotNull( + goal?.let { "g=$it" }, + "a=${bit(askToEnd)}", + "p=${bit(gps)}", + // "m", not the old "k": every saved line had k=0 from when the route was off unless turned on, so those read as on now. + "m=${bit(keepRoute)}", + rest?.let { "r=$it" }, + sets?.let { "s=$it" }, + "l=${bit(log)}", + structure?.let { "x=$it" }, + ).joinToString(SEPARATOR) + + companion object { + private const val SEPARATOR = "|" + private fun bit(b: Boolean) = if (b) "1" else "0" + + /** [text] as written by [encode]; the defaults for anything missing or unreadable, and for null. */ + fun parse(text: String?): SportSettings { + val fields = text.orEmpty().split(SEPARATOR).mapNotNull { part -> + val at = part.indexOf('=') + if (at <= 0) null else part.substring(0, at) to part.substring(at + 1) + }.toMap() + val d = SportSettings() + fun flag(key: String, default: Boolean) = when (fields[key]) { "1" -> true; "0" -> false; else -> default } + return SportSettings( + goal = fields["g"]?.takeIf { WorkoutTarget.parse(it) != null }, + askToEnd = flag("a", d.askToEnd), + gps = flag("p", d.gps), + keepRoute = flag("m", d.keepRoute), + rest = fields["r"]?.takeIf { Strength.Rest.parse(it) != null }, + sets = fields["s"]?.toIntOrNull()?.takeIf { it in Strength.SETS }, + log = flag("l", d.log), + structure = fields["x"]?.takeIf { it.isNotBlank() }, + ) + } + } +} + +/** + * A workout about to start, as Ready sets it up or as Choose's usual card takes it from last time: the sport type + * saved (a side already picked), and what [org.openstrap.app.device.WorkoutSession.start] records with it. + */ +data class WorkoutPlan( + val sportType: Int, + val askToEnd: Boolean = true, + val target: WorkoutTarget? = null, + val useGps: Boolean = false, + val keepRoute: Boolean = false, + val rest: Strength.Rest? = null, + val setsTarget: Int? = null, + /** Strength: a log card after each set. */ + val logSets: Boolean = false, + /** Rounds or periods, as [org.openstrap.app.data.db.AppWorkout.structure]. */ + val structure: String? = null, +) + +/** Choose's sports (workouts spec 2.1): [usual] gets the big card with Start, [recent] the small ones, by tile. */ +data class ChooseSports(val usual: Int?, val recent: List) + +/** One of Choose's Recent chips: a sport by its tile, or a saved workout by its name. */ +sealed interface RecentChip { + data class Sport(val type: Int) : RecentChip + + /** A saved workout: Ready opens on its [structure]. */ + data class Saved(val name: String, val sport: Int, val structure: String) : RecentChip +} + +/** Ready's choices worked out from what you did before (workouts spec 2.2). Pure, so it's tested without the screen. */ +object WorkoutSetup { + /** Your usual is the sport you started most in this many days. */ + const val USUAL_DAYS = 28 + + /** + * Choose's sports from the workouts you [started] (sport, start), newest first: your usual, the sport started most + * in the last [USUAL_DAYS] (ties to the latest), and the next two sports you did. With no usual, up to three + * recent ones, and a new user's Run, Walk and Strength. Sports count by tile, so a treadmill is a run. + */ + fun choose(started: List>, now: Long): ChooseSports { + val mine = started.filter { it.first != Workouts.GENERIC_ACTIVITY }.map { Workouts.tileType(it.first) to it.second } + val usual = mine.filter { it.second >= now - USUAL_DAYS * 86_400L }.groupBy { it.first } + .maxWithOrNull(compareBy>>> { it.value.size }.thenBy { e -> e.value.maxOf { it.second } }) + ?.key + val recent = Workouts.recentSports(mine.map { it.first }, n = 4).filter { it != usual } + if (usual != null) return ChooseSports(usual, recent.take(2)) + return ChooseSports(null, (recent + Workouts.DEFAULT_SPORTS.filter { it !in recent }).take(3)) + } + + /** How many saved workouts Choose's Recent row shows. */ + const val SAVED_CHIPS = 2 + + /** + * Choose's Recent row (workouts spec 2.1): the [recent] sports, then the newest [SAVED_CHIPS] of the [saved] + * workouts (newest first) whose rounds still read. + */ + fun recentChips(recent: List, saved: List): List = + recent.map { RecentChip.Sport(it) } + + saved.filter { Structure.parse(it.structure) != null }.take(SAVED_CHIPS).map { RecentChip.Saved(it.name, it.sport, it.structure) } + + /** + * Choose's Start on your usual (workouts spec 2.1): a plan from last time's [settings] for [type], skipping Ready. + * Its side is the one you last chose ([sides]); GPS is on where it's offered and the phone's location can be used + * ([location]). + */ + fun plan(type: Int, settings: SportSettings, sides: Map, location: Boolean): WorkoutPlan { + val indoor = Workouts.indoorSide(type, sides) + val sport = Workouts.sidesOf(type)?.type(indoor == true) ?: type + val gps = Workouts.gpsOffered(type, indoor) && location + val strength = sport in SportTypes.STRENGTH + // Only a goal Ready would offer now: a distance where there is one, and no kcal goal (Ready has none to show). + val hasDistance = gps || sport in SportTypes.ON_FOOT + val goal = WorkoutTarget.parse(settings.goal)?.takeIf { offered(it, hasDistance = hasDistance, kcal = false) } + return WorkoutPlan( + sportType = sport, askToEnd = settings.askToEnd, target = goal, useGps = gps, keepRoute = gps && settings.keepRoute, + rest = (Strength.Rest.parse(settings.rest) ?: Strength.Rest.DEFAULT).takeIf { strength }, + setsTarget = settings.sets.takeIf { strength }, logSets = strength && settings.log, + // HIIT's rounds, or a run's reps. + structure = Structure.parse(settings.structure)?.takeIf { Structure.fits(it, sport) }?.code, + ) + } + + /** Mind and body: the length chips after Open. */ + val LENGTHS = listOf(15, 30, 45, 60).map { WorkoutTarget.Time(it * 60L) } + + /** + * Whether [goal] can be aimed for now: a distance only where the workout has one, from GPS or steps + * ([hasDistance]); kcal only with a profile to work it out from ([kcal]). + */ + fun offered(goal: WorkoutTarget?, hasDistance: Boolean, kcal: Boolean): Boolean = when (goal) { + is WorkoutTarget.Distance -> hasDistance + is WorkoutTarget.Kcal -> kcal + else -> true + } + + /** The kinds of goal Ready's goal card offers a distance or cardio sport (board ReadyFinal): its chips. */ + enum class GoalKind { None, Distance, Time, Zone, Intervals } + + /** + * The goal kinds for [type]: Just go, a distance where the workout has one ([hasDistance]), a time, a zone, and + * intervals where reps fit the sport ([Structure.fits], runs). + */ + fun goalKinds(type: Int, hasDistance: Boolean): List = listOfNotNull( + GoalKind.None, + GoalKind.Distance.takeIf { hasDistance }, + GoalKind.Time, + GoalKind.Zone, + GoalKind.Intervals.takeIf { Structure.fits(Structure.Reps(400, null, 90, 6), type) }, + ) + + /** The kind of what Ready opens on: reps are Intervals, else the goal's kind; a kcal goal has no chip, so none. */ + fun goalKind(goal: WorkoutTarget?, reps: Structure.Reps?): GoalKind = when { + reps != null -> GoalKind.Intervals + goal is WorkoutTarget.Distance -> GoalKind.Distance + goal is WorkoutTarget.Time -> GoalKind.Time + goal is WorkoutTarget.Zones -> GoalKind.Zone + else -> GoalKind.None + } + + /** + * A zone button tapped on Ready: the zone alone, unless one zone is chosen and another is tapped, which makes the + * range between them. Tapping again after a range starts over from the zone tapped. + */ + fun pickZone(current: WorkoutTarget.Zones?, tapped: Int): WorkoutTarget.Zones = when { + current != null && current.low == current.high && current.low != tapped -> WorkoutTarget.Zones(minOf(current.low, tapped), maxOf(current.low, tapped)) + else -> WorkoutTarget.Zones(tapped, tapped) + } + + /** What each kind opens on before it's been chosen for the sport: 5 km, 30 min, zone 2. */ + val DEFAULT_DISTANCE = WorkoutTarget.Distance(5000) + val DEFAULT_TIME = WorkoutTarget.Time(30 * 60) + val DEFAULT_ZONES = WorkoutTarget.Zones(1, 1) +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/Workouts.kt b/app/src/main/kotlin/org/openstrap/app/data/Workouts.kt index 5a2d2356..e3335b56 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/Workouts.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/Workouts.kt @@ -195,11 +195,8 @@ object Workouts { return e.takeIf { otherStarts.none { s -> s > w.start && s < e.end } } } - /** Zone 0..4 for 50–60, 60–70, 70–80, 80–90 and 90+ % of max; null below 50 %. */ - fun zoneOf(bpm: Int, maxHr: Int): Int? { - val pct = bpm * 100 / maxHr - return if (pct < 50) null else ((pct - 50) / 10).coerceAtMost(ZONES - 1) - } + /** Zone 0..4 in your zones ([HeartZones.current]), by default 50–60, 60–70, 70–80, 80–90 and 90+ % of max; null below zone 1. */ + fun zoneOf(bpm: Int, maxHr: Int): Int? = HeartZones.current.value.zoneOf(bpm, maxHr) /** HR drop from the end to [after] seconds later, each taken as a 5-second median to ride out noise. */ private fun recovery(hr: List, end: Long, after: Long): Int? { @@ -220,7 +217,7 @@ object Workouts { private fun asSamples(readings: List>) = readings.map { (t, bpm) -> WorkoutHeartRate(0, t, bpm) } private fun medianAt(hr: List, t: Long): Int? = - hr.filter { it.timestamp in t - 2..t + 2 }.map { it.bpm }.sorted().let { if (it.isEmpty()) null else it[it.size / 2] } + Stats.upperMiddle(hr.filter { it.timestamp in t - 2..t + 2 }.map { it.bpm }) /** Zepp's sport type ids (Type.type in the summary) for the sports a strap is likely to record. */ fun sportName(type: Int): String = SPORT_NAMES[type] ?: "Workout" @@ -247,7 +244,7 @@ object Workouts { * Sports kept for naming workouts already recorded (by the strap, Zepp or an import) but not offered for new ones: * each is another one under a second name, or the strap's own generic "Activity". */ - val HIDDEN = setOf(143, 40, 224, 55, 10, 57, 58, 51, 109, 24, 226, 16, 223) + val HIDDEN = setOf(143, 40, 224, 55, 10, 57, 58, 51, 109, 24, 226, 16, 223, 215) /** * A sport offered once with an outdoor and an indoor kind (spec 6.3): Ready's switch picks one, and the workout is @@ -342,18 +339,6 @@ object Workouts { /** Sports done only outdoors, whose tile carries the location mark: no indoor side to choose. */ val OUTDOOR_ONLY: Set get() = OUTDOOR - SIDES.map { it.outdoor }.toSet() - /** - * Sports done on your feet, where steps times a stride make a distance (≈) without GPS: runs, walks, hikes. On a - * bike, in water or on a mat, steps say nothing about distance, so those show none. - */ - val ON_FOOT = setOf(1, 2, 3, 11, 15, 40, 215, 224, 225) - - /** Runs, where distance comes with a pace. */ - val RUNS = setOf(1, 2, 11) - - /** On foot but not running: walks and hikes, where distance alone says it. */ - val WALKS = ON_FOOT - RUNS - /** A workout's effort glow never goes below this, so a light one, or one with no Load, still shows a trace. */ const val EFFORT_MIN = 0.12f /** Workouts with a Load before your usual counts: fewer and the fixed [EFFORT_FULL_LOAD] scale is used. */ @@ -369,10 +354,9 @@ object Workouts { * Not the day's Load: that is TRIMP over every minute, on another scale. */ fun usualLoad(loads: List): Float? { - val recent = loads.filterNotNull().filter { it > 0 }.take(USUAL_LOAD_OF).sorted() + val recent = loads.filterNotNull().filter { it > 0 }.take(USUAL_LOAD_OF) if (recent.size < USUAL_LOAD_MIN) return null - val mid = recent.size / 2 - return if (recent.size % 2 == 1) recent[mid].toFloat() else (recent[mid - 1] + recent[mid]) / 2f + return Stats.median(recent) } /** @@ -385,6 +369,39 @@ object Workouts { return (load / full).coerceIn(EFFORT_MIN, 1f) } + /** + * How a sport is recorded and shown (workouts spec 1): which live screen, which choice on Ready and which summary. + * Internal only: the app says the sport, never its family. + */ + enum class Family { Distance, IndoorCardio, Strength, Intervals, MindBody, Games } + + /** The outdoor sides and sports done outdoors, with GPS: runs, walks, hikes, rides, skiing. */ + private val DISTANCE = setOf(1, 3, 4, 11, 12, 15, 215, 224, 225) + + /** Machines and indoor sides, and the open sports whose live screen is the heart rate. */ + private val INDOOR_CARDIO = setOf(2, 5, 8, 9, 16, 21, 23, 40, 51, 57, 58, 76, 97, 108, 109, 130, 143, GENERIC_ACTIVITY) + + private val MIND_BODY = setOf(53, 55, 60, 61) + + /** HIIT. Run intervals keep the run's family, with rounds as their structure. */ + private val INTERVALS = setOf(49) + + /** + * [type]'s family. A sport not listed is Indoor cardio when it has an indoor side ([sidesOf], the swims), else + * Games and sports: tennis, padel, climbing and the rest are played with the phone in a bag. + */ + fun family(type: Int): Family = when (type) { + in SportTypes.STRENGTH -> Family.Strength + in INTERVALS -> Family.Intervals + in MIND_BODY -> Family.MindBody + in DISTANCE -> Family.Distance + in INDOOR_CARDIO -> Family.IndoorCardio + else -> if (sidesOf(type) != null) Family.IndoorCardio else Family.Games + } + + /** Every sport id with a name, kept or hidden, for tests that must cover them all. */ + internal val SPORT_IDS: Set get() = SPORT_NAMES.keys + /** Offered before any workout has been recorded: outdoor run, walk, strength training. */ val DEFAULT_SPORTS = listOf(1, 3, 52) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/ZoneCache.kt b/app/src/main/kotlin/org/openstrap/app/data/ZoneCache.kt similarity index 71% rename from app/src/main/kotlin/org/openstrap/app/ui/workout/ZoneCache.kt rename to app/src/main/kotlin/org/openstrap/app/data/ZoneCache.kt index 5fe64ee3..732a19f8 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/workout/ZoneCache.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/ZoneCache.kt @@ -1,11 +1,11 @@ -package org.openstrap.app.ui.workout +package org.openstrap.app.data /** * Zone seconds for the workout lists, kept per workout so a change to one doesn't redo the rest. Each workout comes - * with a stamp of everything its zones depend on (its row, the max HR used, its HR stream in brief); its zones are + * with a stamp of everything its zones depend on (its row, the max HR used, its HR stream in brief). Its zones are * worked out again only when that stamp changes, and workouts no longer listed are forgotten. Not for concurrent use: - * the view model calls it from one mapLatest, which finishes or is cancelled before the next call starts. A call - * cancelled part way keeps what it had worked out. + * each collection of [RoomWorkoutRepository.workouts] has its own and calls it from one mapLatest, which finishes or is + * cancelled before the next call starts. A call cancelled part way keeps what it had worked out. */ internal class ZoneCache { private val cache = HashMap>>() diff --git a/app/src/main/kotlin/org/openstrap/app/data/db/Daos.kt b/app/src/main/kotlin/org/openstrap/app/data/db/Daos.kt index 0df6d78b..17a29537 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/db/Daos.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/db/Daos.kt @@ -5,9 +5,12 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import kotlinx.coroutines.flow.Flow +import org.openstrap.app.data.HeartRate +// A DAO is its queries, one function each: splitting it by count would only scatter them. +@Suppress("TooManyFunctions") @Dao -interface HealthDao { +interface HealthDao : RangedReads { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertHeartRate(samples: List) @@ -284,8 +287,103 @@ interface HealthDao { @Query("SELECT * FROM app_workout_dismissed ORDER BY start") suspend fun dismissedWorkoutsAll(): List + /* -------------------------------- Workouts, first release (version 25) -------------------------------- */ + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertRounds(rounds: List) + + @Query("SELECT * FROM app_workout_round WHERE workoutStart = :start ORDER BY `index`") + suspend fun roundsOnce(start: Long): List + + @Query("SELECT * FROM app_workout_round WHERE workoutStart = :start ORDER BY `index`") + fun rounds(start: Long): Flow> + + @Query("DELETE FROM app_workout_round WHERE workoutStart = :start") + suspend fun deleteRounds(start: Long) + + @Query("SELECT * FROM app_workout_round ORDER BY workoutStart, `index`") + suspend fun roundsAll(): List + + /** For a restore: an exercise the phone already has keeps its own last reps and weight. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertExercises(e: List) + + /** For a restore, each with a new id. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSavedWorkouts(w: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertExercise(e: ExerciseRow) + + /** The exercises logged, last used first. */ + @Query("SELECT * FROM exercise ORDER BY usedAt DESC") + suspend fun exercisesOnce(): List + + @Query("SELECT * FROM exercise ORDER BY usedAt DESC") + fun exercises(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertSavedWorkout(w: SavedWorkout): Long + + /** Saved workouts, newest first. */ + @Query("SELECT * FROM saved_workout ORDER BY createdAt DESC") + fun savedWorkouts(): Flow> + + @Query("SELECT * FROM saved_workout ORDER BY createdAt DESC") + suspend fun savedWorkoutsOnce(): List + + @Query("DELETE FROM saved_workout WHERE id = :id") + suspend fun deleteSavedWorkout(id: Long) + + @Query("SELECT * FROM personal_best") + fun personalBests(): Flow> + + @Query("SELECT * FROM personal_best") + suspend fun personalBestsOnce(): List + + @Query("DELETE FROM personal_best") + suspend fun deletePersonalBests() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertPersonalBests(bests: List) + + /** A rewards refresh's changes in one go: [bests] replaces them all when given ([replacePersonalBests]). */ + @androidx.room.Transaction + suspend fun storeRewards(weeks: List, bests: List?, badges: List) { + if (weeks.isNotEmpty()) upsertWeekResults(weeks) + if (bests != null) replacePersonalBests(bests) + if (badges.isNotEmpty()) insertBadges(badges) + } + + /** The bests worked out again from every workout and day: what was there goes, so a delete takes its bests too. */ + @androidx.room.Transaction + suspend fun replacePersonalBests(bests: List) { + deletePersonalBests() + insertPersonalBests(bests) + } + + @Query("SELECT * FROM badge ORDER BY earnedAt") + fun badges(): Flow> + + @Query("SELECT * FROM badge ORDER BY earnedAt") + suspend fun badgesOnce(): List + + /** Badges once earned stay (requests spec 7.2): new ones only. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertBadges(badges: List) + + @Query("SELECT * FROM week_result ORDER BY weekStart") + fun weekResults(): Flow> + + @Query("SELECT * FROM week_result ORDER BY weekStart") + suspend fun weekResultsOnce(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertWeekResults(weeks: List) + @androidx.room.Transaction suspend fun deleteAppWorkout(start: Long) { + deleteRounds(start) deleteSets(start) deleteRoute(start) deleteAppWorkoutHeartRate(start) @@ -337,17 +435,71 @@ interface HealthDao { @Query("DELETE FROM sleep_session WHERE day = :day AND nap = :nap") suspend fun deleteSleepSessionsForDay(day: Long, nap: Boolean) - @Query("SELECT * FROM nap_edit") - fun napEdits(): Flow> + /** + * A day's main sleep ([nap] false) or naps as the strap sent them again: the old ones go and [sessions] with their + * [stages] come in, in one go. Apart, a reader could see the night empty, and a kill between them lost it. + */ + @androidx.room.Transaction + suspend fun replaceSleepDay(day: Long, nap: Boolean, sessions: List, stages: List) { + deleteSleepStagesForDay(day, nap) + deleteSleepSessionsForDay(day, nap) + if (sessions.isNotEmpty()) insertSleepSessions(sessions) + if (stages.isNotEmpty()) insertSleepStages(stages) + } + + /** A strap workout's HR stream as the strap re-sent it, whole: the old one goes with the new one coming in. */ + @androidx.room.Transaction + suspend fun replaceWorkoutDetail(start: Long, samples: List) { + deleteWorkoutHeartRate(start) + if (samples.isNotEmpty()) insertWorkoutHeartRate(samples) + } + + /** + * Removes for good the sleep sessions starting at [sessionStarts] with their stages, and saves [markers] over + * their edits ([org.openstrap.app.data.SleepSource.purgeExpired]), all or none. + */ + @androidx.room.Transaction + suspend fun purgeSleepEdits(sessionStarts: List, markers: List) { + for (start in sessionStarts) { + deleteSleepStagesOf(start) + deleteSleepSession(start) + } + if (markers.isNotEmpty()) insertSleepEdits(markers) + } + + /** + * Runs [block] in one transaction, so writes that belong together (a history chunk's rows) land all or none and + * readers never see half of them. + */ + @androidx.room.Transaction + suspend fun inTransaction(block: suspend () -> Unit) { + block() + } + + @Query("SELECT * FROM sleep_edit") + fun sleepEdits(): Flow> + + @Query("SELECT * FROM sleep_edit") + suspend fun sleepEditsOnce(): List - @Query("SELECT * FROM nap_edit") - suspend fun napEditsOnce(): List + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertSleepEdit(edit: SleepEdit) @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertNapEdit(edit: NapEdit) + suspend fun insertSleepEdits(edits: List) + + @Query("DELETE FROM sleep_edit WHERE strapStart = :strapStart") + suspend fun deleteSleepEdit(strapStart: Long) + + /** Sessions the strap started in [from, to], for finding the one a delete removes for good. */ + @Query("SELECT * FROM sleep_session WHERE start BETWEEN :from AND :to ORDER BY start") + suspend fun sleepSessionsStartingOnce(from: Long, to: Long): List + + @Query("DELETE FROM sleep_stage WHERE sessionStart = :sessionStart") + suspend fun deleteSleepStagesOf(sessionStart: Long) - @Query("DELETE FROM nap_edit WHERE strapStart = :strapStart") - suspend fun deleteNapEdit(strapStart: Long) + @Query("DELETE FROM sleep_session WHERE start = :start") + suspend fun deleteSleepSession(start: Long) /** * Naps that overlap a main sleep. The strap can briefly report a stretch of broken night sleep as a nap, @@ -505,7 +657,11 @@ interface HealthDao { * One row per local day (SQLite's 'localtime' follows the phone's zone, DST included), for week, month and * year views. Heart rate here is the heart_rate table only; the day chart also folds in per-minute activity HR. */ - @Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(bpm) AS value, MIN(bpm) AS min, MAX(bpm) AS max, COUNT(*) AS count FROM heart_rate WHERE timestamp BETWEEN :from AND :to AND bpm BETWEEN 30 AND 230 GROUP BY day ORDER BY day") + @Query( + "SELECT date(timestamp, 'unixepoch', 'localtime') AS day, AVG(bpm) AS value, MIN(bpm) AS min, MAX(bpm) AS max, " + + "COUNT(*) AS count FROM heart_rate WHERE timestamp BETWEEN :from AND :to AND bpm " + HeartRate.SQL_VALID + + " GROUP BY day ORDER BY day", + ) fun dailyHeartRate(from: Long, to: Long): Flow> @Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, SUM(steps) AS value, MIN(steps) AS min, MAX(steps) AS max, COUNT(*) AS count FROM activity_minute WHERE timestamp BETWEEN :from AND :to GROUP BY day ORDER BY day") @@ -568,9 +724,6 @@ interface HealthDao { @Query("DELETE FROM day_tag WHERE day = :day AND tag = :tag") suspend fun deleteDayTag(day: Long, tag: String) - @Query("SELECT * FROM day_tag WHERE day = :day ORDER BY tag") - fun dayTags(day: Long): Flow> - @Query("SELECT * FROM day_tag WHERE day BETWEEN :from AND :to ORDER BY day, tag") suspend fun dayTagsOnce(from: Long, to: Long): List @@ -605,6 +758,13 @@ interface HealthDao { @Query("SELECT date(timestamp, 'unixepoch', 'localtime') AS day, SUM(steps) AS value, MIN(steps) AS min, MAX(steps) AS max, COUNT(*) AS count FROM activity_minute WHERE timestamp BETWEEN :from AND :to AND steps BETWEEN 0 AND 250 GROUP BY day ORDER BY day") suspend fun dailyStepsOnce(from: Long, to: Long): List + /** How many minutes are stored, and the latest: a cheap look at whether old days' steps can have changed. */ + @Query("SELECT COUNT(*) AS rowCount, MAX(timestamp) AS latest FROM activity_minute") + suspend fun activityStampOnce(): TableStamp + + @Query("SELECT COUNT(*) FROM activity_minute WHERE timestamp > :after") + suspend fun activityCountAfterOnce(after: Long): Long + /** * Steps per local hour of each local day, for your usual steps by this time of day ([org.openstrap.app.data.UsualSteps]). * Minutes over 250 steps are left out as bad data, as in [dailyStepsOnce]. @@ -613,10 +773,169 @@ interface HealthDao { suspend fun hourlyStepsOnce(from: Long, to: Long): List /** Minutes (epoch seconds / 60) with a heart rate from either table: the strap was worn. */ - @Query("SELECT timestamp / 60 FROM heart_rate WHERE timestamp BETWEEN :from AND :to AND bpm BETWEEN 30 AND 230 UNION SELECT timestamp / 60 FROM activity_minute WHERE timestamp BETWEEN :from AND :to AND heartRate BETWEEN 30 AND 230") + @Query( + "SELECT timestamp / 60 FROM heart_rate WHERE timestamp BETWEEN :from AND :to AND bpm " + HeartRate.SQL_VALID + + " UNION SELECT timestamp / 60 FROM activity_minute WHERE timestamp BETWEEN :from AND :to AND heartRate " + + HeartRate.SQL_VALID, + ) suspend fun wornMinutesOnce(from: Long, to: Long): List - /** A cheap fingerprint of the data in a window: changes when a sync adds minutes, sleep or workouts to it. */ - @Query("SELECT (SELECT COUNT(*) FROM activity_minute WHERE timestamp BETWEEN :from AND :to) + (SELECT COUNT(*) FROM heart_rate WHERE timestamp BETWEEN :from AND :to) * 7 + (SELECT COUNT(*) FROM sleep_session WHERE `end` BETWEEN :from AND :to) * 1009 + (SELECT COUNT(*) FROM workout WHERE start BETWEEN :from AND :to) * 100003") + /** + * A cheap fingerprint of the data in a window: changes when a sync adds minutes, sleep or workouts to it, or when + * any sleep is moved, deleted or restored (every edit changes its editedAt). + */ + @Query("SELECT (SELECT COUNT(*) FROM activity_minute WHERE timestamp BETWEEN :from AND :to) + (SELECT COUNT(*) FROM heart_rate WHERE timestamp BETWEEN :from AND :to) * 7 + (SELECT COUNT(*) FROM sleep_session WHERE `end` BETWEEN :from AND :to) * 1009 + (SELECT COUNT(*) FROM workout WHERE start BETWEEN :from AND :to) * 100003 + (SELECT COUNT(*) + COALESCE(SUM(editedAt), 0) FROM sleep_edit) * 31") suspend fun dataFingerprint(from: Long, to: Long): Long } + +/** + * The ranged reads, so no caller loads a whole table to filter it, and the backup's keyset pages. Apart from + * [HealthDao] only to keep each a readable size: Room builds them into its implementation. + */ +@Suppress("TooManyFunctions") // As HealthDao: one function per query. +interface RangedReads { + /** Whether any workout is stored, strap or finished phone one, without reading them. */ + @Query("SELECT EXISTS(SELECT 1 FROM workout) OR EXISTS(SELECT 1 FROM app_workout WHERE `end` IS NOT NULL)") + suspend fun anyWorkoutOnce(): Boolean + + /** Strap workouts that started in [from, to], by start. */ + @Query("SELECT * FROM workout WHERE start BETWEEN :from AND :to ORDER BY start") + suspend fun workoutsStartingOnce(from: Long, to: Long): List + + /** Finished phone workouts that started in [from, to], by start. */ + @Query("SELECT * FROM app_workout WHERE `end` IS NOT NULL AND start BETWEEN :from AND :to ORDER BY start") + suspend fun appWorkoutsStartingOnce(from: Long, to: Long): List + + /** The sets of the phone workouts that started in [from, to]. */ + @Query("SELECT * FROM app_workout_set WHERE workoutStart BETWEEN :from AND :to ORDER BY workoutStart, `index`") + suspend fun setsOfWorkoutsOnce(from: Long, to: Long): List + + /** The rounds of the phone workouts that started in [from, to]. */ + @Query("SELECT * FROM app_workout_round WHERE workoutStart BETWEEN :from AND :to ORDER BY workoutStart, `index`") + suspend fun roundsOfWorkoutsOnce(from: Long, to: Long): List + + /** The recovery HR of the strap workouts that started in [from, to]. */ + @Query("SELECT * FROM recovery_hr WHERE workoutStart BETWEEN :from AND :to ORDER BY workoutStart") + suspend fun recoveryHeartRateOfWorkoutsOnce(from: Long, to: Long): List + + /** The edits of the strap sessions that started in [from, to], by that start. */ + @Query("SELECT * FROM sleep_edit WHERE strapStart BETWEEN :from AND :to ORDER BY strapStart") + suspend fun sleepEditsStartingOnce(from: Long, to: Long): List + + /** The stages of every session ending in [from, to], as [sleepSessionsOnce] then [sleepStagesOnce] on each give them. */ + @Query( + "SELECT sleep_stage.* FROM sleep_stage JOIN sleep_session ON sleep_session.start = sleep_stage.sessionStart " + + "WHERE sleep_session.`end` BETWEEN :from AND :to ORDER BY sleep_session.start, sleep_stage.start", + ) + suspend fun sleepStagesOfSessionsEndingOnce(from: Long, to: Long): List + + @Query("SELECT * FROM app_workout_dismissed WHERE start BETWEEN :from AND :to ORDER BY start") + suspend fun dismissedWorkoutsStartingOnce(from: Long, to: Long): List + + @Query("SELECT * FROM workout_deleted WHERE start BETWEEN :from AND :to ORDER BY start") + suspend fun deletedWorkoutsStartingOnce(from: Long, to: Long): List + + @Query("SELECT * FROM saved_workout WHERE createdAt BETWEEN :from AND :to ORDER BY createdAt DESC") + suspend fun savedWorkoutsCreatedOnce(from: Long, to: Long): List + + @Query("SELECT * FROM exercise WHERE usedAt BETWEEN :from AND :to ORDER BY usedAt DESC") + suspend fun exercisesUsedOnce(from: Long, to: Long): List + + @Query("SELECT * FROM badge WHERE earnedAt BETWEEN :from AND :to ORDER BY earnedAt") + suspend fun badgesEarnedOnce(from: Long, to: Long): List + + /** Weeks whose first day (an epoch day) starts in [from, to], in epoch seconds. */ + @Query("SELECT * FROM week_result WHERE weekStart * 86400 BETWEEN :from AND :to ORDER BY weekStart") + suspend fun weekResultsStartingOnce(from: Long, to: Long): List + + /** Bests set on a day (an epoch day) that starts in [from, to], in epoch seconds. */ + @Query("SELECT * FROM personal_best WHERE date * 86400 BETWEEN :from AND :to") + suspend fun personalBestsSetOnce(from: Long, to: Long): List + + @Query("SELECT * FROM osa_event WHERE timestamp BETWEEN :from AND :to ORDER BY timestamp") + suspend fun osaEventsOnce(from: Long, to: Long): List + + @Query("SELECT * FROM health_alert WHERE timestamp BETWEEN :from AND :to ORDER BY timestamp") + suspend fun healthAlertsOnce(from: Long, to: Long): List + + /* + * Keyset pages for the backup of the biggest tables: up to [limit] rows after the last one read, in the order the + * whole read would give them, so a page never skips or repeats a row and no table is held whole. + */ + + @Query("SELECT * FROM heart_rate WHERE timestamp > :after AND timestamp <= :to ORDER BY timestamp LIMIT :limit") + suspend fun heartRatePage(after: Long, to: Long, limit: Int): List + + @Query("SELECT * FROM activity_minute WHERE timestamp > :after AND timestamp <= :to ORDER BY timestamp LIMIT :limit") + suspend fun activityPage(after: Long, to: Long, limit: Int): List + + @Query("SELECT * FROM activity_extended WHERE timestamp > :after AND timestamp <= :to ORDER BY timestamp LIMIT :limit") + suspend fun activityExtendedPage(after: Long, to: Long, limit: Int): List + + @Query("SELECT * FROM spo2 WHERE timestamp > :after AND timestamp <= :to ORDER BY timestamp LIMIT :limit") + suspend fun spo2Page(after: Long, to: Long, limit: Int): List + + @Query("SELECT * FROM sleep_spo2 WHERE timestamp > :after AND timestamp <= :to ORDER BY timestamp LIMIT :limit") + suspend fun sleepSpo2Page(after: Long, to: Long, limit: Int): List + + @Query("SELECT * FROM stress WHERE timestamp > :after AND timestamp <= :to ORDER BY timestamp LIMIT :limit") + suspend fun stressPage(after: Long, to: Long, limit: Int): List + + @Query("SELECT * FROM hrv WHERE timestamp > :after AND timestamp <= :to ORDER BY timestamp LIMIT :limit") + suspend fun hrvPage(after: Long, to: Long, limit: Int): List + + @Query("SELECT * FROM hrv_sdnn WHERE timestamp > :after AND timestamp <= :to ORDER BY timestamp LIMIT :limit") + suspend fun hrvSdnnPage(after: Long, to: Long, limit: Int): List + + /** Heart rate of the strap workouts that started in [from, to], after the sample ([afterStart], [afterTime]). */ + @Query( + "SELECT * FROM workout_hr WHERE workoutStart BETWEEN :from AND :to AND " + + "(workoutStart > :afterStart OR (workoutStart = :afterStart AND timestamp > :afterTime)) " + + "ORDER BY workoutStart, timestamp LIMIT :limit", + ) + suspend fun workoutHeartRatePage(from: Long, to: Long, afterStart: Long, afterTime: Long, limit: Int): List + + /** As [workoutHeartRatePage], for the phone's workouts. */ + @Query( + "SELECT * FROM app_workout_hr WHERE workoutStart BETWEEN :from AND :to AND " + + "(workoutStart > :afterStart OR (workoutStart = :afterStart AND timestamp > :afterTime)) " + + "ORDER BY workoutStart, timestamp LIMIT :limit", + ) + suspend fun appWorkoutHeartRatePage(from: Long, to: Long, afterStart: Long, afterTime: Long, limit: Int): List + + /** As [workoutHeartRatePage], for the phone workouts' routes. */ + @Query( + "SELECT * FROM app_workout_route WHERE workoutStart BETWEEN :from AND :to AND " + + "(workoutStart > :afterStart OR (workoutStart = :afterStart AND timestamp > :afterTime)) " + + "ORDER BY workoutStart, timestamp LIMIT :limit", + ) + suspend fun routePage(from: Long, to: Long, afterStart: Long, afterTime: Long, limit: Int): List + + /** [hoursWithData] over [from, to] only, so watching the recent days doesn't scan the whole of every table. */ + @Query( + "SELECT DISTINCT timestamp / 3600 FROM heart_rate WHERE timestamp BETWEEN :from AND :to " + + "UNION SELECT DISTINCT timestamp / 3600 FROM activity_minute " + + "WHERE timestamp BETWEEN :from AND :to AND steps > 0 AND steps != 255 " + + "UNION SELECT DISTINCT `end` / 3600 FROM sleep_session WHERE `end` BETWEEN :from AND :to", + ) + fun hoursWithData(from: Long, to: Long): Flow> + + @Query( + "SELECT DISTINCT timestamp / 3600 FROM heart_rate WHERE timestamp BETWEEN :from AND :to " + + "UNION SELECT DISTINCT timestamp / 3600 FROM activity_minute " + + "WHERE timestamp BETWEEN :from AND :to AND steps > 0 AND steps != 255 " + + "UNION SELECT DISTINCT `end` / 3600 FROM sleep_session WHERE `end` BETWEEN :from AND :to", + ) + suspend fun hoursWithDataOnce(from: Long, to: Long): List + + /** + * The earliest heart rate, minute and sleep end, folded into one number: it changes when older data comes in (a + * restore, a Health Connect import). The first two are index lookups and sleep_session is small, so watching it is + * cheap. + */ + @Query( + "SELECT COALESCE((SELECT MIN(timestamp) FROM heart_rate), 0) * 3 + COALESCE((SELECT MIN(timestamp) FROM activity_minute), 0) * 5 " + + "+ COALESCE((SELECT MIN(`end`) FROM sleep_session), 0) * 7", + ) + fun earliestDataMark(): Flow +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/db/Entities.kt b/app/src/main/kotlin/org/openstrap/app/data/db/Entities.kt index b0284d77..646c963d 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/db/Entities.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/db/Entities.kt @@ -28,8 +28,11 @@ data class ActivityMinute( val heartRate: Int?, ) -/** A main sleep or a nap. The strap's own totals and score are kept as it reported them. */ -@Entity(tableName = "sleep_session", indices = [Index("day")]) +/** + * A main sleep or a nap. The strap's own totals and score are kept as it reported them. Indexed on [nap] and [end] as + * well as [day], since most reads pick sessions by when they ended, and some by main sleep or nap too. + */ +@Entity(tableName = "sleep_session", indices = [Index("day"), Index("nap", "end")]) data class SleepSession( @PrimaryKey val start: Long, val end: Long, @@ -46,15 +49,27 @@ data class SleepSession( ) /** - * The user's own times for a nap the strap detected, kept apart from [SleepSession] because a sync rewrites the - * day's naps. Keyed by the strap's start for the nap, which stays the same across syncs. + * The user's own times for a sleep or nap the strap detected, or its removal, kept apart from [SleepSession] because a + * sync rewrites the day's sessions. Keyed by the strap's start for the session, which stays the same across syncs or + * moves a few minutes ([org.openstrap.app.data.SleepEdits.resolve] allows for that). The strap's rows are never + * changed by an edit, so Undo and Restore bring back exactly what was there. */ -@Entity(tableName = "nap_edit") -data class NapEdit( +@Entity(tableName = "sleep_edit") +data class SleepEdit( @PrimaryKey val strapStart: Long, - val start: Long, - val end: Long, -) + /** Which kind the strap's session was, so an edit never moves or removes a session of the other kind. */ + @ColumnInfo(defaultValue = "0") val nap: Boolean, + /** The user's times; both null when not moved. */ + val start: Long? = null, + val end: Long? = null, + /** Removed: left out of everything. Its session goes for good after [org.openstrap.app.data.SleepEdits.KEEP_DAYS]. */ + @ColumnInfo(defaultValue = "0") val deleted: Boolean = false, + /** When it was last changed, epoch seconds: for the Deleted sleep list and the clean up. */ + @ColumnInfo(defaultValue = "0") val editedAt: Long = 0, +) { + /** True when the user moved the session's times. */ + val moved: Boolean get() = start != null && end != null +} @Entity(tableName = "sleep_stage", indices = [Index("sessionStart")]) data class SleepStage( @@ -167,7 +182,7 @@ data class WorkoutHeartRate( * A workout recorded on the phone from the strap's live heart rate, apart from the strap's own [Workout] so a later * merge with strap-side sessions stays explicit. [end] is null while it is recording. */ -@Entity(tableName = "app_workout") +@Entity(tableName = "app_workout", indices = [Index("end")]) data class AppWorkout( @PrimaryKey val start: Long, val end: Long?, @@ -236,6 +251,16 @@ data class AppWorkout( val totalKcal: Float? = null, /** When the live active kcal first reached a kcal [target], epoch seconds; null until then and for other targets. */ val targetReachedAt: Long? = null, + /** + * Rounds or periods ([org.openstrap.app.data.Phases]): intervals ("t:20/10x8"), run reps ("r:400m/90x6") or a + * game's periods ("p:2x2700/900"); null for an open workout. Added in version 25. + */ + val structure: String? = null, + /** + * A GPS run's fastest stretches ([org.openstrap.app.data.Rewards.Stretch.code]), worked out from the route when it + * ends, kept or not, so its bests can be worked out again later. Null without GPS. Added in version 25. + */ + val stretches: String? = null, ) { val lapMarks: List get() = laps.split(',').mapNotNull { it.toLongOrNull() } @@ -264,7 +289,10 @@ data class AppWorkout( } } -/** One set of a strength workout: when it began and ended, and when the rest after it ended (null while resting). */ +/** + * One set of a strength workout: when it began and ended, and when the rest after it ended (null while resting). Its + * [exercise], [reps] and [weightKg] are logged on the card after it when logging is on (version 25), else null. + */ @Entity(tableName = "app_workout_set", primaryKeys = ["workoutStart", "index"]) data class AppWorkoutSet( val workoutStart: Long, @@ -272,6 +300,80 @@ data class AppWorkoutSet( val start: Long, val end: Long? = null, val restEnd: Long? = null, + val exercise: String? = null, + val reps: Int? = null, + val weightKg: Float? = null, +) + +/** + * One round of an interval workout or one rep of run intervals (version 25): when its work began and ended, when the + * rest after it ended, and its peak heart rate. [workEnd] and [restEnd] are null while under way. + */ +@Entity(tableName = "app_workout_round", primaryKeys = ["workoutStart", "index"]) +data class AppWorkoutRound( + val workoutStart: Long, + val index: Int, + val workStart: Long, + val workEnd: Long? = null, + val restEnd: Long? = null, + val peakBpm: Int? = null, +) + +/** An interval workout saved by name ("Tuesday intervals"), offered on Choose and in More (version 25). */ +@Entity(tableName = "saved_workout") +data class SavedWorkout( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, + val sport: Int, + /** As [AppWorkout.structure]. */ + val structure: String, + /** A goal as [AppWorkout.target], or null. */ + val goal: String? = null, + val createdAt: Long, +) + +/** An exercise named in a strength log, with its last reps and weight to fill the next log card (version 25). */ +@Entity(tableName = "exercise") +data class ExerciseRow( + @PrimaryKey val name: String, + val lastReps: Int? = null, + val lastKg: Float? = null, + /** When it was last logged, epoch seconds, for the list's order. */ + val usedAt: Long, +) + +/** + * A personal best ([org.openstrap.app.data.Rewards]) by its [kind] ("run_5k", "lift:Bench press"): the [value] in its + * kind's unit, the [date] it was set (epoch day) and the workout that set it, if one did (version 25). + */ +@Entity(tableName = "personal_best") +data class PersonalBest( + @PrimaryKey val kind: String, + val value: Double, + val date: Long, + val workoutStart: Long? = null, +) + +/** A badge earned (requests spec 7.2, workouts spec 3.3): when, and the workout or day that earned it (version 25). */ +@Entity(tableName = "badge") +data class BadgeRow( + @PrimaryKey val id: String, + val earnedAt: Long, + val workoutStart: Long? = null, + /** The epoch day, for a badge a day earned. */ + val day: Long? = null, +) + +/** + * A week's workouts against the weekly goal it had (workouts spec 3.1): [weekStart] its first day (epoch day), the + * [goal] then, how many workouts [count]ed and whether it was [met] (version 25). + */ +@Entity(tableName = "week_result") +data class WeekResult( + @PrimaryKey val weekStart: Long, + val goal: Int, + val count: Int, + val met: Boolean, ) /** @@ -312,7 +414,7 @@ data class DeletedWorkout( ) /** A stretch answered "Not a workout", so it isn't offered again ([org.openstrap.app.data.WorkoutSuggestions]). */ -@Entity(tableName = "app_workout_dismissed") +@Entity(tableName = "app_workout_dismissed", indices = [Index("end")]) data class DismissedWorkout( @PrimaryKey val start: Long, val end: Long, @@ -468,6 +570,9 @@ data class SetCount(val workoutStart: Long, val sets: Int) data class HrStreamMark(val workoutStart: Long, val lastSample: Long, val samples: Int, val bpmSum: Long) /** One local day of a sampled metric: its average (or total, for steps), lowest, highest and sample count. */ +/** A table's row count and latest time ([HealthDao.activityStampOnce]). */ +data class TableStamp(val rowCount: Long, val latest: Long?) + data class DailyAggregate( /** The local date as SQLite prints it, `yyyy-MM-dd`. */ val day: String, diff --git a/app/src/main/kotlin/org/openstrap/app/data/db/IndicesMigration.kt b/app/src/main/kotlin/org/openstrap/app/data/db/IndicesMigration.kt new file mode 100644 index 00000000..09fec4df --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/db/IndicesMigration.kt @@ -0,0 +1,18 @@ +package org.openstrap.app.data.db + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Indices for the reads that pick rows by their end, version 26 to 27: sleep sessions by nap and end, phone workouts + * and dismissed stretches by end. Indices only, so no row changes. A named object like [WorkoutsMigration], so it can + * be renumbered on its own. The SQL is Room's own, from the schema exported for this version. + */ +@Suppress("MagicNumber") // The versions it runs between are what the migration is. +object IndicesMigration : Migration(26, 27) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE INDEX IF NOT EXISTS `index_sleep_session_nap_end` ON `sleep_session` (`nap`, `end`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_app_workout_end` ON `app_workout` (`end`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_app_workout_dismissed_end` ON `app_workout_dismissed` (`end`)") + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/db/OpenStrapDatabase.kt b/app/src/main/kotlin/org/openstrap/app/data/db/OpenStrapDatabase.kt index 6b82a1f3..3aada279 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/db/OpenStrapDatabase.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/db/OpenStrapDatabase.kt @@ -44,9 +44,15 @@ import androidx.room.RoomDatabase HabitAgeWeekRow::class, DeletedWorkout::class, DailyEnergyRow::class, - NapEdit::class, + SleepEdit::class, + AppWorkoutRound::class, + SavedWorkout::class, + ExerciseRow::class, + PersonalBest::class, + BadgeRow::class, + WeekResult::class, ], - version = 24, + version = 27, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(from = 3, to = 4), AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6), @@ -58,13 +64,17 @@ import androidx.room.RoomDatabase AutoMigration(from = 18, to = 19), AutoMigration(from = 19, to = 20), AutoMigration(from = 20, to = 21), AutoMigration(from = 21, to = 22), AutoMigration(from = 22, to = 23), AutoMigration(from = 23, to = 24), + AutoMigration(from = 24, to = 25, spec = SleepEditMigration::class), ], ) abstract class OpenStrapDatabase : RoomDatabase() { abstract fun healthDao(): HealthDao companion object { + /** Migrations Room can't make itself, after the automatic ones above. */ + val MIGRATIONS = arrayOf(WorkoutsMigration, IndicesMigration) + fun create(context: Context): OpenStrapDatabase = - Room.databaseBuilder(context, OpenStrapDatabase::class.java, "openstrap.db").build() + Room.databaseBuilder(context, OpenStrapDatabase::class.java, "openstrap.db").addMigrations(*MIGRATIONS).build() } } diff --git a/app/src/main/kotlin/org/openstrap/app/data/db/SleepEditMigration.kt b/app/src/main/kotlin/org/openstrap/app/data/db/SleepEditMigration.kt new file mode 100644 index 00000000..a0deb287 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/db/SleepEditMigration.kt @@ -0,0 +1,17 @@ +package org.openstrap.app.data.db + +import androidx.room.RenameTable +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Version 24 to 25: `nap_edit` becomes `sleep_edit` ([SleepEdit]), one table for sleep and naps that can also mark a + * session deleted. Every row it had was a nap's times, so each keeps them as a nap, not deleted, edited now. Its own + * class so the version it runs at can be renumbered on its own. + */ +@RenameTable(fromTableName = "nap_edit", toTableName = "sleep_edit") +class SleepEditMigration : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE sleep_edit SET nap = 1, deleted = 0, editedAt = ?", arrayOf(System.currentTimeMillis() / 1000)) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/db/WorkoutsMigration.kt b/app/src/main/kotlin/org/openstrap/app/data/db/WorkoutsMigration.kt new file mode 100644 index 00000000..2e923ea7 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/data/db/WorkoutsMigration.kt @@ -0,0 +1,45 @@ +package org.openstrap.app.data.db + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +/** + * Workouts, first release (workouts spec 4), in one migration: a workout's rounds or periods ([AppWorkout.structure]), + * a run's fastest stretches ([AppWorkout.stretches]), a strength set's log ([AppWorkoutSet.exercise], reps, weight), and the new tables for rounds, saved workouts, the + * exercise list, personal bests, badges and weekly goal results. Version 25 to 26, after the sleep edits took 25. A + * named object rather than an auto migration, so it can be renumbered on its own. The SQL is Room's own, from the + * schema exported for this version. + */ +object WorkoutsMigration : Migration(25, 26) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `app_workout` ADD COLUMN `structure` TEXT") + db.execSQL("ALTER TABLE `app_workout` ADD COLUMN `stretches` TEXT") + db.execSQL("ALTER TABLE `app_workout_set` ADD COLUMN `exercise` TEXT") + db.execSQL("ALTER TABLE `app_workout_set` ADD COLUMN `reps` INTEGER") + db.execSQL("ALTER TABLE `app_workout_set` ADD COLUMN `weightKg` REAL") + db.execSQL( + "CREATE TABLE IF NOT EXISTS `app_workout_round` (`workoutStart` INTEGER NOT NULL, `index` INTEGER NOT NULL, " + + "`workStart` INTEGER NOT NULL, `workEnd` INTEGER, `restEnd` INTEGER, `peakBpm` INTEGER, PRIMARY KEY(`workoutStart`, `index`))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `saved_workout` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, " + + "`sport` INTEGER NOT NULL, `structure` TEXT NOT NULL, `goal` TEXT, `createdAt` INTEGER NOT NULL)", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `exercise` (`name` TEXT NOT NULL, `lastReps` INTEGER, `lastKg` REAL, `usedAt` INTEGER NOT NULL, " + + "PRIMARY KEY(`name`))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `personal_best` (`kind` TEXT NOT NULL, `value` REAL NOT NULL, `date` INTEGER NOT NULL, " + + "`workoutStart` INTEGER, PRIMARY KEY(`kind`))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `badge` (`id` TEXT NOT NULL, `earnedAt` INTEGER NOT NULL, `workoutStart` INTEGER, `day` INTEGER, " + + "PRIMARY KEY(`id`))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `week_result` (`weekStart` INTEGER NOT NULL, `goal` INTEGER NOT NULL, `count` INTEGER NOT NULL, " + + "`met` INTEGER NOT NULL, PRIMARY KEY(`weekStart`))", + ) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/data/settings/Goals.kt b/app/src/main/kotlin/org/openstrap/app/data/settings/Goals.kt index b0432174..5a5c9375 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/settings/Goals.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/settings/Goals.kt @@ -1,5 +1,7 @@ package org.openstrap.app.data.settings +import org.openstrap.app.data.Stats + /** Your daily targets. The defaults are the app's old fixed goals, and the strap's 300 for calories. */ data class Goals( val steps: Int = DEFAULT_STEPS, @@ -7,17 +9,11 @@ data class Goals( val activeHours: Int = DEFAULT_ACTIVE_HOURS, /** Active kcal a day, sent to the strap as its calorie target (HEALTH key 0x53). */ val calories: Int = DEFAULT_CALORIES, + /** Workouts a week (workouts spec 3.1), [WORKOUTS_PER_WEEK]. */ + val workoutsPerWeek: Int = org.openstrap.app.data.Rewards.DEFAULT_GOAL, ) { val sleepSeconds: Long get() = sleepMinutes * 60L - /** "8,000 steps, 300 active kcal, 8 h sleep, 10 active hours". */ - fun summary(): String { - val h = sleepMinutes / 60 - val m = sleepMinutes % 60 - val sleep = if (m == 0) "$h h" else "$h h $m min" - return "%,d steps, %,d active kcal, %s sleep, %d active hours".format(steps, calories, sleep, activeHours) - } - companion object { const val DEFAULT_STEPS = 8000 const val DEFAULT_SLEEP_MINUTES = 8 * 60 @@ -27,6 +23,7 @@ data class Goals( val STEPS = 1000..50000 val SLEEP_MINUTES = 4 * 60..12 * 60 val ACTIVE_HOURS = 1..18 + val WORKOUTS_PER_WEEK = org.openstrap.app.data.Rewards.GOALS /** The strap's own range for its calorie target. */ val CALORIES = 200..3000 /** What the minus and plus buttons move the calorie goal by. */ @@ -39,9 +36,6 @@ data class Goals( * the last four weeks. Pure, so it's tested without the sheet. */ object CalorieGoal { - /** Said under the field when a typed value is outside [Goals.CALORIES]. */ - const val OUT_OF_RANGE = "Pick a number from 200 to 3,000" - /** Fewer full days than this and there is no suggestion: a few days say nothing about your usual. */ const val MIN_FULL_DAYS = 7 @@ -61,9 +55,6 @@ object CalorieGoal { */ fun suggested(fullDays: List): Int? { if (fullDays.size < MIN_FULL_DAYS) return null - val sorted = fullDays.sorted() - val n = sorted.size - val median = if (n % 2 == 1) sorted[n / 2] else (sorted[n / 2 - 1] + sorted[n / 2]) / 2f - return (Math.round(median * 1.1f / 10f) * 10).coerceIn(Goals.CALORIES) + return Stats.median(fullDays)?.let { median -> (Math.round(median * 1.1f / 10f) * 10).coerceIn(Goals.CALORIES) } } } diff --git a/app/src/main/kotlin/org/openstrap/app/data/settings/SettingsStore.kt b/app/src/main/kotlin/org/openstrap/app/data/settings/SettingsStore.kt index 6175e98d..bb126852 100644 --- a/app/src/main/kotlin/org/openstrap/app/data/settings/SettingsStore.kt +++ b/app/src/main/kotlin/org/openstrap/app/data/settings/SettingsStore.kt @@ -1,11 +1,14 @@ package org.openstrap.app.data.settings import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -127,35 +130,41 @@ data class QuietHours(val on: Boolean = false, val from: Int = 22 * 60, val to: } } -class SettingsStore(private val context: Context) { - private val address = stringPreferencesKey("device_address") - private val authKey = stringPreferencesKey("device_auth_key") - private val name = stringPreferencesKey("device_name") +/** The app's settings. Tests keep them in a store of their own ([of]). */ +class SettingsStore private constructor(private val store: DataStore) { + constructor(context: Context) : this(context.dataStore) - val pairedDevice: Flow = context.dataStore.data.map { prefs -> - val a = prefs[address] ?: return@map null - val k = prefs[authKey] ?: return@map null + /** [f] of the settings, only when it changes: every write re-emits the whole store, whatever key it touched. */ + private fun read(f: (Preferences) -> T): Flow = store.data.map(f).distinctUntilChanged() + + private val address = stringPreferencesKey(SettingsKeys.DEVICE_ADDRESS) + private val authKey = stringPreferencesKey(SettingsKeys.DEVICE_AUTH_KEY) + private val name = stringPreferencesKey(SettingsKeys.DEVICE_NAME) + + val pairedDevice: Flow = read { prefs -> + val a = prefs[address] ?: return@read null + val k = prefs[authKey] ?: return@read null PairedDevice(a, k, prefs[name] ?: "Helio Strap") } suspend fun savePairedDevice(device: PairedDevice) { - context.dataStore.edit { + store.edit { it[address] = device.address it[authKey] = device.authKeyHex it[name] = device.name } } - private val zeppToken = stringPreferencesKey("zepp_app_token") - private val zeppUser = stringPreferencesKey("zepp_user_id") - private val zeppApi = stringPreferencesKey("zepp_api_base") - private val zeppChina = androidx.datastore.preferences.core.booleanPreferencesKey("zepp_china") - private val zeppLoginToken = stringPreferencesKey("zepp_login_token") - private val zeppAppTokenAt = androidx.datastore.preferences.core.longPreferencesKey("zepp_app_token_at") - private val zeppAppTtl = androidx.datastore.preferences.core.longPreferencesKey("zepp_app_ttl") + private val zeppToken = stringPreferencesKey(SettingsKeys.ZEPP_APP_TOKEN) + private val zeppUser = stringPreferencesKey(SettingsKeys.ZEPP_USER_ID) + private val zeppApi = stringPreferencesKey(SettingsKeys.ZEPP_API_BASE) + private val zeppChina = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.ZEPP_CHINA) + private val zeppLoginToken = stringPreferencesKey(SettingsKeys.ZEPP_LOGIN_TOKEN) + private val zeppAppTokenAt = androidx.datastore.preferences.core.longPreferencesKey(SettingsKeys.ZEPP_APP_TOKEN_AT) + private val zeppAppTtl = androidx.datastore.preferences.core.longPreferencesKey(SettingsKeys.ZEPP_APP_TTL) - val zeppAccount: Flow = context.dataStore.data.map { prefs -> - val t = prefs[zeppToken] ?: return@map null + val zeppAccount: Flow = read { prefs -> + val t = prefs[zeppToken] ?: return@read null ZeppAccount( t, prefs[zeppUser].orEmpty(), prefs[zeppApi] ?: ZeppAccount.DEFAULT_API_BASE, prefs[zeppChina] == true, prefs[zeppLoginToken], prefs[zeppAppTokenAt], prefs[zeppAppTtl], @@ -164,7 +173,7 @@ class SettingsStore(private val context: Context) { /** Replaces the whole sign-in, so a typed-in token drops the login token of an earlier sign-in. */ suspend fun saveZeppAccount(account: ZeppAccount) { - context.dataStore.edit { + store.edit { it[zeppToken] = account.appToken it[zeppUser] = account.userId it[zeppApi] = account.apiBase @@ -176,93 +185,110 @@ class SettingsStore(private val context: Context) { } suspend fun clearZeppAccount() { - context.dataStore.edit { + store.edit { it.remove(zeppToken); it.remove(zeppUser); it.remove(zeppApi); it.remove(zeppChina) it.remove(zeppLoginToken); it.remove(zeppAppTokenAt); it.remove(zeppAppTtl) } } - private val liveHr = androidx.datastore.preferences.core.booleanPreferencesKey("live_hr") + private val liveHr = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.LIVE_HR) /** Whether live heart rate starts automatically on connect. On by default. */ - val liveHeartRate: Flow = context.dataStore.data.map { it[liveHr] ?: true } + val liveHeartRate: Flow = read { it[liveHr] ?: true } suspend fun setLiveHeartRate(enabled: Boolean) { - context.dataStore.edit { it[liveHr] = enabled } + store.edit { it[liveHr] = enabled } } - private val autoStartKey = androidx.datastore.preferences.core.booleanPreferencesKey("auto_start_workouts") + private val autoStartKey = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.AUTO_START_WORKOUTS) /** Notice a workout as it starts and offer to record it from where it began ([org.openstrap.app.data.AutoStart]). On by default. */ - val autoStartWorkouts: Flow = context.dataStore.data.map { it[autoStartKey] ?: true } + val autoStartWorkouts: Flow = read { it[autoStartKey] ?: true } suspend fun setAutoStartWorkouts(enabled: Boolean) { - context.dataStore.edit { it[autoStartKey] = enabled } + store.edit { it[autoStartKey] = enabled } } - private val wearReminderKey = androidx.datastore.preferences.core.booleanPreferencesKey("wear_reminder") + private val wearReminderKey = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.WEAR_REMINDER) /** * Off-body reminder, as Zepp has for the strap (codemap 07 §4 N5): turns on the strap's wear reminder (GENERAL * key 44) on connect and shows a notification while the strap reports it is off-body. On by default. */ - val wearReminder: Flow = context.dataStore.data.map { it[wearReminderKey] ?: true } + val wearReminder: Flow = read { it[wearReminderKey] ?: true } suspend fun setWearReminder(enabled: Boolean) { - context.dataStore.edit { it[wearReminderKey] = enabled } + store.edit { it[wearReminderKey] = enabled } } - private val strapSessionsKey = androidx.datastore.preferences.core.booleanPreferencesKey("strap_sessions") + private val strapSessionsKey = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.STRAP_SESSIONS) /** * On by default: a phone workout also runs a session on the strap (0x0019), so the strap keeps its own record of * it, whose heart rate and effect the workout then takes (verified on the strap 2026-10-30, codemap 05 §5.11). A * debug intent turns it off, to compare the live stream without one. */ - val strapSessions: Flow = context.dataStore.data.map { it[strapSessionsKey] ?: true } + val strapSessions: Flow = read { it[strapSessionsKey] ?: true } suspend fun setStrapSessions(enabled: Boolean) { - context.dataStore.edit { it[strapSessionsKey] = enabled } + store.edit { it[strapSessionsKey] = enabled } } - private val workoutLiveViewKey = androidx.datastore.preferences.core.intPreferencesKey("workout_live_view") + private val workoutLiveViewKey = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.WORKOUT_LIVE_VIEW) /** The live view a GPS workout was left on, Heart (0) or Map (1); the next one opens on it. Heart by default. */ - val workoutLiveView: Flow = context.dataStore.data.map { it[workoutLiveViewKey] ?: 0 } + val workoutLiveView: Flow = read { it[workoutLiveViewKey] ?: 0 } suspend fun setWorkoutLiveView(view: Int) { - context.dataStore.edit { it[workoutLiveViewKey] = view } + store.edit { it[workoutLiveViewKey] = view } } - private val workoutSidesKey = stringPreferencesKey("workout_sides") + private val workoutSidesKey = stringPreferencesKey(SettingsKeys.WORKOUT_SIDES) /** * The side of Ready's Outdoor or Indoor switch you last chose, per sport (spec 6.3), by the sport's key * ([org.openstrap.app.data.Workouts.Sides.outdoor]), true for indoor. Sports never switched aren't in it. */ - val workoutSides: Flow> = context.dataStore.data.map { org.openstrap.app.data.Workouts.parseSides(it[workoutSidesKey]) } + val workoutSides: Flow> = read { org.openstrap.app.data.Workouts.parseSides(it[workoutSidesKey]) } suspend fun setWorkoutSide(sport: Int, indoor: Boolean) { - context.dataStore.edit { + store.edit { val sides = org.openstrap.app.data.Workouts.parseSides(it[workoutSidesKey]) + (sport to indoor) it[workoutSidesKey] = org.openstrap.app.data.Workouts.formatSides(sides) } } - private val birthYear = androidx.datastore.preferences.core.intPreferencesKey("profile_birth_year") - private val sex = stringPreferencesKey("profile_sex") - private val weight = androidx.datastore.preferences.core.floatPreferencesKey("profile_weight_kg") - private val height = androidx.datastore.preferences.core.intPreferencesKey("profile_height_cm") - private val imperial = androidx.datastore.preferences.core.booleanPreferencesKey("profile_imperial") - private val sleepNeed = androidx.datastore.preferences.core.intPreferencesKey("profile_sleep_need_minutes") - private val maxHr = androidx.datastore.preferences.core.intPreferencesKey("profile_max_hr") - private val activityLevel = androidx.datastore.preferences.core.intPreferencesKey("profile_activity_level") - private val activityAt = longPreferencesKey("profile_activity_answered_at") - private val bodyAt = longPreferencesKey("profile_body_measured_at") - private val betaBlocker = androidx.datastore.preferences.core.booleanPreferencesKey("profile_beta_blocker") - private val pregnant = androidx.datastore.preferences.core.booleanPreferencesKey("profile_pregnant") - - val profile: Flow = context.dataStore.data.map { prefs -> + /** + * What Ready was set to the last time each sport started (workouts spec 4), by the sport's tile + * ([org.openstrap.app.data.Workouts.tileType]). Sports never started aren't in it. + */ + val sportSettings: Flow> = read { prefs -> + prefs.asMap().mapNotNull { (key, value) -> + val prefix = SettingsKeys.SPORT_SETTINGS_PREFIX + val sport = key.name.removePrefix(prefix).takeIf { key.name.startsWith(prefix) }?.toIntOrNull() + if (sport == null || value !is String) null else sport to org.openstrap.app.data.SportSettings.parse(value) + }.toMap() + } + + suspend fun setSportSettings(sport: Int, settings: org.openstrap.app.data.SportSettings) { + val key = stringPreferencesKey(SettingsKeys.SPORT_SETTINGS_PREFIX + org.openstrap.app.data.Workouts.tileType(sport)) + store.edit { it[key] = settings.encode() } + } + + private val birthYear = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.PROFILE_BIRTH_YEAR) + private val sex = stringPreferencesKey(SettingsKeys.PROFILE_SEX) + private val weight = androidx.datastore.preferences.core.floatPreferencesKey(SettingsKeys.PROFILE_WEIGHT_KG) + private val height = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.PROFILE_HEIGHT_CM) + private val imperial = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.PROFILE_IMPERIAL) + private val sleepNeed = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.PROFILE_SLEEP_NEED_MINUTES) + private val maxHr = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.PROFILE_MAX_HR) + private val activityLevel = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.PROFILE_ACTIVITY_LEVEL) + private val activityAt = longPreferencesKey(SettingsKeys.PROFILE_ACTIVITY_ANSWERED_AT) + private val bodyAt = longPreferencesKey(SettingsKeys.PROFILE_BODY_MEASURED_AT) + private val betaBlocker = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.PROFILE_BETA_BLOCKER) + private val pregnant = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.PROFILE_PREGNANT) + + val profile: Flow = read { prefs -> UserProfile( prefs[birthYear], prefs[sex]?.let { v -> UserProfile.Sex.entries.firstOrNull { it.name == v } }, @@ -280,7 +306,7 @@ class SettingsStore(private val context: Context) { } suspend fun saveProfile(p: UserProfile) { - context.dataStore.edit { + store.edit { if (p.birthYear != null) it[birthYear] = p.birthYear else it.remove(birthYear) if (p.sex != null) it[sex] = p.sex.name else it.remove(sex) if (p.weightKg != null) it[weight] = p.weightKg else it.remove(weight) @@ -296,59 +322,135 @@ class SettingsStore(private val context: Context) { } } - private val stepGoal = androidx.datastore.preferences.core.intPreferencesKey("goal_steps") - private val sleepGoal = androidx.datastore.preferences.core.intPreferencesKey("goal_sleep_minutes") - private val activeGoal = androidx.datastore.preferences.core.intPreferencesKey("goal_active_hours") - private val calorieGoal = androidx.datastore.preferences.core.intPreferencesKey("goal_calories") + /** Sets only your max heart rate ([UserProfile.maxHeartRate]), from the Heart rate zones page; null learns it again. */ + suspend fun setMaxHeartRate(bpm: Int?) { + store.edit { if (bpm != null) it[maxHr] = bpm else it.remove(maxHr) } + } + + private val heartZonesKey = stringPreferencesKey(SettingsKeys.PROFILE_HR_ZONES) + + /** Your heart rate zones, one set for every sport; [HeartZones.STANDARD] until moved. Not part of [saveProfile]. */ + val heartZones: Flow = read { prefs -> + org.openstrap.app.data.HeartZones.parse(prefs[heartZonesKey]) ?: org.openstrap.app.data.HeartZones.STANDARD + } + + /** + * Stores [zones] ([HeartZones.STANDARD] clears them) and puts them in use at once, so whatever reads them next + * already has them ([HeartZones.current]). + */ + suspend fun setHeartZones(zones: org.openstrap.app.data.HeartZones) { + org.openstrap.app.data.HeartZones.use(zones) + store.edit { if (zones.standard) it.remove(heartZonesKey) else it[heartZonesKey] = zones.encode() } + } + + private val stepGoal = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.GOAL_STEPS) + private val sleepGoal = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.GOAL_SLEEP_MINUTES) + private val activeGoal = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.GOAL_ACTIVE_HOURS) + private val calorieGoal = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.GOAL_CALORIES) + private val workoutGoal = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.GOAL_WORKOUTS_WEEK) - val goals: Flow = context.dataStore.data.map { prefs -> + val goals: Flow = read { prefs -> Goals( prefs[stepGoal] ?: Goals.DEFAULT_STEPS, prefs[sleepGoal] ?: Goals.DEFAULT_SLEEP_MINUTES, prefs[activeGoal] ?: Goals.DEFAULT_ACTIVE_HOURS, prefs[calorieGoal] ?: Goals.DEFAULT_CALORIES, + prefs[workoutGoal] ?: org.openstrap.app.data.Rewards.DEFAULT_GOAL, ) } suspend fun saveGoals(g: Goals) { - context.dataStore.edit { + store.edit { it[stepGoal] = g.steps it[sleepGoal] = g.sleepMinutes it[activeGoal] = g.activeHours it[calorieGoal] = g.calories + it[workoutGoal] = g.workoutsPerWeek } } - private val todayHidden = stringPreferencesKey("today_hidden") - private val todayPinned = stringPreferencesKey("today_pinned") + private val todayHidden = stringPreferencesKey(SettingsKeys.TODAY_HIDDEN) + private val todayPinned = stringPreferencesKey(SettingsKeys.TODAY_PINNED) /** Edit Today's choices: the cards hidden and the ones pinned under the hero, by name. Null until first edited. */ - val todayHiddenNames: Flow?> = context.dataStore.data.map { prefs -> prefs[todayHidden]?.split(',')?.filter { it.isNotEmpty() } } - val todayPinnedNames: Flow?> = context.dataStore.data.map { prefs -> prefs[todayPinned]?.split(',')?.filter { it.isNotEmpty() } } + val todayHiddenNames: Flow?> = read { prefs -> prefs[todayHidden]?.split(',')?.filter { it.isNotEmpty() } } + val todayPinnedNames: Flow?> = read { prefs -> prefs[todayPinned]?.split(',')?.filter { it.isNotEmpty() } } + + private val goalMomentKey = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.CELEBRATE_GOAL) + private val weeklyRecapKey = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.WEEKLY_RECAP) + private val goalMomentWeekKey = longPreferencesKey(SettingsKeys.GOAL_MOMENT_WEEK) + private val recapWeekKey = longPreferencesKey(SettingsKeys.RECAP_WEEK) + private val rewardsStartedKey = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.REWARDS_STARTED) + private val historyBadgesKey = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.HISTORY_BADGES_WAITING) + + /** Celebrations (workouts spec 3.4): the full screen goal moment, once a week. On by default. */ + val goalMoment: Flow = read { it[goalMomentKey] ?: true } + + suspend fun setGoalMoment(on: Boolean) { + store.edit { it[goalMomentKey] = on } + } + + /** Celebrations: the Monday morning recap notification. On by default. */ + val weeklyRecap: Flow = read { it[weeklyRecapKey] ?: true } + + suspend fun setWeeklyRecap(on: Boolean) { + store.edit { it[weeklyRecapKey] = on } + } + + /** The week (epoch day of its first day) the goal moment last showed for, so it shows once a week; null before. */ + val goalMomentWeek: Flow = read { it[goalMomentWeekKey] } + + suspend fun setGoalMomentWeek(week: Long) { + store.edit { it[goalMomentWeekKey] = week } + } + + /** The week (epoch day of its first day) the last recap was about, so each week gets one; null before. */ + val recapWeek: Flow = read { it[recapWeekKey] } + + suspend fun setRecapWeek(week: Long) { + store.edit { it[recapWeekKey] = week } + } + + /** Rewards were worked out once already: later badges are new, not past history. */ + val rewardsStarted: Flow = read { it[rewardsStartedKey] ?: false } + + /** The badges earned for past history on first launch wait to be shown once, all on one screen. */ + val historyBadgesWaiting: Flow = read { it[historyBadgesKey] ?: false } + + suspend fun startRewards(historyBadges: Boolean) { + store.edit { + it[rewardsStartedKey] = true + it[historyBadgesKey] = historyBadges + } + } + + suspend fun historyBadgesShown() { + store.edit { it[historyBadgesKey] = false } + } - private val habitAgeSeen = longPreferencesKey("habit_age_seen") + private val habitAgeSeen = longPreferencesKey(SettingsKeys.HABIT_AGE_SEEN) /** The Sunday (local midnight) of the last Habit Age estimate opened, so Today stops offering it; null before any. */ - val habitAgeSeenAsOf: Flow = context.dataStore.data.map { it[habitAgeSeen] } + val habitAgeSeenAsOf: Flow = read { it[habitAgeSeen] } suspend fun setHabitAgeSeen(asOf: Long) { - context.dataStore.edit { it[habitAgeSeen] = asOf } + store.edit { it[habitAgeSeen] = asOf } } - private val firstScore = longPreferencesKey("first_score_day") + private val firstScore = longPreferencesKey(SettingsKeys.FIRST_SCORE_DAY) /** * The day (epoch day) of the first real Recovery score, so it's marked on that day only; null before one. Scores - * from before this was kept are stored as a day long past ([org.openstrap.app.ui.dashboard.BEFORE_FIRST_SCORE]). + * from before this was kept are stored as a day long past (`BEFORE_FIRST_SCORE`). */ - val firstScoreDay: Flow = context.dataStore.data.map { it[firstScore] } + val firstScoreDay: Flow = read { it[firstScore] } suspend fun setFirstScoreDay(epochDay: Long) { - context.dataStore.edit { it[firstScore] = epochDay } + store.edit { it[firstScore] = epochDay } } suspend fun saveTodayEdits(hidden: List, pinned: List) { - context.dataStore.edit { + store.edit { it[todayHidden] = hidden.joinToString(",") it[todayPinned] = pinned.joinToString(",") // What changed's tile choice, which Edit Today replaced. @@ -356,46 +458,50 @@ class SettingsStore(private val context: Context) { } } - private val companionId = androidx.datastore.preferences.core.intPreferencesKey("companion_association_id") - private val companionNo = androidx.datastore.preferences.core.booleanPreferencesKey("companion_declined") + private val companionId = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.COMPANION_ASSOCIATION_ID) + private val companionNo = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.COMPANION_DECLINED) /** The strap's companion association id ([org.openstrap.app.device.CompanionLink.LEGACY_ID] before Android 13), or null. */ - val companionAssociation: Flow = context.dataStore.data.map { it[companionId] } + val companionAssociation: Flow = read { it[companionId] } suspend fun setCompanionAssociation(id: Int) { - context.dataStore.edit { it[companionId] = id } + store.edit { it[companionId] = id } } /** True once the user turned down Android's association prompt; the strap page then offers battery settings instead. */ - val companionDeclined: Flow = context.dataStore.data.map { it[companionNo] == true } + val companionDeclined: Flow = read { it[companionNo] == true } suspend fun setCompanionDeclined(declined: Boolean) { - context.dataStore.edit { it[companionNo] = declined } + store.edit { it[companionNo] = declined } } - private val heldOff = androidx.datastore.preferences.core.booleanPreferencesKey("held_disconnected") + private val heldOff = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.HELD_DISCONNECTED) /** True after the user tapped Disconnect, until they connect again; background reconnects leave the strap alone. */ - val heldDisconnected: Flow = context.dataStore.data.map { it[heldOff] == true } + val heldDisconnected: Flow = read { it[heldOff] == true } suspend fun setHeldDisconnected(held: Boolean) { - context.dataStore.edit { it[heldOff] = held } + store.edit { it[heldOff] = held } } - /** Forgets the strap and the Zepp sign-in; the user's profile and goals stay. */ + /** + * Forgets the strap and the Zepp sign-in, removing only the keys about them ([STRAP_KEYS] and the history cursors), + * so every other setting stays, a key added later included. + */ suspend fun forgetDevice() { - context.dataStore.edit { prefs -> - val keep = setOf(birthYear, sex, weight, height, imperial, sleepNeed, maxHr, activityLevel, activityAt, bodyAt, betaBlocker, pregnant, stepGoal, sleepGoal, activeGoal, calorieGoal, todayHidden, todayPinned, hcAuto, hcSynced, hcStopped, buzzCalls, buzzNotifications, buzzPattern, callBuzz, quietOn, quietFrom, quietTo, quietCalls, backupFolder, backupFolderName, backupDone, backupFailed, backupFailedAt) - prefs.asMap().keys.filter { it !in keep }.forEach { prefs.remove(it) } + store.edit { prefs -> + prefs.asMap().keys + .filter { it.name in STRAP_KEYS || it.name.startsWith(SettingsKeys.HISTORY_CURSOR_PREFIX) } + .forEach { prefs.remove(it) } } } - private val hcAuto = androidx.datastore.preferences.core.booleanPreferencesKey("health_connect_auto") - private val hcSynced = longPreferencesKey("health_connect_synced") - private val hcStopped = stringPreferencesKey("health_connect_auto_stopped") + private val hcAuto = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.HEALTH_CONNECT_AUTO) + private val hcSynced = longPreferencesKey(SettingsKeys.HEALTH_CONNECT_SYNCED) + private val hcStopped = stringPreferencesKey(SettingsKeys.HEALTH_CONNECT_AUTO_STOPPED) /** Auto sync to Health Connect, kept when the strap is forgotten: it's about the data, not the strap. */ - val healthConnectAuto: Flow = context.dataStore.data.map { prefs -> + val healthConnectAuto: Flow = read { prefs -> HealthConnectAuto( prefs[hcAuto] == true, prefs[hcSynced], @@ -405,7 +511,7 @@ class SettingsStore(private val context: Context) { /** The user's switch; either way clears why it last stopped. */ suspend fun setHealthConnectAuto(enabled: Boolean) { - context.dataStore.edit { + store.edit { it[hcAuto] = enabled it.remove(hcStopped) } @@ -413,7 +519,7 @@ class SettingsStore(private val context: Context) { /** Auto sync turns itself off, and the You page says [why]. */ suspend fun stopHealthConnectAuto(why: HealthConnectAuto.Stopped) { - context.dataStore.edit { + store.edit { it[hcAuto] = false it[hcStopped] = why.name } @@ -421,61 +527,61 @@ class SettingsStore(private val context: Context) { /** OpenStrap wrote to Health Connect at [epochSeconds]. */ suspend fun setHealthConnectSynced(epochSeconds: Long) { - context.dataStore.edit { it[hcSynced] = epochSeconds } + store.edit { it[hcSynced] = epochSeconds } } - private val buzzCalls = androidx.datastore.preferences.core.booleanPreferencesKey("buzz_calls") - private val buzzNotifications = androidx.datastore.preferences.core.booleanPreferencesKey("buzz_notifications") + private val buzzCalls = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.BUZZ_CALLS) + private val buzzNotifications = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.BUZZ_NOTIFICATIONS) /** Buzz the strap while a phone call rings. Off by default; it needs the phone-state permission. */ - val buzzOnCalls: Flow = context.dataStore.data.map { it[buzzCalls] == true } + val buzzOnCalls: Flow = read { it[buzzCalls] == true } /** Buzz the strap once for each app notification that would make a sound. Off by default; it needs notification access. */ - val buzzOnNotifications: Flow = context.dataStore.data.map { it[buzzNotifications] == true } + val buzzOnNotifications: Flow = read { it[buzzNotifications] == true } - private val buzzPattern = stringPreferencesKey("buzz_pattern") + private val buzzPattern = stringPreferencesKey(SettingsKeys.BUZZ_PATTERN) /** How the strap buzzes for a notification; two short buzzes by default. */ - val notificationPattern: Flow = context.dataStore.data.map { prefs -> + val notificationPattern: Flow = read { prefs -> BuzzPattern.entries.firstOrNull { it.name == prefs[buzzPattern] } ?: BuzzPattern.TWO_SHORT } suspend fun setNotificationPattern(pattern: BuzzPattern) { - context.dataStore.edit { it[buzzPattern] = pattern.name } + store.edit { it[buzzPattern] = pattern.name } } - private val callBuzz = stringPreferencesKey("call_buzz") + private val callBuzz = stringPreferencesKey(SettingsKeys.CALL_BUZZ) /** How the strap buzzes for a call; steady by default. */ - val callBuzzStyle: Flow = context.dataStore.data.map { prefs -> + val callBuzzStyle: Flow = read { prefs -> CallBuzz.entries.firstOrNull { it.name == prefs[callBuzz] } ?: CallBuzz.STEADY } suspend fun setCallBuzzStyle(style: CallBuzz) { - context.dataStore.edit { it[callBuzz] = style.name } + store.edit { it[callBuzz] = style.name } } suspend fun setBuzzOnCalls(enabled: Boolean) { - context.dataStore.edit { it[buzzCalls] = enabled } + store.edit { it[buzzCalls] = enabled } } suspend fun setBuzzOnNotifications(enabled: Boolean) { - context.dataStore.edit { it[buzzNotifications] = enabled } + store.edit { it[buzzNotifications] = enabled } } - private val quietOn = androidx.datastore.preferences.core.booleanPreferencesKey("quiet_hours") - private val quietFrom = androidx.datastore.preferences.core.intPreferencesKey("quiet_from_minutes") - private val quietTo = androidx.datastore.preferences.core.intPreferencesKey("quiet_to_minutes") - private val quietCalls = androidx.datastore.preferences.core.booleanPreferencesKey("quiet_calls") + private val quietOn = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.QUIET_HOURS) + private val quietFrom = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.QUIET_FROM_MINUTES) + private val quietTo = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.QUIET_TO_MINUTES) + private val quietCalls = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.QUIET_CALLS) /** When the strap doesn't buzz for notifications; off by default, 22:00 to 07:00 once turned on. */ - val quietHours: Flow = context.dataStore.data.map { prefs -> + val quietHours: Flow = read { prefs -> val d = QuietHours() QuietHours(prefs[quietOn] == true, prefs[quietFrom] ?: d.from, prefs[quietTo] ?: d.to, prefs[quietCalls] == true) } suspend fun setQuietHours(q: QuietHours) { - context.dataStore.edit { + store.edit { it[quietOn] = q.on it[quietFrom] = q.from.mod(24 * 60) it[quietTo] = q.to.mod(24 * 60) @@ -483,21 +589,21 @@ class SettingsStore(private val context: Context) { } } - private val strapBattery = androidx.datastore.preferences.core.intPreferencesKey("strap_battery") - private val strapCharging = androidx.datastore.preferences.core.booleanPreferencesKey("strap_charging") - private val syncDone = longPreferencesKey("strap_last_sync_done") + private val strapBattery = androidx.datastore.preferences.core.intPreferencesKey(SettingsKeys.STRAP_BATTERY) + private val strapCharging = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.STRAP_CHARGING) + private val syncDone = longPreferencesKey(SettingsKeys.STRAP_LAST_SYNC_DONE) /** * What a widget's strap status line reads with the strap away (widgets spec 5): pairing, the battery and charging * as last connected, and when a sync last finished. */ - val strapSnapshot: Flow = context.dataStore.data.map { prefs -> + val strapSnapshot: Flow = read { prefs -> StrapSnapshot(prefs[address] != null && prefs[authKey] != null, prefs[strapBattery], prefs[strapCharging] == true, prefs[syncDone]) } /** The connected strap's battery and charging, written when either changes. */ suspend fun setStrapBattery(percent: Int, charging: Boolean) { - context.dataStore.edit { + store.edit { it[strapBattery] = percent it[strapCharging] = charging } @@ -505,24 +611,24 @@ class SettingsStore(private val context: Context) { /** When a history sync last finished (epoch seconds). Per type cursors are [lastSync]. */ suspend fun setLastSyncDone(epochSeconds: Long) { - context.dataStore.edit { it[syncDone] = epochSeconds } + store.edit { it[syncDone] = epochSeconds } } /** Last synced timestamp (epoch seconds) for a history data type. */ suspend fun lastSync(type: String): Long? = - context.dataStore.data.first()[longPreferencesKey("last_sync_$type")] + store.data.first()[longPreferencesKey(SettingsKeys.LAST_SYNC_PREFIX + type)] suspend fun setLastSync(type: String, epochSeconds: Long) { - context.dataStore.edit { it[longPreferencesKey("last_sync_$type")] = epochSeconds } + store.edit { it[longPreferencesKey(SettingsKeys.LAST_SYNC_PREFIX + type)] = epochSeconds } } - private val backupFolder = stringPreferencesKey("backup_folder") - private val backupFolderName = stringPreferencesKey("backup_folder_name") - private val backupDone = longPreferencesKey("backup_last_done") - private val backupFailed = stringPreferencesKey("backup_failed") - private val backupFailedAt = longPreferencesKey("backup_failed_at") + private val backupFolder = stringPreferencesKey(SettingsKeys.BACKUP_FOLDER) + private val backupFolderName = stringPreferencesKey(SettingsKeys.BACKUP_FOLDER_NAME) + private val backupDone = longPreferencesKey(SettingsKeys.BACKUP_LAST_DONE) + private val backupFailed = stringPreferencesKey(SettingsKeys.BACKUP_FAILED) + private val backupFailedAt = longPreferencesKey(SettingsKeys.BACKUP_FAILED_AT) - val backup: Flow = context.dataStore.data.map { prefs -> + val backup: Flow = read { prefs -> BackupState( prefs[backupFolder], prefs[backupFolderName], prefs[backupDone], prefs[backupFailed]?.let { v -> org.openstrap.app.export.BackupFiles.Failure.entries.firstOrNull { it.name == v } }, @@ -532,7 +638,7 @@ class SettingsStore(private val context: Context) { /** Turns automatic backup on, or moves it to another folder; the last run's result goes with the old folder. */ suspend fun setBackupFolder(uri: String, name: String?) { - context.dataStore.edit { + store.edit { it[backupFolder] = uri if (name != null) it[backupFolderName] = name else it.remove(backupFolderName) it.remove(backupDone); it.remove(backupFailed); it.remove(backupFailedAt) @@ -540,69 +646,69 @@ class SettingsStore(private val context: Context) { } suspend fun clearBackup() { - context.dataStore.edit { + store.edit { it.remove(backupFolder); it.remove(backupFolderName); it.remove(backupDone); it.remove(backupFailed); it.remove(backupFailedAt) } } suspend fun setBackupDone(epochSeconds: Long) { - context.dataStore.edit { + store.edit { it[backupDone] = epochSeconds it.remove(backupFailed); it.remove(backupFailedAt) } } suspend fun setBackupFailed(failure: org.openstrap.app.export.BackupFiles.Failure, epochSeconds: Long) { - context.dataStore.edit { + store.edit { it[backupFailed] = failure.name it[backupFailedAt] = epochSeconds } } - /** The settings a backup carries ([org.openstrap.app.export.SettingsBackup.KEYS]), by key name, as stored. */ - private val updateAutoKey = androidx.datastore.preferences.core.booleanPreferencesKey("update_auto") - private val updateCheckedKey = longPreferencesKey("update_checked_at") - private val updateNotifiedKey = stringPreferencesKey("update_notified_tag") - private val updateInstallingKey = stringPreferencesKey("update_installing_tag") - private val updateSeenKey = stringPreferencesKey("update_seen_tag") + private val updateAutoKey = androidx.datastore.preferences.core.booleanPreferencesKey(SettingsKeys.UPDATE_AUTO) + private val updateCheckedKey = longPreferencesKey(SettingsKeys.UPDATE_CHECKED_AT) + private val updateNotifiedKey = stringPreferencesKey(SettingsKeys.UPDATE_NOTIFIED_TAG) + private val updateInstallingKey = stringPreferencesKey(SettingsKeys.UPDATE_INSTALLING_TAG) + private val updateSeenKey = stringPreferencesKey(SettingsKeys.UPDATE_SEEN_TAG) /** The daily check for a new OpenStrap release on GitHub ([org.openstrap.app.update.AppUpdater]). On unless turned off. */ - val updateAuto: Flow = context.dataStore.data.map { it[updateAutoKey] ?: true } + val updateAuto: Flow = read { it[updateAutoKey] ?: true } suspend fun setUpdateAuto(on: Boolean) { - context.dataStore.edit { it[updateAutoKey] = on } + store.edit { it[updateAutoKey] = on } } /** When the release list was last read from GitHub, epoch seconds. */ - val updateChecked: Flow = context.dataStore.data.map { it[updateCheckedKey] } + val updateChecked: Flow = read { it[updateCheckedKey] } suspend fun setUpdateChecked(epochSeconds: Long) { - context.dataStore.edit { it[updateCheckedKey] = epochSeconds } + store.edit { it[updateCheckedKey] = epochSeconds } } /** The tag of the last release the daily check notified about, so each one is said once. */ - suspend fun updateNotified(): String? = context.dataStore.data.first()[updateNotifiedKey] + suspend fun updateNotified(): String? = store.data.first()[updateNotifiedKey] suspend fun setUpdateNotified(tag: String) { - context.dataStore.edit { it[updateNotifiedKey] = tag } + store.edit { it[updateNotifiedKey] = tag } } /** The tag of the newest release the Updates page has shown, so its dot on the avatar goes once it's been seen. */ - val updateSeen: Flow = context.dataStore.data.map { it[updateSeenKey] } + val updateSeen: Flow = read { it[updateSeenKey] } suspend fun setUpdateSeen(tag: String) { - context.dataStore.edit { it[updateSeenKey] = tag } + store.edit { it[updateSeenKey] = tag } } /** The tag of the release handed to Android to install, until the new version starts; null when none. */ - suspend fun updateInstalling(): String? = context.dataStore.data.first()[updateInstallingKey] + suspend fun updateInstalling(): String? = store.data.first()[updateInstallingKey] suspend fun setUpdateInstalling(tag: String?) { - context.dataStore.edit { if (tag == null) it.remove(updateInstallingKey) else it[updateInstallingKey] = tag } + store.edit { if (tag == null) it.remove(updateInstallingKey) else it[updateInstallingKey] = tag } } + /** The settings a backup carries ([org.openstrap.app.export.SettingsBackup.carries]), by key name, as stored. */ suspend fun backupValues(): Map = - context.dataStore.data.first().asMap().mapKeys { it.key.name }.filterKeys { it in org.openstrap.app.export.SettingsBackup.KEYS } + store.data.first().asMap().mapKeys { it.key.name }.filterKeys(org.openstrap.app.export.SettingsBackup::carries) /** * Restores [values] from a backup, only those this phone has never set, so restoring twice or onto a phone already @@ -610,7 +716,7 @@ class SettingsStore(private val context: Context) { */ suspend fun restoreValues(values: Map): Int { var written = 0 - context.dataStore.edit { prefs -> + store.edit { prefs -> val present = prefs.asMap().keys.mapTo(HashSet()) { it.name } for ((name, v) in org.openstrap.app.export.SettingsBackup.missing(values, present)) { when (v) { @@ -626,4 +732,111 @@ class SettingsStore(private val context: Context) { } return written } + + companion object { + /** + * What [forgetDevice] removes: the paired strap (address, auth key, name), its companion association and the + * answer to Android's association prompt, the Disconnect hold, the battery and sync time widgets show, and the + * Zepp sign-in, which belongs to that strap's account. The next strap starts clean. The history cursors go too. + */ + val STRAP_KEYS: Set = with(SettingsKeys) { + setOf( + DEVICE_ADDRESS, DEVICE_AUTH_KEY, DEVICE_NAME, + COMPANION_ASSOCIATION_ID, COMPANION_DECLINED, HELD_DISCONNECTED, + STRAP_BATTERY, STRAP_CHARGING, STRAP_LAST_SYNC_DONE, + ZEPP_APP_TOKEN, ZEPP_USER_ID, ZEPP_API_BASE, ZEPP_CHINA, ZEPP_LOGIN_TOKEN, ZEPP_APP_TOKEN_AT, ZEPP_APP_TTL, + ) + } + + /** Settings kept in [store] rather than the app's own file, for tests. */ + internal fun of(store: DataStore) = SettingsStore(store) + } +} + +/** + * The DataStore name of every setting [SettingsStore] keeps, shared with [org.openstrap.app.export.SettingsBackup] so a + * backup names the same keys. A key added here is kept when the strap is forgotten unless [SettingsStore.forgetDevice] + * names it. + */ +object SettingsKeys { + const val DEVICE_ADDRESS = "device_address" + const val DEVICE_AUTH_KEY = "device_auth_key" + const val DEVICE_NAME = "device_name" + const val ZEPP_APP_TOKEN = "zepp_app_token" + const val ZEPP_USER_ID = "zepp_user_id" + const val ZEPP_API_BASE = "zepp_api_base" + const val ZEPP_CHINA = "zepp_china" + const val ZEPP_LOGIN_TOKEN = "zepp_login_token" + const val ZEPP_APP_TOKEN_AT = "zepp_app_token_at" + const val ZEPP_APP_TTL = "zepp_app_ttl" + const val LIVE_HR = "live_hr" + const val AUTO_START_WORKOUTS = "auto_start_workouts" + const val WEAR_REMINDER = "wear_reminder" + const val STRAP_SESSIONS = "strap_sessions" + const val WORKOUT_LIVE_VIEW = "workout_live_view" + const val WORKOUT_SIDES = "workout_sides" + const val PROFILE_BIRTH_YEAR = "profile_birth_year" + const val PROFILE_SEX = "profile_sex" + const val PROFILE_WEIGHT_KG = "profile_weight_kg" + const val PROFILE_HEIGHT_CM = "profile_height_cm" + const val PROFILE_IMPERIAL = "profile_imperial" + const val PROFILE_SLEEP_NEED_MINUTES = "profile_sleep_need_minutes" + const val PROFILE_MAX_HR = "profile_max_hr" + const val PROFILE_ACTIVITY_LEVEL = "profile_activity_level" + const val PROFILE_ACTIVITY_ANSWERED_AT = "profile_activity_answered_at" + const val PROFILE_BODY_MEASURED_AT = "profile_body_measured_at" + const val PROFILE_BETA_BLOCKER = "profile_beta_blocker" + const val PROFILE_PREGNANT = "profile_pregnant" + const val PROFILE_HR_ZONES = "profile_hr_zones" + const val GOAL_STEPS = "goal_steps" + const val GOAL_SLEEP_MINUTES = "goal_sleep_minutes" + const val GOAL_ACTIVE_HOURS = "goal_active_hours" + const val GOAL_CALORIES = "goal_calories" + const val GOAL_WORKOUTS_WEEK = "goal_workouts_week" + const val TODAY_HIDDEN = "today_hidden" + const val TODAY_PINNED = "today_pinned" + const val CELEBRATE_GOAL = "celebrate_goal" + const val WEEKLY_RECAP = "weekly_recap" + const val GOAL_MOMENT_WEEK = "goal_moment_week" + const val RECAP_WEEK = "recap_week" + const val REWARDS_STARTED = "rewards_started" + const val HISTORY_BADGES_WAITING = "history_badges_waiting" + const val HABIT_AGE_SEEN = "habit_age_seen" + const val FIRST_SCORE_DAY = "first_score_day" + const val COMPANION_ASSOCIATION_ID = "companion_association_id" + const val COMPANION_DECLINED = "companion_declined" + const val HELD_DISCONNECTED = "held_disconnected" + const val HEALTH_CONNECT_AUTO = "health_connect_auto" + const val HEALTH_CONNECT_SYNCED = "health_connect_synced" + const val HEALTH_CONNECT_AUTO_STOPPED = "health_connect_auto_stopped" + const val BUZZ_CALLS = "buzz_calls" + const val BUZZ_NOTIFICATIONS = "buzz_notifications" + const val BUZZ_PATTERN = "buzz_pattern" + const val CALL_BUZZ = "call_buzz" + const val QUIET_HOURS = "quiet_hours" + const val QUIET_FROM_MINUTES = "quiet_from_minutes" + const val QUIET_TO_MINUTES = "quiet_to_minutes" + const val QUIET_CALLS = "quiet_calls" + const val STRAP_BATTERY = "strap_battery" + const val STRAP_CHARGING = "strap_charging" + const val STRAP_LAST_SYNC_DONE = "strap_last_sync_done" + const val BACKUP_FOLDER = "backup_folder" + const val BACKUP_FOLDER_NAME = "backup_folder_name" + const val BACKUP_LAST_DONE = "backup_last_done" + const val BACKUP_FAILED = "backup_failed" + const val BACKUP_FAILED_AT = "backup_failed_at" + const val UPDATE_AUTO = "update_auto" + const val UPDATE_CHECKED_AT = "update_checked_at" + const val UPDATE_NOTIFIED_TAG = "update_notified_tag" + const val UPDATE_INSTALLING_TAG = "update_installing_tag" + const val UPDATE_SEEN_TAG = "update_seen_tag" + + /** Before a type, the keys of [SettingsStore.lastSync]. */ + const val LAST_SYNC_PREFIX = "last_sync_" + + /** Before a GDSP type's code, the strap's history cursors ([org.openstrap.app.device.HistorySync]). */ + const val HISTORY_CURSOR_PREFIX = LAST_SYNC_PREFIX + "gdsp_" + + /** The key each sport's [SportSettings][org.openstrap.app.data.SportSettings] is stored under, before the sport's id. */ + const val SPORT_SETTINGS_PREFIX = "sport_settings_" } diff --git a/app/src/main/kotlin/org/openstrap/app/device/BlePermissions.kt b/app/src/main/kotlin/org/openstrap/app/device/BlePermissions.kt new file mode 100644 index 00000000..227bf8d7 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/device/BlePermissions.kt @@ -0,0 +1,52 @@ +package org.openstrap.app.device + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings + +/** + * The one place that says which permissions the strap needs, so every check and request asks for the same ones. + */ +object BlePermissions { + /** What scanning for and talking to the strap needs. Scanning never derives location (neverForLocation). */ + val bluetooth: List = listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT) + + /** + * What the app asks for before it pairs, connects or starts a workout: [bluetooth], plus notifications for the + * foreground service's notification on Android 13 and later, where they need asking. Only [bluetooth] has to be + * granted for the action to go ahead. + */ + fun required(): Array = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + (bluetooth + Manifest.permission.POST_NOTIFICATIONS).toTypedArray() + } else { + bluetooth.toTypedArray() + } + + /** Whether the strap may be scanned for and connected to now. */ + fun granted(context: Context): Boolean = + bluetooth.all { context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED } + + /** + * Whether a [RequestMultiplePermissions][androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions] + * result allows the strap. + */ + fun granted(result: Map): Boolean = bluetooth.all { result[it] == true } + + /** + * Whether [permissions], just turned down, were turned down for good: Android shows no rationale for any of them, + * so a new request returns at once with no dialog, and only OpenStrap's settings page ([settingsIntent]) can turn + * them on. Ask it only in a request's answer, since before the first request no rationale is due either. + */ + fun turnedDownForGood(activity: Activity, permissions: Collection): Boolean = + permissions.none { activity.shouldShowRequestPermissionRationale(it) } + + /** OpenStrap's page in system settings, where a permission turned down for good can be turned back on. */ + fun settingsIntent(context: Context): Intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null)) +} diff --git a/app/src/main/kotlin/org/openstrap/app/device/BroadcastWork.kt b/app/src/main/kotlin/org/openstrap/app/device/BroadcastWork.kt new file mode 100644 index 00000000..51c0e5b7 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/device/BroadcastWork.kt @@ -0,0 +1,30 @@ +package org.openstrap.app.device + +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import org.openstrap.app.data.catching + +/** The most a receiver's work after `goAsync()` may take. Android calls a broadcast stuck after about 10 s. */ +internal const val BROADCAST_TIMEOUT_MS = 9_000L + +/** + * The work of a receiver that called `goAsync()`, on [scope] and within [timeoutMs], with [finish] called however it + * ends: done, failed, timed out or cancelled. Five Glance updates can outlast the broadcast window, and without the + * bound the pending result was finished late or never (audit C11). A failure or the timeout is logged as [what]. + */ +internal fun launchForBroadcast( + scope: CoroutineScope, + what: String, + finish: () -> Unit, + timeoutMs: Long = BROADCAST_TIMEOUT_MS, + work: suspend () -> Unit, +): Job = scope.launch { + try { + catching { withTimeout(timeoutMs) { work() } }.onFailure { Log.w("OpenStrap", "$what failed: $it") } + } finally { + finish() + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/device/CompanionLink.kt b/app/src/main/kotlin/org/openstrap/app/device/CompanionLink.kt index 8ac8bfa4..bd798eda 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/CompanionLink.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/CompanionLink.kt @@ -16,6 +16,7 @@ import android.os.Handler import android.os.Looper import android.os.PowerManager import android.util.Log +import androidx.annotation.RequiresApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -27,7 +28,7 @@ import org.openstrap.app.data.settings.SettingsStore /** * Registers the paired strap with Android's companion device manager (a plain association, no watch profile). * Associated, the app may run and start its service from the background, is left out of battery optimization, - * and on Android 12+ is woken through [StrapPresenceService] when the strap comes into range. + * and is woken through [StrapPresenceService] when the strap comes into range. */ class CompanionLink(private val context: Context, private val settings: SettingsStore) { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) @@ -83,6 +84,8 @@ class CompanionLink(private val context: Context, private val settings: Settings @Deprecated("Android 12 and older") override fun onDeviceFound(intentSender: IntentSender) = launch(intentSender) + // Android calls it from 13 on. Before that the association only shows in onResult. + @RequiresApi(Build.VERSION_CODES.TIRAMISU) override fun onAssociationCreated(associationInfo: AssociationInfo) { scope.launch { saved(associationInfo.id) } } @@ -98,7 +101,7 @@ class CompanionLink(private val context: Context, private val settings: Settings /** * The result of the confirmation [associate] launched. On Android 13+ the association arrives through the - * callback; older versions only report it here. Returns false when the user turned it down. + * callback. Older versions only report it here. Returns false when the user turned it down. */ fun onResult(resultCode: Int): Boolean { if (resultCode != Activity.RESULT_OK) { @@ -116,7 +119,7 @@ class CompanionLink(private val context: Context, private val settings: Settings refresh() } - /** Has Android bind [StrapPresenceService] when the strap is in range (Android 12+). Android keeps this across reboots. */ + /** Has Android bind [StrapPresenceService] when the strap is in range. Android keeps this across reboots. */ private fun observePresence(address: String) { val cdm = cdm ?: return runCatching { @@ -125,7 +128,7 @@ class CompanionLink(private val context: Context, private val settings: Settings val id = myAssociationId(address) ?: return cdm.startObservingDevicePresence(ObservingDevicePresenceRequest.Builder().setAssociationId(id).build()) } - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> @Suppress("DEPRECATION") cdm.startObservingDevicePresence(address) + else -> @Suppress("DEPRECATION") cdm.startObservingDevicePresence(address) } }.onFailure { Log.w(TAG, "presence observing failed: $it") } } diff --git a/app/src/main/kotlin/org/openstrap/app/device/ConnectErrors.kt b/app/src/main/kotlin/org/openstrap/app/device/ConnectErrors.kt new file mode 100644 index 00000000..d9f78b3f --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/device/ConnectErrors.kt @@ -0,0 +1,27 @@ +package org.openstrap.app.device + +import org.openstrap.app.ble.GattException +import org.openstrap.app.ble.GattTimeoutException +import org.openstrap.app.ble.GattUnusableException +import org.openstrap.protocol.ProtocolException +import org.openstrap.protocol.ProtocolTimeoutException + +/** + * Turns a failed connect into what the Device page says. The raw exception text ("Disconnected (status 133)") goes to + * the log only (audit E6). + */ +object ConnectErrors { + fun reason(e: Throwable): String = when { + e.isPermission() -> "OpenStrap isn't allowed to use Bluetooth. Allow Nearby devices in the app's settings, then tap Connect." + e is GattUnusableException -> "The saved strap address isn't valid. Forget the strap and pair it again." + e is GattTimeoutException || e is ProtocolTimeoutException -> "The strap stopped answering." + e is GattException -> "The Bluetooth link to the strap dropped." + e is ProtocolException -> "The strap answered in a way OpenStrap doesn't understand." + else -> "Couldn't connect to the strap." + } + + /** True when trying again can't help until the user acts, so no reconnect is scheduled (audit E4). */ + fun isFinal(e: Throwable): Boolean = e is GattUnusableException || e.isPermission() + + private fun Throwable.isPermission(): Boolean = generateSequence(this) { it.cause }.any { it is SecurityException } +} diff --git a/app/src/main/kotlin/org/openstrap/app/device/HealthAlertNotifier.kt b/app/src/main/kotlin/org/openstrap/app/device/HealthAlertNotifier.kt index f03b0f72..0799757f 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/HealthAlertNotifier.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/HealthAlertNotifier.kt @@ -8,11 +8,10 @@ import android.content.Intent import androidx.core.app.NotificationCompat import org.openstrap.app.MainActivity import org.openstrap.app.data.BodySignalsCopy -import org.openstrap.app.ui.dashboard.Metric +import org.openstrap.app.presentation.TimeOfDay import org.openstrap.protocol.services.HealthAlert import java.time.Instant import java.time.ZoneId -import java.time.format.DateTimeFormatter /** * Shows the strap's own health alerts (0x001D `0D`) as notifications, like Zepp's "Health Reminders" channel @@ -35,15 +34,11 @@ class HealthAlertNotifier(private val context: Context) { HealthAlert.Type.HIGH_STRESS -> "High stress" to "Your stress is at ${alert.value}. Take a moment to breathe." HealthAlert.Type.LOW_SPO2 -> "Low blood oxygen" to "Your blood oxygen was ${alert.value}%." HealthAlert.Type.IRREGULAR_RHYTHM -> - "Irregular heart rhythm" to "The strap noticed an irregular heart rhythm at ${TIME.format(Instant.ofEpochSecond(at))}." - } - val metric = when (alert.type) { - HealthAlert.Type.HIGH_HEART_RATE, HealthAlert.Type.LOW_HEART_RATE, HealthAlert.Type.IRREGULAR_RHYTHM -> Metric.HeartRate - HealthAlert.Type.HIGH_STRESS -> Metric.Stress - HealthAlert.Type.LOW_SPO2 -> Metric.Spo2 + "Irregular heart rhythm" to + "The strap noticed an irregular heart rhythm at ${TimeOfDay.format(at, ZoneId.systemDefault())}." } val day = Instant.ofEpochSecond(at).atZone(ZoneId.systemDefault()).toLocalDate() - val open = openRoute(metric.name, day, NOTIFICATION_BASE + alert.type.code) + val open = openRoute(route(alert.type), day, NOTIFICATION_BASE + alert.type.code) val notification = NotificationCompat.Builder(context, CHANNEL) .setSmallIcon(android.R.drawable.stat_sys_warning) .setContentTitle(title) @@ -91,11 +86,20 @@ class HealthAlertNotifier(private val context: Context) { PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - private companion object { - const val CHANNEL = "health" - const val BODY_SIGNALS_ID = 99 - const val NOTIFICATION_BASE = 100 - const val MIN_EPOCH = 1_704_067_200L // 2024-01-01 - val TIME: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()) + internal companion object { + /** + * The metric screen an alert opens, by its `Metric` name in ui.dashboard, written out so device code doesn't + * depend on the screens. A test checks each one still names a metric. + */ + fun route(type: HealthAlert.Type): String = when (type) { + HealthAlert.Type.HIGH_HEART_RATE, HealthAlert.Type.LOW_HEART_RATE, HealthAlert.Type.IRREGULAR_RHYTHM -> "HeartRate" + HealthAlert.Type.HIGH_STRESS -> "Stress" + HealthAlert.Type.LOW_SPO2 -> "Spo2" + } + + private const val CHANNEL = "health" + private const val BODY_SIGNALS_ID = 99 + private const val NOTIFICATION_BASE = 100 + private const val MIN_EPOCH = 1_704_067_200L // 2024-01-01 } } diff --git a/app/src/main/kotlin/org/openstrap/app/device/HistoryLink.kt b/app/src/main/kotlin/org/openstrap/app/device/HistoryLink.kt new file mode 100644 index 00000000..ae94346c --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/device/HistoryLink.kt @@ -0,0 +1,41 @@ +package org.openstrap.app.device + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.openstrap.app.ble.GattClient +import org.openstrap.app.ble.Notification +import org.openstrap.app.data.settings.SettingsStore +import java.util.UUID + +/** What [HistorySync] uses of the GATT link: [GattClient] on the phone, a simulated strap in tests. */ +internal interface HistoryGatt { + val connected: StateFlow + val notifications: Flow + + suspend fun enableNotifications(service: UUID, uuid: UUID) + + suspend fun write(service: UUID, uuid: UUID, value: ByteArray, withResponse: Boolean) +} + +/** Where [HistorySync] keeps each type's cursor, in epoch seconds, by key (`gdsp_`). */ +internal interface SyncCursors { + suspend fun get(key: String): Long? + + suspend fun set(key: String, epochSeconds: Long) +} + +internal fun GattClient.asHistoryGatt(): HistoryGatt = object : HistoryGatt { + override val connected get() = this@asHistoryGatt.connected + override val notifications get() = this@asHistoryGatt.notifications + + override suspend fun enableNotifications(service: UUID, uuid: UUID) = this@asHistoryGatt.enableNotifications(service, uuid) + + override suspend fun write(service: UUID, uuid: UUID, value: ByteArray, withResponse: Boolean) = + this@asHistoryGatt.write(service, uuid, value, withResponse) +} + +internal fun SettingsStore.asSyncCursors(): SyncCursors = object : SyncCursors { + override suspend fun get(key: String) = lastSync(key) + + override suspend fun set(key: String, epochSeconds: Long) = setLastSync(key, epochSeconds) +} diff --git a/app/src/main/kotlin/org/openstrap/app/device/HistorySync.kt b/app/src/main/kotlin/org/openstrap/app/device/HistorySync.kt index da82df52..c68ef466 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/HistorySync.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/HistorySync.kt @@ -2,6 +2,8 @@ package org.openstrap.app.device import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first @@ -9,6 +11,7 @@ import kotlinx.coroutines.launch import org.openstrap.app.ble.GattClient import org.openstrap.app.ble.StrapUuids import org.openstrap.app.data.Workouts +import org.openstrap.app.data.catching import org.openstrap.app.data.db.ActivityExtendedMinute import org.openstrap.app.data.db.ActivityMinute import org.openstrap.app.data.db.DailyMaxHr @@ -88,16 +91,26 @@ import org.openstrap.protocol.history.StressSample as StrapStress * Control runs over channel endpoint 0x004B when listed, otherwise HUAMI(0004); data always * arrives on HUAMI(0005). Each type keeps its own cursor so an interrupted sync resumes. */ -class HistorySync( +class HistorySync internal constructor( private val session: ChannelSession, - private val gatt: GattClient, + private val gatt: HistoryGatt, private val dao: HealthDao, - private val settings: SettingsStore, + private val cursors: SyncCursors, private val state: MutableStateFlow, private val log: (String) -> Unit, /** Called for a newly synced workout the strap auto-detected, to notify about it (codemap 05 §8). */ private val onAutoDetected: (Workout) -> Unit = {}, ) { + constructor( + session: ChannelSession, + gatt: GattClient, + dao: HealthDao, + settings: SettingsStore, + state: MutableStateFlow, + log: (String) -> Unit, + onAutoDetected: (Workout) -> Unit = {}, + ) : this(session, gatt.asHistoryGatt(), dao, settings.asSyncCursors(), state, log, onAutoDetected) + private val zone = ZoneId.systemDefault() private val useChannel = session.services.has(Endpoints.HISTORY) private val control = Channel(Channel.UNLIMITED) @@ -107,7 +120,6 @@ class HistorySync( /** Syncs every type, or only the codes in [only] (an on-demand sync after a data-available push). */ suspend fun run(only: Set? = null) = coroutineScope { state.value = SyncState.Running("Preparing…") - repairEarlierSyncs() val listeners = launch { launch { gatt.notifications.filter { it.characteristic == HISTORY_DATA }.collect { data.send(it.value) } } if (useChannel) { @@ -116,7 +128,11 @@ class HistorySync( launch { gatt.notifications.filter { it.characteristic == HISTORY_CONTROL }.collect { control.send(FetchCodec.parse(it.value)) } } } } + // The catch rethrows a CancellationException only once the sync is cancelled ([cancelled]): a timeout is one too. + @Suppress("SuspendFunSwallowedCancellation") try { + // Inside the try: a database error here ends the sync as Failed instead of escaping the app's scope. + repairEarlierSyncs() gatt.enableNotifications(StrapUuids.MAIN_SERVICE, HISTORY_DATA) if (!useChannel) gatt.enableNotifications(StrapUuids.MAIN_SERVICE, HISTORY_CONTROL) log("history: control via ${if (useChannel) "channel 0x004B" else "HUAMI(0004)"}") @@ -133,23 +149,30 @@ class HistorySync( try { if (spec.followedBy == HistoryDataType.SPORT_DETAIL) syncWorkouts(spec) else syncType(spec) } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException || e is FastRechargeModeException) throw e + // A timeout is a CancellationException too, but only a cancelled sync stops here (audit C2). + if (e is FastRechargeModeException || cancelled(e)) throw e // The link is gone: every later type would fail the same way, and the sync must not end as Done. if (!gatt.connected.value) throw e log("history ${spec.type}: $e") } } - if (only == null && session.services.has(Endpoints.READINESS)) runCatching { syncReadiness() }.onFailure { log("readiness: $it") } + if (only == null && session.services.has(Endpoints.READINESS)) catching { syncReadiness() }.onFailure { log("readiness: $it") } // A session the strap ran alongside a phone workout has just come in: its figures replace the model's. - runCatching { org.openstrap.app.data.WorkoutEffectEngine(dao).takeStrapEffects(System.currentTimeMillis() / 1000 - WORKOUT_LOOKBACK) } + catching { + org.openstrap.app.data.WorkoutEffectEngine(dao).takeStrapEffects(System.currentTimeMillis() / 1000 - WORKOUT_LOOKBACK) + } .onFailure { log("strap effect: $it") } + // Sleep deleted over a week ago goes for good, and a copy the strap just sent again with it. + catching { org.openstrap.app.data.SleepSource(dao).purgeExpired(System.currentTimeMillis() / 1000) } + .onSuccess { if (it > 0) log("history: removed $it sleep sessions deleted over a week ago") } + .onFailure { log("deleted sleep: $it") } state.value = SyncState.Done(System.currentTimeMillis() / 1000) } catch (e: FastRechargeModeException) { // Zepp ends the sync job at once in this mode (xyk.java:66-70); the data waits on the strap. log("history: strap is in fast-charge mode, sync stopped") state.value = SyncState.Failed("The strap is in fast-charge mode. Sync again once it's off.") } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e + if (cancelled(e)) throw e log("history failed: $e") // A wrapped system error's message is just its class name (android.os.DeadObjectException), not for the card. // Whatever a call on a dropped link threw (a missing characteristic, a closed op), the cause is the link. @@ -157,9 +180,16 @@ class HistorySync( state.value = SyncState.Failed(reason ?: "Sync failed") } finally { listeners.cancel() + // A cancelled sync, or anything else that got past the catches, must not stay Running: auto sync skips + // while it is, and the notification would show progress for good. + if (state.value is SyncState.Running) state.value = SyncState.Failed("Sync stopped before it finished.") } } + /** True when [e] is this sync being cancelled, not a timeout thrown as a CancellationException while it runs. */ + private suspend fun cancelled(e: Exception): Boolean = + e is kotlinx.coroutines.CancellationException && !currentCoroutineContext().isActive + /** * Debug aid: fetches every type the strap offers since [fromEpoch] and writes each chunk's payload to * [dir] as `_.bin`, without storing records or moving sync cursors. @@ -180,14 +210,14 @@ class HistorySync( log("dump: types $codes (strap offers ${version?.supportedTypes()?.sorted()})") for (code in codes) { state.value = SyncState.Running("Dumping type $code") - runCatching { + catching { var cursor = fromEpoch repeat(DUMP_CHUNKS) { val chunk = when (val f = client.fetch(code, cursor, zone)) { is GdspFetch.Chunk -> f - is GdspFetch.Refused -> { log("dump $code: start ${f.status}"); return@runCatching } - is GdspFetch.Failed -> { log("dump $code: ${f.reason}"); return@runCatching } - GdspFetch.Empty -> { log("dump $code: empty"); return@runCatching } + is GdspFetch.Refused -> { log("dump $code: start ${f.status}"); return@catching } + is GdspFetch.Failed -> { log("dump $code: ${f.reason}"); return@catching } + GdspFetch.Empty -> { log("dump $code: empty"); return@catching } } client.finish() val header = chunk.headerTime @@ -197,7 +227,7 @@ class HistorySync( val spec = HistoryTypeSpecs.forCode(code) val parsed = spec?.parser?.parse(payload, ParseContext(header, activityRecordSize = recordSize(code, payload, chunk.sizeField))) val next = (parsed as? ParseResult.Success)?.nextCursor - if (next == null || next <= cursor || spec.loops == false) return@runCatching + if (next == null || next <= cursor || spec.loops == false) return@catching cursor = next } }.onFailure { log("dump $code: $it") } @@ -235,11 +265,11 @@ class HistorySync( // they were all deleted here is not that, and going back would only fetch them again to skip them. if (dao.workoutCount() == 0 && dao.deletedWorkoutsAll().isEmpty()) for (type in WORKOUT_TYPES) { val key = "gdsp_${type.code}" - if ((settings.lastSync(key) ?: continue) > now - WORKOUT_LOOKBACK) settings.setLastSync(key, now - WORKOUT_LOOKBACK) + if ((cursors.get(key) ?: continue) > now - WORKOUT_LOOKBACK) cursors.set(key, now - WORKOUT_LOOKBACK) } for (type in HistoryDataType.entries) { val key = "gdsp_${type.code}" - val cursor = settings.lastSync(key) ?: continue + val cursor = cursors.get(key) ?: continue val activityRepaired = type == HistoryDataType.ACTIVITY && padding + outside > 0 if (cursor <= now + FUTURE_SLACK && !activityRepaired) continue val reset = if (type == HistoryDataType.ACTIVITY) { @@ -248,14 +278,14 @@ class HistorySync( now - DEFAULT_LOOKBACK } log("history: cursor for $type was ${Instant.ofEpochSecond(cursor)}, reset to ${Instant.ofEpochSecond(reset)}") - settings.setLastSync(key, reset) + cursors.set(key, reset) } } private suspend fun syncType(spec: HistoryTypeSpec) { val key = "gdsp_${spec.code}" val lookback = if (spec.type in WORKOUT_TYPES) WORKOUT_LOOKBACK else DEFAULT_LOOKBACK - var cursor = settings.lastSync(key) ?: (System.currentTimeMillis() / 1000 - lookback) + var cursor = cursors.get(key) ?: (System.currentTimeMillis() / 1000 - lookback) // The strap keeps updating a day's max exercise HR, so Zepp re-fetches it from the start of the day on every // sync (y4t.java:62-69). if (spec.type == HistoryDataType.DAILY_MAX_EXERCISE_HR) cursor = dayStart(cursor) @@ -283,23 +313,22 @@ class HistorySync( return } is ParseResult.Success -> { - // Drop records with impossible times (stale buffer slots, data from before the clock was set). + // Drop records with impossible times (stale buffer slots, data from before the clock was set), and + // activity minutes that haven't ended yet. val now = System.currentTimeMillis() / 1000 val (records, parsedNext) = holdBackPartialMinute(result.records, result.nextCursor, cursor) - val kept = records.filter { it.timestamp in MIN_VALID_EPOCH..now + FUTURE_SLACK } + val kept = keepable(spec.type, records, now) val dropped = records.size - kept.size - log("history ${spec.type}: ${kept.size} records" + if (dropped > 0) " ($dropped with impossible times dropped)" else "") - store(kept) + val droppedNote = if (dropped > 0) " ($dropped unfinished or with impossible times dropped)" else "" + log("history ${spec.type}: ${kept.size} records$droppedNote") + // The chunk's rows commit together, before the ACK and the cursor: a kill before the commit keeps + // none of them and the cursor where it was, a kill after it fetches the chunk again, and storing + // it again replaces the same rows. + dao.inTransaction { store(kept) } // Only acknowledge once the data is safely in the database. finish() - // A chunk with nothing usable (only gap slots, or only impossible times) still moves the cursor - // past it, or the strap serves the same chunk on every sync. The chunk holds the earliest - // records after the cursor, so skipping it loses nothing, and the cap at now keeps a strap - // clock that runs ahead from pushing the cursor into the future. - val next = parsedNext - ?.let { if (kept.isEmpty()) it else it.coerceAtMost(kept.maxOf { r -> r.timestamp } + 60) } - ?.coerceAtMost(now) - if (next != null && next > cursor) settings.setLastSync(key, next) + val next = nextCursor(spec.type, parsedNext, kept, now) + if (next != null && next > cursor) cursors.set(key, next) if (!HistoryTypeSpecs.shouldContinue(spec, cursor, next)) return cursor = next!! } @@ -340,7 +369,7 @@ class HistorySync( */ private suspend fun syncWorkouts(spec: HistoryTypeSpec) { val key = "gdsp_${spec.code}" - var cursor = settings.lastSync(key) ?: (System.currentTimeMillis() / 1000 - WORKOUT_LOOKBACK) + var cursor = cursors.get(key) ?: (System.currentTimeMillis() / 1000 - WORKOUT_LOOKBACK) repeat(MAX_CHUNKS_PER_TYPE) { when (val w = client.fetchWorkout(cursor, zone)) { WorkoutFetch.None -> return @@ -353,18 +382,20 @@ class HistorySync( } else if (w.summary.timestamp in MIN_VALID_EPOCH..now + FUTURE_SLACK) { log("history workout ${Instant.ofEpochSecond(w.summary.trackId)}: type ${w.summary.sportType}, subtype ${w.summary.subtype}, ${w.detail?.heartRate?.size ?: 0} HR samples") val storedBefore = dao.workout(w.summary.trackId).first() != null - store(listOfNotNull(w.summary, w.detail)) + dao.inTransaction { store(listOfNotNull(w.summary, w.detail)) } val row = workoutRow(w.summary) // One you started on the phone over the same time is the same workout: nothing new to tell. val yours = Workouts.phoneTwin(row, dao.appWorkoutsOnce(row.start, row.end), now) != null - if (!yours && Workouts.notifyAutoDetected(row, storedBefore, now)) runCatching { onAutoDetected(row) }.onFailure { log("auto-detect notification: $it") } + if (!yours && Workouts.notifyAutoDetected(row, storedBefore, now)) { + catching { onAutoDetected(row) }.onFailure { log("auto-detect notification: $it") } + } } else { log("history workout with impossible time ${w.summary.timestamp} dropped") } if (w.retryDetail) { log("history workout: detail failed, will retry next sync"); return } val next = w.nextCursor.coerceAtMost(now) if (next <= cursor) return - settings.setLastSync(key, next) + cursors.set(key, next) cursor = next } } @@ -412,8 +443,7 @@ class HistorySync( // One sample per second; the strap re-sends a workout whole, so replace it. val samples = r.heartRate.associate { (ms, bpm) -> ms / 1000 to bpm } .map { (t, bpm) -> WorkoutHeartRate(r.timestamp, t, bpm) } - dao.deleteWorkoutHeartRate(r.timestamp) - if (samples.isNotEmpty()) dao.insertWorkoutHeartRate(samples) + dao.replaceWorkoutDetail(r.timestamp, samples) } is SportEffect -> effects += SportEffectRow( r.timestamp, r.recoveryTimeMinutes, r.vo2max, r.epoc, r.trainingLoadWeekly, r.trainingLoadMonthly, @@ -490,31 +520,30 @@ class HistorySync( log("history: implausible sleep window $start..$end, skipped") } else { mainWindow = start to end - dao.deleteSleepStagesForDay(day, nap = false) - dao.deleteSleepSessionsForDay(day, nap = false) - dao.insertSleepSessions(listOf(SleepSession( + val session = SleepSession( start, end, day, nap = false, score = r.sleepScore.takeIf { it in 1..100 }, averageHeartRate = HeartRateSentinel.valid(r.averageHeartRate), deepMinutes = r.deepSleepLength, lightMinutes = r.lightSleepLength, remMinutes = r.remSleepLength.takeIf { r.isRemSupported }, awakeMinutes = r.wakeLength, wakeCount = r.wakeCount, - ))) - dao.insertSleepStages(main.map { stage(it, start, r) }) + ) + dao.replaceSleepDay(day, nap = false, listOf(session), main.map { stage(it, start, r) }) } } // The newest copy of a day replaces its naps, including when it has none: the strap can report a stretch // of broken night sleep as a nap and later fold it into the main sleep. The empty record the strap writes // at midnight doesn't claim nap support, so it leaves them alone. if (!r.isShortSleepSupported) return - dao.deleteSleepStagesForDay(day, nap = true) - dao.deleteSleepSessionsForDay(day, nap = true) val stages = r.shortStages.map { stage(it, 0, r) }.filter { it.end > it.start }.sortedBy { it.start } + val naps = mutableListOf() + val napStages = mutableListOf() for ((start, end) in napWindows(r, stages)) { if (mainWindow != null && start < mainWindow.second && end > mainWindow.first) continue - dao.insertSleepSessions(listOf(SleepSession(start, end, day, nap = true))) - dao.insertSleepStages(stages.filter { it.start >= start && it.end <= end }.map { it.copy(sessionStart = start) }) + naps += SleepSession(start, end, day, nap = true) + napStages += stages.filter { it.start >= start && it.end <= end }.map { it.copy(sessionStart = start) } } + dao.replaceSleepDay(day, nap = true, naps, napStages) } /** One window per nap: the strap's nap list when it gives one, otherwise runs of back-to-back nap stages. */ @@ -563,6 +592,48 @@ class HistorySync( private const val FUTURE_SLACK = 120L private const val MAX_CHUNKS_PER_TYPE = 50 private const val DUMP_CHUNKS = 5 + + /** + * The records of a chunk worth storing. Activity keeps only minutes that have ended: the strap sends the + * minute it is still counting and stale slots after it, and a later sync replaced those with the real + * minutes, so steps went up and then back down. Other types allow a strap clock a little ahead. + */ + internal fun keepable(type: HistoryDataType, records: List, now: Long): List = + if (type == HistoryDataType.ACTIVITY) { + records.filter { it.timestamp >= MIN_VALID_EPOCH && it.timestamp < minuteStart(now) } + } else { + records.filter { it.timestamp in MIN_VALID_EPOCH..now + FUTURE_SLACK } + } + + /** + * Where the cursor goes after a chunk, or null to leave it. + * + * A chunk with nothing usable (only gap slots, or only impossible times) still moves the cursor past it, or + * the strap serves the same chunk on every sync. The chunk holds the earliest records after the cursor, so + * skipping it loses nothing, and the cap at now (the current minute for activity) keeps a strap clock that + * runs ahead from pushing the cursor into the future. + * + * Except recent activity: the strap writes a minute a little after it ends and sends the slot blank until + * then. Skipping that slot moved the cursor past a minute that was never fetched, which left gaps of a few + * minutes. So blank recent minutes are fetched again next sync. + */ + internal fun nextCursor(type: HistoryDataType, parsedNext: Long?, kept: List, now: Long): Long? { + val waitForStrap = kept.isEmpty() && type == HistoryDataType.ACTIVITY && parsedNext != null && parsedNext > now - UNWRITTEN_WAIT + if (parsedNext == null || waitForStrap) return null + val next = if (kept.isEmpty()) parsedNext else parsedNext.coerceAtMost(kept.maxOf { it.timestamp } + MINUTE_SECONDS) + return next.coerceAtMost(latestCursor(type, now)) + } + + /** How long the strap may take to write a minute; blank slots older than this are real gaps. */ + private const val UNWRITTEN_WAIT = 10 * 60L + + /** How far a type's cursor may move: activity stops at the current minute, so it is fetched once it ends. */ + internal fun latestCursor(type: HistoryDataType, now: Long): Long = + if (type == HistoryDataType.ACTIVITY) minuteStart(now) else now + + private const val MINUTE_SECONDS = 60L + + private fun minuteStart(epoch: Long) = epoch / MINUTE_SECONDS * MINUTE_SECONDS } } diff --git a/app/src/main/kotlin/org/openstrap/app/device/LocationAvailable.kt b/app/src/main/kotlin/org/openstrap/app/device/LocationAvailable.kt new file mode 100644 index 00000000..90d1624d --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/device/LocationAvailable.kt @@ -0,0 +1,8 @@ +package org.openstrap.app.device + +import android.content.Context +import android.location.LocationManager + +/** Whether the phone's location can be used now: allowed, and turned on. */ +internal fun locationAvailable(context: Context): Boolean = + WorkoutLocation.granted(context) && context.getSystemService(LocationManager::class.java)?.isLocationEnabled == true diff --git a/app/src/main/kotlin/org/openstrap/app/device/PhoneAlerts.kt b/app/src/main/kotlin/org/openstrap/app/device/PhoneAlerts.kt index 40161bdb..70271e3b 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/PhoneAlerts.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/PhoneAlerts.kt @@ -1,18 +1,17 @@ package org.openstrap.app.device import android.Manifest +import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationManager import android.content.ComponentName import android.content.Context import android.content.pm.PackageManager import android.media.AudioManager -import android.os.Build import android.os.SystemClock import android.provider.Settings import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification -import android.telephony.PhoneStateListener import android.telephony.TelephonyCallback import android.telephony.TelephonyManager import java.time.LocalTime @@ -65,8 +64,8 @@ class PhoneAlerts( val callPermission = MutableStateFlow(hasCallPermission()) val notificationAccess = MutableStateFlow(hasNotificationAccess()) - private var callListener: Any? = null - private var modeListener: Any? = null + private var callListener: TelephonyCallback? = null + private var modeListener: AudioManager.OnModeChangedListener? = null private var ringing: Job? = null private var lastBuzz = 0L /** When a call notification last came in, from any app; Android often posts it before the ringing state. */ @@ -124,36 +123,19 @@ class PhoneAlerts( private fun updateCallListener() { val want = callsOn && hasCallPermission() if (want == (callListener != null)) return - // Callbacks are registered and removed on the main thread, which PhoneStateListener needs (it binds a Looper). + // Callbacks are registered and removed on the main thread, so they never race each other. ContextCompat.getMainExecutor(context).execute { if (want && callListener == null) { log("call listener on") - callListener = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - object : TelephonyCallback(), TelephonyCallback.CallStateListener { - override fun onCallStateChanged(state: Int) = onCallState(state) - }.also { telephony.registerTelephonyCallback(context.mainExecutor, it) } - } else { - @Suppress("DEPRECATION") - object : PhoneStateListener() { - @Deprecated("Deprecated in Java") - override fun onCallStateChanged(state: Int, phoneNumber: String?) = onCallState(state) - }.also { @Suppress("DEPRECATION") telephony.listen(it, PhoneStateListener.LISTEN_CALL_STATE) } - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - modeListener = AudioManager.OnModeChangedListener { onAudioMode(it) } - .also { audio.addOnModeChangedListener(context.mainExecutor, it) } - } + callListener = object : TelephonyCallback(), TelephonyCallback.CallStateListener { + override fun onCallStateChanged(state: Int) = onCallState(state) + }.also { telephony.registerTelephonyCallback(context.mainExecutor, it) } + modeListener = AudioManager.OnModeChangedListener { onAudioMode(it) } + .also { audio.addOnModeChangedListener(context.mainExecutor, it) } } else if (!want) { - val l = callListener - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && l is TelephonyCallback) { - telephony.unregisterTelephonyCallback(l) - } else { - @Suppress("DEPRECATION") - if (l is PhoneStateListener) telephony.listen(l, PhoneStateListener.LISTEN_NONE) - } + callListener?.let(telephony::unregisterTelephonyCallback) callListener = null - val m = modeListener - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && m is AudioManager.OnModeChangedListener) audio.removeOnModeChangedListener(m) + modeListener?.let(audio::removeOnModeChangedListener) modeListener = null endAllRinging() } @@ -275,7 +257,7 @@ class PhoneAlerts( fun reconnectStrap() { val c = strap.connection.value if (c is ConnectionState.Connected || (c as? ConnectionState.Failed)?.keyRejected == true) return - if (context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) return + if (!BlePermissions.granted(context)) return strap.backgroundConnect() } @@ -286,7 +268,10 @@ class PhoneAlerts( reconnectStrap() val now = SystemClock.elapsedRealtime() val n = sbn.notification - val callType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) n.extras.getInt(Notification.EXTRA_CALL_TYPE, 0) else 0 + // CallStyle sets its call type extra on every version the app runs on. The key and values only became public + // in 14, and are compiled in, so reading them on 12 and 13 works. + @SuppressLint("InlinedApi") + val callType = n.extras.getInt(Notification.EXTRA_CALL_TYPE, 0) // A call notification, from the dialer, Samsung's call screen or a calling app, never plays a pattern: it // usually comes just before the ringing state, and a motor pattern then cuts off the call's buzz (seen 2026-10-20). if (n.category == Notification.CATEGORY_CALL || callType != 0) { @@ -313,12 +298,13 @@ class PhoneAlerts( fun onNotificationRemoved(key: String) = ringEnded(key) /** - * Android 12's CallStyle says "incoming" outright. An app without it is taken as ringing while its call + * CallStyle says "incoming" outright. An app without it is taken as ringing while its call * notification has a full-screen intent, the incoming call screen; once answered or ended it is removed or posted * again without one. */ + @SuppressLint("InlinedApi") // The call type values, public from 14 but read on 12 and 13 as above. private fun isIncomingCall(n: Notification, callType: Int): Boolean = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && callType != 0 -> callType == Notification.CallStyle.CALL_TYPE_INCOMING + callType != 0 -> callType == Notification.CallStyle.CALL_TYPE_INCOMING else -> n.category == Notification.CATEGORY_CALL && n.fullScreenIntent != null } diff --git a/app/src/main/kotlin/org/openstrap/app/device/SetupStep.kt b/app/src/main/kotlin/org/openstrap/app/device/SetupStep.kt new file mode 100644 index 00000000..efcf6845 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/device/SetupStep.kt @@ -0,0 +1,11 @@ +package org.openstrap.app.device + +import org.openstrap.app.data.catching + +/** + * One setup step after auth. A failed step, a timeout included, is logged and setup goes on without it. A cancelled + * connect (Disconnect, the link lost) stops here instead of carrying on and reporting Failed over the state its + * canceller set (audit C5). + */ +internal suspend inline fun setupStep(name: String, log: (String) -> Unit, block: () -> T): T? = + catching(block).onFailure { log("$name failed: $it") }.getOrNull() diff --git a/app/src/main/kotlin/org/openstrap/app/device/StrapManager.kt b/app/src/main/kotlin/org/openstrap/app/device/StrapManager.kt index 8ba885a4..ff6b644c 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/StrapManager.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/StrapManager.kt @@ -3,11 +3,14 @@ package org.openstrap.app.device import android.content.Context import android.os.SystemClock import android.util.Log +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -24,15 +27,14 @@ import kotlinx.coroutines.sync.withLock import org.openstrap.app.ble.GattClient import org.openstrap.app.ble.GattException import org.openstrap.app.ble.GattStrapLink +import org.openstrap.app.ble.GattUnusableException import org.openstrap.app.ble.StrapUuids +import org.openstrap.app.data.catching import org.openstrap.app.data.db.HealthAlertRow import org.openstrap.app.data.db.HealthDao import org.openstrap.app.data.db.WearEventRow import org.openstrap.app.data.settings.SettingsStore -import org.openstrap.app.data.settings.StrapSnapshot import org.openstrap.app.data.settings.UserProfile -import org.openstrap.app.widget.strapStateChanged -import org.openstrap.app.widget.syncStale import org.openstrap.protocol.services.Gender import org.openstrap.protocol.services.HealthAlert import org.openstrap.protocol.services.UserProfile as StrapUserProfile @@ -77,8 +79,21 @@ class StrapManager( private val context: Context, private val settings: SettingsStore, private val dao: HealthDao, + // Its own scope until AppContainer passes appScope in. Tests pass theirs. + @Suppress("InjectDispatcher") + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), ) : StrapController { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + /** + * The one lane that changes the connection's fields ([connectJob], [reconnectJob], [wantConnected], [retries], + * [gatt], [session], [link], [battery], [charging]). The connect job runs on it too, so two connects can't + * interleave and leave a second GATT client open (audit C5). Others only read those fields. + */ + @Suppress("InjectDispatcher") + private val lane = ((scope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as? CoroutineDispatcher) ?: Dispatchers.Default) + .limitedParallelism(1) + + private fun onLane(block: suspend CoroutineScope.() -> Unit) = scope.launch(lane, block = block) + private val healthAlerts = HealthAlertNotifier(context) private val autoDetected = AutoDetectNotifier(context) private val wearNotifier = WearNotifier(context) @@ -101,19 +116,22 @@ class StrapManager( @Volatile private var refreshGattOnConnect = false override val log = MutableStateFlow>(emptyList()) - private var connectJob: Job? = null - private var reconnectJob: Job? = null + // Written on [lane] only. Volatile, as other threads read them. + @Volatile private var connectJob: Job? = null + @Volatile private var reconnectJob: Job? = null /** True while the user wants the strap connected; cleared by [disconnect]. */ - private var wantConnected = false + @Volatile private var wantConnected = false private var retries = 0 + /** Set when a connect failed in a way retrying can't fix (no permission, a bad address): presence events don't retry. */ + @Volatile private var unusable = false private var lastAutoSync = 0L private var liveJob: Job? = null - private var gatt: GattClient? = null - private var session: ChannelSession? = null - private var link: GattStrapLink? = null - private var battery: Int? = null - private var charging = false - private var firmware: String? = null + @Volatile private var gatt: GattClient? = null + @Volatile private var session: ChannelSession? = null + @Volatile private var link: GattStrapLink? = null + @Volatile private var battery: Int? = null + @Volatile private var charging = false + @Volatile private var firmware: String? = null /** The connected session, for features such as history sync and firmware update. */ val activeSession: ChannelSession? get() = session?.takeIf { connection.value is ConnectionState.Connected } @@ -125,16 +143,18 @@ class StrapManager( } /** Connect if a strap is paired and nothing is connected or in progress. Safe to call often. */ - // Synchronized, and checking connectJob: callers race at process start (app start-up, the companion presence + // On the lane, and checking connectJob: callers race at process start (app start-up, the companion presence // service, phone alerts), before the connect coroutine has moved the state off Disconnected. - @Synchronized override fun autoConnect() { - when (connection.value) { - is ConnectionState.Connected, ConnectionState.Connecting, ConnectionState.Authenticating -> return - else -> Unit + onLane { + when (connection.value) { + is ConnectionState.Connected, ConnectionState.Connecting, ConnectionState.Authenticating -> return@onLane + else -> Unit + } + if (reconnectJob?.isActive == true || connectJob?.isActive == true) return@onLane + hold(false) + wantConnection() } - if (reconnectJob?.isActive == true || connectJob?.isActive == true) return - connect() } /** The user's Disconnect, kept across process restarts; null until read from settings. See [backgroundConnect]. */ @@ -152,17 +172,26 @@ class StrapManager( scope.launch { // Presence events repeat every few seconds while the strap advertises, so a skip isn't logged. if (held ?: settings.heldDisconnected.first().also { held = it }) return@launch + // Every try would fail the same way until the user acts (grants the permission, pairs again). + if (unusable) return@launch autoConnect() } } override fun connect() { hold(false) + onLane { wantConnection() } + } + + /** On the lane. */ + private fun wantConnection() { wantConnected = true + unusable = false reconnectJob?.cancel() startConnect() } + /** On the lane. */ private fun scheduleReconnect() { if (!wantConnected) return // Every attempt would fail at once; Bluetooth coming back on reconnects (see bluetoothChanged), as Zepp waits too. @@ -175,25 +204,28 @@ class StrapManager( retries++ log("reconnecting in ${wait}s") reconnectJob?.cancel() - reconnectJob = scope.launch { + reconnectJob = onLane { delay(wait * 1000L) startConnect() } } + /** On the lane, so the cancel and the new job are one step that nothing else can come between. */ + // The catch-all rethrows whenever this job is cancelled, by asking the job rather than the exception's type (C1). + @Suppress("SuspendFunSwallowedCancellation") private fun startConnect() { connectJob?.cancel() - connectJob = scope.launch { + connectJob = onLane { val device = settings.pairedDevice.first() if (device == null) { connection.value = ConnectionState.NotPaired - return@launch + return@onLane } - runCatching { StrapService.start(context) }.onFailure { log("service start failed: $it") } + catching { StrapService.start(context) }.onFailure { log("service start failed: $it") } connection.value = ConnectionState.Connecting // A client left over from a lost link still holds one of Android's few GATT client slots. gatt?.close() - val client = GattClient(context, device.address).also { gatt = it } + val client = GattClient(context, device.address, ::log).also { gatt = it } try { log("connecting to ${device.address}") client.connect(refreshCache = refreshGattOnConnect) @@ -234,101 +266,114 @@ class StrapManager( connection.value = PairingErrors.authFailure(e) close() } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e + // Asks the coroutine, not the exception: a timeout is a failure to report, only a cancelled job unwinds. + if (!currentCoroutineContext().isActive) { + // Whoever cancelled this (Disconnect, a newer connect, the lost link) owns the state now. A client + // it didn't take over is closed here, so it can't keep a GATT slot. + if (gatt !== client) client.close() + throw e + } log("connect failed: $e") - connection.value = ConnectionState.Failed(e.message ?: "Connection failed") + connection.value = ConnectionState.Failed(ConnectErrors.reason(e)) close() - scheduleReconnect() + if (ConnectErrors.isFinal(e)) { + unusable = true + log("not retrying: ${e.message}") + } else { + scheduleReconnect() + } } } } + private suspend inline fun step(name: String, block: () -> T): T? = setupStep(name, { log(it) }, block) + private suspend fun afterAuth(client: GattClient, l: GattStrapLink, s: ChannelSession) { // Payload MTU reported by the strap (§1.3). if (s.services.has(Endpoints.CONNECT)) { - runCatching { + step("mtu query") { val r = s.request(Endpoints.CONNECT, byteArrayOf(1), 5_000) { it.u8(0) == 0x02 } val n = r.u8(1) or (r.u8(2) shl 8) if (n + 3 in 23..247) l.payloadMtu = n log("strap payload mtu=$n") - }.onFailure { log("mtu query failed: $it") } + } } // The settings protocol version decides how query replies are framed (03 §3.6), so learn it first. settingsProtocol = null if (s.services.has(Endpoints.SETTINGS)) { - runCatching { + step("settings version query") { val r = s.request(Endpoints.SETTINGS, SettingsCodec.versionRequest, 5_000) { it.isNotEmpty() && it.u8(0) == SettingsCodec.VERSION + 1 } settingsProtocol = SettingsCodec.parseVersion(r) log("settings protocol ${settingsProtocol?.version} groups ${settingsProtocol?.groups} (${r.toHex()})") - }.onFailure { log("settings version query failed: $it") } + } } // Resource versions for the firmware check, read once per connect as Zepp does with `03 00 00 00` (codemap 07 // §1.4). Nobody has seen the strap's values yet, so log them raw. var deviceInformation: SettingsGroup? = null if (s.services.has(Endpoints.SETTINGS)) { - runCatching { + step("device information query") { val group = SettingsCodec.GROUP_DEVICE_INFORMATION val r = s.request(Endpoints.SETTINGS, SettingsCodec.query(group, withOptions = false), 5_000) { SettingsCodec.isReplyTo(group, it) } deviceInformation = SettingsCodec.parse(r, settingsProtocol?.version) val keys = deviceInformation?.settings?.values?.joinToString(" ") { "${it.key}=${it.text ?: it.number ?: it.raw.toHex()}" } log("device information ${r.toHex()} -> ${keys ?: "unparsed"}") - }.onFailure { log("device information query failed: $it") } + } } // Runtime feature bitmap: history gates on it (01 §3). Nobody has seen the strap's reply yet, so log it raw. if (s.services.has(DeviceFeatures.ENDPOINT)) { - runCatching { + step("device features query") { val r = s.request(DeviceFeatures.ENDPOINT, DeviceFeatures.request, 5_000) { it.isNotEmpty() && it.u8(0) == DeviceFeatures.REPLY } s.features = DeviceFeatures.parseMessage(r) log("device features ${r.toHex()} -> ${s.features?.let { "mask %02x g2 %s alg %s".format(it.mask, it.group2?.toHex(), it.algorithm) } ?: "unparsed"}") - }.onFailure { log("device features query failed: $it") } + } } - firmware = runCatching { + firmware = step("firmware read") { client.read(StrapUuids.DEVICE_INFO_SERVICE, StrapUuids.SOFTWARE_REVISION).toString(Charsets.UTF_8).trimEnd('\u0000') - }.getOrNull() + } log("firmware=$firmware") - val hardware = runCatching { + val hardware = step("hardware read") { client.read(StrapUuids.DEVICE_INFO_SERVICE, StrapUuids.HARDWARE_REVISION).toString(Charsets.UTF_8).trimEnd('\u0000') - }.getOrNull() - val serial = runCatching { + } + val serial = step("serial read") { client.read(StrapUuids.DEVICE_INFO_SERVICE, StrapUuids.SERIAL_NUMBER).toString(Charsets.UTF_8).trimEnd('\u0000') - }.getOrNull() + } val dfu = GattDfuLink(client).available info.value = StrapInfo(firmware, hardware, serial, dfu, s.services.has(Endpoints.FIND_DEVICE), deviceInformation) log("hardware=$hardware dfu=$dfu") // GENERAL key 45 restarts the strap; like Zepp, offer Restart only when the strap reports it (03 §5.2, 07 §0). if (s.services.has(Endpoints.SETTINGS)) { - runCatching { + step("restart key query") { val group = SettingsCodec.GROUP_GENERAL val r = s.request(Endpoints.SETTINGS, SettingsCodec.query(group, listOf(SettingsCodec.KEY_RESTART), withOptions = false), 5_000) { SettingsCodec.isReplyTo(group, it) } val restart = SettingsCodec.parse(r, settingsProtocol?.version)?.get(SettingsCodec.KEY_RESTART) log("general key 45 ${r.toHex()} -> ${restart?.let { "reported" } ?: "not reported"}") info.value = info.value?.copy(canRestart = restart != null) - }.onFailure { log("restart key query failed: $it") } + } } // Vibration strength: Zepp keeps it in SOUND key 4. Nobody knows yet whether the strap has the group, so read // it whole (read-only) and log the reply; a strap without it answers with an error code. if (s.services.has(Endpoints.SETTINGS)) { - runCatching { + step("sound group query") { val group = SettingsCodec.GROUP_SOUND val r = s.request(Endpoints.SETTINGS, SettingsCodec.query(group), 5_000) { SettingsCodec.isReplyTo(group, it) } val keys = SettingsCodec.parse(r, settingsProtocol?.version)?.settings?.values?.joinToString(" ") { "${it.key}=${it.text ?: it.number ?: it.raw.toHex()}" } log("sound group ${r.toHex()} -> ${keys ?: "unparsed (code ${SettingsCodec.replyCode(r)})"}") - }.onFailure { log("sound group query failed: $it") } + } } - runCatching { + step("battery read") { if (s.services.has(Endpoints.BATTERY)) { BatteryInfo.parseChannel(s.request(Endpoints.BATTERY, BatteryInfo.GET) { it.u8(0) == 0x04 }) } else { BatteryInfo.parse(client.read(StrapUuids.MAIN_SERVICE, StrapUuids.BATTERY)) } - }.onFailure { log("battery read failed: $it") }.getOrNull()?.let { + }?.let { battery = it.level charging = it.charging == true } @@ -336,9 +381,9 @@ class StrapManager( timeProtocol = null if (s.services.has(Endpoints.TIME)) { - timeProtocol = runCatching { + timeProtocol = step("time version query") { TimeSync.parseVersion(s.request(Endpoints.TIME, TimeSync.versionRequest, 5_000) { it.isNotEmpty() && it.u8(0) == TimeSync.VERSION + 1 }) - }.onFailure { log("time version query failed: $it") }.getOrNull() + } log("time protocol $timeProtocol") } syncTime(s, client) @@ -360,7 +405,7 @@ class StrapManager( /** Sets the strap's clock and time zone, and tells it when DST next changes, as Zepp does on connect and on every change. */ private suspend fun syncTime(s: ChannelSession, client: GattClient) { val now = ZonedDateTime.now() - runCatching { + step("time sync") { if (s.services.has(Endpoints.TIME)) { val r = s.request(Endpoints.TIME, TimeSync.setRequest(now)) { it.u8(0) == 0x06 } log("time set: ${r.toHex()}") @@ -368,12 +413,12 @@ class StrapManager( client.write(StrapUuids.MAIN_SERVICE, StrapUuids.CURRENT_TIME, TimeSync.struct(now), withResponse = true) log("time set via 2A2B") } - }.onFailure { log("time sync failed: $it") } - if ((timeProtocol ?: 0) >= 2) runCatching { + } + if ((timeProtocol ?: 0) >= 2) step("dst hint") { val request = TimeSync.nextDstRequest(now) val r = s.request(Endpoints.TIME, request) { it.isNotEmpty() && it.u8(0) == TimeSync.NEXT_DST + 1 } log("next dst ${request.toHex()}: ${r.toHex()}") - }.onFailure { log("dst hint failed: $it") } + } } /** The phone's clock or time zone changed (travel, DST, a manual change): pass it on while connected. */ @@ -398,8 +443,8 @@ class StrapManager( log("bluetooth turned off") client.lost() } - android.bluetooth.BluetoothAdapter.STATE_ON -> scope.launch { - if (!wantConnected || connection.value is ConnectionState.Connected || connectJob?.isActive == true) return@launch + android.bluetooth.BluetoothAdapter.STATE_ON -> onLane { + if (!wantConnected || connection.value is ConnectionState.Connected || connectJob?.isActive == true) return@onLane log("bluetooth back on, reconnecting") retries = 0 reconnectJob?.cancel() @@ -427,11 +472,6 @@ class StrapManager( var previous: SyncState = SyncState.Idle sync.collect { s -> syncRecord.update { it.next(previous, s, System.currentTimeMillis() / 1000) } - // A sync starting while the widgets say the last one is old: their line turns to Syncing (widgets spec 5). - if (s is SyncState.Running && previous !is SyncState.Running) launch { - val done = runCatching { settings.strapSnapshot.first().lastSyncDone }.getOrNull() - if (syncStale(done, System.currentTimeMillis() / 1000)) WidgetUpdater.changedAll(context) - } previous = s } } @@ -439,18 +479,7 @@ class StrapManager( scope.launch { connection.mapNotNull { c -> (c as? ConnectionState.Connected)?.let { it.batteryPercent?.let { b -> b to it.charging } } } .distinctUntilChanged() - .collect { (b, ch) -> runCatching { settings.setStrapBattery(b, ch) }.onFailure { log("strap battery not kept: $it") } } - } - // And the widgets told when that line could change: pairing, the low battery edge, charging (widgets spec 7). - scope.launch { - var before: StrapSnapshot? = null - settings.strapSnapshot.collect { now -> - val was = before - before = now - if (was != null && strapStateChanged(now.paired, now.battery, now.charging, was.paired, was.battery, was.charging)) { - WidgetUpdater.changedAll(context) - } - } + .collect { (b, ch) -> catching { settings.setStrapBattery(b, ch) }.onFailure { log("strap battery not kept: $it") } } } } @@ -462,7 +491,7 @@ class StrapManager( if (!s.services.has(Endpoints.USER_INFO)) return@launch settings.profile.map { it.toStrapProfile() }.distinctUntilChanged().collect { p -> val payload = p.channelPayload() ?: return@collect - runCatching { + catching { val r = s.request(Endpoints.USER_INFO, payload, 5_000) { it.isNotEmpty() && it.u8(0) == StrapUserProfile.SET + 1 } log("profile sent: ${r.toHex()}") }.onFailure { log("profile send failed: $it") } @@ -477,11 +506,11 @@ class StrapManager( private fun CoroutineScope.watchCalorieGoal(s: ChannelSession) = launch { if (!s.services.has(Endpoints.SETTINGS)) return@launch settings.goals.map { it.calories }.distinctUntilChanged().collect { kcal -> - runCatching { + catching { val group = SettingsCodec.parse( settingsQuery(SettingsCodec.GROUP_HEALTH, listOf(SettingsCodec.KEY_CALORIE_TARGET), withOptions = false), settingsProtocol()?.version, - ) ?: return@runCatching - val write = SettingsCodec.calorieTargetFor(group, kcal) ?: return@runCatching + ) ?: return@catching + val write = SettingsCodec.calorieTargetFor(group, kcal) ?: return@catching val ack = command(Endpoints.SETTINGS, write) log(if (SettingsCodec.isWriteSuccess(ack)) "calorie goal sent: $kcal" else "calorie goal refused: ${ack.toHex()}") }.onFailure { log("calorie goal send failed: $it") } @@ -497,7 +526,7 @@ class StrapManager( alert ?: return@collect healthAlerts.show(alert) // Kept so Today can list the day's alerts; a failed write mustn't stop the watcher. - runCatching { dao.insertHealthAlerts(listOf(HealthAlertRow(alert.timestamp, alert.type.code, alert.value))) } + catching { dao.insertHealthAlerts(listOf(HealthAlertRow(alert.timestamp, alert.type.code, alert.value))) } .onFailure { log("health alert not stored: $it") } } } @@ -545,7 +574,7 @@ class StrapManager( delay(WEAR_POLL_MS) if (firmwareUpdating.value) continue wearPolling = true - runCatching { + catching { val reply = settingsQuery(SettingsCodec.GROUP_DEVICE_INFORMATION, listOf(SettingsCodec.KEY_WEAR_STATUS), withOptions = false) SettingsCodec.parse(reply, settingsProtocol?.version)?.let(SettingsCodec::wearStatus)?.let { onWear(it) } }.onFailure { log("wear status poll failed: $it") } @@ -557,11 +586,11 @@ class StrapManager( } /** GENERAL key 44 on, written only when the strap doesn't already report it on. */ - private suspend fun enableWearAlert() = runCatching { + private suspend fun enableWearAlert() = catching { val key = SettingsCodec.KEY_WEAR_STATUS_ALERT val reply = settingsQuery(SettingsCodec.GROUP_GENERAL, listOf(key), withOptions = false) val group = SettingsCodec.parse(reply, settingsProtocol()?.version) ?: throw StrapCommandException("unparsed ${reply.toHex()}") - if (group[key]?.bool == true) return@runCatching + if (group[key]?.bool == true) return@catching // Written even when the strap doesn't list the key, so the reply shows whether it takes it. val ack = command(Endpoints.SETTINGS, SettingsCodec.write(SettingsCodec.GROUP_GENERAL, group.version, listOf(SettingsCodec.boolSetting(key, true)))) log("wear reminder on (was ${group[key]?.bool ?: "not reported"}): ${if (SettingsCodec.isWriteSuccess(ack)) "ok" else "refused"}") @@ -612,7 +641,7 @@ class StrapManager( val s = activeSession ?: throw StrapCommandException("The strap isn't connected") if (!s.services.has(SportSession.ENDPOINT)) throw StrapCommandException("The strap has no sport module") suspend fun send(request: ByteArray): ByteArray { - val reply = runCatching { s.request(SportSession.ENDPOINT, request, 8_000) { SportSession.isReplyTo(request, it) } } + val reply = catching { s.request(SportSession.ENDPOINT, request, 8_000) { SportSession.isReplyTo(request, it) } } .getOrElse { throw StrapCommandException("The strap didn't answer") } log("sport > ${request.toHex()} < ${reply.toHex()}") if (!SportSession.isOk(reply)) throw StrapCommandException("The strap refused the workout command") @@ -626,7 +655,7 @@ class StrapManager( override suspend fun sportSessionKeepAlive() { val s = activeSession ?: return - runCatching { s.send(SportSession.ENDPOINT, SportSession.pauseKeepAlive) } + catching { s.send(SportSession.ENDPOINT, SportSession.pauseKeepAlive) } } /** The strap's `07` once buzzing has stopped; it also ends Find strap. */ @@ -655,8 +684,9 @@ class StrapManager( battery = info.level charging = info.charging == true log("battery update ${info.level}% charging=$charging") - (connection.value as? ConnectionState.Connected)?.let { - connection.value = it.copy(batteryPercent = info.level, charging = charging) + // One atomic change, so a Disconnect landing meanwhile isn't overwritten with Connected. + connection.update { + (it as? ConnectionState.Connected)?.copy(batteryPercent = info.level, charging = info.charging == true) ?: it } } } @@ -673,14 +703,15 @@ class StrapManager( log("steps today=${it.steps} dist=${it.distanceMeters} kcal=${it.calories}" + if (!it.isPlausible) " (total doesn't match distance: counting from synced history)" else "") } todaySteps.value = it - WidgetUpdater.stepsChanged(context) } } } - runCatching { s.send(Endpoints.REALTIME_ACTIVITY, RealtimeSteps.NOTIFY_ON) }.onFailure { log("steps notify failed: $it") } + catching { s.send(Endpoints.REALTIME_ACTIVITY, RealtimeSteps.NOTIFY_ON) }.onFailure { log("steps notify failed: $it") } // Pushes may only come while walking; poll as a fallback. while (isActive && session === s) { - if (!firmwareUpdating.value) runCatching { s.send(Endpoints.REALTIME_ACTIVITY, RealtimeSteps.QUERY) }.onFailure { log("steps query failed: $it") } + if (!firmwareUpdating.value) { + catching { s.send(Endpoints.REALTIME_ACTIVITY, RealtimeSteps.QUERY) }.onFailure { log("steps query failed: $it") } + } delay(60_000) } } @@ -718,7 +749,7 @@ class StrapManager( } } - private fun watchDisconnect(client: GattClient) = scope.launch { + private fun watchDisconnect(client: GattClient) = onLane { client.connected.filter { !it }.first() if (gatt === client) { log("link lost") @@ -744,17 +775,20 @@ class StrapManager( override fun disconnect() { hold(true) - wantConnected = false - reconnectJob?.cancel() - connectJob?.cancel() - liveJob?.cancel() - endFind() - onWear(null) - close() - liveHeartRate.value = null - connection.value = ConnectionState.Disconnected + onLane { + wantConnected = false + reconnectJob?.cancel() + connectJob?.cancel() + liveJob?.cancel() + endFind() + onWear(null) + close() + liveHeartRate.value = null + connection.value = ConnectionState.Disconnected + } } + /** On the lane. */ private fun close() { session?.stop() session = null @@ -790,14 +824,16 @@ class StrapManager( val only = takeNext() if (only != null && only.isEmpty()) break HistorySync(s, client, dao, settings, sync, ::log, autoDetected::show).run(only) - (sync.value as? SyncState.Done)?.let { d -> runCatching { settings.setLastSyncDone(d.atEpochSeconds) }.onFailure { log("sync time not kept: $it") } } + (sync.value as? SyncState.Done)?.let { d -> + catching { settings.setLastSyncDone(d.atEpochSeconds) }.onFailure { log("sync time not kept: $it") } + } } - runCatching { notifyBodySignals() }.onFailure { log("body signals: $it") } - runCatching { WorkoutGapFill(dao) { settings.profile.first() }.run()?.let { log(it) } }.onFailure { log("workout gap fill: $it") } - runCatching { updateEffectModel() }.onFailure { log("workout effect: $it") } - runCatching { updateDailyEnergy() }.onFailure { log("daily energy: $it") } - // Everything a widget shows may have moved (widgets spec 7). - WidgetUpdater.changedAll(context) + catching { notifyBodySignals() }.onFailure { log("body signals: $it") } + catching { WorkoutGapFill(dao) { settings.profile.first() }.run()?.let { log(it) } } + .onFailure { log("workout gap fill: $it") } + catching { updateEffectModel() }.onFailure { log("workout effect: $it") } + catching { updateDailyEnergy() }.onFailure { log("daily energy: $it") } + // Everything a widget shows may have moved (widgets spec 7): they watch for this. syncEnded.tryEmit(Unit) } finally { syncLock.unlock() @@ -870,7 +906,7 @@ class StrapManager( // Device-side chunk ACKs on 0016 carry transport errors (e.g. status 5 = unknown endpoint). val acks = link?.let { l -> launch { l.controlNotifications.collect { log("probe %04x ack < %s".format(endpoint, it.toHex())) } } } log("probe %04x > %s".format(endpoint, payload.toHex())) - runCatching { s.send(endpoint, payload) }.onFailure { log("probe failed: $it") } + catching { s.send(endpoint, payload) }.onFailure { log("probe failed: $it") } delay(5_000) listener.cancel() acks?.cancel() @@ -885,11 +921,13 @@ class StrapManager( scope.launch { log("simulating a strap: $script, $stepsPerMinute steps/min") // No strap is paired on the emulator: a reconnect loop would knock the fake one offline. - reconnectJob?.cancel() - connectJob?.cancel() + withContext(lane) { + reconnectJob?.cancel() + connectJob?.cancel() + } connection.value = ConnectionState.Connected(battery, "simulated") // As a real connection does: the service carries the notifications. - runCatching { StrapService.start(context) }.onFailure { log("service start failed: $it") } + catching { StrapService.start(context) }.onFailure { log("service start failed: $it") } var steps = 1_000 var second = 0 for ((bpm, seconds) in script) repeat(seconds) { @@ -980,7 +1018,7 @@ class StrapManager( LiveHeartRate.parseMeasurement(n.value)?.let { onLiveHr(it) } } } - runCatching { client.enableNotifications(StrapUuids.HEART_RATE_SERVICE, StrapUuids.HR_MEASUREMENT) } + catching { client.enableNotifications(StrapUuids.HEART_RATE_SERVICE, StrapUuids.HR_MEASUREMENT) } .onFailure { log("2A37 subscribe failed: $it") } val channel = s.services.has(Endpoints.HEART_RATE) @@ -1005,7 +1043,7 @@ class StrapManager( } private suspend fun stopLive(s: ChannelSession?, client: GattClient?) { - runCatching { + catching { if (s != null && s.services.has(Endpoints.HEART_RATE)) { s.send(Endpoints.OPEN_APP, LiveHeartRate.CLOSE) s.send(Endpoints.HEART_RATE, LiveHeartRate.UNSUBSCRIBE) @@ -1024,7 +1062,7 @@ class StrapManager( ): ByteArray { val s = activeSession ?: throw StrapCommandException("The strap isn't connected") if (firmwareUpdating.value) throw StrapCommandException("Wait for the firmware update to finish") - val reply = runCatching { + val reply = catching { s.request(endpoint, request, timeoutMs, matches = matches) }.getOrElse { throw StrapCommandException("The strap didn't answer") } log("cmd %04x > %s < %s".format(endpoint, request.toHex(), reply.toHex())) @@ -1065,7 +1103,7 @@ class StrapManager( @Volatile private var settingsProtocol: SettingsProtocol? = null private suspend fun settingsProtocol(): SettingsProtocol? = settingsProtocol - ?: runCatching { SettingsCodec.parseVersion(command(Endpoints.SETTINGS, SettingsCodec.versionRequest)) }.getOrNull() + ?: catching { SettingsCodec.parseVersion(command(Endpoints.SETTINGS, SettingsCodec.versionRequest)) }.getOrNull() ?.also { settingsProtocol = it } /** A settings query; a `04` push for another group arriving meanwhile isn't taken as its reply (03 §3.6.1). */ @@ -1089,7 +1127,7 @@ class StrapManager( ?: throw StrapCommandException("The strap couldn't read its settings") val request = SettingsCodec.restartFor(group) ?: throw StrapCommandException("This strap can't be restarted from the app") log("restart: asking the strap to restart") - val reply = runCatching { command(Endpoints.SETTINGS, request) } + val reply = catching { command(Endpoints.SETTINGS, request) } .getOrElse { e -> if (activeSession == null) { log("restart: link dropped before the ack"); return } else throw e } if (!SettingsCodec.isWriteSuccess(reply)) throw StrapCommandException("The strap didn't accept the restart (code ${reply.getOrNull(1)})") } @@ -1169,7 +1207,7 @@ class StrapManager( * `03` until it got a `06` (fw 3.25, 2026-10-28). Waiting the usual 8 s for it held up every buzz behind it. */ private suspend fun findBuzz() { - val reply = runCatching { command(Endpoints.FIND_DEVICE, FindDevice.BUZZ, BUZZ_REPLY_MS) }.getOrElse { + val reply = catching { command(Endpoints.FIND_DEVICE, FindDevice.BUZZ, BUZZ_REPLY_MS) }.getOrElse { log("buzz: no answer to 03, sending 06 and trying again") sendFindDevice(FindDevice.STOP) delay(BUZZ_RESET_MS) @@ -1183,7 +1221,7 @@ class StrapManager( val start = SystemClock.elapsedRealtime() var n = 0L while (callHold) { - buzzLock.withLock { if (callHold) runCatching { playMotor(CALL_PULSE) }.onFailure { if (callHold) log("call pulse failed: $it") } } + buzzLock.withLock { if (callHold) catching { playMotor(CALL_PULSE) }.onFailure { if (callHold) log("call pulse failed: $it") } } n++ delay((start + n * CALL_PULSE_PERIOD_MS - SystemClock.elapsedRealtime()).coerceAtLeast(0)) } @@ -1223,7 +1261,7 @@ class StrapManager( log("find strap: timed out") // Cleared first, so stopBuzzing's endFind doesn't cancel this job before the STOP goes out. findJob = null - runCatching { stopBuzzing() } + catching { stopBuzzing() } } } @@ -1241,22 +1279,37 @@ class StrapManager( if (firmwareUpdating.value) throw StrapCommandException("Wait for the firmware update to finish") log("cmd %04x > %s".format(Endpoints.FIND_DEVICE, request.toHex())) if (request.contentEquals(FindDevice.STOP)) findBuzzOn = false - runCatching { s.send(Endpoints.FIND_DEVICE, request) }.getOrElse { throw StrapCommandException("The strap didn't answer") } + catching { s.send(Endpoints.FIND_DEVICE, request) }.getOrElse { throw StrapCommandException("The strap didn't answer") } } - /** GENERAL key 47; null when the strap doesn't report it. Zepp refuses OTA while it is on (07 §1.6). */ - private suspend fun fastRechargeOn(): Boolean? = runCatching { - val reply = settingsQuery(SettingsCodec.GROUP_GENERAL, listOf(SettingsCodec.KEY_FAST_RECHARGE), withOptions = false) - SettingsCodec.parse(reply, settingsProtocol()?.version)?.get(SettingsCodec.KEY_FAST_RECHARGE)?.bool + /** + * GENERAL key 47, null when the strap doesn't report it. Zepp refuses OTA while it is on (07 §1.6). Asked straight + * on the session, as [installFirmware] has set [firmwareUpdating] by then and [command] refuses while it is set. + */ + private suspend fun fastRechargeOn(s: ChannelSession): Boolean? = catching { + val group = SettingsCodec.GROUP_GENERAL + val request = SettingsCodec.query(group, listOf(SettingsCodec.KEY_FAST_RECHARGE), withOptions = false) + val reply = s.request(Endpoints.SETTINGS, request, 8_000) { SettingsCodec.isReplyTo(group, it) } + SettingsCodec.parse(reply, settingsProtocol?.version)?.get(SettingsCodec.KEY_FAST_RECHARGE)?.bool }.onFailure { log("fast-charge query failed: $it") }.getOrNull() override suspend fun installFirmware(file: ByteArray, type: Int, onProgress: (DfuProgress) -> Unit) { val client = gatt if (client == null || connection.value !is ConnectionState.Connected) throw DfuException(-1, "The strap isn't connected") - if (sync.value is SyncState.Running) throw DfuException(-1, "Wait for the sync to finish first") - if (firmwareUpdating.value) throw DfuException(-1, "An update is already running") - if (session?.services?.has(Endpoints.SETTINGS) == true && fastRechargeOn() == true) throw DfuException(-1, "Turn off fast-charge mode on the strap first") + // Claimed first, so a sync can't start between the check below and the claim: startSync gives way to it. if (!firmwareUpdating.compareAndSet(false, true)) throw DfuException(-1, "An update is already running") + // Given back on a refusal, and when the caller is cancelled during the fast-charge query. + var claimed = false + try { + if (sync.value is SyncState.Running || syncLock.isLocked) throw DfuException(-1, "Wait for the sync to finish first") + val s = session + if (s != null && s.services.has(Endpoints.SETTINGS) && fastRechargeOn(s) == true) { + throw DfuException(-1, "Turn off fast-charge mode on the strap first") + } + claimed = true + } finally { + if (!claimed) firmwareUpdating.value = false + } val wake = context.getSystemService(PowerManager::class.java) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "OpenStrap:firmware").apply { acquire(30 * 60_000L) } try { @@ -1264,7 +1317,7 @@ class StrapManager( val link = GattDfuLink(client) if (!link.available) throw DfuException(-1, "This strap doesn't offer the firmware update service") // Bigger ATT packets make the transfer several times faster; fall back to 20-byte writes if refused. - runCatching { client.requestMtu(247) }.onFailure { log("dfu: mtu request failed: $it") } + catching { client.requestMtu(247) }.onFailure { log("dfu: mtu request failed: $it") } log("dfu: ${file.size} bytes, type ${"%02x".format(type)}, crc ${"%08x".format(crc32(file))}, att mtu ${client.mtu}") link.enable() refreshGattOnConnect = true diff --git a/app/src/main/kotlin/org/openstrap/app/device/StrapPresenceService.kt b/app/src/main/kotlin/org/openstrap/app/device/StrapPresenceService.kt index 90eb1137..084e92a7 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/StrapPresenceService.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/StrapPresenceService.kt @@ -13,7 +13,6 @@ import org.openstrap.app.OpenStrapApp * Android binds this while the associated strap is in range (see [CompanionLink]), starting the process if it * was killed. Each "appeared" event reconnects; the connection's own retries handle the strap going out of range. */ -@RequiresApi(Build.VERSION_CODES.S) class StrapPresenceService : CompanionDeviceService() { private fun appeared(what: String) { diff --git a/app/src/main/kotlin/org/openstrap/app/device/StrapService.kt b/app/src/main/kotlin/org/openstrap/app/device/StrapService.kt index 8fccbdb4..ff5c4b6a 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/StrapService.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/StrapService.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlin.math.roundToInt -import org.openstrap.app.ui.glow.chargeShown +import org.openstrap.app.presentation.chargeShown import org.openstrap.app.MainActivity import org.openstrap.app.BuildConfig import org.openstrap.app.OpenStrapApp @@ -32,9 +32,12 @@ import org.openstrap.app.R import org.openstrap.app.data.WorkoutRecorder import org.openstrap.app.data.WorkoutTarget import org.openstrap.app.data.Workouts -import org.openstrap.app.ui.workout.NotificationCopy -import org.openstrap.app.ui.workout.ZONE_COLORS -import androidx.compose.ui.graphics.toArgb +import org.openstrap.app.presentation.NotificationCopy +import org.openstrap.app.presentation.StrapBands +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.presentation.Units +import org.openstrap.app.presentation.WorkoutColors +import org.openstrap.app.presentation.WorkoutWords /** * Keeps the process alive while connected so syncing and live HR survive the app being backgrounded, and for the @@ -74,14 +77,16 @@ class StrapService : LifecycleService() { // A GPS workout adds the location type, allowed because its start comes from the visible ready screen. After a // restart in the background Android refuses it; the workout carries on without GPS. val gps = (workout.state.value as? WorkoutState.Recording)?.workout?.useGps == true && WorkoutLocation.granted(this) - val base = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE else 0 - val withGps = if (gps && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) base or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else base + val base = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + val withGps = if (gps) base or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else base // Posting it again would undo a swipe; once in the foreground, the service stays there without it. if (!foreground || hidden.value == null || workout.state.value is WorkoutState.Recording) { - try { - ServiceCompat.startForeground(this, NOTIFICATION_ID, first, withGps) - } catch (e: SecurityException) { - ServiceCompat.startForeground(this, NOTIFICATION_ID, first, base) + val log: (String) -> Unit = { (strap as? StrapManager)?.log(it) } + // Refused for the location type, it tries again without. Refused outright (a start from the background + // with no exemption), it stops rather than crash: a workout records on, but can be killed. + if (!tryForeground(first, withGps, log) && (withGps == base || !tryForeground(first, base, log))) { + stopSelf() + return START_NOT_STICKY } foreground = true } @@ -173,6 +178,22 @@ class StrapService : LifecycleService() { return if (recording) START_STICKY else START_NOT_STICKY } + /** + * [ServiceCompat.startForeground] as [type], false when Android refuses it: a [SecurityException] for a type the app + * may not use now, an [IllegalStateException] (`ForegroundServiceStartNotAllowedException`) for a start it doesn't allow. + */ + private fun tryForeground(n: Notification, type: Int, log: (String) -> Unit): Boolean = + try { + ServiceCompat.startForeground(this, NOTIFICATION_ID, n, type) + true + } catch (e: SecurityException) { + log("service: foreground as $type refused: $e") + false + } catch (e: IllegalStateException) { + log("service: foreground start not allowed: $e") + false + } + override fun onCreate() { super.onCreate() running = true @@ -225,7 +246,7 @@ class StrapService : LifecycleService() { ?.ifEmpty { null } ?: "Helio Strap" val connected = c as? ConnectionState.Connected val battery = connected?.batteryPercent - val low = connected != null && !connected.charging && battery != null && battery <= LOW_BATTERY + val low = connected != null && StrapBands.lowBattery(battery, connected.charging) val text = when { c is ConnectionState.Failed -> "Couldn't reach the strap" c is ConnectionState.Connecting -> "Connecting…" @@ -305,11 +326,11 @@ class StrapService : LifecycleService() { w.distanceFromSteps, target, session.zoneSeconds.value.toList(), confirming, session.kcal.value?.active, ) val actions = WorkoutNotification.actions(r.paused, confirming, ::service) - if (!liveUpdatesShown()) return WorkoutNotification.build(this, card, actions, openApp()).build() + if (!liveUpdatesShown()) return WorkoutNotification.build(this, card, actions, openApp()).unsaved(r).build() val color = when { - r.paused || !connected -> PAUSED_GREY - zone != null -> ZONE_COLORS[zone].toArgb() - else -> PAUSED_GREY + r.paused || !connected -> WorkoutColors.PAUSED_GREY + zone != null -> WorkoutColors.zone(zone) + else -> WorkoutColors.PAUSED_GREY } val bar = NotificationCompat.ProgressStyle() .setStyledByProgress(false) @@ -328,9 +349,10 @@ class StrapService : LifecycleService() { } else { // Bright up to the heart rate, faded after, like the app's zone bar and its gauges. style.setStyledByProgress(true) - style.setProgressSegments(ZONE_COLORS.map { NotificationCompat.ProgressStyle.Segment(ZONE_SPAN).setColor(it.toArgb()) }) + style.setProgressSegments(WorkoutColors.ZONES.map { NotificationCompat.ProgressStyle.Segment(ZONE_SPAN).setColor(it) }) if (bpm != null && !r.paused) { - style.setProgress((WorkoutRecorder.zonePosition(bpm, r.maxHeartRate) * ZONE_SPAN * ZONE_COLORS.size).toInt()) + val span = ZONE_SPAN * WorkoutColors.ZONES.size + style.setProgress((WorkoutRecorder.zonePosition(bpm, r.maxHeartRate) * span).toInt()) WorkoutRecorder.liveZone(bpm, r.maxHeartRate)?.let { z -> style.setProgressTrackerIcon(tracker(z)) } } } @@ -350,6 +372,7 @@ class StrapService : LifecycleService() { .apply { if (r.paused) setShortCriticalText("Paused") } // In the header, after the running time. .setSubText(card.kcal) + .unsaved(r) .setContentIntent(openApp()) .setShowWhen(true) .setUsesChronometer(!r.paused) @@ -358,6 +381,10 @@ class StrapService : LifecycleService() { return b.build() } + /** In the header: the database refused the last save, so a process death now would lose some of the workout. */ + private fun NotificationCompat.Builder.unsaved(r: WorkoutState.Recording): NotificationCompat.Builder = + apply { if (r.saveFailed) setSubText(NOT_SAVED) } + /** "End workout?": a stray tap in a pocket can't end it. It falls back after [CONFIRM_MS]. */ private fun NotificationCompat.Builder.endConfirmation(): NotificationCompat.Builder = setContentText(NotificationCopy.END_QUESTION).apply { WorkoutNotification.actions(paused = false, confirming = true, ::service).forEach(::addAction) } @@ -386,13 +413,13 @@ class StrapService : LifecycleService() { val end = w.end ?: System.currentTimeMillis() / 1000 val duration = WorkoutRecorder.activeSeconds(w.copy(end = null, pausedAt = null), end) val facts = listOfNotNull( - clock(duration), - w.distanceMeters?.takeIf { it >= 10 }?.let { (if (w.distanceFromSteps) "≈ " else "") + org.openstrap.app.ui.glow.km(it) + if (it >= 1000) " km" else "" }, + Units.workoutClock(duration), + w.distanceMeters?.let { Units.distance(it, Units.Precision.Workout, w.distanceFromSteps) }, w.averageHeartRate?.let { "$it bpm avg" }, ).joinToString(", ") val auto = w.endReason == org.openstrap.app.data.db.AppWorkout.END_AUTO || w.endReason == org.openstrap.app.data.db.AppWorkout.END_CAR val cut = w.recordedEnd?.let { it - end }?.takeIf { auto && it >= 60 } - val time = java.time.Instant.ofEpochSecond(end).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + val time = TimeOfDay.format(end, java.time.ZoneId.systemDefault()) val text = if (cut != null) "$facts\nEnded at $time, when you'd stopped. The ${cut / 60} min after were left out." else facts val b = NotificationCompat.Builder(this, SAVED_CHANNEL) .setSmallIcon(R.drawable.ic_stat_openstrap) @@ -430,15 +457,16 @@ class StrapService : LifecycleService() { .setSmallIcon(R.drawable.ic_stat_openstrap) .setContentTitle(title) .setContentText( - if (!connected) "Still recording, reconnecting…" + if (!connected) "${WorkoutWords.STRAP_GONE}. Still recording." else listOfNotNull(bpm?.let { "$it bpm" }, r.workout.setsTarget?.let { "$done of $it sets" }).joinToString(", "), ) - .setColor(if (connected) (WorkoutRecorder.liveZone(bpm, r.maxHeartRate)?.let { ZONE_COLORS[it].toArgb() } ?: PAUSED_GREY) else PAUSED_GREY) + .setColor(if (connected) WorkoutColors.zone(WorkoutRecorder.liveZone(bpm, r.maxHeartRate)) else WorkoutColors.PAUSED_GREY) .setOngoing(true) .setOnlyAlertOnce(true) .setCategory(NotificationCompat.CATEGORY_WORKOUT) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setRequestPromotedOngoing(true) + .unsaved(r) .setContentIntent(openApp()) if (confirming) return b.endConfirmation().build() if (until != null) { @@ -460,7 +488,7 @@ class StrapService : LifecycleService() { /** "Looks like you've stopped": the same notification, asking whether to end where the wearer stopped. */ private fun stoppedNotification(r: WorkoutState.Recording, at: Long): Notification { - val time = java.time.Instant.ofEpochSecond(at).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + val time = TimeOfDay.format(at, java.time.ZoneId.systemDefault()) val sport = Workouts.sportName(r.workout.sportType).lowercase() val text = if (r.inCar) "You last moved at $time. End the $sport there, so the drive isn't counted?" else "Your heart rate has been back near resting since $time. End the $sport at $time?" @@ -482,7 +510,7 @@ class StrapService : LifecycleService() { /** "Looks like a workout": record it from where the effort began, or not. Swiping it away is "Not now". */ private fun offerNotification(o: org.openstrap.app.data.AutoStart.Offer): Notification { - val time = java.time.Instant.ofEpochSecond(o.start).atZone(java.time.ZoneId.systemDefault()).format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")) + val time = TimeOfDay.format(o.start, java.time.ZoneId.systemDefault()) val sport = Workouts.sportName(o.sportType).lowercase() val text = "Your heart rate has been up since $time, ${o.averageHeartRate} bpm on average. Record a $sport from $time?" return NotificationCompat.Builder(this, OFFER_CHANNEL) @@ -500,7 +528,7 @@ class StrapService : LifecycleService() { } /** The app's gauge marker in each zone's colour: a solid dot in a soft halo of itself, drawn once per zone. */ - private val trackers = arrayOfNulls(ZONE_COLORS.size) + private val trackers = arrayOfNulls(WorkoutColors.ZONES.size) private fun tracker(zone: Int): androidx.core.graphics.drawable.IconCompat = trackers[zone] ?: run { val d = resources.displayMetrics.density @@ -508,7 +536,7 @@ class StrapService : LifecycleService() { val bitmap = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888) val canvas = android.graphics.Canvas(bitmap) val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG) - val color = ZONE_COLORS[zone].toArgb() + val color = WorkoutColors.ZONES[zone] paint.color = androidx.core.graphics.ColorUtils.setAlphaComponent(color, 77) canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint) paint.color = color @@ -541,13 +569,10 @@ class StrapService : LifecycleService() { private const val REQUEST_START = 5 private const val REQUEST_UNDO = 6 private const val CONFIRM_MS = 6_000L + private const val NOT_SAVED = "Not saved yet" private const val FIGURES_REFRESH_MS = 30 * 60_000L - /** Battery percent at which the everyday notification says to charge. */ - private const val LOW_BATTERY = 15 - /** The live screen's paused grey, for a paused or disconnected workout. */ - private const val PAUSED_GREY = 0xFF5A5F6A.toInt() - /** The red dot in the "OS." logo, the app's heart colour. */ - private val BRAND_DOT = org.openstrap.app.ui.theme.MetricColors.heart.toArgb() + /** The red dot in the "OS." logo, the app's heart colour: zone 5's red. */ + private const val BRAND_DOT = WorkoutColors.ZONE_5 private const val ACTION_RECORD = "org.openstrap.app.workout.RECORD" private const val ACTION_DECLINE = "org.openstrap.app.workout.DECLINE" private const val WORKOUT_REFRESH_MS = 10_000L @@ -587,7 +612,5 @@ class StrapService : LifecycleService() { context.startForegroundService(intent) } - private fun clock(seconds: Long) = if (seconds >= 3600) "%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) - else "%d:%02d".format(seconds / 60, seconds % 60) } } diff --git a/app/src/main/kotlin/org/openstrap/app/device/WeeklyRecap.kt b/app/src/main/kotlin/org/openstrap/app/device/WeeklyRecap.kt new file mode 100644 index 00000000..2976d32e --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/device/WeeklyRecap.kt @@ -0,0 +1,98 @@ +package org.openstrap.app.device + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import java.time.LocalTime +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.flow.first +import org.openstrap.app.MainActivity +import org.openstrap.app.OpenStrapApp +import org.openstrap.app.R +import org.openstrap.app.data.Rewards +import org.openstrap.app.data.catching +import org.openstrap.app.presentation.RewardsCopy + +/** + * The weekly recap (workouts spec 3.4): one notification on the first morning of the week about the week before, from + * the weeks stored ([org.openstrap.app.data.RewardsKeeper]), opening the recap page. Off with its switch, and nothing + * for a week without a workout: no nudges. + */ +object WeeklyRecap { + private const val WORK = "weekly-recap" + private const val CHANNEL = "weekly_recap" + private const val ID = 7401 + + /** The morning hours it may come in. */ + private val MORNING = LocalTime.of(8, 0)..LocalTime.of(12, 0) + + /** Looks every few hours, so one look falls in the morning; a no-op when already scheduled. */ + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder(3, TimeUnit.HOURS).build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK, ExistingPeriodicWorkPolicy.KEEP, request) + } + + /** Posts last week's recap when it's the first morning of the week, the switch is on and it hasn't been sent. */ + suspend fun check(context: Context) { + val container = (context.applicationContext as OpenStrapApp).container + val settings = container.settings + val now = java.time.LocalDateTime.now() + val firstDay = container.rewards.firstDay + val thisWeek = Rewards.weekStart(now.toLocalDate(), firstDay) + if (now.toLocalDate() != thisWeek || now.toLocalTime() !in MORNING) return + val lastWeek = thisWeek.minusWeeks(1) + if (!settings.weeklyRecap.first() || settings.recapWeek.first() == lastWeek.toEpochDay()) return + // The weeks are only worked out again while the app is open: workouts synced since would be missed, and the + // week is never sent again. Brought up to date first; a refresh that fails still sends what's stored. + val recordingStart = (container.workout.state.value as? WorkoutState.Recording)?.workout?.start + catching { container.rewards.refreshNow(recordingStart) }.onFailure { Log.w("OpenStrap", "weekly recap: weeks not refreshed: $it") } + val weeks = container.database.healthDao().weekResultsOnce() + val week = weeks.firstOrNull { it.weekStart == lastWeek.toEpochDay() } + settings.setRecapWeek(lastWeek.toEpochDay()) + if (week == null || week.count == 0) return + // Android 13 brought the notification permission. Before it, the check would always read as denied. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) return + show(context, RewardsCopy.weekRange(lastWeek), RewardsCopy.recapText(week, Rewards.inARow(weeks, lastWeek))) + } + + private fun show(context: Context, range: String, text: String) { + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(NotificationChannel(CHANNEL, "Weekly recap", NotificationManager.IMPORTANCE_DEFAULT)) + val intent = Intent(context, MainActivity::class.java) + .putExtra(MainActivity.EXTRA_OPEN_RECAP, true) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + val open = PendingIntent.getActivity(context, ID, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + manager.notify( + ID, + NotificationCompat.Builder(context, CHANNEL) + .setSmallIcon(R.drawable.ic_stat_openstrap) + .setContentTitle("Your week, $range") + .setContentText(text) + .setAutoCancel(true) + .setContentIntent(open) + .build(), + ) + } +} + +/** The recap's look every few hours ([WeeklyRecap.schedule]). A failed look is logged, and the next one tries again. */ +class RecapWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + catching { WeeklyRecap.check(applicationContext) }.onFailure { Log.w("OpenStrap", "weekly recap: look failed: $it") } + return Result.success() + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/device/WidgetUpdater.kt b/app/src/main/kotlin/org/openstrap/app/device/WidgetUpdater.kt deleted file mode 100644 index fd408fbb..00000000 --- a/app/src/main/kotlin/org/openstrap/app/device/WidgetUpdater.kt +++ /dev/null @@ -1,314 +0,0 @@ -package org.openstrap.app.device - -import android.app.AlarmManager -import android.app.PendingIntent -import android.appwidget.AppWidgetManager -import android.content.BroadcastReceiver -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.os.Build -import android.util.Log -import androidx.glance.appwidget.GlanceAppWidget -import androidx.glance.appwidget.GlanceAppWidgetReceiver -import androidx.glance.appwidget.updateAll -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import org.openstrap.app.OpenStrapApp -import org.openstrap.app.data.MomentInputs -import org.openstrap.app.widget.MomentWidget -import org.openstrap.app.widget.MomentWidgetReceiver -import org.openstrap.app.widget.RecoveryWidget -import org.openstrap.app.widget.RecoveryWidgetReceiver -import org.openstrap.app.widget.SleepWidget -import org.openstrap.app.widget.SleepWidgetReceiver -import org.openstrap.app.widget.StartWidget -import org.openstrap.app.widget.StartWidgetReceiver -import org.openstrap.app.widget.StepsWidget -import org.openstrap.app.widget.StepsWidgetReceiver -import org.openstrap.app.widget.WidgetKind -import org.openstrap.app.widget.WidgetRefresh -import org.openstrap.app.widget.SYNC_STALE_SECONDS -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId - -/** - * The one place that asks home screen widgets to refresh (widgets spec 7). Everything that changes what a widget - * shows calls [changed]; nothing refreshes on a timer it doesn't need. Calls are cheap, return at once and never - * throw: the work runs on its own scope and failures are logged. - * - * Only widgets that are placed are updated, and the moment and midnight alarms are set only while one is. A kind - * not registered in [widgets] yet is never placed. - */ -object WidgetUpdater { - /** A widget's receiver, which the launcher knows it by, and its Glance widget. */ - class Registration(val receiver: Class, val widget: () -> GlanceAppWidget) - - /** Each widget, once it exists. */ - private val widgets: Map = mapOf( - WidgetKind.Moment to Registration(MomentWidgetReceiver::class.java) { MomentWidget() }, - WidgetKind.Sleep to Registration(SleepWidgetReceiver::class.java) { SleepWidget() }, - WidgetKind.Steps to Registration(StepsWidgetReceiver::class.java) { StepsWidget() }, - WidgetKind.Recovery to Registration(RecoveryWidgetReceiver::class.java) { RecoveryWidget() }, - WidgetKind.Start to Registration(StartWidgetReceiver::class.java) { StartWidget() }, - ) - - /** [kind]'s receiver and widget, null before it exists: for the debug widget gallery. */ - internal fun registration(kind: WidgetKind): Registration? = widgets[kind] - - /** Steps change by the second while the strap is connected; the widget follows at most this often. */ - const val STEPS_EVERY_MS = 15 * 60_000L - - /** Without exact alarms a moment may change this late on a widget (spec 7: a few minutes late is fine). */ - const val ALARM_WINDOW_MS = 5 * 60_000L - - private const val TAG = "OpenStrap" - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val steps = UpdateThrottle(STEPS_EVERY_MS) - - /** Refreshes the placed widgets of [kinds]. */ - fun changed(context: Context, kinds: Set) { - val app = context.applicationContext - scope.launch { update(app, kinds) } - } - - /** Refreshes every placed widget: a sync ended, a new day, the strap's state changed. */ - fun changedAll(context: Context) = changed(context, WidgetKind.entries.toSet()) - - /** Today's steps moved: the Steps widget follows, at most every [STEPS_EVERY_MS]. */ - fun stepsChanged(context: Context) { - val app = context.applicationContext - val wait = steps.request(System.currentTimeMillis()) ?: return - if (wait == 0L) { changed(app, setOf(WidgetKind.Steps)); return } - scope.launch { - delay(wait) - steps.ran(System.currentTimeMillis()) - update(app, setOf(WidgetKind.Steps)) - } - } - - /** The widgets on the home screen, any page: a kind counts once its receiver has an id (spec 3.1). */ - fun placed(context: Context): Set = try { - val manager = AppWidgetManager.getInstance(context) ?: return emptySet() - widgets.filter { (_, r) -> manager.getAppWidgetIds(ComponentName(context, r.receiver)).isNotEmpty() }.keys - } catch (e: Exception) { - Log.w(TAG, "widgets: placed check failed: $e") - emptySet() - } - - /** - * Sets the alarm for the moment's next change ([MomentInputs.nextChange]), from the inputs the Moment widget just - * rendered with, so the two can't drift apart, or for [tick] when that comes first: the time something the widget - * shows moves on by itself within the moment ("38 min since your run"). When it fires Moment refreshes, and its - * render schedules the next. Clears the alarm when there's neither or no Moment widget ([momentAlarm]). A tick - * never wakes the phone ([momentWakes]): it's only worth drawing while the screen is on, so it waits for that. - */ - fun scheduleMoment(context: Context, inputs: MomentInputs, tick: Instant? = null, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()) { - try { - val next = inputs.nextChange(now, zone) - val at = momentAlarm(WidgetKind.Moment in placed(context), next, tick) - if (at == null) cancel(context, ACTION_MOMENT) else setAlarm(context, ACTION_MOMENT, at.toEpochMilli(), wake = momentWakes(next, tick)) - } catch (e: Exception) { - Log.w(TAG, "widgets: moment alarm not set: $e") - } - } - - /** - * When Moment's alarm should next fire: the earlier of the moment's [nextChange] and the shown content's [tick], - * only while a Moment widget is [placed]. Null clears it. - */ - fun momentAlarm(placed: Boolean, nextChange: Instant?, tick: Instant?): Instant? = - if (!placed) null else listOfNotNull(nextChange, tick).minOrNull() - - /** Whether Moment's alarm wakes the phone: only for the moment's [nextChange], not for a [tick] that comes first. */ - fun momentWakes(nextChange: Instant?, tick: Instant?): Boolean = - tick == null || (nextChange != null && nextChange <= tick) - - /** - * A widget was placed or removed (any kind): midnight's alarm follows whether any is placed, Moment's is cleared - * once none is (a placed one sets its own as it renders), and Moment refreshes for the duplicate rule (spec 3.1). - */ - fun placedChanged(context: Context) { - val app = context.applicationContext - scheduleMidnight(app) - try { - if (WidgetKind.Moment !in placed(app)) cancel(app, ACTION_MOMENT) - } catch (e: Exception) { - Log.w(TAG, "widgets: moment alarm not cleared: $e") - } - changed(app, setOf(WidgetKind.Moment)) - } - - /** - * Android 15 and later: gives the launcher's picker each widget's generated preview ([GlanceAppWidget.providePreview]), - * the real layout with sample values (spec 6.4), once per app version. The system limits how often this may be - * called, so a limited or failed call is tried again on the next start; older systems use the provider XML's - * `previewLayout` and `previewImage`. - */ - fun publishPreviews(context: Context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return - val app = context.applicationContext - val prefs = app.getSharedPreferences(PREVIEW_PREFS, Context.MODE_PRIVATE) - val version = org.openstrap.app.BuildConfig.VERSION_CODE - if (prefs.getInt(PREVIEW_VERSION, -1) == version) return - scope.launch { - try { - val manager = androidx.glance.appwidget.GlanceAppWidgetManager(app) - val results = widgets.values.map { manager.setWidgetPreviews(it.receiver.kotlin) } - if (results.all { it == androidx.glance.appwidget.GlanceAppWidgetManager.SET_WIDGET_PREVIEWS_RESULT_SUCCESS }) { - prefs.edit().putInt(PREVIEW_VERSION, version).apply() - } else { - Log.i(TAG, "widgets: previews not all set ($results), trying again next start") - } - } catch (e: Exception) { - Log.w(TAG, "widgets: previews not set: $e") - } - } - } - - private const val PREVIEW_PREFS = "widgets" - private const val PREVIEW_VERSION = "previews_version" - - /** Sets the alarm for the next local midnight while any widget is placed, and clears it when none is. */ - fun scheduleMidnight(context: Context, zone: ZoneId = ZoneId.systemDefault()) { - try { - if (placed(context).isEmpty()) { cancel(context, ACTION_MIDNIGHT); return } - val midnight = LocalDate.now(zone).plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() - setAlarm(context, ACTION_MIDNIGHT, midnight) - } catch (e: Exception) { - Log.w(TAG, "widgets: midnight alarm not set: $e") - } - } - - internal suspend fun update(context: Context, kinds: Set) { - val shown = placed(context).intersect(kinds) - for (kind in shown) { - try { - // A running session loads again on its version; one that isn't loads as it starts. - WidgetRefresh.bump(kind) - widgets.getValue(kind).widget().updateAll(context) - } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e - Log.w(TAG, "widgets: $kind not updated: $e") - } - } - if (shown.isNotEmpty()) { - scheduleMidnight(context) - scheduleStale(context) - } - } - - /** - * Sets the alarm for the moment the last sync turns stale ([staleAt]), so the status line says so with the strap - * away and nothing else refreshing (spec 5), while any widget is placed; clears it otherwise. - */ - suspend fun scheduleStale(context: Context) { - try { - val done = (context.applicationContext as OpenStrapApp).container.settings.strapSnapshot.first().lastSyncDone - val at = staleAt(placed(context).isNotEmpty(), done, System.currentTimeMillis() / 1000) - if (at == null) cancel(context, ACTION_STALE) else setAlarm(context, ACTION_STALE, at * 1000) - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "widgets: stale alarm not set: $e") - } - } - - /** - * When the last sync, done at [lastSyncDone] (epoch seconds), turns stale: a second past [SYNC_STALE_SECONDS], - * while widgets are [placed] and that's still ahead of [now]. Null for no alarm. - */ - fun staleAt(placed: Boolean, lastSyncDone: Long?, now: Long): Long? = - lastSyncDone?.let { it + SYNC_STALE_SECONDS + 1 }?.takeIf { placed && it > now } - - /** - * Exact while the app may set exact alarms (Android 11 and older always, later with "Alarms and reminders"), - * otherwise inside [ALARM_WINDOW_MS]. Never `USE_EXACT_ALARM`: that's for clocks and calendars (spec 7). - */ - /** Sets [action]'s alarm for [atMs]; one that doesn't [wake] is held until the phone is awake for something else. */ - private fun setAlarm(context: Context, action: String, atMs: Long, wake: Boolean = true) { - val alarms = context.getSystemService(AlarmManager::class.java) ?: return - val pending = pendingIntent(context, action) - if (!wake) { - alarms.setWindow(AlarmManager.RTC, atMs, ALARM_WINDOW_MS, pending) - return - } - val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms() - try { - if (exact) alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, atMs, pending) - else alarms.setWindow(AlarmManager.RTC_WAKEUP, atMs, ALARM_WINDOW_MS, pending) - } catch (e: SecurityException) { - // The exact-alarm access was taken back between the check and the call. - alarms.setWindow(AlarmManager.RTC_WAKEUP, atMs, ALARM_WINDOW_MS, pending) - } - } - - private fun cancel(context: Context, action: String) { - context.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(context, action)) - } - - private fun pendingIntent(context: Context, action: String): PendingIntent = PendingIntent.getBroadcast( - context, when (action) { ACTION_MOMENT -> 1; ACTION_MIDNIGHT -> 2; else -> 3 }, - Intent(context, WidgetAlarmReceiver::class.java).setAction(action), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - internal const val ACTION_MOMENT = "org.openstrap.app.widget.MOMENT" - internal const val ACTION_MIDNIGHT = "org.openstrap.app.widget.MIDNIGHT" - internal const val ACTION_STALE = "org.openstrap.app.widget.STALE" -} - -/** Where [WidgetUpdater]'s alarms land: the moment changed, or a new day began. Not a widget, and not exported. */ -class WidgetAlarmReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val kinds = when (intent.action) { - WidgetUpdater.ACTION_MOMENT -> setOf(WidgetKind.Moment) - WidgetUpdater.ACTION_MIDNIGHT, WidgetUpdater.ACTION_STALE -> WidgetKind.entries.toSet() - else -> return - } - val app = context.applicationContext - val done = goAsync() - CoroutineScope(Dispatchers.Default).launch { - try { - WidgetUpdater.update(app, kinds) - // The update sets the next midnight only when something was placed; this one has passed either way. - if (intent.action == WidgetUpdater.ACTION_MIDNIGHT) WidgetUpdater.scheduleMidnight(app) - } catch (e: Exception) { - Log.w("OpenStrap", "widgets: alarm ${intent.action} failed: $e") - } finally { - done.finish() - } - } - } -} - -/** - * At most one update every [everyMs], and none lost: a request inside the gap waits for its end, and requests while - * one waits join it. - */ -class UpdateThrottle(private val everyMs: Long) { - private var last: Long? = null - private var waiting = false - - /** A request at [now] (ms): 0 to update now, the ms to wait before updating, or null when one already waits. */ - @Synchronized - fun request(now: Long): Long? { - if (waiting) return null - val l = last - if (l == null || now - l >= everyMs) { last = now; return 0 } - waiting = true - return l + everyMs - now - } - - /** The waiting update ran at [now] (ms). */ - @Synchronized - fun ran(now: Long) { - last = now - waiting = false - } -} diff --git a/app/src/main/kotlin/org/openstrap/app/device/WorkoutLocation.kt b/app/src/main/kotlin/org/openstrap/app/device/WorkoutLocation.kt index 9447444b..02f159f7 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/WorkoutLocation.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/WorkoutLocation.kt @@ -71,19 +71,15 @@ class WorkoutLocation(private val context: Context, private val onFix: (Route.Po } /** - * Updates once a second at high accuracy. From API 31 that has to be asked for: the plain overload leaves fused - * at balanced power, which may never turn the GPS chip on. + * Updates once a second at high accuracy. That has to be asked for: the plain overload leaves fused at balanced + * power, which may never turn the GPS chip on. */ @SuppressLint("MissingPermission") private fun request(provider: String): Boolean { val m = manager ?: return false return runCatching { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val request = LocationRequest.Builder(INTERVAL_MS).setQuality(LocationRequest.QUALITY_HIGH_ACCURACY).build() - m.requestLocationUpdates(provider, request, context.mainExecutor, listener) - } else { - m.requestLocationUpdates(provider, INTERVAL_MS, 0f, listener, Looper.getMainLooper()) - } + val request = LocationRequest.Builder(INTERVAL_MS).setQuality(LocationRequest.QUALITY_HIGH_ACCURACY).build() + m.requestLocationUpdates(provider, request, context.mainExecutor, listener) source = provider }.isSuccess } @@ -114,28 +110,29 @@ class WorkoutLocation(private val context: Context, private val onFix: (Route.Po /** How long fused gets to produce a location before plain GPS takes over, as in Zepp (native 01 §2.1). */ internal const val FUSED_TIMEOUT_MS = 20_000L - private const val FUSED = "fused" // LocationManager.FUSED_PROVIDER, named from API 31 + private const val FUSED = LocationManager.FUSED_PROVIDER private const val GPS = LocationManager.GPS_PROVIDER private const val TAG = "OpenStrap" - /** From a mock-location app rather than the GPS chip. `isMock` replaced `isFromMockProvider` in API 31. */ - @Suppress("DEPRECATION") - internal fun isMock(l: Location): Boolean = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) l.isMock else l.isFromMockProvider + /** From a mock-location app rather than the GPS chip. */ + internal fun isMock(l: Location): Boolean = l.isMock /** * Everything the route can use from a fix: speed and bearing let `Route.smooth` follow turns (native 02 §3.7), - * altitude is kept for later. Each only when the fix has it; the accuracies need API 26, below the app's 28. + * altitude is kept for later. Each only when the fix has it. */ internal fun point(l: Location) = Route.Point( l.time / 1000, l.latitude, l.longitude, if (l.hasAccuracy()) l.accuracy else Float.MAX_VALUE, - speed = l.speed.takeIf { l.hasSpeed() }, - bearing = l.bearing.takeIf { l.hasBearing() }, + speed = l.speed.takeIf { l.hasSpeed() && !EMULATOR }, + bearing = l.bearing.takeIf { l.hasBearing() && !EMULATOR }, altitude = l.altitude.takeIf { l.hasAltitude() }, speedAccuracy = l.speedAccuracyMetersPerSecond.takeIf { l.hasSpeedAccuracy() }, bearingAccuracy = l.bearingAccuracyDegrees.takeIf { l.hasBearingAccuracy() }, ) + /** A debug build on the emulator, whose `geo fix` always claims standing still: its speed and bearing are left out. */ + private val EMULATOR = org.openstrap.app.BuildConfig.DEBUG && Build.HARDWARE == "ranchu" + /** Fine or approximate; approximate fixes are too coarse to count, so distance falls back to steps. */ fun granted(context: Context) = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || diff --git a/app/src/main/kotlin/org/openstrap/app/device/WorkoutNotification.kt b/app/src/main/kotlin/org/openstrap/app/device/WorkoutNotification.kt index c721ce27..6ea2d15a 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/WorkoutNotification.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/WorkoutNotification.kt @@ -13,14 +13,13 @@ import android.os.SystemClock import android.util.TypedValue import android.view.View import android.widget.RemoteViews -import androidx.compose.ui.graphics.toArgb import androidx.core.app.NotificationCompat import org.openstrap.app.MainActivity import org.openstrap.app.R import org.openstrap.app.data.Workouts -import org.openstrap.app.ui.workout.NotificationCopy -import org.openstrap.app.ui.workout.WorkoutCard -import org.openstrap.app.ui.workout.ZONE_COLORS +import org.openstrap.app.presentation.NotificationCopy +import org.openstrap.app.presentation.WorkoutCard +import org.openstrap.app.presentation.WorkoutColors /** * The ongoing workout notification as the black card (patterns D4), for phones that don't show it as a Live Update, @@ -152,7 +151,7 @@ object WorkoutNotification { var x = 0 widths.forEachIndexed { zone, w -> if (w == 0) return@forEachIndexed - paint.color = ZONE_COLORS[zone].toArgb() + paint.color = WorkoutColors.ZONES[zone] canvas.drawRect(x.toFloat(), 0f, (x + w).toFloat(), 1f, paint) x += w } diff --git a/app/src/main/kotlin/org/openstrap/app/device/WorkoutSession.kt b/app/src/main/kotlin/org/openstrap/app/device/WorkoutSession.kt index 12b0410a..9a09ca43 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/WorkoutSession.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/WorkoutSession.kt @@ -2,6 +2,7 @@ package org.openstrap.app.device import android.content.Context import android.os.PowerManager +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -23,14 +25,13 @@ import kotlinx.coroutines.withTimeoutOrNull import org.openstrap.app.data.AutoStart import org.openstrap.app.data.RecoveryEngine import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.catching import org.openstrap.app.data.db.AppWorkout import org.openstrap.app.data.db.AppWorkoutHeartRate import org.openstrap.app.data.db.AppWorkoutRoutePoint import org.openstrap.app.data.Route import org.openstrap.app.data.db.HealthDao import org.openstrap.app.data.settings.SettingsStore -import org.openstrap.app.widget.WidgetKind -import kotlin.math.roundToInt sealed interface WorkoutState { data object Idle : WorkoutState @@ -46,9 +47,30 @@ sealed interface WorkoutState { /** Strength: the sets so far, and when the rest under way ends (null when not resting). */ val sets: List = emptyList(), val restUntil: Long? = null, + /** Strength: the exercise the next sets are of, null for numbered sets; and a log card after each set. */ + val exercise: String? = null, + val logSets: Boolean = false, + /** + * A structured workout ([org.openstrap.app.data.Structure]): its rounds so far, which say the phase + * ([org.openstrap.app.data.Phases]), and the seconds added to the rest under way (+10 s). + */ + val rounds: List = emptyList(), + val restExtra: Long = 0, + /** + * The last save to the database failed. It records on in memory and the next change saves again, but a + * process death now would lose what is unsaved, so the notification says so. + */ + val saveFailed: Boolean = false, ) : WorkoutState { + /** Parsed once per state, not on every read: the rounds' clock reads it each second. */ + val structure: org.openstrap.app.data.Structure? by lazy { org.openstrap.app.data.Structure.parse(workout.structure) } + + /** The phase now, by the workout's active time. */ + fun phase(now: Long): org.openstrap.app.data.Phase? = structure?.let { s -> + org.openstrap.app.data.Phases.phase(s, rounds, { t -> WorkoutRecorder.activeSeconds(workout, minOf(t, now)) }, restExtra) + } val paused: Boolean get() = workout.pausedAt != null - val strength: Boolean get() = workout.sportType in org.openstrap.app.data.Strength.SPORTS + val strength: Boolean get() = workout.sportType in org.openstrap.app.data.SportTypes.STRENGTH /** A set under way. */ val inSet: Boolean get() = sets.lastOrNull()?.let { it.end == null } == true val resting: Boolean get() = restUntil != null @@ -72,17 +94,24 @@ data class CalmDown(val start: Long, val end: Long, val until: Long, val reading * workout that was still recording when the process died. Minutes the phone missed are filled after the next sync * ([WorkoutGapFill]). */ +// Built once, in AppContainer, and every parameter is something a test swaps for its own. +@Suppress("LongParameterList") class WorkoutSession( private val context: Context, private val dao: HealthDao, private val settings: SettingsStore, private val strap: StrapController, + /** Max heart rate as every zone reads it, read afresh as each workout starts. */ + private val maxHeartRates: org.openstrap.app.data.MaxHeartRates, /** A coaching buzz never plays while a call rings: a motor pattern cuts off the call's buzz (hardware test). */ private val callRinging: () -> Boolean = { false }, /** A finished workout was saved or changed, by its start: auto sync writes it to Health Connect. Returns at once. */ private val onSaved: (start: Long) -> Unit = {}, + /** Epoch seconds now. Tests pass a clock they move by hand. */ + private val clock: () -> Long = { System.currentTimeMillis() / 1000 }, + /** Where the workout's long-lived work runs: the app's scope, `AppContainer.appScope`. */ + private val scope: CoroutineScope = fallbackScope(), ) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val lock = Mutex() private val _state = MutableStateFlow(WorkoutState.Idle) @@ -121,21 +150,12 @@ class WorkoutSession( val calmDown: StateFlow = _calmDown private var calmJob: Job? = null - /** A sport a launcher shortcut or the Quick Settings tile asked the ready screen to open with; the screen clears it. */ - val readyRequest = MutableStateFlow(null) - - /** The Start widget's All sports asked for the ready screen on its sport picker; the screen clears it. */ - val readyPickRequest = MutableStateFlow(false) - - /** The start of a strap workout a notification asked to open on the Activity tab; the screen clears it. */ - val openRequest = MutableStateFlow(null) - /** The live screen is tucked away to use the rest of the app; the notification brings it back. */ val minimized = MutableStateFlow(false) /** For "Looks like you've stopped": live readings and the strap's step totals of the last [STOP_WINDOW_S]. */ - private var window = listOf>() - private var steps = listOf>() + @Volatile private var window = listOf>() + @Volatile private var steps = listOf>() /** Nothing before this counts as activity for the end prompt: the start, or the last "Keep going". */ private var askSince = 0L private var restingHr = DEFAULT_RESTING_HR @@ -145,31 +165,6 @@ class WorkoutSession( private var lastSecond = -1L private var wake: PowerManager.WakeLock? = null - init { - // A workout starting or ending changes the moment and the Start widget (widgets spec 7). - scope.launch { - var was: Boolean? = null - var wasKind: Class<*>? = null - var wasPaused: Boolean? = null - _state.collect { st -> - val recording = st is WorkoutState.Recording - val paused = (st as? WorkoutState.Recording)?.paused - // A summary closing can follow a discard, which takes a workout out of Start's week and Moment's - // after a workout. A pause or a resume stops or restarts Start's timer. - val kinds = when { - was != null && was != recording -> setOf(WidgetKind.Moment, WidgetKind.Start) - wasKind != null && wasKind != st::class.java -> setOf(WidgetKind.Moment, WidgetKind.Start) - paused != null && wasPaused != null && paused != wasPaused -> setOf(WidgetKind.Start) - else -> emptySet() - } - if (kinds.isNotEmpty()) WidgetUpdater.changed(context, kinds) - was = recording - wasKind = st::class.java - wasPaused = paused - } - } - } - /** Picks up a workout left recording when the process died, then starts watching for auto-start. Called once at app start. */ fun restore() { scope.launch { @@ -198,10 +193,14 @@ class WorkoutSession( savedPoints = _route.value.size // A restart mustn't ask at once about a stop it can't see the start of. askSince = now - begin(w, maxHr) val sets = dao.setsOnce(w.start) val restUntil = sets.lastOrNull()?.takeIf { it.end != null && it.restEnd == null }?.let { it.end!! + restRule(w).seconds } - (_state.value as? WorkoutState.Recording)?.let { _state.value = it.copy(sets = sets, restUntil = restUntil) } + // Logging is the sport's setting at its start, which Ready saved then. + val log = runCatching { settings.sportSettings.first()[org.openstrap.app.data.Workouts.tileType(w.sportType)]?.log }.getOrNull() == true + val rounds = if (w.structure != null) dao.roundsOnce(w.start) else emptyList() + begin(w, maxHr) { + it.copy(sets = sets, restUntil = restUntil, exercise = sets.lastOrNull()?.exercise, logSets = log, rounds = rounds) + } } } finally { _restored.value = true @@ -217,6 +216,8 @@ class WorkoutSession( rest: org.openstrap.app.data.Strength.Rest? = null, setsTarget: Int? = null, /** Auto-start: the workout began here, earlier, and [seed] holds its live readings so far. */ from: Long? = null, seed: List> = emptyList(), seedSteps: Int? = null, + /** Strength: a log card after each set. Rounds or periods ([AppWorkout.structure]). */ + logSets: Boolean = false, structure: String? = null, ) { scope.launch { lock.withLock { @@ -224,7 +225,7 @@ class WorkoutSession( val w = AppWorkout( start = from ?: now(), end = null, sportType = sportType, askToEnd = askToEnd, target = target?.code, useGps = useGps, keepRoute = useGps && keepRoute, - rest = rest?.code, setsTarget = setsTarget, + rest = rest?.code, setsTarget = setsTarget, structure = structure, ) _route.value = emptyList() savedPoints = 0 @@ -244,9 +245,8 @@ class WorkoutSession( window = emptyList() askSince = w.start minimized.value = false - begin(w, maxHeartRate()) firstSteps = seedSteps - for ((t, bpm) in seed) record(t, bpm) + begin(w, maxHeartRate(), seed) { it.copy(logSets = logSets) } } } } @@ -388,7 +388,6 @@ class WorkoutSession( fun resume() = resumeFrom(toStrap = true) private fun resumeFrom(toStrap: Boolean) = update { w -> - lastReading = null (w.pausedAt?.let { w.withPause(it, now()) } ?: w).also { if (it != w) strapState(org.openstrap.protocol.services.SportSession.State.RESUME, it, send = toStrap) } } @@ -443,12 +442,25 @@ class WorkoutSession( } if (p is org.openstrap.protocol.services.SportSession.Push.Status && p.status == org.openstrap.protocol.services.SportSession.STATUS_HR_NORMAL) { if (r.paused || !r.workout.askToEnd || r.endPrompt != null) return - val at = WorkoutRecorder.lastActive(window, steps, restingHr, r.maxHeartRate)?.takeIf { it > r.workout.start } ?: now() - _state.value = r.copy(endPrompt = at) - if (!callRinging()) scope.launch { runCatching { strap.buzz(STOP_BUZZ) } } + askToEnd(WorkoutRecorder.lastActive(window, steps, restingHr, r.maxHeartRate)?.takeIf { it > r.workout.start } ?: now()) } } + /** + * Opens "Looks like you've stopped" at [at], and the strap buzzes. Applied to the state as it is now, not to one read + * earlier, so a locked write can't drop it and it can't drop one: unless the question is open already, the workout + * is paused or it doesn't ask. + */ + private fun askToEnd(at: Long, inCar: Boolean = false) { + var asked = false + _state.update { s -> + val r = s as? WorkoutState.Recording + asked = r != null && !r.paused && r.workout.askToEnd && r.endPrompt == null + if (r != null && asked) r.copy(endPrompt = at, inCar = inCar) else s + } + if (asked && !callRinging()) scope.launch { runCatching { strap.buzz(STOP_BUZZ) } } + } + /** Saves the workout; ended while paused, it ends where the pause began. */ fun end() = finish(null, AppWorkout.END_USER) @@ -461,8 +473,7 @@ class WorkoutSession( /** "Keep going": closes the question; it comes back only after a new active spell. */ fun keepGoing() { askSince = now() - val r = _state.value as? WorkoutState.Recording ?: return - if (r.endPrompt != null) _state.value = r.copy(endPrompt = null, inCar = false) + _state.update { s -> (s as? WorkoutState.Recording)?.takeIf { it.endPrompt != null }?.copy(endPrompt = null, inCar = false) ?: s } } /** [toStrap] false when the strap ended its session itself. */ @@ -475,36 +486,45 @@ class WorkoutSession( // Written here: an update() queued behind this lock would find the workout already ended. val w = r.workout.let { w -> distanceNow(w)?.let { (m, steps) -> w.copy(distanceMeters = m, distanceFromSteps = steps) } ?: w } val recorded = w.pausedAt ?: now() - // The heart rate stays up for a while after a workout; that's not a new one to offer. - offerNotBefore = now() val end = at?.coerceIn(w.start, recorded) ?: recorded val ended = w.copy(end = end, pausedAt = null, endReason = reason, recordedEnd = recorded.takeIf { it > end }) val kept = WorkoutRecorder.activeSeconds(ended, end) >= MIN_SAVED_S + closeLast(r, end) + // Before anything stops: a workout that couldn't be saved, or deleted, keeps recording, so End can be + // tried again and a restart doesn't bring back one that looked ended. The route is still in memory + // here, kept or not, so a run gets its VO2max and its fastest stretches (its bests, workouts spec 3.2) + // either way. + val stored = catching { + if (kept) { + val route = _route.value + val stretches = org.openstrap.app.data.Rewards.stretches(route, ended.pauseRanges) + .let(org.openstrap.app.data.Rewards.Stretch::format) + dao.upsertAppWorkout(withEffect(ended.copy(stretches = stretches), dao.appWorkoutHeartRateOnce(w.start), route)) + } else { + dao.deleteAppWorkout(w.start) + } + } + stored.exceptionOrNull()?.let { + log("workout ${if (kept) "not saved" else "not deleted"}, still recording: ${it.message}") + _state.update { s -> (s as? WorkoutState.Recording)?.copy(saveFailed = true) ?: s } + return@withLock + } + // The heart rate stays up for a while after a workout, and that's not a new one to offer. + offerNotBefore = now() strapState(WorkoutRecorder.closingState(kept), w, send = toStrap) keepAlive?.cancel() - // A set or rest under way closes with the workout. - r.sets.lastOrNull()?.let { last -> - when { - last.end == null -> last.copy(end = end) - last.restEnd == null -> last.copy(restEnd = end) - else -> null - } - }?.let { dao.upsertSets(listOf(it)) } stop(keepLive = true) // Under a minute is a mis-tap or a test, not a workout: nothing is kept, and the app says so. if (!kept) { strap.setWorkoutActive(false) - dao.deleteAppWorkout(w.start) - _state.value = WorkoutState.Idle + _state.update { WorkoutState.Idle } // A toast, so it shows whether it ended in the app or from the notification. context.mainExecutor.execute { android.widget.Toast.makeText(context, "Under a minute, so not saved", android.widget.Toast.LENGTH_SHORT).show() } return@withLock } - // The route is still in memory here, kept or not, so a run gets its VO2max either way. - dao.upsertAppWorkout(withEffect(ended, dao.appWorkoutHeartRateOnce(w.start), _route.value)) - _state.value = WorkoutState.Ended(w.start) + _state.update { WorkoutState.Ended(w.start) } watchCalmDown(w.start, recorded) // Its days' energy rows take its kcal now, after the summary is up, so Health Connect gets them with it. refreshDays(ended.from, end) @@ -515,6 +535,24 @@ class WorkoutSession( } } + /** A set or rest under way closes with the workout at [end], and so does a round's work or rest. */ + private suspend fun closeLast(r: WorkoutState.Recording, end: Long) { + r.sets.lastOrNull()?.let { last -> + when { + last.end == null -> last.copy(end = end) + last.restEnd == null -> last.copy(restEnd = end) + else -> null + } + }?.let { set -> catching { dao.upsertSets(listOf(set)) }.onFailure { log("last set not closed: ${it.message}") } } + r.rounds.lastOrNull()?.let { last -> + when { + last.workEnd == null -> last.copy(workEnd = end, restEnd = end) + last.restEnd == null -> last.copy(restEnd = end) + else -> null + } + }?.let { round -> catching { dao.upsertRounds(listOf(round)) }.onFailure { log("last round not closed: ${it.message}") } } + } + /** * Deletes a phone workout, and closes its summary if it is the one showing. The strap's own records of it * ([Workouts.strapTwins]) go too, as they show as this one: left, they would come back as a workout of their own. @@ -522,19 +560,21 @@ class WorkoutSession( fun discard(start: Long) { scope.launch { lock.withLock { - val w = dao.appWorkoutOnce(start) - if (w?.end != null) { - val t = now() - val range = org.openstrap.app.data.Workouts.phoneRange(w, t) - org.openstrap.app.data.Workouts.strapTwins(dao.workoutsOnce(range.first, range.last), range) - .forEach { dao.deleteStrapWorkout(it.start, t) } - } - dao.deleteAppWorkout(start) + // Not deleted, its summary stays, so Discard can be tried again. + val w = catching { + dao.appWorkoutOnce(start).also { w -> + if (w?.end != null) { + val t = now() + val range = org.openstrap.app.data.Workouts.phoneRange(w, t) + org.openstrap.app.data.Workouts.strapTwins(dao.workoutsOnce(range.first, range.last), range) + .forEach { dao.deleteStrapWorkout(it.start, t) } + } + dao.deleteAppWorkout(start) + } + }.onFailure { log("workout not discarded: ${it.message}") }.getOrElse { return@withLock } if (w?.end != null) refreshDays(w.start, w.recordedEnd ?: w.end) - if ((_state.value as? WorkoutState.Ended)?.start == start) { - _state.value = WorkoutState.Idle - endCalmDown() - } + val showing = _state.value + if ((showing as? WorkoutState.Ended)?.start == start && _state.compareAndSet(showing, WorkoutState.Idle)) endCalmDown() } } } @@ -574,7 +614,7 @@ class WorkoutSession( * ending one. Only while no workout records or shows; nothing is written. */ fun showSummary(start: Long) { - if (_state.value is WorkoutState.Idle) _state.value = WorkoutState.Ended(start, preview = true) + _state.compareAndSet(WorkoutState.Idle, WorkoutState.Ended(start, preview = true)) } /** @@ -609,10 +649,37 @@ class WorkoutSession( val now = now() val sets = r.sets.toMutableList() sets.lastOrNull()?.takeIf { it.restEnd == null && it.end != null }?.let { sets[sets.lastIndex] = it.copy(restEnd = now) } - sets += org.openstrap.app.data.db.AppWorkoutSet(r.workout.start, sets.size, now) + sets += org.openstrap.app.data.db.AppWorkoutSet(r.workout.start, sets.size, now, exercise = r.exercise) r.copy(sets = sets, restUntil = null) } + /** The exercise for the sets from now on ("Next exercise"), and the set under way if there is one; null numbers them. */ + fun setExercise(name: String?) = editSets { r -> + val clean = name?.trim()?.takeIf { it.isNotEmpty() } + val sets = if (r.inSet) r.sets.dropLast(1) + r.sets.last().copy(exercise = clean) else r.sets + r.copy(sets = sets, exercise = clean) + } + + /** + * The log card's Save: set [index]'s [reps] and [kg], of [exercise], kept for the next card of that exercise too. + * Logging never touches the set's times. + */ + fun logSet(index: Int, exercise: String?, reps: Int, kg: Float) { + val name = exercise?.trim()?.takeIf { it.isNotEmpty() } + editSets { r -> + val i = r.sets.indexOfFirst { it.index == index } + if (i < 0) return@editSets r + r.copy(sets = r.sets.toMutableList().also { it[i] = it[i].copy(exercise = name, reps = reps, weightKg = kg) }) + } + if (name != null) scope.launch { + catching { dao.upsertExercise(org.openstrap.app.data.db.ExerciseRow(name, reps, kg, now())) } + .onFailure { log("exercise $name not saved: ${it.message}") } + } + } + + /** The exercises logged before, last used first, for the exercise list and the log card. */ + fun exercises(): kotlinx.coroutines.flow.Flow> = dao.exercises() + /** "Set done": the rest begins, ending at its time or earlier on heart rate. */ fun setDone() = editSets { r -> if (!r.inSet) return@editSets r @@ -629,8 +696,14 @@ class WorkoutSession( val r = _state.value as? WorkoutState.Recording ?: return@withLock val changed = change(r) if (changed == r) return@withLock - dao.upsertSets(changed.sets) - _state.value = changed + // Every set is written each time, so the next change saves what a failed one didn't. + val failed = catching { dao.upsertSets(changed.sets) }.onFailure { log("sets not saved: ${it.message}") }.isFailure + // Only what [change] may touch, applied to the state as it is now: an end prompt or a rest's +10 s set + // while the save ran stays. + _state.update { + (it as? WorkoutState.Recording) + ?.copy(sets = changed.sets, restUntil = changed.restUntil, exercise = changed.exercise, saveFailed = failed) ?: it + } } } } @@ -645,6 +718,109 @@ class WorkoutSession( cue(org.openstrap.app.data.Cue.RestOver) } + /* ------------------------------------------- Rounds -------------------------------------------- */ + + /** Skip work, Skip rest, Done (every minute), or start the rounds after the warm up: the phase under way ends now. */ + fun skipPhase() = stepRounds(skip = true) + + /** Interval workouts saved by name, newest first. */ + fun savedWorkouts(): kotlinx.coroutines.flow.Flow> = dao.savedWorkouts() + + /** Saves [structure] for [sport] as [name] ("Tuesday intervals"); only the newest [MAX_SAVED] are kept. */ + fun saveWorkout(name: String, sport: Int, structure: String) { + scope.launch { + runCatching { + dao.upsertSavedWorkout(org.openstrap.app.data.db.SavedWorkout(name = name, sport = sport, structure = structure, createdAt = now())) + dao.savedWorkoutsOnce().drop(MAX_SAVED).forEach { dao.deleteSavedWorkout(it.id) } + }.onFailure { (strap as? StrapManager)?.log("workout not saved: ${it.message}") } + } + } + + /** "+10 s" on a rest. */ + fun addRest() { + val t = now() + _state.update { s -> + (s as? WorkoutState.Recording)?.takeIf { it.phase(t) is org.openstrap.app.data.Phase.Rest } + ?.let { it.copy(restExtra = it.restExtra + REST_ADD_S) } ?: s + } + } + + /** + * Moves a structured workout's rounds on ([org.openstrap.app.data.Phases]): once a second by the clock (and by the + * distance, for run reps), or now on a tap ([skip]). A switch buzzes the strap ([org.openstrap.app.data.WorkoutBuzz]), + * and the phone beeps the last three seconds of a timed phase when its sound is on. Paused, the clock doesn't move. + */ + private fun stepRounds(skip: Boolean) { + scope.launch { + lock.withLock { + val r = _state.value as? WorkoutState.Recording ?: return@withLock + val s = r.structure ?: return@withLock + if (r.paused) return@withLock + val t = now() + val w = r.workout + val active = { at: Long -> WorkoutRecorder.activeSeconds(w, minOf(at, t)) } + // Copied only when a round ends and needs its peak, not every second. + val peak = { a: Long, b: Long -> org.openstrap.app.data.Strength.peak(synchronized(allReadings) { allReadings.toList() }, a, b) } + val meters = { from: Long -> repMeters(from, w.pauseRanges) } + val step = if (skip) org.openstrap.app.data.Phases.skip(s, r.rounds, w.start, t, active, peak) + else org.openstrap.app.data.Phases.tick(s, r.rounds, w.start, t, active, meters, peak, r.restExtra) + if (step.rounds != r.rounds) { + // Every round is written each time, as the sets are, so the next step saves what a failed one didn't. + val failed = catching { dao.upsertRounds(step.rounds) }.onFailure { log("rounds not saved: ${it.message}") }.isFailure + _state.update { s -> + val now = s as? WorkoutState.Recording ?: return@update s + now.copy(rounds = step.rounds, restExtra = if (step.switch != null) 0 else now.restExtra, saveFailed = failed) + } + } + step.switch?.let { buzz(org.openstrap.app.data.WorkoutBuzz.of(it, game = s is org.openstrap.app.data.Structure.Periods), it.name) } + if (step.switch == null && !skip) { + val ends = when (val p = (_state.value as? WorkoutState.Recording)?.phase(t)) { + is org.openstrap.app.data.Phase.Work -> p.endsAt + is org.openstrap.app.data.Phase.Rest -> p.endsAt + else -> null + } + val left = ends?.let { it - active(t) } + if (left != null && left in 1..COUNTDOWN_S) beep() + } + } + } + } + + /** + * The rep's metres from [from], worked out again only on a new fix, rep or pause: the rounds' clock asks each + * second, and smoothing the rep's fixes each time added up over a long rep. + */ + private var repMetersOf: Pair>, Float?>? = null + + private fun repMeters(from: Long, pauses: List): Float? { + val route = _route.value + val key = Triple(from, route.size, pauses) + repMetersOf?.let { (k, m) -> if (k == key) return m } + val m = route.filter { it.t >= from }.takeIf { it.size > 1 }?.let { Route.distance(it, pauses).toFloat() } + repMetersOf = key to m + return m + } + + /** A named buzz ([org.openstrap.app.data.WorkoutBuzz]), as quiet as a coaching cue: never during a call or Do Not Disturb. */ + private fun buzz(pattern: List, what: String) { + if (callRinging()) return + val filter = context.getSystemService(android.app.NotificationManager::class.java).currentInterruptionFilter + if (filter == android.app.NotificationManager.INTERRUPTION_FILTER_NONE || filter == android.app.NotificationManager.INTERRUPTION_FILTER_ALARMS) return + scope.launch { runCatching { strap.buzz(pattern) }.onFailure { (strap as? StrapManager)?.log("buzz $what not played: ${it.message}") } } + (strap as? StrapManager)?.log("buzz: $what") + } + + /** One short beep from the phone, only while its ringer is on (workouts spec 2.5, the countdown). */ + private fun beep() { + val audio = context.getSystemService(android.media.AudioManager::class.java) ?: return + if (audio.ringerMode != android.media.AudioManager.RINGER_MODE_NORMAL) return + runCatching { + val tone = android.media.ToneGenerator(android.media.AudioManager.STREAM_NOTIFICATION, BEEP_VOLUME) + tone.startTone(android.media.ToneGenerator.TONE_PROP_BEEP, BEEP_MS) + scope.launch { delay(BEEP_MS + 100L); tone.release() } + } + } + /** Puts back what an early end or a trim left out: the workout as recorded. */ fun undoTrim(start: Long) = editSaved(start) { it.copy(end = it.recordedEnd ?: it.end, recordedEnd = null, shownStart = null) } @@ -662,10 +838,12 @@ class WorkoutSession( private fun editSaved(start: Long, change: (AppWorkout) -> AppWorkout) { scope.launch { lock.withLock { - val w = dao.appWorkoutOnce(start)?.takeIf { it.end != null } ?: return@withLock + val w = catching { dao.appWorkoutOnce(start) }.getOrNull()?.takeIf { it.end != null } ?: return@withLock val changed = change(w) if (changed == w) return@withLock - dao.upsertAppWorkout(withEffect(changed, dao.appWorkoutHeartRateOnce(start))) + catching { dao.upsertAppWorkout(withEffect(changed, dao.appWorkoutHeartRateOnce(start))) } + .onFailure { log("workout edit not saved: ${it.message}") } + .getOrElse { return@withLock } // Its kcal moved, so its days' energy rows follow: over all it was recorded, which holds both ranges. refreshDays(w.start, w.recordedEnd ?: w.end ?: start) onSaved(start) @@ -689,7 +867,7 @@ class WorkoutSession( } fun dismissSummary() { - if (_state.value is WorkoutState.Ended) _state.value = WorkoutState.Idle + _state.update { if (it is WorkoutState.Ended) WorkoutState.Idle else it } endCalmDown() } @@ -699,14 +877,16 @@ class WorkoutSession( val r = _state.value as? WorkoutState.Recording ?: return@withLock val w = change(r.workout) if (w == r.workout) return@withLock - dao.upsertAppWorkout(w) - _state.value = r.copy(workout = w) + // The whole workout is written each time, and again at its end, so a failed save costs nothing kept. + val failed = catching { dao.upsertAppWorkout(w) }.onFailure { log("workout not saved: ${it.message}") }.isFailure + // Only the workout is ours: everything else (an end prompt, say) may have changed while the save ran. + _state.update { (it as? WorkoutState.Recording)?.copy(workout = w, saveFailed = failed) ?: it } } } } private suspend fun maxHeartRate() = - runCatching { RecoveryEngine(dao).maxHeartRate(settings.profile.first()).roundToInt() }.getOrDefault(DEFAULT_MAX_HR) + runCatching { maxHeartRates.now().bpm }.getOrDefault(DEFAULT_MAX_HR) private var coach = org.openstrap.app.data.Coach(null) @@ -734,10 +914,16 @@ class WorkoutSession( val points = _route.value if (w.keepRoute && points.size > savedPoints) { val fresh = points.subList(savedPoints, points.size) - runCatching { dao.insertRoutePoints(fresh.map { AppWorkoutRoutePoint.of(w.start, it) }) } + catching { dao.insertRoutePoints(fresh.map { AppWorkoutRoutePoint.of(w.start, it) }) } .onSuccess { savedPoints = points.size } + .onFailure { log("${fresh.size} route points not saved, kept for the next flush: ${it.message}") } } val (meters, fromSteps) = distanceNow(w) ?: return + // A distance goal buzzes at halfway and once reached; when is kept for the summary. + if (!r.paused) coach.distance(now(), meters)?.let { c -> + cue(c) + if (c == org.openstrap.app.data.Cue.GoalReached) { val t = now(); update { it.copy(targetReachedAt = t) } } + } if (kotlin.math.abs((w.distanceMeters ?: 0f) - meters) < 5f) return update { it.copy(distanceMeters = meters, distanceFromSteps = fromSteps) } } @@ -752,10 +938,10 @@ class WorkoutSession( /** * The strap's steps since the start times a stride for their cadence and your height; null without either, and - * for sports not done on foot ([org.openstrap.app.data.Workouts.ON_FOOT]). + * for sports not done on foot ([org.openstrap.app.data.SportTypes.ON_FOOT]). */ private fun stepsDistance(w: AppWorkout): Float? { - if (w.sportType !in org.openstrap.app.data.Workouts.ON_FOOT) return null + if (w.sportType !in org.openstrap.app.data.SportTypes.ON_FOOT) return null val first = firstSteps ?: return null val last = steps.lastOrNull()?.second ?: return null val height = heightCm ?: return null @@ -765,7 +951,14 @@ class WorkoutSession( return taken * org.openstrap.app.data.Activity.stepLength((taken / minutes).toInt(), height) } - private suspend fun begin(w: AppWorkout, maxHr: Int) { + /** + * Starts recording [w]: its state is [setup] of a fresh one, and [seed] (auto-start's readings so far) is recorded + * before the live readings start, so [record] only ever runs on one coroutine at a time. + */ + private suspend fun begin( + w: AppWorkout, maxHr: Int, seed: List> = emptyList(), + setup: (WorkoutState.Recording) -> WorkoutState.Recording = { it }, + ) { // Waited for, so the calm down's own switch-off can't land after this workout turns the stream on. calmJob?.cancelAndJoin() calmJob = null @@ -773,16 +966,19 @@ class WorkoutSession( strapSessions = runCatching { settings.strapSessions.first() }.getOrDefault(true) heightCm = runCatching { settings.profile.first().heightCm }.getOrNull() if (w.useGps) location.start() - _state.value = WorkoutState.Recording(w, maxHr) + _state.update { setup(WorkoutState.Recording(w, maxHr)) } strap.setWorkoutActive(true) - runCatching { StrapService.start(context) } + // From the background (a restore after a widget's broadcast, say) Android may refuse it: the workout records + // on, but with no foreground service it can be killed. + runCatching { StrapService.start(context) }.onFailure { log("service start failed: $it") } strap.autoConnect() // The CPU must keep up with the strap's readings and the flushes while the screen is off. wake = context.getSystemService(PowerManager::class.java) .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "OpenStrap:workout").apply { acquire(MAX_WAKE_MS) } watchForCar() restingHr = runCatching { dao.restingHrOnce(now() - RESTING_LOOKBACK_S, now()).lastOrNull()?.bpm }.getOrNull() ?: DEFAULT_RESTING_HR - recordJob?.cancel() + recordJob?.cancelAndJoin() + for ((t, bpm) in seed) record(t, bpm) recordJob = scope.launch { launch { strap.heartRateReadings.collect(::onReading) } if (strapSessions) { @@ -809,12 +1005,19 @@ class WorkoutSession( } launch { countKcal(w, maxHr) } // Strength rest is timed to the second, not to the flush. - if (w.sportType in org.openstrap.app.data.Strength.SPORTS) launch { + if (w.sportType in org.openstrap.app.data.SportTypes.STRENGTH) launch { while (isActive) { delay(1_000) checkRest() } } + // So are a structured workout's rounds, screen on or off. + if (org.openstrap.app.data.Structure.parse(w.structure) != null) launch { + while (isActive) { + stepRounds(skip = false) + delay(1_000 - System.currentTimeMillis() % 1_000) + } + } var tick = 0 while (isActive) { delay(FLUSH_MS) @@ -868,12 +1071,12 @@ class WorkoutSession( } /** Asks once the wearer has been calm and still for a while ([WorkoutRecorder.stoppedSince]); the strap buzzes. */ - private fun checkStopped() { + @androidx.annotation.VisibleForTesting + internal fun checkStopped() { val r = _state.value as? WorkoutState.Recording ?: return if (r.paused || !r.workout.askToEnd || r.endPrompt != null) return - val at = WorkoutRecorder.stoppedSince(window, steps, restingHr, r.maxHeartRate, maxOf(askSince, r.workout.start), now()) ?: return - _state.value = r.copy(endPrompt = at) - if (!callRinging()) scope.launch { runCatching { strap.buzz(STOP_BUZZ) } } + val since = maxOf(askSince, r.workout.start) + WorkoutRecorder.stoppedSince(window, steps, restingHr, r.maxHeartRate, since, now())?.let { askToEnd(it) } } /** @@ -888,8 +1091,7 @@ class WorkoutSession( val r = _state.value as? WorkoutState.Recording ?: return if (r.paused || !r.workout.askToEnd || r.endPrompt != null) return val at = WorkoutRecorder.lastActive(window, steps, restingHr, r.maxHeartRate)?.takeIf { it > r.workout.start } ?: now() - _state.value = r.copy(endPrompt = at, inCar = true) - if (!callRinging()) scope.launch { runCatching { strap.buzz(STOP_BUZZ) } } + askToEnd(at, inCar = true) } } private var carRegistered = false @@ -909,6 +1111,12 @@ class WorkoutSession( */ private fun cue(c: org.openstrap.app.data.Cue) { if (callRinging()) return + // Mind and body is a quiet strap: no halfway and no zone cues, and one soft buzz when its length is over. + val r = _state.value as? WorkoutState.Recording + if (r != null && org.openstrap.app.data.Workouts.family(r.workout.sportType) == org.openstrap.app.data.Workouts.Family.MindBody) { + if (c == org.openstrap.app.data.Cue.GoalReached) buzz(org.openstrap.app.data.WorkoutBuzz.SOFT, "length over") + return + } val filter = context.getSystemService(android.app.NotificationManager::class.java).currentInterruptionFilter if (filter == android.app.NotificationManager.INTERRUPTION_FILTER_NONE || filter == android.app.NotificationManager.INTERRUPTION_FILTER_ALARMS) return scope.launch { runCatching { strap.buzz(c.pattern) }.onFailure { (strap as? StrapManager)?.log("coach $c not played: ${it.message}") } } @@ -947,32 +1155,69 @@ class WorkoutSession( buffer += AppWorkoutHeartRate(r.workout.start, t, bpm) } synchronized(allReadings) { allReadings += t to bpm } - _recent.value = (_recent.value + (t to bpm)).dropWhile { it.first < t - RECENT_S } window = (window + (t to bpm)).dropWhile { it.first < t - STOP_WINDOW_S } sum += bpm count++ - lastReading?.let { (pt, pbpm) -> + // A pause between this reading and the one before isn't time in zone. Checked here rather than cleared on + // resume, so only this coroutine touches what a reading adds up. + lastReading?.takeIf { (pt, _) -> (r.workout.pauseRanges.lastOrNull()?.last ?: Long.MIN_VALUE) <= pt }?.let { (pt, pbpm) -> val z = WorkoutRecorder.liveZone(pbpm, r.maxHeartRate)!! _zoneSeconds.value = _zoneSeconds.value.copyOf().also { it[z] += (t - pt).coerceIn(0, WorkoutRecorder.MAX_STEP_S).toInt() } } lastReading = t to bpm coach.feed(t, WorkoutRecorder.liveZone(bpm, r.maxHeartRate), WorkoutRecorder.activeSeconds(r.workout, t))?.let(::cue) _stats.value = WorkoutRecorder.Stats(Math.round(sum.toDouble() / count).toInt(), maxOf(_stats.value?.max ?: 0, bpm), minOf(_stats.value?.min ?: bpm, bpm)) + // Last, so whoever sees the reading in [recent] also sees its zone time and stats. + _recent.value = (_recent.value + (t to bpm)).dropWhile { it.first < t - RECENT_S } } private suspend fun flush() { val batch = synchronized(buffer) { buffer.toList().also { buffer.clear() } } if (batch.isEmpty()) return - runCatching { dao.insertAppWorkoutHeartRate(batch) }.onFailure { - // Kept for the next flush rather than lost. - synchronized(buffer) { buffer.addAll(0, batch) } - } + catching { dao.insertAppWorkoutHeartRate(batch) } + .onSuccess { + if (flushFailing) log("heart rate saved again") + flushFailing = false + } + .onFailure { e -> + // Kept for the next flush rather than lost, but only the newest [MAX_UNSAVED]: a database that keeps + // failing mustn't fill the memory too. + val dropped = synchronized(buffer) { + buffer.addAll(0, batch) + (buffer.size - MAX_UNSAVED).coerceAtLeast(0).also { buffer.subList(0, it).clear() } + } + // Once a spell, not every flush: the log keeps only its last lines. + if (!flushFailing) log("heart rate not saved, kept for the next flush: ${e.message}") + if (dropped > 0 && !flushDropping) log("heart rate unsaved for too long, the oldest readings are dropped") + flushFailing = true + flushDropping = dropped > 0 + } } - private fun now() = System.currentTimeMillis() / 1000 + /** Whether the last flush failed, and whether it had to drop readings, so a spell of failures is logged once. */ + private var flushFailing = false + private var flushDropping = false + + private fun now() = clock() + + /** To the strap's log, which the Device screen shows. */ + private fun log(message: String) = (strap as? StrapManager)?.log(message) internal companion object { + /** + * For a session built without the app's scope (tests): one that logs an uncaught exception rather than letting + * it kill the process, as `AppContainer.appScope` does. + */ + // Only a fallback: the app passes its own scope, with its dispatcher, in. + @Suppress("InjectDispatcher") + fun fallbackScope() = CoroutineScope( + SupervisorJob() + Dispatchers.Default + + CoroutineExceptionHandler { _, e -> android.util.Log.e("OpenStrap", "workout: uncaught $e", e) }, + ) + const val FLUSH_MS = 10_000L + /** Live readings held while the database refuses them: two hours, one a second. */ + const val MAX_UNSAVED = 7_200 /** The live kcal is worked out again this often. */ const val KCAL_TICK_MS = 5_000L /** History minutes and the strap's step minutes are read again every sixth kcal tick, 30 s. */ @@ -997,6 +1242,14 @@ class WorkoutSession( const val WATCH_TICK_MS = 5_000L /** Three short pulses: unlike the stop question's two long ones. */ val OFFER_BUZZ = listOf(150, 150, 150, 150, 150) + /** Saved workouts kept (workouts spec 2.5). */ + const val MAX_SAVED = 10 + /** "+10 s" on an interval's rest. */ + const val REST_ADD_S = 10L + /** The phone beeps the last this many seconds of a timed work or rest. */ + const val COUNTDOWN_S = 3L + const val BEEP_MS = 120 + const val BEEP_VOLUME = 70 /** A safety net should end() never come; the strap's battery is long gone by then. */ const val MAX_WAKE_MS = 12 * 3600_000L } diff --git a/app/src/main/kotlin/org/openstrap/app/device/WorkoutTileService.kt b/app/src/main/kotlin/org/openstrap/app/device/WorkoutTileService.kt index 646e2988..30ecbd53 100644 --- a/app/src/main/kotlin/org/openstrap/app/device/WorkoutTileService.kt +++ b/app/src/main/kotlin/org/openstrap/app/device/WorkoutTileService.kt @@ -32,12 +32,10 @@ class WorkoutTileService : TileService() { val r = state as? WorkoutState.Recording tile.state = if (r != null) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE tile.label = if (r != null) Workouts.sportName(r.workout.sportType) else "Start workout" - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - tile.subtitle = when { - r?.paused == true -> "Paused" - r != null -> "Recording" - else -> Workouts.sportName(last?.sportType ?: Workouts.DEFAULT_SPORTS.first()) - } + tile.subtitle = when { + r?.paused == true -> "Paused" + r != null -> "Recording" + else -> Workouts.sportName(last?.sportType ?: Workouts.DEFAULT_SPORTS.first()) } tile.updateTile() } diff --git a/app/src/main/kotlin/org/openstrap/app/export/AutoBackup.kt b/app/src/main/kotlin/org/openstrap/app/export/AutoBackup.kt index 600ac6f2..a1121ce3 100644 --- a/app/src/main/kotlin/org/openstrap/app/export/AutoBackup.kt +++ b/app/src/main/kotlin/org/openstrap/app/export/AutoBackup.kt @@ -1,7 +1,10 @@ package org.openstrap.app.export import android.content.Context +import android.content.Intent import android.net.Uri +import android.util.Log +import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.work.BackoffPolicy import androidx.work.Constraints @@ -12,15 +15,18 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import org.openstrap.app.AppContainer import org.openstrap.app.OpenStrapApp +import org.openstrap.app.data.BackupRepository +import org.openstrap.app.data.catching +import org.openstrap.app.data.settings.SettingsStore import org.openstrap.app.device.WorkoutState import java.time.LocalDate import java.time.ZoneId @@ -31,8 +37,16 @@ import java.util.concurrent.TimeUnit * goes into a folder the user chose, which Google Drive, Nextcloud or Syncthing may keep somewhere else too. Written * under a temporary name and renamed when complete ([BackupFiles]), the [BackupFiles.KEEP] newest kept. Never while a * workout records: the run waits and tries again. A failure is kept for the You page to say, never a notification. + * [restored] and [workout] are the workout session's, so a backup never runs beside a recording. */ -class AutoBackup(private val context: Context, private val container: AppContainer, private val zone: ZoneId = ZoneId.systemDefault()) { +class AutoBackup( + private val context: Context, + private val settings: SettingsStore, + private val restored: Flow, + private val workout: StateFlow, + private val backups: BackupRepository, + private val zone: ZoneId = ZoneId.systemDefault(), +) { sealed interface Outcome { /** Written, as [name]. */ @@ -45,24 +59,53 @@ class AutoBackup(private val context: Context, private val container: AppContain } suspend fun run(): Outcome = lock.withLock { - val folder = container.settings.backup.first().folder ?: return@withLock Outcome.Off + val folder = settings.backup.first().folder ?: return@withLock Outcome.Off // Until the session has looked for a workout left recording, Idle doesn't mean none. - withTimeoutOrNull(10_000) { container.workout.restored.first { it } } - if (container.workout.state.value is WorkoutState.Recording) return@withLock Outcome.Recording + withTimeoutOrNull(10_000) { restored.first { it } } + if (workout.value is WorkoutState.Recording) return@withLock Outcome.Recording val now = System.currentTimeMillis() / 1000 - try { + catching { val name = withContext(Dispatchers.IO) { write(Uri.parse(folder)) } - container.settings.setBackupDone(now) + settings.setBackupDone(now) Outcome.Done(name) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { + }.getOrElse { e -> val failure = (e as? Failed)?.failure ?: BackupFiles.classify(e) - container.settings.setBackupFailed(failure, now) + if (e !is Failed) Log.w("OpenStrap", "backup: failed ($failure): $e") + settings.setBackupFailed(failure, now) Outcome.Failed(failure) } } + /** One backup now, the daily one's way. During a workout it waits for the end instead, through WorkManager. */ + suspend fun runNow(): Outcome = run().also { if (it == Outcome.Recording) afterWorkout(context) } + + /** + * Automatic backup into the folder [uri] the user just picked: keeps the right to write there across restarts, lets + * go of the folder before and starts the daily backup. False, with nothing changed, when Android won't keep the right. + */ + suspend fun chooseFolder(uri: Uri): Boolean { + val kept = runCatching { context.contentResolver.takePersistableUriPermission(uri, FOLDER_ACCESS) } + kept.onFailure { Log.w("OpenStrap", "backup: folder permission not kept: $it") } + if (kept.isFailure) return false + val before = settings.backup.first().folder + if (before != null && before != uri.toString()) releaseFolder(before) + val name = withContext(Dispatchers.IO) { runCatching { DocumentFile.fromTreeUri(context, uri)?.name }.getOrNull() } + settings.setBackupFolder(uri.toString(), name) + schedule(context) + return true + } + + /** Stops automatic backup and lets go of its folder. The backups already there stay. */ + suspend fun turnOff() { + cancel(context) + settings.backup.first().folder?.let(::releaseFolder) + settings.clearBackup() + } + + private fun releaseFolder(folder: String) { + runCatching { context.contentResolver.releasePersistableUriPermission(folder.toUri(), FOLDER_ACCESS) } + } + private class Failed(val failure: BackupFiles.Failure) : Exception(failure.name) private suspend fun write(folder: Uri): String { @@ -76,17 +119,16 @@ class AutoBackup(private val context: Context, private val container: AppContain dir.listFiles().filter { it.name?.let(BackupFiles::isTemp) == true }.forEach { it.delete() } val temp = dir.createFile(TEMP_MIME, BackupFiles.tempName(today)) ?: throw Failed(BackupFiles.Failure.CantWrite) + var written = false try { - val settings = SettingsBackup.toJson(container.settings.backupValues()) - (resolver.openOutputStream(temp.uri, "w") ?: throw Failed(BackupFiles.Failure.CantWrite)).use { - container.csv.exportZip(it, settings = settings) - } + (resolver.openOutputStream(temp.uri, "w") ?: throw Failed(BackupFiles.Failure.CantWrite)).use { backups.writeZip(it) } // A second run on the same day replaces the first. dir.findFile(name)?.delete() if (!temp.renameTo(name)) copyAs(dir, temp, name) - } catch (e: Throwable) { - runCatching { temp.delete() } - throw e + written = true + } finally { + // Failed or cancelled part way: the temp copy goes, and the failure itself is what's reported. + if (!written) deleteQuietly(temp) } for (old in BackupFiles.toDelete(dir.listFiles().mapNotNull { it.name })) dir.findFile(old)?.delete() @@ -97,15 +139,26 @@ class AutoBackup(private val context: Context, private val container: AppContain private fun copyAs(dir: DocumentFile, temp: DocumentFile, name: String) { val resolver = context.contentResolver val target = dir.createFile(ZIP_MIME, name) ?: throw Failed(BackupFiles.Failure.CantWrite) + var copied = false try { - resolver.openInputStream(temp.uri)!!.use { input -> resolver.openOutputStream(target.uri, "w")!!.use { input.copyTo(it) } } - } catch (e: Throwable) { - runCatching { target.delete() } - throw e + val input = resolver.openInputStream(temp.uri) + val output = input?.let { resolver.openOutputStream(target.uri, "w") } + if (input == null || output == null) { + input?.close() + throw Failed(BackupFiles.Failure.CantWrite) + } + input.use { i -> output.use { i.copyTo(it) } } + copied = true + } finally { + if (!copied) deleteQuietly(target) } temp.delete() } + private fun deleteQuietly(file: DocumentFile) { + runCatching { file.delete() }.onFailure { Log.w("OpenStrap", "backup: ${file.name} not deleted: $it") } + } + companion object { /** One backup at a time, the daily one and Back up now alike. */ private val lock = Mutex() @@ -114,6 +167,7 @@ class AutoBackup(private val context: Context, private val container: AppContain private const val AFTER_WORKOUT = "auto-backup-retry" private const val TEMP_MIME = "application/octet-stream" private const val ZIP_MIME = "application/zip" + private const val FOLDER_ACCESS = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION /** Starts the daily backup; a no-op when it's already scheduled. */ fun schedule(context: Context) { @@ -143,8 +197,11 @@ class AutoBackup(private val context: Context, private val container: AppContain class BackupWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val container = (applicationContext as OpenStrapApp).container + val backup = AutoBackup( + applicationContext, container.settings, container.workout.restored, container.workout.state, container.backups, + ) // A failure is kept for the You page, and tomorrow's run tries again. - return when (AutoBackup(applicationContext, container).run()) { + return when (backup.run()) { AutoBackup.Outcome.Recording -> Result.retry() else -> Result.success() } diff --git a/app/src/main/kotlin/org/openstrap/app/export/CsvBackups.kt b/app/src/main/kotlin/org/openstrap/app/export/CsvBackups.kt new file mode 100644 index 00000000..780fbd71 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/export/CsvBackups.kt @@ -0,0 +1,72 @@ +package org.openstrap.app.export + +import android.content.Context +import android.net.Uri +import android.util.Log +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.openstrap.app.data.BackupRepository +import org.openstrap.app.data.BackupResult +import org.openstrap.app.data.catching +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.settings.SettingsStore +import java.io.FileNotFoundException +import java.io.InputStream +import java.io.OutputStream + +/** + * [BackupRepository] as zips of CSV files ([CsvExporter], [CsvImporter]) with the settings beside them + * ([SettingsBackup]), read and written through the content resolver. [log] takes what Diagnostics should keep. + */ +class CsvBackups( + private val context: Context, + private val database: OpenStrapDatabase, + private val settings: SettingsStore, + private val csv: CsvExporter, + private val log: (String) -> Unit = {}, + private val io: CoroutineDispatcher = Dispatchers.IO, +) : BackupRepository { + + override suspend fun exportCsv(to: Uri): BackupResult = catching { + withContext(io) { output(to).use { writeZip(it) } } + BackupResult.Saved + }.getOrElse { BackupResult.Failed(it) } + + override suspend fun writeZip(out: OutputStream) { + csv.exportZip(out, settings = SettingsBackup.toJson(settings.backupValues())) + } + + override suspend fun importCsv(from: Uri): BackupResult = catching { + val result = withContext(io) { input(from).use { CsvImporter(database).importZip(it) } } + // A damaged settings file costs the settings only, not the records already restored. + val restored = result.settings?.let { json -> + catching { settings.restoreValues(SettingsBackup.fromJson(json)) } + .onFailure { warn("restore: settings not restored: $it") } + .getOrDefault(0) + } ?: 0 + BackupResult.Restored(result.total, restored > 0, result.skipped) + }.getOrElse { BackupResult.Failed(it) } + + override suspend fun exportSettings(to: Uri): BackupResult = catching { + val json = SettingsBackup.toJson(settings.backupValues()) + withContext(io) { output(to).use { it.write(json.toByteArray(Charsets.UTF_8)) } } + BackupResult.Saved + }.getOrElse { BackupResult.Failed(it) } + + override suspend fun importSettings(from: Uri): BackupResult = catching { + val json = withContext(io) { input(from).use { it.readBytes().toString(Charsets.UTF_8) } } + BackupResult.Restored(0, settings.restoreValues(SettingsBackup.fromJson(json)) > 0, 0) + }.getOrElse { BackupResult.Failed(it) } + + private fun output(uri: Uri): OutputStream = + context.contentResolver.openOutputStream(uri) ?: throw FileNotFoundException("no output stream for $uri") + + private fun input(uri: Uri): InputStream = + context.contentResolver.openInputStream(uri) ?: throw FileNotFoundException("no input stream for $uri") + + private fun warn(message: String) { + Log.w("OpenStrap", message) + log(message) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/export/CsvExporter.kt b/app/src/main/kotlin/org/openstrap/app/export/CsvExporter.kt index 8aa4f37b..f144b6ab 100644 --- a/app/src/main/kotlin/org/openstrap/app/export/CsvExporter.kt +++ b/app/src/main/kotlin/org/openstrap/app/export/CsvExporter.kt @@ -8,7 +8,7 @@ import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream /** - * Writes every table as a CSV file inside one zip ([CSV_TABLES]). Timestamps are ISO-8601 UTC plus raw epoch seconds. + * Writes each table of user data ([CSV_TABLES]) as a CSV file inside one zip. Timestamps are ISO-8601 UTC plus raw epoch seconds. * [settings], when given, goes in beside them as [SettingsBackup.FILE]. [CsvImporter] reads the zip back. */ class CsvExporter(private val dao: HealthDao) { @@ -18,7 +18,7 @@ class CsvExporter(private val dao: HealthDao) { val w = zip.writer() for (table in CSV_TABLES) { zip.putNextEntry(ZipEntry(table.file)) - w.writeCsv(table.header, table.rows(dao, from, to)) + table.writeTo(w, from, to) w.flush() zip.closeEntry() } @@ -31,7 +31,11 @@ class CsvExporter(private val dao: HealthDao) { } } - private suspend fun CsvTable.rows(dao: HealthDao, from: Long, to: Long) = read(dao, from, to).map(row) + /** The table's file, written a page at a time as the rows are read ([CsvTable.forEachPage]). */ + private suspend fun CsvTable.writeTo(w: Writer, from: Long, to: Long) { + w.append(header).append('\n') + forEachPage(dao, from, to) { page -> w.writeRows(page.map(row)) } + } companion object { fun iso(epochSeconds: Long): String = Instant.ofEpochSecond(epochSeconds).toString() @@ -42,13 +46,21 @@ class CsvExporter(private val dao: HealthDao) { internal fun Writer.writeCsv(header: String, rows: List>) { append(header).append('\n') + writeRows(rows) + } + + private fun Writer.writeRows(rows: List>) { for (row in rows) { row.joinTo(this, ",") { v -> v?.toString()?.let(::escape) ?: "" } append('\n') } } + /** Quotes a cell that holds a comma, a quote or a line break, as [CsvReader] reads it back. */ private fun escape(s: String): String = - if (s.any { it == ',' || it == '"' || it == '\n' }) "\"" + s.replace("\"", "\"\"") + "\"" else s + if (s.any { it in SPECIAL }) "\"" + s.replace("\"", "\"\"") + "\"" else s + + /** The characters that make a cell need quotes. */ + private const val SPECIAL = ",\"\n\r" } } diff --git a/app/src/main/kotlin/org/openstrap/app/export/CsvImporter.kt b/app/src/main/kotlin/org/openstrap/app/export/CsvImporter.kt index 6c90e7de..acc3c3a0 100644 --- a/app/src/main/kotlin/org/openstrap/app/export/CsvImporter.kt +++ b/app/src/main/kotlin/org/openstrap/app/export/CsvImporter.kt @@ -1,6 +1,7 @@ package org.openstrap.app.export import androidx.room.withTransaction +import org.openstrap.app.data.Days import org.openstrap.app.data.db.OpenStrapDatabase import java.io.InputStream import java.time.Instant @@ -30,6 +31,12 @@ class CsvImporter(private val db: OpenStrapDatabase, private val zone: ZoneId = private class Outcome(val stored: Int, val present: Int, val bad: Int) + /** One CSV row read: its time and, for a span, its end, its text key for a [Unique] table, and the row. */ + private class Parsed(val time: Long, val end: Long, val id: String?, val value: T) + + /** A file's rows as read, waiting to be written, and how many were unreadable. */ + private class Pending(val table: CsvTable, val rows: List>, val bad: Int) + suspend fun importZip(input: InputStream): Result = db.withTransaction { val tables = CSV_TABLES.associateBy { it.file } val counts = linkedMapOf() @@ -37,13 +44,13 @@ class CsvImporter(private val db: OpenStrapDatabase, private val zone: ZoneId = val added = mutableMapOf>() // Child tables wait until every parent is in, whatever order the zip lists them in, and the tables a deleted // workout is left out of wait until every deleted workout is in. - val children = mutableListOf, List>>() - val deletable = mutableListOf, List>>() + val children = mutableListOf>() + val deletable = mutableListOf>() var present = 0 var skipped = 0 var settings: String? = null - fun count(table: CsvTable<*>, o: Outcome) { - counts[table.file] = o.stored; present += o.present; skipped += o.bad + fun count(file: String, o: Outcome) { + counts[file] = o.stored; present += o.present; skipped += o.bad } ZipInputStream(input).use { zip -> while (true) { @@ -53,13 +60,12 @@ class CsvImporter(private val db: OpenStrapDatabase, private val zone: ZoneId = continue } val table = tables[entry.name.substringAfterLast('/')] ?: continue - // Only this entry's bytes, without closing the zip (a Reader's readLines would close it). - val lines = zip.readBytes().toString(Charsets.UTF_8).lines().filter { it.isNotBlank() } - if (lines.isEmpty()) continue + // Only this entry, read as it streams: the reader is never closed, so the zip stays open. + val pending = table.read(CsvReader(zip.reader(Charsets.UTF_8))) ?: continue when { - table.key.parent != null -> children += table to lines - table.key.table in DELETABLE -> deletable += table to lines - else -> count(table, table.backfill(lines, added)) + table.key.parent != null -> children += pending + table.key.table in DELETABLE -> deletable += pending + else -> count(table.file, pending.backfill(added)) } } } @@ -68,66 +74,76 @@ class CsvImporter(private val db: OpenStrapDatabase, private val zone: ZoneId = "workout" to deleted.mapTo(HashSet()) { it.start }, "sport_effect" to deleted.mapNotNullTo(HashSet()) { it.effectEnd }, ) - for ((table, lines) in deletable) count(table, table.backfill(lines, added, gone[table.key.table].orEmpty())) - for ((table, lines) in children) count(table, table.backfill(lines, added)) + for (p in deletable) count(p.table.file, p.backfill(added, gone[p.table.key.table].orEmpty())) + for (p in children) count(p.table.file, p.backfill(added)) // Throwing rolls back, so a zip that isn't ours leaves the database untouched. if (counts.isEmpty()) throw IllegalArgumentException("This isn't an OpenStrap export.") Result(counts, present, skipped, settings) } - /** Writes the rows of [lines] that fall before the phone's first day for this table, less those timed at [gone]. */ - private suspend fun CsvTable.backfill(lines: List, added: MutableMap>, gone: Set = emptySet()): Outcome { - val index = parseLine(lines.first()).withIndex().associate { (i, name) -> name to i } + /** This table's rows from [records], the header first; null for an empty file. */ + private fun CsvTable.read(records: CsvReader): Pending? { + val index = (records.next() ?: return null).withIndex().associate { (i, name) -> name to i } var bad = 0 - val rows = mutableListOf>() - for (line in lines.drop(1)) { + val rows = mutableListOf>() + while (true) { + val cells = CsvRow(index, records.next() ?: break) runCatching { - val cells = CsvRow(index, parseLine(line)) val time = cells.long(key.epoch) - if (time in gone) return@runCatching - rows += Triple(time, key.span?.let { cells.long(it.epoch) } ?: time, parse(cells)) + rows += Parsed(time, key.span?.let { cells.long(it.epoch) } ?: time, key.unique?.let { cells.string(it.csv) }, parse(cells)) }.onFailure { bad++ } } + return Pending(this, rows, bad) + } + + /** Writes the rows that fall before the phone's first day for this table, less those timed at [gone]. */ + private suspend fun Pending.backfill(added: MutableMap>, gone: Set = emptySet()): Outcome { + val key = table.key + val rows = rows.filter { it.time !in gone } val fresh = if (rows.isEmpty()) rows else { - val from = rows.minOf { it.first } - val to = rows.maxOf { it.second } + val from = rows.minOf { it.time } + val to = rows.maxOf { it.end } val parent = key.parent val span = key.span when { - parent != null -> added[parent].orEmpty().let { starts -> rows.filter { it.first in starts } } + parent != null -> added[parent].orEmpty().let { starts -> rows.filter { it.time in starts } } else -> { val first = query("SELECT MIN(${key.column}) FROM ${key.table}") { c -> if (c.isNull(0)) null else c.getLong(0) }.single() val cutoff = first?.let { dayStart(it) } ?: Long.MAX_VALUE // A deletion is the user's, not the strap's history: every one comes back, less those already here. + val unique = key.unique val before = when (key.table) { - in WHOLE -> { + in WHOLE -> if (unique != null) { + val have = query("SELECT ${unique.column} FROM ${key.table}") { it.getString(0) }.toHashSet() + rows.filter { it.id !in have } + } else { val have = query("SELECT ${key.column} FROM ${key.table}") { it.getLong(0) }.toHashSet() - rows.filter { it.first !in have } + rows.filter { it.time !in have } } // One row a minute, keyed by it: a minute the phone has keeps the strap's value, any other // minute comes back, so a day the strap only partly re-sent is filled in too. in MINUTES -> { val have = query("SELECT ${key.column} FROM ${key.table} WHERE ${key.column} BETWEEN ? AND ?", from, to) { it.getLong(0) }.toHashSet() - rows.filter { it.first !in have } + rows.filter { it.time !in have } } - else -> rows.filter { it.first < cutoff } + else -> rows.filter { it.time < cutoff } } if (span == null) before else { // A night that starts the evening before the strap's first day can still be one it has. val have = query("SELECT ${key.column}, ${span.column} FROM ${key.table} WHERE ${key.column} <= ? AND ${span.column} >= ?", to, from) { it.getLong(0) to it.getLong(1) } - before.filter { (start, end) -> have.none { (s, e) -> s == start || (s < end && e > start) } } + before.filter { row -> have.none { (s, e) -> s == row.time || (s < row.end && e > row.time) } } } } } } - if (key.span != null) added[key.table] = fresh.mapTo(HashSet()) { it.first } - fresh.map { it.third }.chunked(2_000).forEach { write(db.healthDao(), it) } + if (key.span != null) added[key.table] = fresh.mapTo(HashSet()) { it.time } + fresh.map { it.value }.chunked(2_000).forEach { table.write(db.healthDao(), it) } return Outcome(fresh.size, rows.size - fresh.size, bad) } - private fun dayStart(epoch: Long): Long = Instant.ofEpochSecond(epoch).atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() + private fun dayStart(epoch: Long): Long = Days.start(epoch, zone) private fun query(sql: String, vararg args: Any, read: (android.database.Cursor) -> R): List = db.openHelper.writableDatabase.query(sql, args).use { c -> buildList { while (c.moveToNext()) add(read(c)) } } @@ -137,29 +153,12 @@ class CsvImporter(private val db: OpenStrapDatabase, private val zone: ZoneId = private val DELETABLE = setOf("workout", "sport_effect") /** Tables restored whole rather than only before the phone's first day: what the user did, which the strap can't re-send. */ - private val WHOLE = setOf("workout_deleted") + private val WHOLE = setOf("workout_deleted", "saved_workout", "exercise", "badge", "week_result", "personal_best", "habit_age_week") /** Minute tables, keyed by the minute: restored wherever the phone has no row for it, not only before its first day. */ private val MINUTES = setOf("heart_rate", "activity_minute", "activity_extended") - /** One CSV line into cells: commas separate, double quotes wrap a cell and "" inside is a quote. */ - fun parseLine(line: String): List { - val cells = mutableListOf() - val cell = StringBuilder() - var quoted = false - var i = 0 - while (i < line.length) { - val c = line[i] - when { - quoted && c == '"' && line.getOrNull(i + 1) == '"' -> { cell.append('"'); i++ } - c == '"' -> quoted = !quoted - c == ',' && !quoted -> { cells += cell.toString(); cell.clear() } - else -> cell.append(c) - } - i++ - } - cells += cell.toString() - return cells - } + /** One CSV line into cells, as [CsvReader] reads it. */ + fun parseLine(line: String): List = CsvReader(line.reader()).next() ?: listOf("") } } diff --git a/app/src/main/kotlin/org/openstrap/app/export/CsvReader.kt b/app/src/main/kotlin/org/openstrap/app/export/CsvReader.kt new file mode 100644 index 00000000..a5dc9d2d --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/export/CsvReader.kt @@ -0,0 +1,64 @@ +package org.openstrap.app.export + +import java.io.Reader + +/** + * Reads CSV records one at a time from [source], as [CsvExporter] writes them: commas separate cells, double quotes + * wrap a cell, "" inside one is a quote, and a quoted cell may hold commas and line breaks. Records end at \n, \r\n + * or \r outside quotes. Blank lines are skipped. It never closes [source], so it can read one zip entry and stop. + */ +class CsvReader(source: Reader) { + private val input = source.buffered() + private var pending = NONE + + private fun read(): Int = if (pending != NONE) pending.also { pending = NONE } else input.read() + + /** The next record's cells, or null at the end. */ + fun next(): List? { + while (true) { + val cells = record() ?: return null + if (cells.size > 1 || cells[0].isNotEmpty()) return cells + } + } + + /** Every record left. */ + fun all(): List> = generateSequence { next() }.toList() + + /** Reads the next character if it is [code] and says so, else leaves it to be read next. */ + private fun peek(code: Int): Boolean { + val next = read() + if (next != code) pending = next + return next == code + } + + private fun record(): List? { + var c = read() + if (c == -1) return null + val cells = mutableListOf() + val cell = StringBuilder() + var quoted = false + while (c != -1) { + val ch = c.toChar() + when { + quoted && ch == '"' -> if (peek('"'.code)) cell.append('"') else quoted = false + quoted -> cell.append(ch) + ch == '"' -> quoted = true + ch == ',' -> { cells += cell.toString(); cell.clear() } + ch == '\n' || ch == '\r' -> { + // A \r ends the record alone or with the \n after it. + if (ch == '\r') peek('\n'.code) + break + } + else -> cell.append(ch) + } + c = read() + } + cells += cell.toString() + return cells + } + + private companion object { + /** [pending] holds no character read ahead. Not -1, which is the end of [input]. */ + const val NONE = -2 + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/export/CsvTables.kt b/app/src/main/kotlin/org/openstrap/app/export/CsvTables.kt index 5d71ee18..e05b8228 100644 --- a/app/src/main/kotlin/org/openstrap/app/export/CsvTables.kt +++ b/app/src/main/kotlin/org/openstrap/app/export/CsvTables.kt @@ -1,10 +1,15 @@ package org.openstrap.app.export -import kotlinx.coroutines.flow.first +import org.openstrap.app.data.Days import org.openstrap.app.data.db.ActivityExtendedMinute import org.openstrap.app.data.db.AppWorkout import org.openstrap.app.data.db.AppWorkoutHeartRate +import org.openstrap.app.data.db.AppWorkoutRound import org.openstrap.app.data.db.AppWorkoutRoutePoint +import org.openstrap.app.data.db.BadgeRow +import org.openstrap.app.data.db.ExerciseRow +import org.openstrap.app.data.db.SavedWorkout +import org.openstrap.app.data.db.WeekResult import org.openstrap.app.data.db.AppWorkoutSet import org.openstrap.app.data.db.DismissedWorkout import org.openstrap.app.data.db.ActivityMinute @@ -14,6 +19,7 @@ import org.openstrap.app.data.db.DailyEnergyRow import org.openstrap.app.data.db.DailyMaxHr import org.openstrap.app.data.db.DeletedWorkout import org.openstrap.app.data.db.DayTagRow +import org.openstrap.app.data.db.HabitAgeWeekRow import org.openstrap.app.data.db.HealthAlertRow import org.openstrap.app.data.db.HealthDao import org.openstrap.app.data.db.HeartRateSample @@ -23,9 +29,11 @@ import org.openstrap.app.data.db.NightMetricsRow import org.openstrap.app.data.db.OsaEventRow import org.openstrap.app.data.db.OsaResultRow import org.openstrap.app.data.db.PaiSample +import org.openstrap.app.data.db.PersonalBest import org.openstrap.app.data.db.RecoveryHeartRateRow import org.openstrap.app.data.db.RestingHeartRate import org.openstrap.app.data.db.SkinTemperature +import org.openstrap.app.data.db.SleepEdit import org.openstrap.app.data.db.SleepSession import org.openstrap.app.data.db.SleepSpo2Sample import org.openstrap.app.data.db.SleepStage @@ -61,9 +69,16 @@ class CsvRow(private val index: Map, private val cells: List( val file: String, val header: String, val key: Key, + /** Every row in [from, to], in the file's order. A [Paged] read also hands them over a page at a time. */ val read: suspend (HealthDao, Long, Long) -> List, val row: (T) -> List, val parse: (CsvRow) -> T, val write: suspend (HealthDao, List) -> Unit, -) +) { + /** The rows [read] gives, in the same order, a page at a time when it's [Paged], else all in one. */ + suspend fun forEachPage(dao: HealthDao, from: Long, to: Long, block: suspend (List) -> Unit) { + val paged = read as? Paged<*> + if (paged == null) block(read(dao, from, to)) + else paged.forEachPage(dao, from, to) { + // A Paged read is the read of this table, so its rows are this table's. + @Suppress("UNCHECKED_CAST") + block(it as List) + } + } +} + +/** Rows a backup page holds: enough to keep the queries few, few enough that a year of minutes is never in memory. */ +const val CSV_PAGE = 5000 + +/** + * A table read a page at a time by keyset: [page] gives up to a page of rows after the last row of the one before + * (null for the first), in the table's order. The backup writes each page as it comes, so the biggest tables (a row a + * minute for years) are never held whole. Called as a plain read, it gathers every page. + */ +class Paged(private val page: suspend (HealthDao, Long, Long, T?, Int) -> List) : suspend (HealthDao, Long, Long) -> List { + suspend fun forEachPage(dao: HealthDao, from: Long, to: Long, size: Int = CSV_PAGE, block: suspend (List) -> Unit) { + var last: T? = null + while (true) { + val rows = page(dao, from, to, last, size) + if (rows.isNotEmpty()) block(rows) + if (rows.size < size) return + last = rows.last() + } + } + + override suspend fun invoke(dao: HealthDao, from: Long, to: Long): List = buildList { forEachPage(dao, from, to) { addAll(it) } } +} + +/** A [Paged] read of a table keyed by its [time] alone, from [page] (rows after a time, up to a time, at most a limit). */ +private fun byTime(page: suspend HealthDao.(Long, Long, Int) -> List, time: (T) -> Long) = + Paged { d, f, t, last, n -> d.page(last?.let(time) ?: (f - 1), t, n) } + +/** + * A [Paged] read of a workout's samples, keyed by the workout's [start] then the sample's [time], from [page] (the + * workouts started in a range, after a start and time, at most a limit). + */ +private fun byWorkout(page: suspend HealthDao.(Long, Long, Long, Long, Int) -> List, start: (T) -> Long, time: (T) -> Long) = + Paged { d, f, t, last, n -> d.page(f, t, last?.let(start) ?: (f - 1), last?.let(time) ?: Long.MIN_VALUE, n) } private fun iso(epochSeconds: Long) = CsvExporter.iso(epochSeconds) private fun hex(bytes: ByteArray?) = bytes?.joinToString("") { "%02x".format(it) } /** Local midnight of the day a sleep belongs to, for rows from exports that didn't carry it: the day you woke up. */ private fun sleepDay(start: Long, end: Long, nap: Boolean): Long { - val zone = ZoneId.systemDefault() - return Instant.ofEpochSecond(if (nap) start else end).atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() + return Days.start(if (nap) start else end, ZoneId.systemDefault()) } private fun stageCode(name: String) = when (name) { @@ -97,20 +156,25 @@ private fun stageCode(name: String) = when (name) { else -> name.toIntOrNull() ?: SleepStage.LIGHT } +/** + * The tables a backup carries: every table of user data. Left out: raw_record, records the app can't decode yet, kept + * only to parse later and never read back, so a restore would add nothing anything shows. + */ val CSV_TABLES: List> = listOf( CsvTable("heart_rate.csv", "time,epoch,bpm,source", Key("heart_rate", "timestamp", "epoch"), - { d, f, t -> d.heartRateOnce(f, t) }, { listOf(iso(it.timestamp), it.timestamp, it.bpm, it.source) }, + byTime(HealthDao::heartRatePage) { it.timestamp }, { listOf(iso(it.timestamp), it.timestamp, it.bpm, it.source) }, { HeartRateSample(it.long("epoch"), it.int("bpm"), it.intOrNull("source") ?: HeartRateSample.SOURCE_HISTORY) }, { d, r -> d.insertHeartRate(r) }), CsvTable("activity_minute.csv", "time,epoch,steps,intensity,kind,heart_rate", Key("activity_minute", "timestamp", "epoch"), - { d, f, t -> d.activityOnce(f, t) }, { listOf(iso(it.timestamp), it.timestamp, it.steps, it.intensity, it.kind, it.heartRate) }, + byTime(HealthDao::activityPage) { it.timestamp }, + { listOf(iso(it.timestamp), it.timestamp, it.steps, it.intensity, it.kind, it.heartRate) }, { ActivityMinute(it.long("epoch"), it.int("steps"), it.int("intensity"), it.int("kind"), it.intOrNull("heart_rate")) }, { d, r -> d.insertActivity(r) }), CsvTable("activity_extended.csv", "time,epoch,floors,run,fat_burn,stand,walk,sit,steps,calories,distance_cm", Key("activity_extended", "timestamp", "epoch"), - { d, f, t -> d.activityExtended(f, t).first() }, + byTime(HealthDao::activityExtendedPage) { it.timestamp }, { listOf(iso(it.timestamp), it.timestamp, it.floors, it.run, it.fatBurn, it.stand, it.walk, it.sit, it.steps, it.calories, it.distanceCm) }, { ActivityExtendedMinute(it.long("epoch"), it.int("floors"), it.int("run"), it.int("fat_burn"), it.int("stand"), @@ -135,29 +199,40 @@ val CSV_TABLES: List> = listOf( { d, r -> d.insertSleepSessions(r) }), CsvTable("sleep_stage.csv", "session_start_epoch,start,end,stage", Key("sleep_stage", "sessionStart", "session_start_epoch", parent = "sleep_session"), - { d, f, t -> d.sleepSessionsOnce(f, t).flatMap { s -> d.sleepStagesOnce(s.start) } }, + { d, f, t -> d.sleepStagesOfSessionsEndingOnce(f, t) }, { listOf(it.sessionStart, iso(it.start), iso(it.end), CsvExporter.stageName(it.stage)) }, { SleepStage(it.instant("start"), it.instant("end"), it.long("session_start_epoch"), stageCode(it.string("stage"))) }, { d, r -> d.insertSleepStages(r) }), + // The user's own times for a sleep or nap, and the ones they deleted. The strap re-sends its sessions, so a restore + // without these would bring deleted ones back. The sessions above are the strap's, as synced. + CsvTable("sleep_edit.csv", "strap_start_epoch,nap,start_epoch,end_epoch,deleted,edited_epoch", + Key("sleep_edit", "strapStart", "strap_start_epoch"), + { d, f, t -> d.sleepEditsStartingOnce(f, t) }, + { listOf(it.strapStart, it.nap, it.start, it.end, it.deleted, it.editedAt) }, + { + SleepEdit(it.long("strap_start_epoch"), it.bool("nap"), it.longOrNull("start_epoch"), it.longOrNull("end_epoch"), + it.bool("deleted"), it.longOrNull("edited_epoch") ?: 0) + }, + { d, r -> d.insertSleepEdits(r) }), CsvTable("spo2.csv", "time,epoch,percent", Key("spo2", "timestamp", "epoch"), - { d, f, t -> d.spo2Once(f, t) }, { listOf(iso(it.timestamp), it.timestamp, it.percent) }, + byTime(HealthDao::spo2Page) { it.timestamp }, { listOf(iso(it.timestamp), it.timestamp, it.percent) }, { Spo2Sample(it.long("epoch"), it.int("percent")) }, { d, r -> d.insertSpo2(r) }), CsvTable("sleep_spo2.csv", "time,epoch,percent", Key("sleep_spo2", "timestamp", "epoch"), - { d, f, t -> d.sleepSpo2Once(f, t) }, { listOf(iso(it.timestamp), it.timestamp, it.percent) }, + byTime(HealthDao::sleepSpo2Page) { it.timestamp }, { listOf(iso(it.timestamp), it.timestamp, it.percent) }, { SleepSpo2Sample(it.long("epoch"), it.int("percent")) }, { d, r -> d.insertSleepSpo2(r) }), CsvTable("stress.csv", "time,epoch,value", Key("stress", "timestamp", "epoch"), - { d, f, t -> d.stressOnce(f, t) }, { listOf(iso(it.timestamp), it.timestamp, it.value) }, + byTime(HealthDao::stressPage) { it.timestamp }, { listOf(iso(it.timestamp), it.timestamp, it.value) }, { StressSample(it.long("epoch"), it.int("value")) }, { d, r -> d.insertStress(r) }), CsvTable("hrv.csv", "time,epoch,milliseconds", Key("hrv", "timestamp", "epoch"), - { d, f, t -> d.hrvOnce(f, t) }, { listOf(iso(it.timestamp), it.timestamp, it.milliseconds) }, + byTime(HealthDao::hrvPage) { it.timestamp }, { listOf(iso(it.timestamp), it.timestamp, it.milliseconds) }, { HrvSample(it.long("epoch"), it.int("milliseconds")) }, { d, r -> d.insertHrv(r) }), CsvTable("hrv_sdnn.csv", "time,epoch,milliseconds", Key("hrv_sdnn", "timestamp", "epoch"), - { d, f, t -> d.hrvSdnn(f, t).first() }, { listOf(iso(it.timestamp), it.timestamp, it.milliseconds) }, + byTime(HealthDao::hrvSdnnPage) { it.timestamp }, { listOf(iso(it.timestamp), it.timestamp, it.milliseconds) }, { HrvSdnnSample(it.long("epoch"), it.float("milliseconds")) }, { d, r -> d.insertHrvSdnn(r) }), CsvTable("resting_hr.csv", "day,epoch,bpm", Key("resting_hr", "day", "epoch"), @@ -178,7 +253,7 @@ val CSV_TABLES: List> = listOf( { d, r -> d.insertPai(r) }), CsvTable("workout.csv", "start,start_epoch,end_epoch,sport_type,exercise_seconds,calories,avg_hr,max_hr,min_hr,aerobic_te,anaerobic_te,training_load,vo2max,steps,subtype", Key("workout", "start", "start_epoch", span = Span("`end`", "end_epoch")), - { d, f, t -> d.workouts().first().filter { it.start in f..t }.sortedBy { it.start } }, + { d, f, t -> d.workoutsStartingOnce(f, t) }, { listOf(iso(it.start), it.start, it.end, it.sportType, it.exerciseSeconds, it.calories, it.averageHeartRate, it.maxHeartRate, it.minHeartRate, it.aerobicTrainingEffect, it.anaerobicTrainingEffect, it.trainingLoad, it.vo2max, it.steps, it.subtype) @@ -192,18 +267,20 @@ val CSV_TABLES: List> = listOf( { d, r -> d.insertWorkouts(r) }), CsvTable("workout_hr.csv", "workout_start_epoch,epoch,bpm", Key("workout_hr", "workoutStart", "workout_start_epoch", parent = "workout"), - { d, f, t -> d.workoutHeartRateAll().filter { it.workoutStart in f..t } }, { listOf(it.workoutStart, it.timestamp, it.bpm) }, + byWorkout(HealthDao::workoutHeartRatePage, { it.workoutStart }) { it.timestamp }, { listOf(it.workoutStart, it.timestamp, it.bpm) }, { WorkoutHeartRate(it.long("workout_start_epoch"), it.long("epoch"), it.int("bpm")) }, { d, r -> d.insertWorkoutHeartRate(r) }), // Workouts recorded on the phone; one still recording is left out until it ends. CsvTable("app_workout.csv", "start,start_epoch,end_epoch,sport_type,source,pauses,end_reason,avg_hr,max_hr,ask_to_end,recorded_end_epoch,shown_start_epoch,target," + - "use_gps,keep_route,distance_m,distance_from_steps,rest,sets_target,laps,epoc,aerobic_te,recovery_minutes,vo2max,active_kcal,total_kcal,target_reached_epoch", + "use_gps,keep_route,distance_m,distance_from_steps,rest,sets_target,laps,epoc,aerobic_te,recovery_minutes,vo2max,active_kcal," + + "total_kcal,target_reached_epoch,structure,stretches,anaerobic_te,effect_from_strap,own_recovery_minutes", Key("app_workout", "start", "start_epoch", span = Span("`end`", "end_epoch")), - { d, f, t -> d.appWorkoutsAll().filter { it.start in f..t } }, + { d, f, t -> d.appWorkoutsStartingOnce(f, t) }, { listOf(iso(it.start), it.start, it.end, it.sportType, it.source, it.pauses, it.endReason, it.averageHeartRate, it.maxHeartRate, it.askToEnd, it.recordedEnd, it.shownStart, it.target, it.useGps, it.keepRoute, it.distanceMeters, it.distanceFromSteps, - it.rest, it.setsTarget, it.laps, it.epoc, it.aerobicTrainingEffect, it.recoveryMinutes, it.vo2max, it.activeKcal, it.totalKcal, it.targetReachedAt) + it.rest, it.setsTarget, it.laps, it.epoc, it.aerobicTrainingEffect, it.recoveryMinutes, it.vo2max, it.activeKcal, it.totalKcal, it.targetReachedAt, it.structure, it.stretches, + it.anaerobicTrainingEffect, it.effectFromStrap, it.ownRecoveryMinutes) }, { AppWorkout(it.long("start_epoch"), it.long("end_epoch"), it.int("sport_type"), it.intOrNull("source") ?: AppWorkout.SOURCE_APP, @@ -216,18 +293,23 @@ val CSV_TABLES: List> = listOf( laps = if (it.has("laps")) it.string("laps") else "", epoc = it.floatOrNull("epoc"), aerobicTrainingEffect = it.floatOrNull("aerobic_te"), recoveryMinutes = it.intOrNull("recovery_minutes"), vo2max = it.floatOrNull("vo2max"), activeKcal = it.floatOrNull("active_kcal"), totalKcal = it.floatOrNull("total_kcal"), - targetReachedAt = it.longOrNull("target_reached_epoch")) + targetReachedAt = it.longOrNull("target_reached_epoch"), structure = if (it.has("structure")) it.string("structure") else null, + stretches = if (it.has("stretches")) it.string("stretches") else null, + anaerobicTrainingEffect = it.floatOrNull("anaerobic_te"), + effectFromStrap = it.has("effect_from_strap") && it.bool("effect_from_strap"), + ownRecoveryMinutes = it.intOrNull("own_recovery_minutes")) }, { d, r -> d.insertAppWorkouts(r) }), CsvTable("app_workout_hr.csv", "workout_start_epoch,epoch,bpm,from_history", Key("app_workout_hr", "workoutStart", "workout_start_epoch", parent = "app_workout"), - { d, f, t -> d.appWorkoutHeartRateAll().filter { it.workoutStart in f..t } }, { listOf(it.workoutStart, it.timestamp, it.bpm, it.fromHistory) }, + byWorkout(HealthDao::appWorkoutHeartRatePage, { it.workoutStart }) { it.timestamp }, + { listOf(it.workoutStart, it.timestamp, it.bpm, it.fromHistory) }, { AppWorkoutHeartRate(it.long("workout_start_epoch"), it.long("epoch"), it.int("bpm"), it.has("from_history") && it.bool("from_history")) }, { d, r -> d.insertAppWorkoutHeartRate(r) }), // Only routes the wearer chose to keep are ever stored, so only those are exported. CsvTable("app_workout_route.csv", "workout_start_epoch,epoch,lat,lon,accuracy_m,speed_ms,bearing_deg,altitude_m,speed_accuracy_ms,bearing_accuracy_deg", Key("app_workout_route", "workoutStart", "workout_start_epoch", parent = "app_workout"), - { d, f, t -> d.routeAll().filter { it.workoutStart in f..t } }, + byWorkout(HealthDao::routePage, { it.workoutStart }) { it.timestamp }, { listOf(it.workoutStart, it.timestamp, it.lat, it.lon, it.accuracy, it.speed, it.bearing, it.altitude, it.speedAccuracy, it.bearingAccuracy) }, { AppWorkoutRoutePoint(it.long("workout_start_epoch"), it.long("epoch"), it.string("lat").toDouble(), it.string("lon").toDouble(), it.float("accuracy_m"), @@ -235,26 +317,75 @@ val CSV_TABLES: List> = listOf( it.floatOrNull("speed_accuracy_ms"), it.floatOrNull("bearing_accuracy_deg")) }, { d, r -> d.insertRoutePoints(r) }), - CsvTable("app_workout_set.csv", "workout_start_epoch,set_index,start_epoch,end_epoch,rest_end_epoch", + CsvTable("app_workout_set.csv", "workout_start_epoch,set_index,start_epoch,end_epoch,rest_end_epoch,exercise,reps,weight_kg", Key("app_workout_set", "workoutStart", "workout_start_epoch", parent = "app_workout"), - { d, f, t -> d.setsAll().filter { it.workoutStart in f..t } }, { listOf(it.workoutStart, it.index, it.start, it.end, it.restEnd) }, - { AppWorkoutSet(it.long("workout_start_epoch"), it.int("set_index"), it.long("start_epoch"), it.longOrNull("end_epoch"), it.longOrNull("rest_end_epoch")) }, + { d, f, t -> d.setsOfWorkoutsOnce(f, t) }, + { listOf(it.workoutStart, it.index, it.start, it.end, it.restEnd, it.exercise, it.reps, it.weightKg) }, + { + AppWorkoutSet(it.long("workout_start_epoch"), it.int("set_index"), it.long("start_epoch"), it.longOrNull("end_epoch"), it.longOrNull("rest_end_epoch"), + if (it.has("exercise")) it.string("exercise") else null, it.intOrNull("reps"), it.floatOrNull("weight_kg")) + }, { d, r -> d.upsertSets(r) }), + CsvTable("app_workout_round.csv", "workout_start_epoch,round_index,work_start_epoch,work_end_epoch,rest_end_epoch,peak_bpm", + Key("app_workout_round", "workoutStart", "workout_start_epoch", parent = "app_workout"), + { d, f, t -> d.roundsOfWorkoutsOnce(f, t) }, + { listOf(it.workoutStart, it.index, it.workStart, it.workEnd, it.restEnd, it.peakBpm) }, + { + AppWorkoutRound(it.long("workout_start_epoch"), it.int("round_index"), it.long("work_start_epoch"), it.longOrNull("work_end_epoch"), + it.longOrNull("rest_end_epoch"), it.intOrNull("peak_bpm")) + }, + { d, r -> d.upsertRounds(r) }), + // What the user named and saved, which no history can bring back. + CsvTable("saved_workout.csv", "created_epoch,name,sport_type,structure,goal", + Key("saved_workout", "createdAt", "created_epoch"), + { d, f, t -> d.savedWorkoutsCreatedOnce(f, t) }, + { listOf(it.createdAt, it.name, it.sport, it.structure, it.goal) }, + { + SavedWorkout(name = it.string("name"), sport = it.int("sport_type"), structure = it.string("structure"), + goal = if (it.has("goal")) it.string("goal") else null, createdAt = it.long("created_epoch")) + }, + { d, r -> d.insertSavedWorkouts(r) }), + CsvTable("exercise.csv", "used_epoch,name,last_reps,last_kg", + Key("exercise", "usedAt", "used_epoch"), + { d, f, t -> d.exercisesUsedOnce(f, t) }, + { listOf(it.usedAt, it.name, it.lastReps, it.lastKg) }, + { ExerciseRow(it.string("name"), it.intOrNull("last_reps"), it.floatOrNull("last_kg"), it.long("used_epoch")) }, + { d, r -> d.insertExercises(r) }), + // Badges are never taken back and each past week keeps the goal it had (Rewards): neither can be worked out again. + CsvTable("badge.csv", "earned_epoch,id,workout_start_epoch,day", + Key("badge", "earnedAt", "earned_epoch"), + { d, f, t -> d.badgesEarnedOnce(f, t) }, + { listOf(it.earnedAt, it.id, it.workoutStart, it.day) }, + { BadgeRow(it.string("id"), it.long("earned_epoch"), it.longOrNull("workout_start_epoch"), it.longOrNull("day")) }, + { d, r -> d.insertBadges(r) }), + CsvTable("week_result.csv", "week_start_day,goal,count,met", + Key("week_result", "weekStart", "week_start_day"), + { d, f, t -> d.weekResultsStartingOnce(f, t) }, + { listOf(it.weekStart, it.goal, it.count, it.met) }, + { WeekResult(it.long("week_start_day"), it.int("goal"), it.int("count"), it.bool("met")) }, + { d, r -> d.upsertWeekResults(r) }), + // Bests by kind. A rewards refresh works them out again from the workouts above, so these mostly show what a backup had. + CsvTable("personal_best.csv", "kind,value,date,workout_start_epoch", + Key("personal_best", "date", "date", unique = Unique("kind", "kind")), + { d, f, t -> d.personalBestsSetOnce(f, t) }, + { listOf(it.kind, it.value, it.date, it.workoutStart) }, + { PersonalBest(it.string("kind"), it.string("value").toDouble(), it.long("date"), it.longOrNull("workout_start_epoch")) }, + { d, r -> d.insertPersonalBests(r) }), CsvTable("app_workout_dismissed.csv", "start_epoch,end_epoch", Key("app_workout_dismissed", "start", "start_epoch"), - { d, f, t -> d.dismissedWorkoutsAll().filter { it.start in f..t } }, { listOf(it.start, it.end) }, + { d, f, t -> d.dismissedWorkoutsStartingOnce(f, t) }, { listOf(it.start, it.end) }, { DismissedWorkout(it.long("start_epoch"), it.long("end_epoch")) }, { d, r -> d.insertDismissedWorkouts(r) }), // Strap workouts deleted in OpenStrap. The strap still holds them and re-sends them, so a restore without these // would bring them back. A restore also leaves out the workout rows and sport effects these name. CsvTable("workout_deleted.csv", "start_epoch,effect_end_epoch,deleted_epoch", Key("workout_deleted", "start", "start_epoch"), - { d, f, t -> d.deletedWorkoutsAll().filter { it.start in f..t } }, { listOf(it.start, it.effectEnd, it.deletedAt) }, + { d, f, t -> d.deletedWorkoutsStartingOnce(f, t) }, { listOf(it.start, it.effectEnd, it.deletedAt) }, { DeletedWorkout(it.long("start_epoch"), it.longOrNull("effect_end_epoch"), it.longOrNull("deleted_epoch") ?: it.long("start_epoch")) }, { d, r -> d.insertDeletedWorkouts(r) }), CsvTable("sport_effect.csv", "end_epoch,recovery_minutes,vo2max,epoc,weekly_load,monthly_load,optimal_load_min,optimal_load_max,max_hr", Key("sport_effect", "`end`", "end_epoch"), - { d, f, t -> d.sportEffects().first().filter { it.end in f..t } }, + { d, f, t -> d.sportEffectsOnce(f, t) }, { listOf(it.end, it.recoveryMinutes, it.vo2max, it.epoc, it.weeklyLoad, it.monthlyLoad, it.optimalLoadMin, it.optimalLoadMax, it.maximalHeartRate) @@ -267,16 +398,16 @@ val CSV_TABLES: List> = listOf( { d, r -> d.insertSportEffects(r) }), CsvTable("recovery_hr.csv", "workout_start_epoch,samples_hex", Key("recovery_hr", "workoutStart", "workout_start_epoch", parent = "workout"), - { d, f, t -> d.recoveryHeartRateAll().filter { it.workoutStart in f..t } }, { listOf(it.workoutStart, hex(it.samples)) }, + { d, f, t -> d.recoveryHeartRateOfWorkoutsOnce(f, t) }, { listOf(it.workoutStart, hex(it.samples)) }, { RecoveryHeartRateRow(it.long("workout_start_epoch"), it.bytes("samples_hex") ?: ByteArray(0)) }, { d, r -> d.insertRecoveryHeartRate(r) }), CsvTable("daily_max_hr.csv", "day,epoch,bpm", Key("daily_max_hr", "day", "epoch"), - { d, f, t -> d.dailyMaxHr(f, t).first() }, { listOf(iso(it.day), it.day, it.bpm) }, + { d, f, t -> d.dailyMaxHrOnce(f, t) }, { listOf(iso(it.day), it.day, it.bpm) }, { DailyMaxHr(it.long("epoch"), it.int("bpm")) }, { d, r -> d.insertDailyMaxHr(r) }), CsvTable("strap_readiness.csv", "day_epoch,updated_epoch,score,hrv_score,resting_hr_score,skin_temperature_score,breathing_score,sleep_hrv,sleep_resting_hr,algorithm_version", Key("strap_readiness", "day", "day_epoch"), - { d, f, t -> d.strapReadinessAll().filter { it.day in f..t } }, + { d, f, t -> d.strapReadinessOnce(f, t) }, { listOf(it.day, it.updatedAt, it.score, it.hrvScore, it.restingHrScore, it.skinTemperatureScore, it.breathingScore, it.sleepHrv, it.sleepRestingHr, it.algorithmVersion) @@ -298,12 +429,12 @@ val CSV_TABLES: List> = listOf( { d, r -> d.insertOsaResults(r) }), CsvTable("osa_event.csv", "time,epoch,drop,spo2_hex,heart_rate_hex", Key("osa_event", "timestamp", "epoch"), - { d, f, t -> d.osaEvents(f, t).first() }, { listOf(iso(it.timestamp), it.timestamp, it.drop, hex(it.spo2), hex(it.heartRate)) }, + { d, f, t -> d.osaEventsOnce(f, t) }, { listOf(iso(it.timestamp), it.timestamp, it.drop, hex(it.spo2), hex(it.heartRate)) }, { OsaEventRow(it.long("epoch"), it.int("drop"), it.bytes("spo2_hex"), it.bytes("heart_rate_hex")) }, { d, r -> d.insertOsaEvents(r) }), CsvTable("health_alert.csv", "time,epoch,type,value", Key("health_alert", "timestamp", "epoch"), - { d, f, t -> d.healthAlerts(f, t).first() }, { listOf(iso(it.timestamp), it.timestamp, it.type, it.value) }, + { d, f, t -> d.healthAlertsOnce(f, t) }, { listOf(iso(it.timestamp), it.timestamp, it.type, it.value) }, { HealthAlertRow(it.long("epoch"), it.int("type"), it.long("value")) }, { d, r -> d.insertHealthAlerts(r) }), // The strap's on/off-body changes; worn is blank where the link went down and the state became unknown. CsvTable("wear_event.csv", "time,epoch,worn", @@ -333,6 +464,12 @@ val CSV_TABLES: List> = listOf( it.intOrNull("strap_hrv"), it.intOrNull("strap_resting_hr")) }, { d, r -> d.insertNightMetrics(r) }), + // Each Sunday's Habit Age as it was shown: only the current week is ever worked out, so a past one can't come back. + CsvTable("habit_age_week.csv", "as_of,as_of_epoch,estimate,low,high,updated_epoch", + Key("habit_age_week", "asOf", "as_of_epoch"), + { d, f, t -> d.habitAgeWeeksOnce(f, t) }, { listOf(iso(it.asOf), it.asOf, it.estimate, it.low, it.high, it.updatedAt) }, + { HabitAgeWeekRow(it.long("as_of_epoch"), it.float("estimate"), it.int("low"), it.int("high"), it.long("updated_epoch")) }, + { d, r -> r.forEach { row -> d.insertHabitAgeWeek(row) } }), // Worked out from the data above, but kept so a later change of model doesn't rewrite the history that was shown. CsvTable("daily_energy.csv", "day,day_epoch,updated_epoch,total_kcal,active_kcal,bmr_kcal,minutes_with_data,partial,workout_kcal,walking_kcal,hourly_active_kcal", Key("daily_energy", "day", "day_epoch"), diff --git a/app/src/main/kotlin/org/openstrap/app/export/SettingsBackup.kt b/app/src/main/kotlin/org/openstrap/app/export/SettingsBackup.kt index 32e0c228..77edd631 100644 --- a/app/src/main/kotlin/org/openstrap/app/export/SettingsBackup.kt +++ b/app/src/main/kotlin/org/openstrap/app/export/SettingsBackup.kt @@ -1,13 +1,16 @@ package org.openstrap.app.export import org.json.JSONObject +import org.openstrap.app.data.settings.SettingsKeys /** * The settings a backup carries, as settings.json beside the CSV files: what the user chose about themselves and the * app. Left out on purpose: the strap's address and auth key, the Zepp sign-in, the companion association, the strap's - * battery and sync cursors (a restored cursor would skip history), call and notification buzzing (each needs a - * permission granted on this phone), the experimental strap sessions, and the backup folder itself (its permission - * belongs to one phone). Keys are the DataStore names in [org.openstrap.app.data.settings.SettingsStore]. + * battery and sync cursors (a restored cursor would skip history), call and notification buzzing and Health Connect + * auto sync (each needs a permission granted on this phone), the experimental strap sessions, the backup folder itself + * (its permission belongs to one phone), and what the app keeps for itself, such as the weeks a recap or goal moment + * last showed and the update checks. Keys are [SettingsKeys], as [org.openstrap.app.data.settings.SettingsStore] + * stores them. */ object SettingsBackup { const val FILE = "settings.json" @@ -15,44 +18,62 @@ object SettingsBackup { enum class Type { INT, LONG, FLOAT, BOOLEAN, STRING } - val KEYS: Map = linkedMapOf( - "profile_birth_year" to Type.INT, - "profile_sex" to Type.STRING, - "profile_weight_kg" to Type.FLOAT, - "profile_height_cm" to Type.INT, - "profile_imperial" to Type.BOOLEAN, - "profile_sleep_need_minutes" to Type.INT, - "profile_max_hr" to Type.INT, - "profile_activity_level" to Type.INT, - "profile_activity_answered_at" to Type.LONG, - "profile_body_measured_at" to Type.LONG, - "profile_beta_blocker" to Type.BOOLEAN, - "profile_pregnant" to Type.BOOLEAN, - "goal_steps" to Type.INT, - "goal_sleep_minutes" to Type.INT, - "goal_active_hours" to Type.INT, - "today_hidden" to Type.STRING, - "today_pinned" to Type.STRING, - "first_score_day" to Type.LONG, - "live_hr" to Type.BOOLEAN, - "auto_start_workouts" to Type.BOOLEAN, - "wear_reminder" to Type.BOOLEAN, - "workout_live_view" to Type.INT, - "workout_sides" to Type.STRING, - "buzz_pattern" to Type.STRING, - "call_buzz" to Type.STRING, - "update_auto" to Type.BOOLEAN, - "quiet_hours" to Type.BOOLEAN, - "quiet_from_minutes" to Type.INT, - "quiet_to_minutes" to Type.INT, - "quiet_calls" to Type.BOOLEAN, - ) + val KEYS: Map = with(SettingsKeys) { + linkedMapOf( + PROFILE_BIRTH_YEAR to Type.INT, + PROFILE_SEX to Type.STRING, + PROFILE_WEIGHT_KG to Type.FLOAT, + PROFILE_HEIGHT_CM to Type.INT, + PROFILE_IMPERIAL to Type.BOOLEAN, + PROFILE_SLEEP_NEED_MINUTES to Type.INT, + PROFILE_MAX_HR to Type.INT, + PROFILE_HR_ZONES to Type.STRING, + PROFILE_ACTIVITY_LEVEL to Type.INT, + PROFILE_ACTIVITY_ANSWERED_AT to Type.LONG, + PROFILE_BODY_MEASURED_AT to Type.LONG, + PROFILE_BETA_BLOCKER to Type.BOOLEAN, + PROFILE_PREGNANT to Type.BOOLEAN, + GOAL_STEPS to Type.INT, + GOAL_SLEEP_MINUTES to Type.INT, + GOAL_ACTIVE_HOURS to Type.INT, + GOAL_CALORIES to Type.INT, + GOAL_WORKOUTS_WEEK to Type.INT, + TODAY_HIDDEN to Type.STRING, + TODAY_PINNED to Type.STRING, + CELEBRATE_GOAL to Type.BOOLEAN, + WEEKLY_RECAP to Type.BOOLEAN, + FIRST_SCORE_DAY to Type.LONG, + LIVE_HR to Type.BOOLEAN, + AUTO_START_WORKOUTS to Type.BOOLEAN, + WEAR_REMINDER to Type.BOOLEAN, + WORKOUT_LIVE_VIEW to Type.INT, + WORKOUT_SIDES to Type.STRING, + BUZZ_PATTERN to Type.STRING, + CALL_BUZZ to Type.STRING, + UPDATE_AUTO to Type.BOOLEAN, + QUIET_HOURS to Type.BOOLEAN, + QUIET_FROM_MINUTES to Type.INT, + QUIET_TO_MINUTES to Type.INT, + QUIET_CALLS to Type.BOOLEAN, + ) + } + + /** Keys made of a prefix and a sport's id, one per sport: what Ready was last set to for it. */ + private val prefixes: Map = mapOf(SettingsKeys.SPORT_SETTINGS_PREFIX to Type.STRING) + + /** The type [key] is backed up as, or null when it isn't backed up. */ + fun typeOf(key: String): Type? = KEYS[key] ?: prefixes.entries.firstOrNull { (prefix, _) -> + key.startsWith(prefix) && key.removePrefix(prefix).toIntOrNull() != null + }?.value + + /** Whether a backup carries [key]. */ + fun carries(key: String): Boolean = typeOf(key) != null /** The backed-up keys of [values] as JSON; anything else in it is left out. */ fun toJson(values: Map): String { val settings = JSONObject() - for ((key, type) in KEYS) { - val v = values[key] ?: continue + for ((key, v) in values) { + val type = typeOf(key) ?: continue if (convert(v, type) != null) settings.put(key, v) } return JSONObject().put("version", VERSION).put("settings", settings).toString(2) @@ -65,8 +86,8 @@ object SettingsBackup { fun fromJson(json: String): Map { val settings = JSONObject(json).optJSONObject("settings") ?: return emptyMap() return buildMap { - for ((key, type) in KEYS) { - if (!settings.has(key)) continue + for (key in settings.keys()) { + val type = typeOf(key) ?: continue convert(settings.get(key), type)?.let { put(key, it) } } } diff --git a/app/src/main/kotlin/org/openstrap/app/health/AutoHealthConnect.kt b/app/src/main/kotlin/org/openstrap/app/health/AutoHealthConnect.kt index bd707ad2..a81ab209 100644 --- a/app/src/main/kotlin/org/openstrap/app/health/AutoHealthConnect.kt +++ b/app/src/main/kotlin/org/openstrap/app/health/AutoHealthConnect.kt @@ -8,11 +8,10 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.openstrap.app.OpenStrapApp +import org.openstrap.app.data.catching import org.openstrap.app.data.settings.HealthConnectAuto import org.openstrap.app.data.settings.SettingsStore import org.openstrap.app.device.StrapController @@ -57,6 +56,16 @@ object AutoHealthConnect { fun workoutRange(from: Long, end: Long, now: Long): Pair = from / 3600 * 3600 to minOf(maxOf(now, end), (end / 3600 + 1) * 3600 - 1) + /** + * The range written after a sleep the strap started at [strapStart] was edited: one that reaches every time it + * can be shown at ([org.openstrap.app.data.SleepEdits.MAX_SHIFT_SECONDS] either side of a session up to 16 h), not + * past [now]. The export's own sleep delete reaches a day further back. + */ + fun sleepRange(strapStart: Long, now: Long): Pair { + val shift = org.openstrap.app.data.SleepEdits.MAX_SHIFT_SECONDS + return strapStart - shift to minOf(now, strapStart + org.openstrap.app.data.SleepEdits.SLEEP_MAX_SECONDS + shift) + } + /** Why auto sync has to stop: Health Connect not [available], or the write permissions not [granted]. Null when fine. */ fun stopReason(available: Boolean, granted: Boolean): HealthConnectAuto.Stopped? = when { !available -> HealthConnectAuto.Stopped.UNAVAILABLE @@ -76,29 +85,25 @@ class HealthConnectAutoSync( private val settings: SettingsStore, private val exporter: HealthConnectExporter, strap: StrapController, + /** The app's scope ([org.openstrap.app.AppContainer.appScope]): the writes outlive any screen. */ + private val scope: CoroutineScope, ) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - init { scope.launch { strap.syncEnded.collect { syncEnded() } } } /** A strap sync ended: queues a write of the recent days, unless one is queued already. */ private suspend fun syncEnded() { - try { + catching { val auto = settings.healthConnectAuto.first() - if (!auto.on) return + if (!auto.on) return@catching val delay = AutoHealthConnect.delayMs(System.currentTimeMillis(), auto.lastSynced?.times(1000)) // KEEP: a write already waiting reads the database when it runs, so it covers this sync too. WorkManager.getInstance(context).enqueueUniqueWork( WORK, ExistingWorkPolicy.KEEP, OneTimeWorkRequestBuilder().setInitialDelay(delay, TimeUnit.MILLISECONDS).build(), ) - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - Log.w(TAG, "health connect: auto sync not queued: $e") - } + }.onFailure { Log.w(TAG, "health connect: auto sync not queued: $it") } } /** A phone workout was saved (ended, added from history, trimmed): writes it now. */ @@ -111,6 +116,20 @@ class HealthConnectAutoSync( } } + /** + * A sleep or nap the strap started at [strapStart] was moved, deleted or brought back: writes its night again now + * ([AutoHealthConnect.sleepRange]), not on the next sync, so Health Connect moves or drops it too. + */ + fun sleepEdited(strapStart: Long) { + scope.launch { + guarded("sleep $strapStart") { + val (from, to) = AutoHealthConnect.sleepRange(strapStart, System.currentTimeMillis() / 1000) + val n = exporter.exportSleep(from, to) + Log.i(TAG, "health connect: wrote the sleep at $strapStart again, $n records") + } + } + } + /** Writes the recent days ([AutoHealthConnect.range]); from [HealthConnectWorker]. */ suspend fun syncRecent() { guarded("recent days") { @@ -137,8 +156,10 @@ class HealthConnectAutoSync( */ suspend fun check(): Boolean { if (!settings.healthConnectAuto.first().on) return false - val available = runCatching { exporter.isAvailable() }.getOrDefault(false) - val granted = available && runCatching { exporter.hasPermissions() }.getOrDefault(false) + val available = catching { exporter.isAvailable() } + .onFailure { Log.w(TAG, "health connect: availability not read: $it") }.getOrDefault(false) + val granted = available && catching { exporter.hasPermissions() } + .onFailure { Log.w(TAG, "health connect: permissions not read: $it") }.getOrDefault(false) val why = AutoHealthConnect.stopReason(available, granted) ?: return true settings.stopHealthConnectAuto(why) Log.i(TAG, "health connect: auto sync stopped, $why") @@ -147,16 +168,16 @@ class HealthConnectAutoSync( /** Runs [write] while auto sync is on and still can; a permission taken back mid-write stops it too. Never throws. */ private suspend fun guarded(what: String, write: suspend () -> Unit) { - try { - if (!check()) return - write() - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: SecurityException) { - runCatching { settings.stopHealthConnectAuto(HealthConnectAuto.Stopped.PERMISSIONS) } - Log.w(TAG, "health connect: auto sync stopped writing $what: $e") - } catch (e: Exception) { - Log.w(TAG, "health connect: auto sync of $what failed: $e") + catching { + if (check()) write() + }.onFailure { e -> + if (e is SecurityException) { + catching { settings.stopHealthConnectAuto(HealthConnectAuto.Stopped.PERMISSIONS) } + .onFailure { Log.w(TAG, "health connect: auto sync not turned off: $it") } + Log.w(TAG, "health connect: auto sync stopped writing $what: $e") + } else { + Log.w(TAG, "health connect: auto sync of $what failed: $e") + } } } diff --git a/app/src/main/kotlin/org/openstrap/app/health/CalorieRecords.kt b/app/src/main/kotlin/org/openstrap/app/health/CalorieRecords.kt index 12d18859..3e828620 100644 --- a/app/src/main/kotlin/org/openstrap/app/health/CalorieRecords.kt +++ b/app/src/main/kotlin/org/openstrap/app/health/CalorieRecords.kt @@ -1,5 +1,6 @@ package org.openstrap.app.health +import org.openstrap.app.data.Days import org.openstrap.app.data.db.DailyEnergyRow import java.time.Instant import java.time.ZoneId @@ -38,10 +39,10 @@ object CalorieRecords { } /** Local midnight of the day [epoch] falls in, in [zone]. */ - fun dayStart(epoch: Long, zone: ZoneId): Long = Instant.ofEpochSecond(epoch).atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() + fun dayStart(epoch: Long, zone: ZoneId): Long = Days.start(epoch, zone) /** The next local midnight after [day] (itself one), 23 to 25 hours on. */ - fun dayEnd(day: Long, zone: ZoneId): Long = Instant.ofEpochSecond(day).atZone(zone).toLocalDate().plusDays(1).atStartOfDay(zone).toEpochSecond() + fun dayEnd(day: Long, zone: ZoneId): Long = Days.end(day, zone) /** The span of whole local days [from] to [to] are in, for the stored days to write and the old records to clear. */ fun days(from: Long, to: Long, zone: ZoneId): Pair = dayStart(from, zone) to dayEnd(dayStart(maxOf(from, to), zone), zone) diff --git a/app/src/main/kotlin/org/openstrap/app/health/HealthConnectExporter.kt b/app/src/main/kotlin/org/openstrap/app/health/HealthConnectExporter.kt index acf3bde8..7b65ef8d 100644 --- a/app/src/main/kotlin/org/openstrap/app/health/HealthConnectExporter.kt +++ b/app/src/main/kotlin/org/openstrap/app/health/HealthConnectExporter.kt @@ -5,6 +5,8 @@ import androidx.health.connect.client.HealthConnectClient import androidx.health.connect.client.HealthConnectFeatures import androidx.health.connect.client.records.metadata.DataOrigin import androidx.health.connect.client.request.ReadRecordsRequest +import org.openstrap.app.data.Days +import org.openstrap.app.data.HeartRate import org.openstrap.app.data.db.ActivityMinute import org.openstrap.app.data.db.HeartRateSample import org.openstrap.app.data.db.HrvSample @@ -40,6 +42,36 @@ import java.time.ZoneOffset * wrote (Health Connect keeps it after an uninstall), so it restores OpenStrap's own data and nothing else's. */ class HealthConnectExporter(private val context: Context, private val dao: HealthDao) { + /** Health Connect's writes and OpenStrap's own records in it, for [RecordStore.write]. */ + private val store = object : RecordStore { + override suspend fun insert(records: List) { + client.insertRecords(records) + } + + override suspend fun ownRecords(type: KClass, range: TimeRangeFilter): List? = try { + readAll(type, range) + } catch (_: SecurityException) { + // Without a read grant Health Connect may refuse even this app's own records. + null + } + + override suspend fun deleteIds(type: KClass, ids: List) = client.deleteRecords(type, ids, emptyList()) + + override suspend fun deleteRange(type: KClass, range: TimeRangeFilter) = client.deleteRecords(type, range) + } + + /** Every record of [type] in [range] that OpenStrap wrote, a page at a time. */ + private suspend fun readAll(type: KClass, range: TimeRangeFilter): List { + val out = mutableListOf() + var page: String? = null + do { + val request = ReadRecordsRequest(type, range, setOf(DataOrigin(context.packageName)), pageSize = 1000, pageToken = page) + val response = client.readRecords(request) + out += response.records + page = response.pageToken + } while (page != null) + return out + } val permissions = setOf( HealthPermission.getWritePermission(HeartRateRecord::class), @@ -128,38 +160,10 @@ class HealthConnectExporter(private val context: Context, private val dao: Healt records += heartRate(from, to) records += steps(from, to) records += workouts(dao.appWorkoutsOnce(from, to)) - records += calories(from, to, now) + val (kcal, kcalRanges) = calories(from, to, now) + records += kcal - // Every night the delete below can reach (from a day before [from]) is written again, so a night ending - // just before the range isn't taken out of Health Connect and left out. - val napEdits = dao.napEditsOnce().associateBy { it.strapStart } - for (strapSession in dao.sleepSessionsOnce(from - 86_400, to)) { - // A nap goes with the user's own times; its record id stays the strap's start, so it replaces the old one. - val s = org.openstrap.app.data.NapTimes.apply(strapSession, napEdits) - // Rows synced before stage ends were made exclusive can be zero length; Health Connect rejects those, - // and stages must stay inside the session. - val stages = dao.sleepStagesOnce(strapSession.start).mapNotNull { - val start = it.start.coerceAtLeast(s.start) - val end = maxOf(it.end, it.start + 60).coerceAtMost(s.end) - if (end <= start) return@mapNotNull null - SleepSessionRecord.Stage( - Instant.ofEpochSecond(start), Instant.ofEpochSecond(end), - when (it.stage) { - SleepStage.AWAKE -> SleepSessionRecord.STAGE_TYPE_AWAKE - SleepStage.LIGHT -> SleepSessionRecord.STAGE_TYPE_LIGHT - SleepStage.DEEP -> SleepSessionRecord.STAGE_TYPE_DEEP - SleepStage.REM -> SleepSessionRecord.STAGE_TYPE_REM - else -> SleepSessionRecord.STAGE_TYPE_UNKNOWN - }, - ) - } - records += SleepSessionRecord( - startTime = Instant.ofEpochSecond(s.start), startZoneOffset = offset(s.start), - endTime = Instant.ofEpochSecond(s.end), endZoneOffset = offset(s.end), - stages = stages, - metadata = meta("sleep:${strapSession.start}"), - ) - } + records += sleep(from, to, now) for (o in dao.spo2Once(from, to)) { if (o.percent !in 70..100) continue @@ -187,14 +191,68 @@ class HealthConnectExporter(private val context: Context, private val dao: Healt ) } - // Sleep that OpenStrap exported earlier may since have changed shape (a nap folded into the night), - // so its old records in this range are replaced rather than updated. This only touches OpenStrap's records. - client.deleteRecords(SleepSessionRecord::class, TimeRangeFilter.between(Instant.ofEpochSecond(from - 86_400), Instant.ofEpochSecond(to))) - // Health Connect caps the batch size per call. - records.chunked(500).forEach { client.insertRecords(it) } + store.write(records, kcalRanges + (SleepSessionRecord::class to sleepRange(from, to))) + return records.size + } + + /** + * Writes only the sleep in [from, to] again, as [export] does: for a sleep the user moved, deleted or brought back, + * which changes nothing else. Returns the number of records written. + */ + suspend fun exportSleep(from: Long, to: Long, now: Long = System.currentTimeMillis() / 1000): Int { + val records = sleep(from, to, now) + store.write(records, listOf(SleepSessionRecord::class to sleepRange(from, to))) return records.size } + /** + * Sleep that OpenStrap exported earlier may since have changed shape (a nap folded into the night), so its old + * records in this range are replaced rather than updated. This only touches OpenStrap's records. + */ + private fun sleepRange(from: Long, to: Long) = TimeRangeFilter.between(Instant.ofEpochSecond(from - 86_400), Instant.ofEpochSecond(to)) + + /** + * The sleep sessions [sleepRange] reaches, as shown. Each carries [now] as its version, so it replaces the record + * with its id that an earlier export wrote. + */ + private suspend fun sleep(from: Long, to: Long, now: Long): List { + val records = mutableListOf() + // Every night [sleepRange] reaches (from a day before [from]) is written again, so a night ending + // just before the range isn't taken out of Health Connect and left out. + val sleep = org.openstrap.app.data.SleepSource(dao) + for (r in sleep.resolvedOnce(from - 86_400, to)) { + // A sleep goes with the user's own times; its record id stays the strap's start, so it replaces the old one. + // A deleted one isn't written, so the delete below takes it out of Health Connect. + val strapSession = r.strap + val s = r.shown ?: continue + // Rows synced before stage ends were made exclusive can be zero length; Health Connect rejects those, + // and stages must stay inside the session. + val stages = sleep.stagesOnce(r).mapNotNull { + val start = it.start.coerceAtLeast(s.start) + val end = maxOf(it.end, it.start + 60).coerceAtMost(s.end) + if (end <= start) return@mapNotNull null + SleepSessionRecord.Stage( + Instant.ofEpochSecond(start), Instant.ofEpochSecond(end), + when (it.stage) { + SleepStage.AWAKE -> SleepSessionRecord.STAGE_TYPE_AWAKE + SleepStage.LIGHT -> SleepSessionRecord.STAGE_TYPE_LIGHT + SleepStage.DEEP -> SleepSessionRecord.STAGE_TYPE_DEEP + SleepStage.REM -> SleepSessionRecord.STAGE_TYPE_REM + else -> SleepSessionRecord.STAGE_TYPE_UNKNOWN + }, + ) + } + records += SleepSessionRecord( + startTime = Instant.ofEpochSecond(s.start), startZoneOffset = offset(s.start), + endTime = Instant.ofEpochSecond(s.end), endZoneOffset = offset(s.end), + stages = stages, + metadata = meta("sleep:${strapSession.start}", now), + ) + } + + return records + } + /** * Exports the phone workout that started at [start], as it's saved: the session, its distance, and the heart rate * and steps over it ([AutoHealthConnect.workoutRange], whole hours of heart rate), and the calories of the days it @@ -205,28 +263,31 @@ class HealthConnectExporter(private val context: Context, private val dao: Healt val w = dao.appWorkoutOnce(start) ?: return 0 val end = w.end ?: return 0 val (from, to) = AutoHealthConnect.workoutRange(w.from, end, now) - val records = heartRate(from, to) + steps(from, to) + workouts(listOf(w)) + calories(w.from, end, now) - records.chunked(500).forEach { client.insertRecords(it) } + val (kcal, kcalRanges) = calories(w.from, end, now) + val records = heartRate(from, to) + steps(from, to) + workouts(listOf(w)) + kcal + store.write(records, kcalRanges) return records.size } /** * Active kcal an hour and each finished full day's total ([CalorieRecords]) over the whole local days [from] to * [to] are in, from the stored days, each only with its permission granted. A day worked out again can lose an - * hour or turn partial, so OpenStrap's old calorie records over those days go first, as its sleep does. + * hour or turn partial, so OpenStrap's calorie records over those days are replaced, as its sleep is: with the + * types and range to replace them in. */ - private suspend fun calories(from: Long, to: Long, now: Long): List { + private suspend fun calories(from: Long, to: Long, now: Long): Pair, List, TimeRangeFilter>>> { val granted = client.permissionController.getGrantedPermissions() val active = HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class) in granted val total = HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class) in granted - if (!active && !total) return emptyList() + if (!active && !total) return emptyList() to emptyList() val zone = ZoneId.systemDefault() val (a, b) = CalorieRecords.days(from, to, zone) val rows = dao.dailyEnergyOnce(a, b - 1) val range = TimeRangeFilter.between(Instant.ofEpochSecond(a), Instant.ofEpochSecond(minOf(b, now))) val records = mutableListOf() + val replaced = mutableListOf, TimeRangeFilter>>() if (active) { - client.deleteRecords(ActiveCaloriesBurnedRecord::class, range) + replaced += ActiveCaloriesBurnedRecord::class to range records += CalorieRecords.activeHours(rows, zone, now).map { ActiveCaloriesBurnedRecord( startTime = Instant.ofEpochSecond(it.start), startZoneOffset = offset(it.start), @@ -236,7 +297,7 @@ class HealthConnectExporter(private val context: Context, private val dao: Healt } } if (total) { - client.deleteRecords(TotalCaloriesBurnedRecord::class, range) + replaced += TotalCaloriesBurnedRecord::class to range records += CalorieRecords.dayTotals(rows, zone, now).map { TotalCaloriesBurnedRecord( startTime = Instant.ofEpochSecond(it.start), startZoneOffset = offset(it.start), @@ -245,13 +306,13 @@ class HealthConnectExporter(private val context: Context, private val dao: Healt ) } } - return records + return records to replaced } /** Heart rate: one record per hour with all samples in it. */ private suspend fun heartRate(from: Long, to: Long): List { val hr = dao.heartRateOnce(from, to) + dao.activityOnce(from, to) - .mapNotNull { m -> m.heartRate?.takeIf { it in 30..230 }?.let { org.openstrap.app.data.db.HeartRateSample(m.timestamp, it) } } + .mapNotNull { m -> m.heartRate?.takeIf { it in HeartRate.VALID }?.let { HeartRateSample(m.timestamp, it) } } return hr.distinctBy { it.timestamp }.sortedBy { it.timestamp }.groupBy { it.timestamp / 3600 }.map { (hour, samples) -> val start = hour * 3600 val end = samples.last().timestamp + 1 // not the end of the hour, which can be in the future @@ -299,30 +360,38 @@ class HealthConnectExporter(private val context: Context, private val dao: Healt /** * Copies what OpenStrap wrote to Health Connect back into the database, filling gaps only: a day the strap has * already synced keeps its own, richer rows. Returns the number of rows read. Without the history permission, - * Health Connect only returns the last 30 days. + * Health Connect only returns the last 30 days. Read a year at a time, so ten years are never in memory at once. */ - suspend fun import(): Int { + suspend fun import(now: Instant = Instant.now()): Int { val granted = client.permissionController.getGrantedPermissions() - val range = TimeRangeFilter.after(Instant.now().minus(Duration.ofDays(10 * 365))) - val ours = setOf(DataOrigin(context.packageName)) - suspend fun read(type: KClass): List { - if (HealthPermission.getReadPermission(type) !in granted) return emptyList() - val out = mutableListOf() - var page: String? = null - do { - val response = client.readRecords(ReadRecordsRequest(type, range, ours, pageSize = 1000, pageToken = page)) - out += response.records - page = response.pageToken - } while (page != null) - return out + suspend fun read(type: KClass, range: TimeRangeFilter): List = + if (HealthPermission.getReadPermission(type) !in granted) emptyList() else readAll(type, range) + var n = 0 + val start = now.minus(Duration.ofDays(IMPORT_YEARS * 365L)) + for (year in 0 until IMPORT_YEARS) { + // Each year reaches a day into the one before, so a record across the boundary (a night) is whole in one of + // them whichever way Health Connect matches a span. Read twice, it is only kept once. + val from = start.plus(Duration.ofDays(year * 365L)) + val reach = if (year == 0) from else from.minus(Duration.ofDays(1)) + val range = if (year == IMPORT_YEARS - 1) TimeRangeFilter.after(reach) + else TimeRangeFilter.between(reach, from.plus(Duration.ofDays(365))) + n += importRange { type -> read(type, range) } } + return n + } + + /** [import] over one range, reading each kind of record in it with [read]. */ + private suspend fun importRange(read: suspend (KClass) -> List): Int { var n = 0 + @Suppress("UNCHECKED_CAST") // [read] returns records of the type it is given. + suspend fun of(type: KClass): List = read(type) as List - val hr = read(HeartRateRecord::class).flatMap { r -> r.samples.map { HeartRateSample(it.time.epochSecond, it.beatsPerMinute.toInt()) } } + val hr = of(HeartRateRecord::class) + .flatMap { r -> r.samples.map { HeartRateSample(it.time.epochSecond, it.beatsPerMinute.toInt()) } } dao.insertHeartRateIfMissing(hr); n += hr.size // Steps were exported a minute at a time; a longer record (another export, an edit) is spread over its minutes. - val steps = read(StepsRecord::class).flatMap { r -> + val steps = of(StepsRecord::class).flatMap { r -> val start = r.startTime.epochSecond / 60 * 60 val minutes = ((r.endTime.epochSecond - start + 59) / 60).coerceAtLeast(1) (0 until minutes).map { i -> @@ -332,12 +401,14 @@ class HealthConnectExporter(private val context: Context, private val dao: Healt } dao.insertActivityIfMissing(steps); n += steps.size - for (r in read(SleepSessionRecord::class)) { + val sessions = mutableListOf() + val allStages = mutableListOf() + val zone = ZoneId.systemDefault() + for (r in of(SleepSessionRecord::class)) { val start = r.startTime.epochSecond val end = r.endTime.epochSecond val nap = end - start < 3 * 3600 - val zone = ZoneId.systemDefault() - val day = Instant.ofEpochSecond(if (nap) start else end).atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() + val day = Days.start(if (nap) start else end, zone) val stages = r.stages.map { SleepStage( it.startTime.epochSecond, it.endTime.epochSecond, start, @@ -350,24 +421,65 @@ class HealthConnectExporter(private val context: Context, private val dao: Healt ) } fun minutes(stage: Int) = (stages.filter { it.stage == stage }.sumOf { it.end - it.start } / 60).toInt().takeIf { stages.isNotEmpty() } - dao.insertSleepSessionsIfMissing(listOf(SleepSession( + sessions += SleepSession( start, end, day, nap, deepMinutes = minutes(SleepStage.DEEP), lightMinutes = minutes(SleepStage.LIGHT), remMinutes = minutes(SleepStage.REM), awakeMinutes = minutes(SleepStage.AWAKE), wakeCount = stages.count { it.stage == SleepStage.AWAKE }.takeIf { stages.isNotEmpty() }, - ))) - dao.insertSleepStagesIfMissing(stages) + ) + allStages += stages n += 1 + stages.size } + // The range's sleep in two writes, not two a night. + if (sessions.isNotEmpty()) dao.insertSleepSessionsIfMissing(sessions) + if (allStages.isNotEmpty()) dao.insertSleepStagesIfMissing(allStages) - val spo2 = read(OxygenSaturationRecord::class).map { Spo2Sample(it.time.epochSecond, it.percentage.value.toInt()) } + val spo2 = of(OxygenSaturationRecord::class).map { Spo2Sample(it.time.epochSecond, it.percentage.value.toInt()) } dao.insertSpo2IfMissing(spo2); n += spo2.size - val hrv = read(HeartRateVariabilityRmssdRecord::class).map { HrvSample(it.time.epochSecond, it.heartRateVariabilityMillis.toInt()) } + val hrv = of(HeartRateVariabilityRmssdRecord::class).map { HrvSample(it.time.epochSecond, it.heartRateVariabilityMillis.toInt()) } dao.insertHrvIfMissing(hrv); n += hrv.size - val rhr = read(RestingHeartRateRecord::class).map { RestingHeartRate(it.time.epochSecond, it.beatsPerMinute.toInt()) } + val rhr = of(RestingHeartRateRecord::class).map { RestingHeartRate(it.time.epochSecond, it.beatsPerMinute.toInt()) } dao.insertRestingHrIfMissing(rhr); n += rhr.size return n } + + private companion object { + /** How far back [import] reads, a year at a time. */ + const val IMPORT_YEARS = 10 + } } + +/** The Health Connect calls a write makes, apart so a test can stand in for Health Connect. */ +internal interface RecordStore { + suspend fun insert(records: List) + + /** OpenStrap's own records of [type] in [range], or null when Health Connect won't let it read them. */ + suspend fun ownRecords(type: KClass, range: TimeRangeFilter): List? + + suspend fun deleteIds(type: KClass, ids: List) + + suspend fun deleteRange(type: KClass, range: TimeRangeFilter) +} + +/** + * Writes [records], replacing OpenStrap's own records of each type in [replaced] over its range: the new records go + * in first, and only then are the old ones that weren't written again (by clientRecordId) removed, so a failed insert + * leaves what was there. Where this app may not read its own records back, the stale ones can only be found by time, + * so that range is cleared first, as every write did before. + */ +internal suspend fun RecordStore.write(records: List, replaced: List, TimeRangeFilter>>) { + val old = replaced.map { (type, range) -> Triple(type, range, ownRecords(type, range)) } + for ((type, range, own) in old) if (own == null) deleteRange(type, range) + // Health Connect caps the batch size per call. + records.chunked(HC_BATCH).forEach { insert(it) } + val written = records.mapTo(HashSet()) { it.metadata.clientRecordId } + for ((type, _, own) in old) { + val stale = own.orEmpty().filter { it.metadata.clientRecordId !in written }.map { it.metadata.id } + stale.chunked(HC_BATCH).forEach { deleteIds(type, it) } + } +} + +/** The most records one Health Connect call takes. */ +internal const val HC_BATCH = 500 diff --git a/app/src/main/kotlin/org/openstrap/app/presentation/Bands.kt b/app/src/main/kotlin/org/openstrap/app/presentation/Bands.kt new file mode 100644 index 00000000..6f4d26b9 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/presentation/Bands.kt @@ -0,0 +1,97 @@ +package org.openstrap.app.presentation + +import org.openstrap.app.data.Recovery + +/* + * Where a score or reading falls, defined once (decisions 8 and 10 in Units). The values and words are the ones the + * screens wrote out by hand before. + */ + +/** Recovery's bands: 85 and up "Ready for more", 70 "Ready", from [Recovery.FAIR_FROM] "Moderate", else "Take it easy". */ +object RecoveryBands { + const val READY_FOR_MORE_FROM = 85 + const val READY_FROM = 70 + const val MODERATE_FROM = Recovery.FAIR_FROM + + /** The words low to high, one per bar of the scale. */ + val WORDS = listOf("Take it easy", "Moderate", "Ready", "Ready for more") + + /** Which band [score] is in, 0 (lowest) to 3. */ + fun bar(score: Int): Int = FROM.count { score >= it } + + private val FROM = listOf(MODERATE_FROM, READY_FROM, READY_FOR_MORE_FROM) + + /** The status word beside the score. An [alert] (signals off your normal) always reads "Take it easy". */ + fun word(score: Int, alert: Boolean = false): String = if (alert) WORDS[0] else WORDS[bar(score)] +} + +/** The sleep score's bands, the ones the strap's score is built for: 90 "Great", 80 "Good", 60 "Fair", else "Poor". */ +object SleepBands { + const val GREAT_FROM = 90 + const val GOOD_FROM = 80 + const val FAIR_FROM = 60 + + /** The words low to high, one per bar of the scale. */ + val WORDS = listOf("Poor", "Fair", "Good", "Great") + + /** Which band [score] is in, 0 (lowest) to 3. */ + fun bar(score: Int): Int = FROM.count { score >= it } + + private val FROM = listOf(FAIR_FROM, GOOD_FROM, GREAT_FROM) + + fun word(score: Int): String = WORDS[bar(score)] +} + +/** How a day's Load compares with your usual. */ +enum class LoadBand(val text: String) { + Above("Above your usual"), + Usual("Usual"), + Below("Below your usual"), + + /** Before the evening, a Load not above your usual is only so far: against a whole usual day it would always look low. */ + SoFar("So far today"), +} + +/** Today's Load rule: within [BAND] of your usual either way is usual. */ +object LoadBands { + /** A Load within this share of your usual counts as usual. */ + const val BAND = 0.25f + + /** [load] against [usual]. [endOfDay] false keeps a low Load at [LoadBand.SoFar]. Null without a usual. */ + fun of(load: Float, usual: Float?, endOfDay: Boolean = true): LoadBand? { + if (usual == null || usual <= 0f) return null + return when { + load > usual * (1 + BAND) -> LoadBand.Above + !endOfDay -> LoadBand.SoFar + load < usual * (1 - BAND) -> LoadBand.Below + else -> LoadBand.Usual + } + } + + /** Your usual as a range, the band a Load counts as usual in, null without a usual. */ + fun usualRange(usual: Float?): ClosedFloatingPointRange? = usual?.takeIf { it > 0f }?.let { it * (1 - BAND)..it * (1 + BAND) } +} + +/** When the strap needs a word: its data out of date, its battery low. */ +object StrapBands { + /** + * A last sync older than this is out of date. Connected, the strap syncs at least hourly, so 3 hours is two missed + * syncs or more: one alone happens in normal wear. + */ + const val STALE_AFTER_S = 3 * 3600L + + /** At or under this battery level, while not charging, the strap's battery is low. */ + const val LOW_BATTERY = 15 + + /** Whether a sync at [lastSync] is out of date at [now], both epoch seconds: more than [STALE_AFTER_S] before. */ + fun stale(lastSync: Long?, now: Long): Boolean = lastSync != null && now - lastSync > STALE_AFTER_S + + /** Whether [percent] is low: at or under [LOW_BATTERY] and not [charging]. */ + fun lowBattery(percent: Int?, charging: Boolean): Boolean = percent != null && !charging && percent <= LOW_BATTERY +} + +/** + * Charge as a whole number the way every screen, widget and the notification show it, cut rather than rounded, so they + * all agree. + */ +fun chargeShown(value: Float): Int = value.toInt() diff --git a/app/src/main/kotlin/org/openstrap/app/presentation/DataCopy.kt b/app/src/main/kotlin/org/openstrap/app/presentation/DataCopy.kt new file mode 100644 index 00000000..4497c772 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/presentation/DataCopy.kt @@ -0,0 +1,84 @@ +package org.openstrap.app.presentation + +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.SleepEdits +import org.openstrap.app.data.Vo2max +import org.openstrap.app.data.settings.Goals +import java.time.ZoneId +import java.util.Locale + +/* + * Text the data package used to write itself (audit finding A10). Data now hands over values only, and the screens + * ask here for the words. + */ + +/** A recovery contributor's line under its name in the score detail: "52 ms, +4% vs usual", "7 h 40 asleep". */ +object RecoveryCopy { + fun detail(d: Recovery.Detail, locale: Locale = Locale.getDefault()): String = when (d) { + is Recovery.Detail.Hrv -> "${whole(d.ms, locale)} ms, ${Signed.of(d.percentVsUsual, locale)}% vs usual" + is Recovery.Detail.RestingHr -> "${whole(d.bpm, locale)} bpm, ${Signed.of(d.vsUsual, 0, locale)} vs usual" + is Recovery.Detail.Asleep -> "${minutes(d.minutes, locale)} asleep" + is Recovery.Detail.SleepDebt -> if (d.minutes == 0) "No sleep debt" else "${minutes(d.minutes, locale)} of sleep debt" + is Recovery.Detail.LowestHeartRate -> "Lowest heart rate ${minutes(d.minutesBeforeWaking, locale)} before waking" + is Recovery.Detail.Temperature -> "${Signed.of(d.vsUsual, 1, locale)} °C vs usual" + is Recovery.Detail.Breathing -> "${Signed.of(d.vsUsual, 1, locale)} /min vs usual" + is Recovery.Detail.BloodOxygen -> + "${String.format(locale, "%.1f", d.percent)} %, ${Signed.of(d.vsUsual, 1, locale)} vs usual" + is Recovery.Detail.Load -> "Load ${whole(d.yesterday, locale)}, usual ${whole(d.usual, locale)}" + } + + private fun whole(n: Int, locale: Locale) = String.format(locale, "%d", n) + + private fun minutes(m: Int, locale: Locale) = Units.duration(m * Units.MINUTE, locale) +} + +/** Why a sleep edit can't be saved ([SleepEdits.check]), as the edit sheet says it. */ +object SleepEditCopy { + fun text( + p: SleepEdits.Problem, + zone: ZoneId, + use24Hour: Boolean = ClockSetting.use24Hour, + locale: Locale = Locale.getDefault(), + ): String = when (p) { + SleepEdits.Problem.WakeBeforeSleep -> "Waking up has to come after falling asleep." + is SleepEdits.Problem.TooShort -> if (p.nap) "A nap needs at least 5 minutes." else "Sleep needs at least an hour." + is SleepEdits.Problem.TooLong -> if (p.nap) "A nap can be 4 hours at most." else "Sleep can be 16 hours at most." + SleepEdits.Problem.Future -> "That's later than now." + SleepEdits.Problem.TooFarFromStrap -> "Keep it within 3 hours of what the strap saw." + is SleepEdits.Problem.RunsInto -> + "That runs into your ${if (p.nap) "nap" else "sleep"} at ${TimeOfDay.format(p.start, zone, use24Hour, locale)}." + } +} + +/** The VO2max estimate's words: why there is none, and its FRIEND band. */ +object Vo2maxCopy { + fun reason(w: Vo2max.Withheld): String = when (w) { + Vo2max.Withheld.Age -> "Needs an age between 20 and 79." + Vo2max.Withheld.Sex -> "Needs your sex: the equation differs for women and men." + Vo2max.Withheld.Body -> "Needs a weight and height entered in the last 90 days." + Vo2max.Withheld.Activity -> "Answer the activity question (asked every 6 months)." + Vo2max.Withheld.Medication -> + "Not shown while taking beta-blockers or during pregnancy: resting heart rate doesn't reflect fitness then." + Vo2max.Withheld.RestingHr -> "Needs at least 5 nights of resting heart rate in the last 2 weeks." + } + + fun band(b: Vo2max.Friend.Band): String = when (b) { + Vo2max.Friend.Band.Below30 -> "Below the 30th percentile" + Vo2max.Friend.Band.P30to50 -> "30th to 50th percentile" + Vo2max.Friend.Band.P50to70 -> "50th to 70th percentile" + Vo2max.Friend.Band.P70to90 -> "70th to 90th percentile" + Vo2max.Friend.Band.Top10 -> "Top 10 %" + } +} + +/** The goals sheet's words. */ +object GoalCopy { + /** "8,000 steps, 300 active kcal, 8 h sleep, 10 active hours", the numbers in the phone's language. */ + fun summary(g: Goals, locale: Locale = Locale.getDefault()): String = + "${Units.count(g.steps.toLong(), locale)} steps, ${Units.count(g.calories.toLong(), locale)} active kcal, " + + "${Units.duration(g.sleepSeconds, locale)} sleep, ${String.format(locale, "%d", g.activeHours)} active hours" + + /** Under the calorie field when a typed value is outside [range]: "Pick a number from 200 to 3,000". */ + fun outOfRange(range: IntRange, locale: Locale = Locale.getDefault()): String = + "Pick a number from ${Units.count(range.first.toLong(), locale)} to ${Units.count(range.last.toLong(), locale)}" +} diff --git a/app/src/main/kotlin/org/openstrap/app/presentation/DayWords.kt b/app/src/main/kotlin/org/openstrap/app/presentation/DayWords.kt new file mode 100644 index 00000000..fe8f4e4a --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/presentation/DayWords.kt @@ -0,0 +1,96 @@ +package org.openstrap.app.presentation + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.TextStyle +import java.time.temporal.ChronoUnit +import java.util.Locale + +/** + * When a day was, in words (decision 6 in [Units]): "Today", "Yesterday", the weekday for the rest of the last 7 days + * ("Tuesday"), then the date ("Tue 21 Oct"), with the year when it's from an earlier one ("Tue 21 Oct 2025"). The + * words are the app's English, the weekday and month names the phone's language. + */ +object DayWords { + const val TODAY = "Today" + const val YESTERDAY = "Yesterday" + + /** A day this many days back or fewer, past yesterday, goes by its weekday. A week back would repeat today's. */ + private const val WEEKDAY_WITHIN_DAYS = 6L + + private const val SHORT = "EEE d MMM" + private const val SHORT_WITH_YEAR = "EEE d MMM yyyy" + + /** + * [date] seen from [today], for a heading: "Today", "Yesterday", "Tuesday", "Tue 21 Oct", "Tue 21 Oct 2025". A + * day after [today] has no word for it, so it takes its date. + */ + fun relative(date: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String { + val days = ChronoUnit.DAYS.between(date, today) + return when { + days == 0L -> TODAY + days == 1L -> YESTERDAY + days in 2..WEEKDAY_WITHIN_DAYS -> date.dayOfWeek.getDisplayName(TextStyle.FULL, locale) + date.year == today.year -> date.format(Formatters.of(SHORT, locale)) + else -> date.format(Formatters.of(SHORT_WITH_YEAR, locale)) + } + } + + /** [relative] after other words: "today" and "yesterday" in lowercase, a weekday or date as it is. */ + fun inSentence(date: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String = + when (val word = relative(date, today, locale)) { + TODAY, YESTERDAY -> word.lowercase(Locale.ROOT) + else -> word + } + + /** + * A moment joined with its time, after other words: "10:42" today, "yesterday 10:42", "Tuesday 10:42", + * "Tue 21 Oct 10:42". [now] and [epoch] in epoch seconds. + */ + fun at( + epoch: Long, now: Long, zone: ZoneId, use24Hour: Boolean = ClockSetting.use24Hour, locale: Locale = Locale.getDefault(), + ): String { + val clock = TimeOfDay.format(epoch, zone, use24Hour, locale) + val date = Instant.ofEpochSecond(epoch).atZone(zone).toLocalDate() + val today = Instant.ofEpochSecond(now).atZone(zone).toLocalDate() + return if (date == today) clock else "${inSentence(date, today, locale)} $clock" + } + + /** + * When something last happened, after other words: "today at 10:42", "yesterday at 10:42", or further back the + * date alone, "on Tue 21 Oct". For a backup or an update check, where an older time no longer matters. + */ + fun whenDone( + epoch: Long, now: Long, zone: ZoneId, use24Hour: Boolean = ClockSetting.use24Hour, locale: Locale = Locale.getDefault(), + ): String { + val date = Instant.ofEpochSecond(epoch).atZone(zone).toLocalDate() + val today = Instant.ofEpochSecond(now).atZone(zone).toLocalDate() + val clock = TimeOfDay.format(epoch, zone, use24Hour, locale) + return when (date) { + today -> "today at $clock" + today.minusDays(1) -> "yesterday at $clock" + else -> "on ${short(date, locale)}" + } + } + + /** A day for a chart or a short label, always dated: "Tue 21 Oct". */ + fun short(date: LocalDate, locale: Locale = Locale.getDefault()): String = date.format(Formatters.of(SHORT, locale)) + + /** A weekday's short name, for a week's strip of bars: "Tue". */ + fun weekdayShort(date: LocalDate, locale: Locale = Locale.getDefault()): String = + date.dayOfWeek.getDisplayName(TextStyle.SHORT, locale) + + /** A day and month with no weekday, for chart axes, ranges and widgets: "21 Oct". */ + fun dayMonth(date: LocalDate, locale: Locale = Locale.getDefault()): String = date.format(Formatters.of("d MMM", locale)) + + /** [dayMonth] with its year, for a date that may be from any year: "21 Oct 2025". */ + fun dayMonthYear(date: LocalDate, locale: Locale = Locale.getDefault()): String = + date.format(Formatters.of("d MMM yyyy", locale)) + + /** The month first, as a share card's corner reads: "Oct 21". */ + fun monthDay(date: LocalDate, locale: Locale = Locale.getDefault()): String = date.format(Formatters.of("MMM d", locale)) + + /** A month's short name, for a year's chart axis: "Oct". */ + fun monthShort(date: LocalDate, locale: Locale = Locale.getDefault()): String = date.format(Formatters.of("MMM", locale)) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/NotificationCopy.kt b/app/src/main/kotlin/org/openstrap/app/presentation/NotificationCopy.kt similarity index 84% rename from app/src/main/kotlin/org/openstrap/app/ui/workout/NotificationCopy.kt rename to app/src/main/kotlin/org/openstrap/app/presentation/NotificationCopy.kt index 91fb085b..d0664b23 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/workout/NotificationCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/presentation/NotificationCopy.kt @@ -1,11 +1,9 @@ -package org.openstrap.app.ui.workout +package org.openstrap.app.presentation -import androidx.compose.ui.graphics.toArgb import org.openstrap.app.data.WorkoutRecorder import org.openstrap.app.data.WorkoutTarget import org.openstrap.app.data.Workouts -import org.openstrap.app.ui.glow.PausedColor -import org.openstrap.app.ui.glow.km +import java.util.Locale /** * What the ongoing workout notification shows (spec 6.3, patterns D4): the black card with the time, bpm in the zone @@ -20,7 +18,7 @@ data class WorkoutCard( val activeSeconds: Long, /** Whether the chronometer runs: stopped while paused. */ val running: Boolean, - /** The bpm slot: "152 bpm", or "Paused", "Reconnecting", "Waiting" in grey. */ + /** The bpm slot: "152 bpm", or "Paused", [WorkoutWords.STRAP_GONE], "Waiting" in grey. */ val bpm: String, /** The zone colouring the bpm and [zoneLine]; null draws them grey. */ val zone: Int?, @@ -44,9 +42,6 @@ object NotificationCopy { /** The question while End waits for a second tap. */ const val END_QUESTION = "End the workout? Tap Yes, end to save it." - /** Grey for the bpm slot without a zone: the live screen's paused colour. */ - val MUTED = PausedColor - /** * The card for a workout recording now. [bpm] is the live reading while the strap is [connected]. [distanceMeters] * counts only for a [gps] workout, marked ≈ when it came from steps (GPS refused after a restart). [confirming] @@ -55,13 +50,13 @@ object NotificationCopy { fun card( sport: String, activeSeconds: Long, paused: Boolean, connected: Boolean, bpm: Int?, maxHr: Int, gps: Boolean, distanceMeters: Float?, distanceFromSteps: Boolean, target: WorkoutTarget?, zoneSeconds: List, confirming: Boolean, - activeKcal: Float? = null, + activeKcal: Float? = null, locale: Locale = Locale.getDefault(), ): WorkoutCard { val reading = bpm.takeIf { connected && !paused } val zone = WorkoutRecorder.liveZone(reading, maxHr) val slot = when { paused -> "Paused" - !connected -> "Reconnecting" + !connected -> WorkoutWords.STRAP_GONE reading == null -> "Waiting" else -> "$reading bpm" } @@ -69,10 +64,10 @@ object NotificationCopy { confirming -> END_QUESTION paused -> null !connected -> "Still recording" - else -> zone?.let { WorkoutCopy.zoneTitle(it).let { (a, b) -> "$a $b" } } + else -> zone?.let(WorkoutWords::zoneLine) } val detail = when { - gps -> distanceMeters?.takeIf { it >= 10 }?.let { (if (distanceFromSteps) "≈ " else "") + km(it) + if (it >= 1000) " km" else "" } + gps -> distanceMeters?.let { Units.distance(it, Units.Precision.Workout, distanceFromSteps, locale) } paused -> null target is WorkoutTarget.Zones -> { val inTarget = zoneSeconds.withIndex().filter { it.index in target.low..target.high }.sumOf { it.value } @@ -80,11 +75,11 @@ object NotificationCopy { } target is WorkoutTarget.Time -> "Goal ${target.label}" target is WorkoutTarget.Kcal -> "Goal ${target.label}" - zone != null -> WorkoutCopy.zoneTime(zoneSeconds.getOrElse(zone) { 0 }) + zone != null -> WorkoutWords.zoneTime(zoneSeconds.getOrElse(zone) { 0 }) else -> null } - val kcal = WorkoutCopy.kcalText(activeKcal) - val clock = org.openstrap.app.ui.glow.workoutClock(activeSeconds) + val kcal = WorkoutWords.kcalText(activeKcal) + val clock = Units.workoutClock(activeSeconds, locale) return WorkoutCard( sport, activeSeconds, !paused, slot, zone, zoneLine, detail, zoneSeconds, kcal, kcal != null && kcalFits(sport, clock, kcal, slot, reading = zone != null, collapsed = true), @@ -105,8 +100,8 @@ object NotificationCopy { private const val COLLAPSED_CHARS = 32 private const val EXPANDED_UNITS = 34 - /** The colour of the bpm and the zone line: the zone's, or [MUTED]. */ - fun accent(zone: Int?): Int = (zone?.let { ZONE_COLORS.getOrNull(it) } ?: MUTED).toArgb() + /** The colour of the bpm and the zone line: the zone's, or the app's paused grey without one. */ + fun accent(zone: Int?): Int = WorkoutColors.zone(zone) /** * Each zone's share of a strip [width] pixels wide, in whole pixels that add up to it (largest remainder), so the diff --git a/app/src/main/kotlin/org/openstrap/app/presentation/RewardsCopy.kt b/app/src/main/kotlin/org/openstrap/app/presentation/RewardsCopy.kt new file mode 100644 index 00000000..1aee094e --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/presentation/RewardsCopy.kt @@ -0,0 +1,234 @@ +package org.openstrap.app.presentation + +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.util.Locale +import org.openstrap.app.data.Rewards +import org.openstrap.app.data.db.BadgeRow +import org.openstrap.app.data.db.PersonalBest +import org.openstrap.app.data.db.WeekResult + +/* + * What the weekly goal, bests and badges say (workouts spec 3): the Earned rows on a summary, the best card, the goal + * moment, the weekly recap and the Goals and bests page. Pure, so it's tested without a workout ending. Here rather than + * in ui, so the weekly recap notification reads it without depending on the screens. + */ + +/** A row under Earned: a new best, a badge, or the weekly goal ([met] once this week's is). */ +data class EarnedLine(val kind: Kind, val text: String, val value: String? = null, val note: String? = null, val met: Boolean = false) { + enum class Kind { Best, Badge, Goal } +} + +/** What a just ended workout earned and what follows the summary. */ +data class SummaryRewards( + val earned: List, + /** The best for the full screen card, the one that matters most; null without a new best. */ + val best: Rewards.NewBest?, + /** This workout met the weekly goal: the goal moment may follow. */ + val goalMet: Boolean, + val week: Rewards.WeekLine, + val inARow: Int, + /** The first day of this workout's week, epoch day. */ + val weekStart: Long, +) + +object RewardsCopy { + /** "Fastest 5 km", "Longest run", "Heaviest bench press", "Most steps in a day". */ + fun bestTitle(kind: String, locale: Locale = Locale.getDefault()): String { + Rewards.Kind.fastestMeters(kind)?.let { return "Fastest " + distanceName(it) } + Rewards.Kind.liftName(kind)?.let { return "Heaviest " + it.lowercase(locale) } + return when (kind) { + Rewards.Kind.LONGEST_RUN -> "Longest run" + Rewards.Kind.LONGEST_WORKOUT -> "Longest workout" + Rewards.Kind.MOST_STEPS -> "Most steps in a day" + else -> "Personal best" + } + } + + /** "1 km", "5 km", "half marathon". */ + fun distanceName(meters: Int): String = if (meters == 21097) "half marathon" else "${meters / 1000} km" + + /** The badge art's short text for a best, drawn in dots, so ASCII digits always: "5K", "HM", "KG", "RUN". */ + fun bestShort(kind: String): String { + Rewards.Kind.fastestMeters(kind)?.let { return if (it == 21097) "HM" else dotFormat("%dK", it / 1000) } + if (Rewards.Kind.liftName(kind) != null) return "KG" + return when (kind) { + Rewards.Kind.LONGEST_RUN -> "RUN" + Rewards.Kind.MOST_STEPS -> "STEP" + else -> "TIME" + } + } + + /** A best's value: "26:41", "14.21 km", "1 h 32", "23,410", "65 kg". The longest run is one workout's distance. */ + fun bestValue(kind: String, value: Double, locale: Locale = Locale.getDefault()): String = when { + Rewards.Kind.fastestMeters(kind) != null -> Units.workoutClock(Math.round(value), locale) + Rewards.Kind.liftName(kind) != null -> org.openstrap.app.data.Strength.kgText(value.toFloat()) + " kg" + kind == Rewards.Kind.LONGEST_RUN -> workoutDistance(value, locale) + kind == Rewards.Kind.LONGEST_WORKOUT -> Units.duration(Math.round(value), locale) + else -> Units.count(Math.round(value), locale) + } + + /** A run's distance as a workout shows it, and "0 m" for nothing, where a best has to show a figure. */ + private fun workoutDistance(meters: Double, locale: Locale): String = + Units.distance(meters.toFloat(), Units.Precision.Workout, locale = locale) ?: String.format(locale, "%d m", 0) + + /** + * What a badge's art reads: its short text, except the first weekly goal, which shows the goal it was met at + * ([goalAt], "3/3"), or a check when that week isn't known. + */ + fun face(b: Rewards.Badge, goalAt: Int?): String = if (b.id == "goal_first" && goalAt != null) "$goalAt/$goalAt" else b.short + + /** "22 s faster", "2.5 kg more", "1.2 km farther", "4 min longer": how far [b] beat [before]. */ + fun beatBy(b: PersonalBest, before: PersonalBest, locale: Locale = Locale.getDefault()): String { + val diff = kotlin.math.abs(b.value - before.value) + return when { + Rewards.Kind.fastestMeters(b.kind) != null -> Units.secondsOrClock(Math.round(diff), locale) + " faster" + Rewards.Kind.liftName(b.kind) != null -> org.openstrap.app.data.Strength.kgText(diff.toFloat()) + " kg more" + b.kind == Rewards.Kind.LONGEST_RUN -> workoutDistance(diff, locale) + " farther" + b.kind == Rewards.Kind.LONGEST_WORKOUT -> "${Math.round(diff / 60).coerceAtLeast(1)} min longer" + else -> Units.count(Math.round(diff), locale) + " more" + } + } + + /** "22 s faster than your best from August." ("August 2025" in another year than [now]). */ + fun beatLine(n: Rewards.NewBest, now: Long, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault()): String { + val d = Instant.ofEpochSecond(n.before.date).atZone(zone) + val pattern = if (d.year == Instant.ofEpochSecond(now).atZone(zone).year) "MMMM" else "MMMM yyyy" + val by = beatBy(n.best, n.before, locale).replaceFirstChar { it.uppercase() } + return "$by than your best from ${d.format(Formatters.of(pattern, locale))}." + } + + /** Which new best leads: the fastest stretches first, then the longest run, a lift, the longest workout. */ + fun leading(bests: List): Rewards.NewBest? = bests.minByOrNull { n -> + val k = n.best.kind + when { + Rewards.Kind.fastestMeters(k) != null -> 0 + k == Rewards.Kind.LONGEST_RUN -> 1 + Rewards.Kind.liftName(k) != null -> 2 + else -> 3 + } + } + + /** + * The weekly goal row, last under Earned for a workout that counts toward it (workouts spec 2.8): "Weekly goal met" on the workout that met it, + * else "Weekly goal", with "3 of 4" and what's left or the weeks in a row. + */ + fun goalLine(week: Rewards.WeekLine, inARow: Int): EarnedLine { + val note = when { + week.metByThis && inARow > 1 -> "$inARow weeks in a row" + week.count >= week.goal -> "Goal met this week" + else -> (week.goal - week.count).let { "$it more to go this week" } + } + return EarnedLine(EarnedLine.Kind.Goal, if (week.metByThis) "Weekly goal met" else "Weekly goal", "${week.count} of ${week.goal}", note, met = week.count >= week.goal) + } + + /** Everything a just ended [w] earned, from all [facts] and the badges already [stored]. */ + fun summary( + w: Rewards.Done, workouts: List, days: List, kept: List, stored: List, + goal: Int, firstDay: DayOfWeek, zone: ZoneId, now: Long, + ): SummaryRewards { + val all = workouts.filter { it.start != w.start } + w + val today = Instant.ofEpochSecond(now).atZone(zone).toLocalDate() + val weeks = Rewards.weeks(all, kept, goal, today, firstDay, zone) + val bests = Rewards.newBests(w, all, days, zone) + val badges = Rewards.newBadges(w, all, days, weeks, stored, firstDay, zone) + val week = Rewards.weekLine(w, all, goal, firstDay, zone) + val weekStart = Rewards.weekStart(Instant.ofEpochSecond(w.shownStart).atZone(zone).toLocalDate(), firstDay) + val inARow = Rewards.inARow(weeks, weekStart) + val earned = bests.map { n -> + EarnedLine(EarnedLine.Kind.Best, bestTitle(n.best.kind), bestValue(n.best.kind, n.best.value), "New best, ${beatBy(n.best, n.before)}") + } + badges.map { EarnedLine(EarnedLine.Kind.Badge, it.title, null, "New badge") } + + // The goal line only for a workout that counts toward it. + listOfNotNull(goalLine(week, inARow).takeIf { w.counts }) + return SummaryRewards(earned, leading(bests), week.metByThis, week, inARow, weekStart.toEpochDay()) + } + + /** The goal moment's line: "6 weeks in a row. Rest days never count against you." */ + fun momentLine(inARow: Int): String = + (if (inARow > 1) "$inARow weeks in a row. " else "Your first week in a row. ") + "Rest days never count against you." + + /** "Thursday, 30 October 2026, morning run": when a best was set, and what. */ + fun whenSet(date: Long, sportName: String, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault()): String { + val t = Instant.ofEpochSecond(date).atZone(zone) + val part = when (t.hour) { in 4..11 -> "morning"; in 12..16 -> "afternoon"; in 17..21 -> "evening"; else -> "night" } + return t.format(Formatters.of("EEEE, d MMMM yyyy", locale)) + ", $part ${sportName.lowercase(locale)}" + } + + /** "27 October to 2 November": a week from its first day. */ + fun weekRange(first: LocalDate, locale: Locale = Locale.getDefault()): String { + val f = Formatters.of("d MMMM", locale) + return "${first.format(f)} to ${first.plusDays(6).format(f)}" + } + + /** "Goal met 6 weeks in a row. Best is 9.", or without a run, what the best was. */ + fun runLine(inARow: Int, best: Int): String = when { + inARow > 0 && best > inARow -> "Goal met $inARow ${weeks(inARow)} in a row. Best is $best." + inARow > 0 -> "Goal met $inARow ${weeks(inARow)} in a row, your best yet." + best > 0 -> "Your best is $best ${weeks(best)} in a row." + else -> "Meet your goal to start a run of weeks." + } + + private fun weeks(n: Int) = if (n == 1) "week" else "weeks" + + /** A locked badge's need: "712 of 1,000 km", "3 of 10 workouts", and metres as km over many days: "4.2 of 5 km". */ + fun progressLine(have: Long, need: Long, unit: String, locale: Locale = Locale.getDefault()): String = when (unit) { + "m" -> "${Units.km(have.toFloat(), Units.Precision.Day, locale)} of ${Units.count(need / 1000, locale)} km" + else -> "${Units.count(have, locale)} of ${Units.count(need, locale)} $unit" + } + + /** The recap notification: "4 of 4 workouts, goal met. 6 weeks in a row." */ + fun recapText(week: WeekResult, inARow: Int): String = + "${week.count} of ${week.goal} workouts" + if (week.met) ", goal met." + (if (inARow > 1) " $inARow weeks in a row." else "") else "." +} + +/** What the workout from [start] earned ([RewardsCopy.summary]), from [workouts] and what's stored; null before it's listed. */ +suspend fun org.openstrap.app.data.RewardsKeeper.summary(start: Long, workouts: List, goal: Int): SummaryRewards? { + val f = facts(workouts) + val w = f.workouts.firstOrNull { it.start == start } ?: return null + val (kept, badges) = stored() + return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { + RewardsCopy.summary(w, f.workouts, f.days, kept, badges, goal, f.firstDay, f.zone, System.currentTimeMillis() / 1000) + } +} + +/** What the Goals and bests page and the weekly recap show, worked out from everything ([RewardsKeeper.facts]). */ +data class RewardsView( + val goal: Int, + /** Every week against its goal, this one last. */ + val weeks: List, + val thisWeek: LocalDate, + val firstDay: DayOfWeek, + val inARow: Int, + val bestRun: Int, + val bests: List, + val earned: List, + /** How far toward each locked badge with a count. */ + val progress: Map>, + /** Workouts that count, for the week's days. */ + val counted: List, + val all: List, +) { + fun week(first: LocalDate): WeekResult? = weeks.firstOrNull { it.weekStart == first.toEpochDay() } + + /** The locked badge closest to done, for "next". */ + val next: Rewards.Badge? get() = Rewards.BADGES.filter { b -> earned.none { it.id == b.id } && progress[b.id] != null } + .maxByOrNull { b -> progress[b.id]!!.let { (have, need) -> have.toDouble() / need } } +} + +/** The Goals and bests page's view, against the weekly [goal]. */ +suspend fun org.openstrap.app.data.RewardsKeeper.view(workouts: List, goal: Int, bests: List, earned: List): RewardsView { + val f = facts(workouts) + val (kept, _) = stored() + return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.Default) { + val today = LocalDate.now(f.zone) + val thisWeek = Rewards.weekStart(today, f.firstDay) + val weeks = Rewards.weeks(f.workouts, kept, goal, today, f.firstDay, f.zone) + val progress = Rewards.BADGES.mapNotNull { b -> Rewards.progress(b, f.workouts, f.days, weeks, thisWeek)?.let { b.id to it } }.toMap() + RewardsView( + goal, weeks, thisWeek, f.firstDay, Rewards.inARow(weeks, thisWeek), Rewards.bestRun(weeks), bests, earned, progress, + Rewards.counted(f.workouts, f.firstDay, f.zone).map { it.second }, f.workouts, + ) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/presentation/SportUse.kt b/app/src/main/kotlin/org/openstrap/app/presentation/SportUse.kt new file mode 100644 index 00000000..555ba086 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/presentation/SportUse.kt @@ -0,0 +1,17 @@ +package org.openstrap.app.presentation + +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.Workouts + +/** A sport someone has done, for the start card: how often and when last. */ +data class SportUse(val sportType: Int, val count: Int, val lastStart: Long?) + +/** The 3 most-used sports across both kinds of workout, else three sensible defaults. */ +fun topSports(workouts: List, n: Int = 3): List { + // The strap's own "Activity" records aren't a sport to offer. + val used = workouts.filter { it.workout.sportType != Workouts.GENERIC_ACTIVITY }.groupBy { it.workout.sportType } + .map { (type, ws) -> SportUse(type, ws.size, ws.maxOf { it.workout.start }) } + .sortedWith(compareByDescending { it.count }.thenByDescending { it.lastStart }) + .take(n) + return used + Workouts.DEFAULT_SPORTS.filter { d -> used.none { it.sportType == d } }.take(n - used.size).map { SportUse(it, 0, null) } +} diff --git a/app/src/main/kotlin/org/openstrap/app/presentation/Units.kt b/app/src/main/kotlin/org/openstrap/app/presentation/Units.kt new file mode 100644 index 00000000..92945328 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/presentation/Units.kt @@ -0,0 +1,209 @@ +package org.openstrap.app.presentation + +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.DecimalStyle +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.abs +import kotlin.math.pow +import kotlin.math.roundToLong + +/** + * How the app writes a unit, one style each, as decided at the wave 3 design gate + * (docs/audits/2026-11-02-wave3-format-choices.md, all ten recommendations approved on 2026-11-02): + * + * 1. Distance. A workout shows 2 decimals ("5.21 km"), a day's total 1 ("5.2 km"). Under 1 km it's metres ("820 m"), + * under 10 m nothing. The decimal mark follows the phone's language, except in the dot font, which keeps the dot. + * Distance from steps always gets "≈ " in front. + * 2. Duration. "7 h 40" everywhere, "7 h" on the hour and "40 min" under an hour. "7:40" only where a design asks + * for a clock look. + * 3. Workout timer. "12:34", then "1:02:03" past the hour, written once. A finished workout under a minute reads "45 s". + * 4. Time of day. Every clock follows the phone's 12 or 24 hour setting through [TimeOfDay], widgets and the + * notification too. + * 5. Pace. "5:24 /km" everywhere, shown once a workout has covered 100 m, and never slower than 60 min a km ([Pace]). + * 6. Day words. "Today", "Yesterday", the weekday for the rest of the last 7 days, then "Tue 21 Oct", with the year + * from an earlier year. Lowercase after other words ("yesterday 10:42"). Charts keep "Tue 21 Oct" ([DayWords]). + * 7. Week start. The phone's locale decides the first day of every week, in one place ([org.openstrap.app.data.Weeks]). + * 8. Score bands. Recovery 85 and 70, sleep 90, 80 and 60, words unchanged, defined once. Load follows Today's rule + * ([LoadBands]). + * 9. Valid heart rate. 30 to 230 bpm everywhere, database queries and workouts included + * ([org.openstrap.app.data.HeartRate]). + * 10. Strap out of date. One threshold, 3 hours, and one low battery level, 15% ([StrapBands]). + * + * Every function takes the locale it writes in, the phone's by default. Text drawn in the dot font passes [DOT_LOCALE] + * or uses [dotFormat], since the font has only ASCII digits and both marks but no other script's digits. + */ +object Units { + /** The locale for text drawn in the dot font: ASCII digits and a dot decimal mark. */ + val DOT_LOCALE: Locale = Locale.ROOT + + /** In front of a distance worked out from steps rather than measured. */ + const val FROM_STEPS = "≈ " + + /** A distance under this many metres shows nothing: too little to mean anything. */ + const val SHOWN_FROM_METERS = 10f + + /** Metres in a kilometre, where a distance turns from metres to km. */ + const val METERS_PER_KM = 1000f + + internal const val MINUTE = 60L + private const val HOUR = 3600L + private const val MINUTES_PER_HOUR = 60L + + /** How many decimals a distance in km takes: a workout's own, or a whole day's total. */ + enum class Precision(val decimals: Int) { + /** The live screen, summary, notification and share card: "5.21 km". */ + Workout(2), + + /** Today, Activity and the widgets: "5.2 km". */ + Day(1), + } + + /** + * [meters] with its unit: "5.21 km" or "5.2 km" by [precision], "820 m" under a kilometre, with "≈ " in front + * when [fromSteps]. Null under [SHOWN_FROM_METERS]. + */ + fun distance(meters: Float, precision: Precision, fromSteps: Boolean = false, locale: Locale = Locale.getDefault()): String? { + if (meters < SHOWN_FROM_METERS) return null + val text = if (meters < METERS_PER_KM) "${metres(meters, locale)} m" else "${km(meters, precision, locale)} km" + return if (fromSteps) FROM_STEPS + text else text + } + + /** [meters] as a number of km alone, for a figure with its unit drawn apart: "5.21". */ + fun km(meters: Float, precision: Precision, locale: Locale = Locale.getDefault()): String = + String.format(locale, "%.${precision.decimals}f", meters / METERS_PER_KM) + + /** Whole metres, cut rather than rounded so 999.6 m never reads as a kilometre's 1000. */ + private fun metres(meters: Float, locale: Locale): String = String.format(locale, "%d", meters.toInt()) + + /** A count with the language's grouping: "23,410" steps, "23.410" in German. */ + fun count(n: Long, locale: Locale = Locale.getDefault()): String = String.format(locale, "%,d", n) + + /** [count] for an Int. */ + fun count(n: Int, locale: Locale = Locale.getDefault()): String = count(n.toLong(), locale) + + /** [v] with [places] decimals in the phone's language, for text: "1.4", "1,4" in German. */ + fun decimal(v: Number, places: Int, locale: Locale = Locale.getDefault()): String = String.format(locale, "%.${places}f", v) + + /** A length of time: "40 min", "7 h", "7 h 40", "7 h 05". Under a minute is "0 min". */ + fun duration(seconds: Long, locale: Locale = Locale.getDefault()): String { + val minutes = seconds.coerceAtLeast(0) / MINUTE + val h = minutes / MINUTES_PER_HOUR + val m = minutes % MINUTES_PER_HOUR + return when { + h == 0L -> String.format(locale, "%d min", m) + m == 0L -> String.format(locale, "%d h", h) + else -> String.format(locale, "%d h %02d", h, m) + } + } + + /** A length of time with a clock's look, hours and minutes, where a design asks for it: "7:40". */ + fun clockDuration(seconds: Long, locale: Locale = Locale.getDefault()): String = + String.format(locale, "%d:%02d", seconds / HOUR, seconds % HOUR / MINUTE) + + /** The workout timer: "12:34", or "1:02:03" past the hour. */ + fun workoutClock(seconds: Long, locale: Locale = Locale.getDefault()): String = + if (seconds >= HOUR) String.format(locale, "%d:%02d:%02d", seconds / HOUR, seconds / MINUTE % MINUTES_PER_HOUR, seconds % MINUTE) + else String.format(locale, "%d:%02d", seconds / MINUTE, seconds % MINUTE) + + /** A finished workout's length, or a gap between two: "45 s" under a minute, else [workoutClock]. */ + fun secondsOrClock(seconds: Long, locale: Locale = Locale.getDefault()): String = + if (seconds < MINUTE) String.format(locale, "%d s", seconds) else workoutClock(seconds, locale) +} + +/** A change against your usual, with its sign: "+3", "−3", or "±0" when it rounds to nothing, never "-0". */ +object Signed { + fun of(v: Int, locale: Locale = Locale.getDefault()): String { + val text = String.format(locale, "%d", abs(v)) + return when { + v > 0 -> "+$text" + v < 0 -> "−$text" + else -> "±$text" + } + } + + /** [v] to [decimals] places with its sign: "+0.4", "−3", "±0.0". */ + fun of(v: Float, decimals: Int, locale: Locale = Locale.getDefault()): String { + val text = String.format(locale, "%.${decimals}f", abs(v)) + // Decided on the rounded value, not the text, so a comma decimal mark can't make "0,0" read as a change. + val rounded = (abs(v) * TEN.pow(decimals)).roundToLong() + return when { + rounded == 0L -> "±$text" + v > 0 -> "+$text" + else -> "−$text" + } + } + + private const val TEN = 10f +} + +/** A pace, decision 5 in [Units]: "5:24 /km", from 100 m covered, nothing slower than an hour a km. */ +object Pace { + /** Pace shows once a workout has covered this many metres: before that one GPS jump swings it wildly. */ + const val FROM_METERS = 100f + + /** A pace slower than this, seconds a km, is standing still and shows nothing. */ + const val SLOWEST_S = 3600.0 + + /** Seconds a km for [meters] in [seconds], null with no distance or under [FROM_METERS]. */ + fun of(meters: Float?, seconds: Long): Double? = + meters?.takeIf { it >= FROM_METERS }?.let { seconds / (it / Units.METERS_PER_KM.toDouble()) } + + /** A pace as a clock alone, "5:24", for a figure with its unit drawn apart. Null with none or slower than [SLOWEST_S]. */ + fun clock(secondsPerKm: Double?, locale: Locale = Locale.getDefault()): String? { + if (secondsPerKm == null || secondsPerKm <= 0.0 || secondsPerKm > SLOWEST_S) return null + val s = Math.round(secondsPerKm) + return String.format(locale, "%d:%02d", s / Units.MINUTE, s % Units.MINUTE) + } + + /** A pace with its unit, "5:24 /km", null as for [clock]. */ + fun text(secondsPerKm: Double?, locale: Locale = Locale.getDefault()): String? = clock(secondsPerKm, locale)?.let { "$it /km" } +} + +/** A time of day, decision 4 in [Units]: every clock in the app follows the phone's 12 or 24 hour setting through here. */ +object TimeOfDay { + private const val MINUTES_PER_DAY = 1440 + + /** [epoch] as a time of day: "22:30", or "10:30 PM" on a 12 hour phone. [use24Hour] is the phone's setting ([ClockSetting]). */ + fun format(epoch: Long, zone: ZoneId, use24Hour: Boolean = ClockSetting.use24Hour, locale: Locale = Locale.getDefault()): String = + Instant.ofEpochSecond(epoch).atZone(zone).format(formatter(use24Hour, locale)) + + /** A wall clock time with no date. */ + fun format(time: LocalTime, use24Hour: Boolean = ClockSetting.use24Hour, locale: Locale = Locale.getDefault()): String = + time.format(formatter(use24Hour, locale)) + + /** Minutes since midnight, wrapping past either end of the day: 1470 is "00:30". */ + fun minutes(minutes: Int, use24Hour: Boolean = ClockSetting.use24Hour, locale: Locale = Locale.getDefault()): String = + format(LocalTime.ofSecondOfDay(Math.floorMod(minutes, MINUTES_PER_DAY) * Units.MINUTE), use24Hour, locale) + + private fun formatter(use24Hour: Boolean, locale: Locale) = Formatters.of(if (use24Hour) "HH:mm" else "h:mm a", locale) +} + +/** + * The phone's 12 or 24 hour setting, kept here so data code, widgets and tests can write a time without a Context. + * The app sets it from `DateFormat.is24HourFormat` when it starts and when the setting changes. Until then it's 24 + * hours, as the app wrote every time before it followed the setting. + */ +object ClockSetting { + @Volatile var use24Hour: Boolean = true +} + +/** + * [String.format] for text drawn in the dot font: ASCII digits and a dot decimal mark whatever the phone's language, + * since the font has no other script's digits and a comma would read as a thousands mark. + */ +fun dotFormat(format: String, vararg args: Any?): String = String.format(Units.DOT_LOCALE, format, *args) + +/** + * Date and time formatters by pattern and locale, built once each. They write the locale's own digits + * ([DecimalStyle.of]), so an Arabic phone's clocks and dates match its other numbers: a plain pattern would keep ASCII. + */ +internal object Formatters { + private val cache = ConcurrentHashMap, DateTimeFormatter>() + + fun of(pattern: String, locale: Locale): DateTimeFormatter = + cache.getOrPut(pattern to locale) { DateTimeFormatter.ofPattern(pattern, locale).withDecimalStyle(DecimalStyle.of(locale)) } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/device/UpdateCopy.kt b/app/src/main/kotlin/org/openstrap/app/presentation/UpdateCopy.kt similarity index 89% rename from app/src/main/kotlin/org/openstrap/app/ui/device/UpdateCopy.kt rename to app/src/main/kotlin/org/openstrap/app/presentation/UpdateCopy.kt index ca83df2f..9d1da2c9 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/device/UpdateCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/presentation/UpdateCopy.kt @@ -1,4 +1,4 @@ -package org.openstrap.app.ui.device +package org.openstrap.app.presentation import org.openstrap.app.update.Install import org.openstrap.app.update.InstallFailure @@ -6,23 +6,25 @@ import org.openstrap.app.update.Release import org.openstrap.app.update.Releases import org.openstrap.app.update.UpdateState import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.Locale -/** The words of updates on the You and Updates pages and in their notifications. */ +/** + * The words of updates on the You and Updates pages and in their notifications. Here rather than in ui, so the update + * notifier reads them without depending on the screens. + */ object UpdateCopy { /** The You page's Updates row: a new version, a check under way, up to date, or only the version when never checked. */ fun summary(state: UpdateState, installed: String, now: Long, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault()): String { Releases.newer(state.releases, installed).firstOrNull()?.let { return "OpenStrap ${it.versionName} is ready to install" } if (state.checking) return "Checking" val checked = state.checkedAt ?: return "Version $installed" - return "Up to date. Checked ${BackupCopy.whenDone(checked, now, zone, locale)}" + return "Up to date. Checked ${DayWords.whenDone(checked, now, zone, locale = locale)}" } /** The Check now row's value. */ fun checkStatus(state: UpdateState, now: Long, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault()): String { if (state.checking) return "Checking GitHub" - val checked = state.checkedAt?.let { BackupCopy.whenDone(it, now, zone, locale) } + val checked = state.checkedAt?.let { DayWords.whenDone(it, now, zone, locale = locale) } if (state.checkFailed) return if (checked != null) "Couldn't reach GitHub. Last checked $checked" else "Couldn't reach GitHub" return if (checked != null) "Last checked $checked" else "Never checked" } @@ -64,7 +66,7 @@ object UpdateCopy { /** When a release came out, beside its version over its notes: "25 Oct 2026". */ fun date(publishedAt: java.time.Instant, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault()): String = - publishedAt.atZone(zone).format(DateTimeFormatter.ofPattern("d MMM yyyy", locale)) + publishedAt.atZone(zone).format(Formatters.of("d MMM yyyy", locale)) /** A new release's notification title. */ fun availableTitle(release: Release): String = "OpenStrap ${release.versionName} is out" diff --git a/app/src/main/kotlin/org/openstrap/app/presentation/WorkoutColors.kt b/app/src/main/kotlin/org/openstrap/app/presentation/WorkoutColors.kt new file mode 100644 index 00000000..71d0a19e --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/presentation/WorkoutColors.kt @@ -0,0 +1,30 @@ +package org.openstrap.app.presentation + +/** + * A workout's colours as ARGB ints, so Compose, the notification and the widgets share them: Compose wraps one in + * `Color(int)`, a notification takes it as it is. + */ +object WorkoutColors { + /** Zone 1, easy: the cool blue of light sleep. */ + const val ZONE_1 = 0xFF8FA8FF.toInt() + + /** Zone 2, aerobic. Also the rest green between sets and intervals. */ + const val ZONE_2 = 0xFF3DDC97.toInt() + const val ZONE_3 = 0xFFFFD166.toInt() + const val ZONE_4 = 0xFFFF9F43.toInt() + + /** Zone 5, max: the heart red. */ + const val ZONE_5 = 0xFFFF5A5F.toInt() + + /** Zone 1 (easy) to 5 (max), cool to hot, by zone index 0 to 4. */ + val ZONES: List = listOf(ZONE_1, ZONE_2, ZONE_3, ZONE_4, ZONE_5) + + /** Rest between sets or intervals. */ + const val REST_GREEN = ZONE_2 + + /** A paused workout, a lost strap or no reading yet: a cool grey rather than a zone, in the app and the notification. */ + const val PAUSED_GREY = 0xFF9AA0B4.toInt() + + /** Zone [index] (0 to 4)'s colour, or [PAUSED_GREY] with no zone. */ + fun zone(index: Int?): Int = index?.let(ZONES::getOrNull) ?: PAUSED_GREY +} diff --git a/app/src/main/kotlin/org/openstrap/app/presentation/WorkoutWords.kt b/app/src/main/kotlin/org/openstrap/app/presentation/WorkoutWords.kt new file mode 100644 index 00000000..8d7631f3 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/presentation/WorkoutWords.kt @@ -0,0 +1,44 @@ +package org.openstrap.app.presentation + +import java.util.Locale + +/** + * The words a workout uses on the live screens and in its notification alike, kept here so both read them from one + * place: the zones, a missing heart rate, the strap gone, a finished kilometre. + */ +object WorkoutWords { + /** Zone 1 (easy) to 5 (max), by zone index 0 to 4. */ + val ZONE_NAMES: List = listOf("Easy", "Aerobic", "Tempo", "Threshold", "Max") + + /** Where the heart rate goes while the strap is away, on every live screen and the notification. */ + const val STRAP_GONE = "Waiting for the strap" + + /** The live screen's note while the strap is away. */ + const val STRAP_GONE_NOTE = + "The strap isn't connected. Recording carries on, and the minutes missed are filled from its own history after it syncs." + + private const val MINUTE = 60 + + /** "Zone 3, aerobic" as its two parts, drawn as two text elements. */ + fun zoneTitle(zone: Int): Pair = "Zone ${zone + 1}," to ZONE_NAMES[zone].lowercase(Locale.ROOT) + + /** [zoneTitle] as one line: "Zone 3, aerobic". */ + fun zoneLine(zone: Int): String = zoneTitle(zone).let { (name, word) -> "$name $word" } + + /** "9 min in this zone", or under a minute in words. */ + fun zoneTime(seconds: Int): String = if (seconds < MINUTE) "Under a minute in this zone" else "${seconds / MINUTE} min in this zone" + + /** What a live screen says where the heart rate goes and there's none: "Paused", [STRAP_GONE], or waiting for it. */ + fun noHeartRate(paused: Boolean, connected: Boolean = true): String = when { + paused -> "Paused" + !connected -> STRAP_GONE + else -> "Waiting for heart rate" + } + + /** "312 kcal" for the notification and the capsule, null before there's a figure. */ + fun kcalText(activeKcal: Float?): String? = activeKcal?.let { "${it.toInt()} kcal" } + + /** A finished kilometre, [index] from 0: "Km 3 in 5:42". The live screens and the summary all name one this way. */ + fun split(index: Int, seconds: Long, locale: Locale = Locale.getDefault()): String = + "Km ${index + 1} in ${Units.workoutClock(seconds, locale)}" +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmClock.kt b/app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmClock.kt new file mode 100644 index 00000000..dd6617a2 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmClock.kt @@ -0,0 +1,16 @@ +package org.openstrap.app.ui.alarms + +import org.openstrap.app.presentation.ClockSetting +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.presentation.Units +import java.time.LocalTime + +/** + * An alarm's time for the dot font, on the phone's clock: the digits ("6:40") and, on a 12 hour phone, the "AM" or + * "PM" apart, since the font has no letters. + */ +internal fun dotClock(hour: Int, minute: Int, use24Hour: Boolean = ClockSetting.use24Hour): Pair { + val text = TimeOfDay.format(LocalTime.of(hour, minute), use24Hour, Units.DOT_LOCALE) + val space = text.indexOf(' ') + return if (space < 0) text to null else text.substring(0, space) to text.substring(space + 1) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmsScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmsScreen.kt index 14840e55..083ae09d 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmsScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/alarms/AlarmsScreen.kt @@ -29,7 +29,7 @@ import androidx.compose.ui.graphics.Color import org.openstrap.app.ui.glow.DestructiveInk import org.openstrap.app.ui.glow.GlowLayout import org.openstrap.app.ui.glow.GlowType -import org.openstrap.app.ui.glow.ControlSurface +import org.openstrap.app.ui.theme.ControlSurface import org.openstrap.app.ui.glow.PageButton import org.openstrap.app.ui.glow.PageChip import org.openstrap.app.ui.glow.PageConfirm @@ -43,6 +43,9 @@ import org.openstrap.app.ui.dashboard.ScreenList import org.openstrap.app.ui.theme.MetricColors import org.openstrap.protocol.services.Alarm import org.openstrap.protocol.services.SettingsCodec +import org.openstrap.app.presentation.ClockSetting +import org.openstrap.app.presentation.TimeOfDay +import java.time.LocalTime private val DAY_LETTERS = listOf("M", "T", "W", "T", "F", "S", "S") private val DAY_NAMES = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") @@ -71,7 +74,7 @@ fun alarmsSummary(state: AlarmsUiState): String? { if (state.caps == null) return null val on = state.alarms.filter { it.enabled } val first = on.firstOrNull() ?: return if (state.alarms.isEmpty()) "None" else "All off" - val text = "%02d:%02d %s".format(first.hour, first.minute, daysLabel(first.days).lowercase()) + val text = "${TimeOfDay.format(LocalTime.of(first.hour, first.minute))} ${daysLabel(first.days).lowercase()}" return if (on.size > 1) "$text and ${on.size - 1} more" else text } @@ -176,10 +179,12 @@ private fun AlarmsPanel( state.alarms.forEach { a -> Row(Modifier.fillMaxWidth().clickable(enabled = !state.busy) { onEdit(a) }.padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - org.openstrap.app.ui.glow.DotMatrixText( - "%02d:%02d".format(a.hour, a.minute), dot = org.openstrap.app.ui.glow.DotSize.Edge, - color = if (a.enabled) Color.White else Color.White.copy(alpha = 0.45f), - ) + val ink = if (a.enabled) Color.White else Color.White.copy(alpha = 0.45f) + val (digits, marker) = dotClock(a.hour, a.minute) + Row(verticalAlignment = Alignment.Bottom) { + org.openstrap.app.ui.glow.DotMatrixText(digits, dot = org.openstrap.app.ui.glow.draw.DotSize.Edge, color = ink) + marker?.let { Text(it, style = GlowType.secondary, color = ink, modifier = Modifier.padding(start = 6.dp)) } + } Text(daysLabel(a.days), style = GlowType.secondary, color = Color.White.copy(alpha = 0.6f), modifier = Modifier.padding(top = 6.dp)) } org.openstrap.app.ui.glow.GlowSwitch(a.enabled, { onToggle(a, it) }, enabled = !state.busy) @@ -241,7 +246,7 @@ private fun HeartAlertsPanel(state: AlarmsUiState, vm: AlarmsViewModel) { @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable private fun AlarmSheet(alarm: Alarm, isNew: Boolean, onSave: (Alarm) -> Unit, onDelete: () -> Unit, onDismiss: () -> Unit) { - val time = rememberTimePickerState(alarm.hour, alarm.minute, is24Hour = true) + val time = rememberTimePickerState(alarm.hour, alarm.minute, is24Hour = ClockSetting.use24Hour) var days by remember { mutableIntStateOf(alarm.days) } PageSheet(onDismiss, if (isNew) "New alarm" else "Edit alarm", note = daysLabel(days)) { TimePicker(time, Modifier.align(Alignment.CenterHorizontally), colors = AlarmClockColors()) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsScreen.kt index f1ca6f0f..9991d736 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsScreen.kt @@ -3,7 +3,6 @@ package org.openstrap.app.ui.alarms import android.Manifest import android.content.ComponentName import android.content.Intent -import android.os.Build import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -17,22 +16,21 @@ import androidx.compose.material3.TimePicker import androidx.compose.material3.rememberTimePickerState import androidx.compose.ui.Alignment import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect -import kotlinx.coroutines.launch +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.openstrap.app.data.settings.BuzzPattern import org.openstrap.app.data.settings.CallBuzz import org.openstrap.app.data.settings.QuietHours -import org.openstrap.app.data.settings.SettingsStore -import org.openstrap.app.device.PhoneAlerts +import org.openstrap.app.device.BlePermissions import org.openstrap.app.device.StrapNotificationListener import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.PaddingValues @@ -49,6 +47,8 @@ import org.openstrap.app.ui.glow.PageSwitchRow import androidx.compose.ui.graphics.Color import org.openstrap.app.ui.glow.GlowSegments import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.presentation.ClockSetting +import org.openstrap.app.presentation.TimeOfDay /** What the Strap page's Phone alerts row says: "Calls and notifications", "Calls", "Notifications" or "Off". */ fun phoneAlertsSummary(calls: Boolean, notifications: Boolean): String = when { @@ -58,8 +58,8 @@ fun phoneAlertsSummary(calls: Boolean, notifications: Boolean): String = when { else -> "Off" } -/** "22:00" for minutes after midnight. */ -internal fun quietClock(minutes: Int): String = "%02d:%02d".format(minutes / 60, minutes % 60) +/** Minutes after midnight on the phone's clock: "22:00", or "10:00 PM". */ +internal fun quietClock(minutes: Int): String = TimeOfDay.minutes(minutes) /** Under the Quiet hours switch: what it holds back, and when. */ internal fun quietHoursSummary(q: QuietHours, callsOn: Boolean): String = when { @@ -74,36 +74,43 @@ internal fun quietHoursSummary(q: QuietHours, callsOn: Boolean): String = when { * offline too. (The off-body reminder is a row of its own on the Strap page.) */ @Composable -fun PhoneAlertsScreen(alerts: PhoneAlerts, settings: SettingsStore, contentPadding: PaddingValues, onBack: () -> Unit) { +fun PhoneAlertsScreen(vm: PhoneAlertsViewModel, contentPadding: PaddingValues, onBack: () -> Unit) { BackHandler(onBack = onBack) val context = LocalContext.current - val scope = rememberCoroutineScope() - val calls by settings.buzzOnCalls.collectAsState(false) - val notifications by settings.buzzOnNotifications.collectAsState(false) - val callPermission by alerts.callPermission.collectAsState() - val notificationAccess by alerts.notificationAccess.collectAsState() - val pattern by settings.notificationPattern.collectAsState(BuzzPattern.TWO_SHORT) - val callStyle by settings.callBuzzStyle.collectAsState(CallBuzz.STEADY) - var previewError by remember { mutableStateOf(null) } - var callPreviewError by remember { mutableStateOf(null) } - val quiet by settings.quietHours.collectAsState(QuietHours()) + val calls by vm.calls.collectAsStateWithLifecycle() + val notifications by vm.notifications.collectAsStateWithLifecycle() + val callPermission by vm.callPermission.collectAsStateWithLifecycle() + val notificationAccess by vm.notificationAccess.collectAsStateWithLifecycle() + val pattern by vm.pattern.collectAsStateWithLifecycle() + val callStyle by vm.callStyle.collectAsStateWithLifecycle() + val previewError by vm.previewError.collectAsStateWithLifecycle() + val callPreviewError by vm.callPreviewError.collectAsStateWithLifecycle() + DisposableEffect(vm) { onDispose { vm.clearPreviewErrors() } } + val quiet by vm.quiet.collectAsStateWithLifecycle() // Which end of the quiet hours is being picked: true for From, false for To. var pickingFrom by remember { mutableStateOf(null) } - // The user may grant access in system settings and come back. - LifecycleResumeEffect(Unit) { alerts.refresh(); onPauseOrDispose { } } + // Phone access turned down for good: Android shows no dialog any more, so the switch offers settings instead (audit E5). + val activity = androidx.activity.compose.LocalActivity.current + var phoneBlocked by rememberSaveable { mutableStateOf(false) } val askPhone = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> - alerts.refresh() - if (granted) scope.launch { settings.setBuzzOnCalls(true) } + vm.refresh() + if (granted) vm.setBuzzOnCalls(true) + phoneBlocked = !granted && activity != null && + BlePermissions.turnedDownForGood(activity, listOf(Manifest.permission.READ_PHONE_STATE)) + } + val openAppSettings = { context.startActivity(BlePermissions.settingsIntent(context)) } + // The user may grant access in system settings and come back. Phone access turned on there counts afresh, so a + // later turn-down asks again before it counts as for good. + LifecycleResumeEffect(Unit) { + vm.refresh() + if (vm.hasCallPermission()) phoneBlocked = false + onPauseOrDispose { } } val openNotificationAccess = { - val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS) - .putExtra(Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME, ComponentName(context, StrapNotificationListener::class.java).flattenToString()) - } else { - Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) - } + val intent = Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS) + .putExtra(Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME, ComponentName(context, StrapNotificationListener::class.java).flattenToString()) runCatching { context.startActivity(intent) } .onFailure { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } Unit @@ -118,29 +125,28 @@ fun PhoneAlertsScreen(alerts: PhoneAlerts, settings: SettingsStore, contentPaddi calls && callPermission, { on -> when { - !on -> scope.launch { settings.setBuzzOnCalls(false) } - callPermission -> scope.launch { settings.setBuzzOnCalls(true) } + !on -> vm.setBuzzOnCalls(false) + callPermission -> vm.setBuzzOnCalls(true) + phoneBlocked -> openAppSettings() else -> askPhone.launch(Manifest.permission.READ_PHONE_STATE) } }, divider = false, ) + if (phoneBlocked && !callPermission) { + PageNote("Needs phone access. Android won't ask again, so turn it on under Permissions.", color = MetricColors.warn) + PageButton("Open settings", openAppSettings, Modifier.fillMaxWidth()) + } if (calls && callPermission) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Call buzz", style = GlowType.body, color = Color.White) // Picking one plays it for a few seconds, so the two can be compared on the wrist. - GlowSegments(CallBuzz.entries.map { it.label }, callStyle.ordinal, { i -> - val style = CallBuzz.entries[i] - scope.launch { - settings.setCallBuzzStyle(style) - callPreviewError = runCatching { alerts.previewCall(style) }.exceptionOrNull()?.let { it.message ?: "Couldn't buzz the strap" } - } - }) + GlowSegments(CallBuzz.entries.map { it.label }, callStyle.ordinal, { i -> vm.pickCallBuzz(CallBuzz.entries[i]) }) callPreviewError?.let { PageNote(it, color = MetricColors.warn) } } } PageSwitchRow("Notifications", "Buzzes for notifications that make a sound.", notifications && notificationAccess, { on -> - scope.launch { settings.setBuzzOnNotifications(on) } + vm.setBuzzOnNotifications(on) if (on && !notificationAccess) openNotificationAccess() }) if (notifications && !notificationAccess) { @@ -151,24 +157,18 @@ fun PhoneAlertsScreen(alerts: PhoneAlerts, settings: SettingsStore, contentPaddi Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Buzz pattern", style = GlowType.body, color = Color.White) // Picking a pattern plays it once, so it can be felt before the next notification. - GlowSegments(BuzzPattern.entries.map { it.label }, pattern.ordinal, { i -> - val p = BuzzPattern.entries[i] - scope.launch { - settings.setNotificationPattern(p) - previewError = runCatching { alerts.preview(p) }.exceptionOrNull()?.let { it.message ?: "Couldn't buzz the strap" } - } - }) + GlowSegments(BuzzPattern.entries.map { it.label }, pattern.ordinal, { i -> vm.pickPattern(BuzzPattern.entries[i]) }) previewError?.let { PageNote(it, color = MetricColors.warn) } } PageSwitchRow("Quiet hours", quietHoursSummary(quiet, calls && callPermission), quiet.on, { on -> - scope.launch { settings.setQuietHours(quiet.copy(on = on)) } + vm.updateQuietHours { it.copy(on = on) } }) if (quiet.on) { PageRow("From", quietClock(quiet.from), onClick = { pickingFrom = true }, divider = false) PageRow("To", quietClock(quiet.to), onClick = { pickingFrom = false }) if (calls && callPermission) { PageSwitchRow("Calls too", "Off lets a ringing phone still buzz.", quiet.calls, { on -> - scope.launch { settings.setQuietHours(quiet.copy(calls = on)) } + vm.updateQuietHours { it.copy(calls = on) } }) } } @@ -184,7 +184,7 @@ fun PhoneAlertsScreen(alerts: PhoneAlerts, settings: SettingsStore, contentPaddi if (from) "Quiet from" else "Quiet until", if (from) quiet.from else quiet.to, onSave = { m -> pickingFrom = null - scope.launch { settings.setQuietHours(if (from) quiet.copy(from = m) else quiet.copy(to = m)) } + vm.updateQuietHours { if (from) it.copy(from = m) else it.copy(to = m) } }, onDismiss = { pickingFrom = null }, ) @@ -195,7 +195,7 @@ fun PhoneAlertsScreen(alerts: PhoneAlerts, settings: SettingsStore, contentPaddi @OptIn(ExperimentalMaterial3Api::class) @Composable private fun QuietTimeSheet(title: String, minutes: Int, onSave: (Int) -> Unit, onDismiss: () -> Unit) { - val time = rememberTimePickerState(minutes / 60, minutes % 60, is24Hour = true) + val time = rememberTimePickerState(minutes / 60, minutes % 60, is24Hour = ClockSetting.use24Hour) PageSheet(onDismiss, title) { TimePicker(time, Modifier.align(Alignment.CenterHorizontally), colors = AlarmClockColors()) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsViewModel.kt b/app/src/main/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsViewModel.kt new file mode 100644 index 00000000..d4167372 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsViewModel.kt @@ -0,0 +1,103 @@ +package org.openstrap.app.ui.alarms + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.openstrap.app.data.catching +import org.openstrap.app.data.settings.BuzzPattern +import org.openstrap.app.data.settings.CallBuzz +import org.openstrap.app.data.settings.QuietHours +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.device.PhoneAlerts + +/** What the Phone alerts page needs from [PhoneAlerts]: the two accesses and the buzz previews. */ +interface BuzzAlerts { + val callPermission: StateFlow + val notificationAccess: StateFlow + fun refresh() + fun hasCallPermission(): Boolean + suspend fun preview(pattern: BuzzPattern) + suspend fun previewCall(style: CallBuzz) +} + +/** + * The Phone alerts page: buzzing on calls and notifications, the patterns and quiet hours. Every write runs here, so + * leaving the page mid-write doesn't cancel it, and quiet hours change from what is stored, never from the screen's + * copy, which reads the defaults until the store has answered (audit A2). + */ +class PhoneAlertsViewModel(private val settings: SettingsStore, private val alerts: BuzzAlerts) : ViewModel() { + constructor(alerts: PhoneAlerts, settings: SettingsStore) : this(settings, alerts.asBuzzAlerts()) + + val calls: StateFlow = settings.buzzOnCalls.state(false) + val notifications: StateFlow = settings.buzzOnNotifications.state(false) + val pattern: StateFlow = settings.notificationPattern.state(BuzzPattern.TWO_SHORT) + val callStyle: StateFlow = settings.callBuzzStyle.state(CallBuzz.STEADY) + val quiet: StateFlow = settings.quietHours.state(QuietHours()) + val callPermission: StateFlow get() = alerts.callPermission + val notificationAccess: StateFlow get() = alerts.notificationAccess + + private val _previewError = MutableStateFlow(null) + /** Why the last notification pattern didn't play on the strap, or null. */ + val previewError = _previewError.asStateFlow() + + private val _callPreviewError = MutableStateFlow(null) + /** Why the last call buzz didn't play on the strap, or null. */ + val callPreviewError = _callPreviewError.asStateFlow() + + private val quietWrites = Mutex() + + fun refresh() = alerts.refresh() + + fun hasCallPermission(): Boolean = alerts.hasCallPermission() + + fun setBuzzOnCalls(on: Boolean) = viewModelScope.launch { settings.setBuzzOnCalls(on) } + + fun setBuzzOnNotifications(on: Boolean) = viewModelScope.launch { settings.setBuzzOnNotifications(on) } + + /** Keeps [style] and plays it for a few seconds, so the two can be compared on the wrist. */ + fun pickCallBuzz(style: CallBuzz) = viewModelScope.launch { + settings.setCallBuzzStyle(style) + _callPreviewError.value = catching { alerts.previewCall(style) }.exceptionOrNull()?.let { it.message ?: NO_BUZZ } + } + + /** Keeps [pattern] and plays it once, so it can be felt before the next notification. */ + fun pickPattern(pattern: BuzzPattern) = viewModelScope.launch { + settings.setNotificationPattern(pattern) + _previewError.value = catching { alerts.preview(pattern) }.exceptionOrNull()?.let { it.message ?: NO_BUZZ } + } + + /** The page closed: a failed preview isn't said again next time, as before the page had a view model. */ + fun clearPreviewErrors() { + _previewError.value = null + _callPreviewError.value = null + } + + /** Quiet hours as [change] makes them from what is stored. One change at a time, so two quick taps both count. */ + fun updateQuietHours(change: (QuietHours) -> QuietHours) = viewModelScope.launch { + quietWrites.withLock { settings.setQuietHours(change(settings.quietHours.first())) } + } + + private fun Flow.state(initial: T): StateFlow = stateIn(viewModelScope, SharingStarted.Eagerly, initial) + + private companion object { + const val NO_BUZZ = "Couldn't buzz the strap" + } +} + +private fun PhoneAlerts.asBuzzAlerts(): BuzzAlerts = object : BuzzAlerts { + override val callPermission: StateFlow get() = this@asBuzzAlerts.callPermission + override val notificationAccess: StateFlow get() = this@asBuzzAlerts.notificationAccess + override fun refresh() = this@asBuzzAlerts.refresh() + override fun hasCallPermission(): Boolean = this@asBuzzAlerts.hasCallPermission() + override suspend fun preview(pattern: BuzzPattern) = this@asBuzzAlerts.preview(pattern) + override suspend fun previewCall(style: CallBuzz) = this@asBuzzAlerts.previewCall(style) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/components/Cards.kt b/app/src/main/kotlin/org/openstrap/app/ui/components/Cards.kt index 391d1002..5b02dff6 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/components/Cards.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/components/Cards.kt @@ -1,96 +1,16 @@ package org.openstrap.app.ui.components -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp -import org.openstrap.app.ui.theme.AppIcons val PanelShape = org.openstrap.app.ui.glow.GlowLayout.CardShape -/** The base card: a slightly raised surface with a hairline border, as in WHOOP/Oura. */ -@Composable -fun Panel( - modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null, - /** When set, the border fades to this colour (used to point at a card after jumping to it). */ - highlight: Color? = null, - content: @Composable ColumnScope.() -> Unit, -) { - val border by androidx.compose.animation.animateColorAsState( - highlight ?: MaterialTheme.colorScheme.outlineVariant, androidx.compose.animation.core.tween(450), label = "panel border", - ) - Surface( - modifier = modifier.fillMaxWidth().let { if (onClick != null) it.pressClickable(onClick, PanelShape) else it }, - shape = PanelShape, - color = MaterialTheme.colorScheme.surfaceContainer, - border = BorderStroke(if (highlight != null) 1.5.dp else 1.dp, border), - ) { - Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp), content = content) - } -} - /** Small uppercase tracked label: "TIME ASLEEP". */ @Composable fun SectionLabel(text: String, modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.onSurfaceVariant) { Text(text.uppercase(), style = MaterialTheme.typography.labelMedium, color = color, modifier = modifier) } - -/** Label row with an optional leading accent icon and trailing text (e.g. "Last 7 days"). */ -@Composable -fun PanelHeader( - title: String, - icon: ImageVector? = null, - accent: Color = MaterialTheme.colorScheme.onSurfaceVariant, - trailing: String? = null, - /** Shows a "›" at the end: the card opens something when tapped. */ - chevron: Boolean = false, -) { - Row(verticalAlignment = Alignment.CenterVertically) { - if (icon != null) { - Icon(icon, null, tint = accent, modifier = Modifier.size(19.dp)) - Spacer(Modifier.width(8.dp)) - } - SectionLabel(title, Modifier.weight(1f)) - if (trailing != null) Text(trailing, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - if (chevron) { - Spacer(Modifier.width(4.dp)) - Icon(AppIcons.CaretRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) - } - } -} - -fun formatDuration(seconds: Long): String { - val h = seconds / 3600 - val m = (seconds % 3600) / 60 - return when { - // A short workout reads "45s", not "0m". - seconds in 1 until 60 -> "${seconds}s" - h > 0 && m == 0L -> "${h}h" - h > 0 -> "${h}h ${m}m" - else -> "${m}m" - } -} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/components/ChartModels.kt b/app/src/main/kotlin/org/openstrap/app/ui/components/ChartModels.kt new file mode 100644 index 00000000..4bc90570 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/components/ChartModels.kt @@ -0,0 +1,36 @@ +package org.openstrap.app.ui.components + +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput + +/** A point on a time axis: x is epoch seconds, y is the value. */ +data class ChartPoint(val x: Long, val y: Float) + +/** + * Scrubbing for every chart: drag sideways to read values straight away, or press and hold first (the + * older gesture). Vertical drags still scroll the list and a plain tap still reaches the card. + */ +internal fun Modifier.scrub(key: Any?, onScrub: (Float?) -> Unit): Modifier = this + .pointerInput(key) { + detectHorizontalDragGestures( + onDragStart = { onScrub(it.x) }, + onDragEnd = { onScrub(null) }, + onDragCancel = { onScrub(null) }, + ) { change, _ -> change.consume(); onScrub(change.position.x) } + } + .pointerInput(key) { + detectDragGesturesAfterLongPress( + onDragStart = { onScrub(it.x) }, + onDrag = { change, _ -> change.consume(); onScrub(change.position.x) }, + onDragEnd = { onScrub(null) }, + onDragCancel = { onScrub(null) }, + ) + } + +/** A day on a weekly trend chart. */ +data class TrendPoint(val label: String, val value: Float?) + +/** A sleep stage interval for the hypnogram. */ +data class StageSpan(val start: Long, val end: Long, val stage: Int) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/components/Charts.kt b/app/src/main/kotlin/org/openstrap/app/ui/components/Charts.kt deleted file mode 100644 index c16c0a95..00000000 --- a/app/src/main/kotlin/org/openstrap/app/ui/components/Charts.kt +++ /dev/null @@ -1,302 +0,0 @@ -package org.openstrap.app.ui.components - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.input.pointer.pointerInput -import kotlin.math.abs -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.drawText -import androidx.compose.ui.text.rememberTextMeasurer -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import kotlin.math.ceil -import kotlin.math.floor - -/** A point on a time axis: x is epoch seconds, y is the value. */ -data class ChartPoint(val x: Long, val y: Float) - -private val HOUR_FMT = DateTimeFormatter.ofPattern("HH:mm") -private fun hourLabel(epoch: Long) = Instant.ofEpochSecond(epoch).atZone(ZoneId.systemDefault()).format(HOUR_FMT) - -@Composable -private fun axisStyle() = TextStyle(color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 10.sp, fontFamily = org.openstrap.app.ui.theme.FunnelSans) - -private fun DrawScope.label(m: TextMeasurer, text: String, style: TextStyle, x: Float, y: Float, alignEnd: Boolean = false, center: Boolean = false) { - val layout = m.measure(text, style) - val dx = when { - alignEnd -> -layout.size.width.toFloat() - center -> -layout.size.width / 2f - else -> 0f - } - drawText(layout, topLeft = Offset((x + dx).coerceIn(0f, size.width - layout.size.width), y)) -} - -/** - * Scrubbing for every chart: drag sideways to read values straight away, or press and hold first (the - * older gesture). Vertical drags still scroll the list and a plain tap still reaches the card. - */ -internal fun Modifier.scrub(key: Any?, onScrub: (Float?) -> Unit): Modifier = this - .pointerInput(key) { - detectHorizontalDragGestures( - onDragStart = { onScrub(it.x) }, - onDragEnd = { onScrub(null) }, - onDragCancel = { onScrub(null) }, - ) { change, _ -> change.consume(); onScrub(change.position.x) } - } - .pointerInput(key) { - detectDragGesturesAfterLongPress( - onDragStart = { onScrub(it.x) }, - onDrag = { change, _ -> change.consume(); onScrub(change.position.x) }, - onDragEnd = { onScrub(null) }, - onDragCancel = { onScrub(null) }, - ) - } - -/** "62 bpm", "97%", "36.4°C", or the bare value when there's no unit. */ -fun withUnit(value: String, unit: String) = when { - unit.isEmpty() -> value - unit.startsWith("%") || unit.startsWith("°") -> value + unit - else -> "$value $unit" -} - -/** The touch readout: a pill with [text] centred over [x], above [anchorY] when there's room, else below. */ -private fun DrawScope.readout(m: TextMeasurer, text: String, style: TextStyle, bubble: Color, x: Float, anchorY: Float) { - val layout = m.measure(text, style) - val bw = layout.size.width + 16.dp.toPx() - val bh = layout.size.height + 8.dp.toPx() - val bx = (x - bw / 2).coerceIn(0f, (size.width - bw).coerceAtLeast(0f)) - val by = (anchorY - bh - 12.dp.toPx()).let { if (it < 0f) anchorY + 12.dp.toPx() else it } - drawRoundRect(bubble, Offset(bx, by), Size(bw, bh), CornerRadius(bh / 2)) - drawText(layout, topLeft = Offset(bx + 8.dp.toPx(), by + 4.dp.toPx())) -} - -@Composable -private fun readoutStyle() = TextStyle(color = MaterialTheme.colorScheme.onSurface, fontSize = 12.sp, fontFamily = org.openstrap.app.ui.theme.FunnelDisplay) - -/** A coloured stretch of time drawn as a strip under a [LineChart] (e.g. what Charge was doing). */ -data class ChartSegment(val from: Long, val to: Long, val color: Color) - -/** - * Time-series chart in the Oura/WHOOP style: a smooth (monotone, never overshooting) line over a soft - * gradient, a faint solid grid with values on the right, a haloed dot at the latest reading and a - * labelled average. Drag across it to read any point (see [scrub]). Gaps longer than [gapSeconds] break the line so - * off-wrist periods aren't bridged. [segments] adds a Garmin-style state strip under the plot. - */ -@Composable -fun LineChart( - points: List, - color: Color, - xRange: LongRange, - modifier: Modifier = Modifier, - height: Dp = 160.dp, - gapSeconds: Long = 15 * 60, - showAverage: Boolean = true, - markMin: Boolean = false, - band: LongRange? = null, - bandColor: Color = Color.Unspecified, - /** 0..1: how much of the line is drawn, left to right (for the focus animation). */ - reveal: Float = 1f, - /** A fixed value axis (e.g. 0..100 for scores); null fits the data. */ - yRange: ClosedFloatingPointRange? = null, - segments: List = emptyList(), - /** Formats a value for the average label and the touch readout. */ - format: (Float) -> String = { Math.round(it).toString() }, - /** Shown after the value in the touch readout, e.g. "bpm" or "%". */ - unit: String = "", - /** Colours the line and fill by value instead of [color]: (value, colour) stops, lowest first. */ - valueGradient: List>? = null, - /** Labels the time axis and the touch readout; clock times by default. */ - xLabel: (Long) -> String = ::hourLabel, - /** A horizontal band for the usual range of values (your normal), drawn behind the line. */ - normal: ClosedFloatingPointRange? = null, -) { - val measurer = rememberTextMeasurer() - val style = axisStyle() - val grid = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.7f) - val surface = MaterialTheme.colorScheme.surfaceContainer - val onSurface = MaterialTheme.colorScheme.onSurface - val bubble = MaterialTheme.colorScheme.surfaceContainerHighest - val readoutStyle = readoutStyle() - val bandFill = if (bandColor == Color.Unspecified) MaterialTheme.colorScheme.primary.copy(alpha = 0.07f) else bandColor - var touchX by remember { mutableStateOf(null) } - val stripH = if (segments.isEmpty()) 0.dp else 8.dp - - Canvas( - modifier.fillMaxWidth().height(height + stripH) - .scrub(points) { touchX = it }, - ) { - if (xRange.last <= xRange.first) return@Canvas - val axisW = 28.dp.toPx() - val axisH = 18.dp.toPx() - val strip = stripH.toPx() - val w = size.width - axisW - val h = size.height - axisH - strip - val minV = yRange?.start ?: listOfNotNull(points.minOfOrNull { it.y }, normal?.start).minOrNull() ?: 0f - val maxV = yRange?.endInclusive ?: listOfNotNull(points.maxOfOrNull { it.y }, normal?.endInclusive).maxOrNull() ?: 1f - val step = niceStep((maxV - minV).coerceAtLeast(4f) / (if (yRange != null) 4f else 3f)) - val bottom = floor(minV / step) * step - val top = (ceil(maxV / step) * step).coerceAtLeast(bottom + step) - fun xOf(t: Long) = (t - xRange.first).toFloat() / (xRange.last - xRange.first) * w - fun yOf(v: Float) = h - (v - bottom) / (top - bottom) * h - - normal?.let { - val y0 = yOf(it.endInclusive).coerceIn(0f, h) - val y1 = yOf(it.start).coerceIn(0f, h) - if (y1 > y0) drawRoundRect(color.copy(alpha = 0.10f), Offset(0f, y0), Size(w, y1 - y0), CornerRadius(4.dp.toPx())) - } - band?.let { - val x0 = xOf(it.first).coerceIn(0f, w) - val x1 = xOf(it.last).coerceIn(0f, w) - if (x1 > x0) drawRoundRect(bandFill, Offset(x0, 0f), Size(x1 - x0, h), CornerRadius(6.dp.toPx())) - } - - var v = bottom - while (v <= top + 0.001f) { - val y = yOf(v) - drawLine(grid, Offset(0f, y), Offset(w, y), 1.dp.toPx()) - label(measurer, v.toInt().toString(), style, size.width, y - 7.sp.toPx(), alignEnd = true) - v += step - } - // Inclusive ranges like 00:00..23:59:59 label as 00:00, 06:00, … rather than 05:59. - val span = (xRange.last - xRange.first + 1) - for (i in 0..4) { - val t = (xRange.first + span * i / 4).coerceAtMost(xRange.last) - label(measurer, xLabel(t), style, xOf(t), h + strip + 4.dp.toPx(), center = i in 1..3, alignEnd = i == 4) - } - - // State strip: rounded runs of colour under the plot. - if (segments.isNotEmpty()) { - val y0 = h + 3.dp.toPx() - val sh = strip - 3.dp.toPx() - drawRoundRect(grid.copy(alpha = 0.25f), Offset(0f, y0), Size(w, sh), CornerRadius(sh / 2)) - for (sgm in segments) { - val x0 = xOf(sgm.from).coerceIn(0f, w) - val x1 = xOf(sgm.to).coerceIn(0f, w) - if (x1 - x0 >= 1f) drawRoundRect(sgm.color, Offset(x0, y0), Size(x1 - x0, sh), CornerRadius(sh / 2)) - } - } - if (points.isEmpty()) return@Canvas - - val runs = mutableListOf(mutableListOf(points.first())) - for (i in 1 until points.size) { - if (points[i].x - points[i - 1].x > gapSeconds) runs += mutableListOf() - runs.last() += points[i] - } - clipRect(right = w * reveal.coerceIn(0f, 1f)) { - for (run in runs) { - if (run.size < 2) { - // A lone reading (e.g. a spot SpO2) gets a ringed dot so it's visible on its own. - val o = Offset(xOf(run[0].x), yOf(run[0].y)) - drawCircle(color, 5.dp.toPx(), o) - drawCircle(surface, 2.dp.toPx(), o) - continue - } - val pts = run.map { Offset(xOf(it.x), yOf(it.y)) } - val line = smoothPath(pts) - val fill = Path().apply { - addPath(line) - lineTo(pts.last().x, h) - lineTo(pts.first().x, h) - close() - } - if (valueGradient != null) { - // Stops placed at their values' heights, so the line changes colour as it rises and falls. - val y0 = yOf(valueGradient.first().first) - val y1 = yOf(valueGradient.last().first) - val stops = valueGradient.map { (v, c) -> ((yOf(v) - y1) / (y0 - y1)).coerceIn(0f, 1f) to c }.reversed().toTypedArray() - val lineBrush = Brush.verticalGradient(*stops, startY = y1, endY = y0) - val fillBrush = Brush.verticalGradient(*stops.map { (f, c) -> f to c.copy(alpha = 0.16f) }.toTypedArray(), startY = y1, endY = y0) - drawPath(fill, fillBrush) - drawPath(line, lineBrush, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) - } else { - drawPath(fill, Brush.verticalGradient(listOf(color.copy(alpha = 0.30f), color.copy(alpha = 0.02f)), startY = 0f, endY = h)) - drawPath(line, color, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) - } - } - - if (showAverage) { - val avg = points.map { it.y }.average().toFloat() - val avgY = yOf(avg) - drawLine(onSurface.copy(alpha = 0.4f), Offset(0f, avgY), Offset(w, avgY), 1.dp.toPx()) - // A small pill so the label stays readable where it crosses the line. - val text = measurer.measure("avg ${format(avg)}", style) - val pw = text.size.width + 10.dp.toPx() - val ph = text.size.height + 2.dp.toPx() - val py = (avgY - ph / 2).coerceIn(0f, h - ph) - drawRoundRect(bubble, Offset(2.dp.toPx(), py), Size(pw, ph), CornerRadius(ph / 2)) - drawText(text, topLeft = Offset(2.dp.toPx() + 5.dp.toPx(), py + 1.dp.toPx())) - } - if (markMin) { - val p = points.minBy { it.y } - val o = Offset(xOf(p.x), yOf(p.y)) - drawCircle(color, 6.dp.toPx(), o) - drawCircle(surface, 3.dp.toPx(), o) - } - // The latest reading: a solid dot with a soft halo, like Oura's "now". - val last = points.last() - val lo = Offset(xOf(last.x), yOf(last.y)) - drawCircle(color.copy(alpha = 0.25f), 8.dp.toPx(), lo) - drawCircle(color, 4.dp.toPx(), lo) - drawCircle(surface, 1.5.dp.toPx(), lo) - } - - // Touch readout: a guide line, a dot on the curve and a bubble with the value and time. - touchX?.let { tx -> - val x = tx.coerceIn(0f, w) - val t = xRange.first + ((x / w) * (xRange.last - xRange.first)).toLong() - val p = points.minBy { abs(it.x - t) } - if (abs(p.x - t) > gapSeconds) return@let - val px = xOf(p.x) - val py = yOf(p.y) - drawLine(onSurface.copy(alpha = 0.5f), Offset(px, 0f), Offset(px, h), 1.dp.toPx()) - drawCircle(surface, 6.dp.toPx(), Offset(px, py)) - drawCircle(color, 4.5.dp.toPx(), Offset(px, py)) - readout(measurer, "${withUnit(format(p.y), unit)} at ${xLabel(p.x)}", readoutStyle, bubble, px, py) - } - } -} - -/** - * A smooth path through [pts] using monotone cubic interpolation (Fritsch–Carlson): curves like a spline - * but never overshoots the data, so a smoothed heart rate can't show a peak or dip that didn't happen. - */ -internal fun smoothPath(pts: List): Path = org.openstrap.app.ui.glow.draw.composePath(org.openstrap.app.ui.glow.draw.smoothOps(pts)) - -private fun niceStep(raw: Float): Float { - val candidates = floatArrayOf(1f, 2f, 5f, 10f, 20f, 25f, 50f, 100f) - return candidates.firstOrNull { it >= raw } ?: raw -} - -/** A day on a weekly trend chart. */ -data class TrendPoint(val label: String, val value: Float?) - -/** A sleep stage interval for the hypnogram. */ -data class StageSpan(val start: Long, val end: Long, val stage: Int) - diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/ActivityCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/ActivityCopy.kt index 92cf268f..0877fffb 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/ActivityCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/ActivityCopy.kt @@ -5,6 +5,11 @@ import org.openstrap.app.data.UsualSteps import java.time.LocalDate import java.time.format.TextStyle import java.util.Locale +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.LoadBand +import org.openstrap.app.presentation.LoadBands +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.presentation.Units /* * The words of the Activity tab's details (spec 5.3 and 6.1): steps against your usual by now, Heart points, active @@ -47,7 +52,12 @@ internal object ActivityCopy { } /** An hour's steps in the headline while it's tapped: "steps, 09:00 to 10:00". */ - fun hourLabel(hour: Int): String = "steps, %02d:00 to %02d:00".format(hour, (hour + 1) % 24) + fun hourLabel(hour: Int, locale: Locale = Locale.getDefault()): String { + fun clock(h: Int) = TimeOfDay.minutes(h * MINUTES_PER_HOUR, locale = locale) + return "steps, ${clock(hour)} to ${clock(hour + 1)}" + } + + private const val MINUTES_PER_HOUR = 60 /* -------------------------------------------- Heart points ------------------------------------------ */ @@ -113,21 +123,18 @@ internal object ActivityCopy { /* ------------------------------------------------ Load ---------------------------------------------- */ /** Load's scale in its info sheet, low to high, in the words beside it. */ - val LOAD_SCALE = listOf("Below your usual", "Usual", "Above your usual") - - /** Which bar of [LOAD_SCALE] a Load is in against your usual for a whole day; null without a usual. */ - fun loadBar(load: Float, usual: Float?): Int? { - if (usual == null || usual <= 0f) return null - return when { - load > usual * (1 + TodayCopy.LOAD_BAND) -> 2 - load < usual * (1 - TodayCopy.LOAD_BAND) -> 0 - else -> 1 - } + val LOAD_SCALE = listOf(LoadBand.Below, LoadBand.Usual, LoadBand.Above).map { it.text } + + /** Which bar of [LOAD_SCALE] a Load is in against your usual for a whole day, by [LoadBands]. Null without a usual. */ + fun loadBar(load: Float, usual: Float?): Int? = when (LoadBands.of(load, usual)) { + null -> null + LoadBand.Below -> 0 + LoadBand.Usual, LoadBand.SoFar -> 1 + LoadBand.Above -> 2 } - /** Your usual as a band on Load's chart: within [TodayCopy.LOAD_BAND] of it, as Today reads a usual Load. */ - fun usualBand(usual: Float?): ClosedFloatingPointRange? = - usual?.takeIf { it > 0f }?.let { it * (1 - TodayCopy.LOAD_BAND)..it * (1 + TodayCopy.LOAD_BAND) } + /** Your usual as a band on Load's chart: within [LoadBands.BAND] of it, as Today reads a usual Load. */ + fun usualBand(usual: Float?): ClosedFloatingPointRange? = LoadBands.usualRange(usual) /** The band's label: "Your usual, 30 to 50". */ fun usualBandLabel(band: ClosedFloatingPointRange): String = "Your usual, ${Math.round(band.start)} to ${Math.round(band.endInclusive)}" @@ -177,12 +184,12 @@ internal object ActivityCopy { /* ------------------------------------------ Time to recover ----------------------------------------- */ - /** When the workout time to recover counts from ended: "today at 18:30", "yesterday at 07:10", "Tue 20 Oct at 18:30". */ - fun ended(date: LocalDate, clock: String, today: LocalDate, locale: Locale = Locale.getDefault()): String = when (date) { - today -> "today at $clock" - today.minusDays(1) -> "yesterday at $clock" - else -> "${date.format(java.time.format.DateTimeFormatter.ofPattern("EEE d MMM", locale))} at $clock" - } + /** + * When the workout time to recover counts from ended, by [DayWords]: "today at 18:30", "yesterday at 07:10", + * "Tuesday at 18:30", "Tue 13 Oct at 18:30". + */ + fun ended(date: LocalDate, clock: String, today: LocalDate, locale: Locale = Locale.getDefault()): String = + "${DayWords.inSentence(date, today, locale)} at $clock" /** The sentence under the hours to go: which workout they count from. */ fun recover(sport: String, ended: String): String = "Counted from your ${sport.lowercase()}, which ended $ended." @@ -190,7 +197,7 @@ internal object ActivityCopy { /* ----------------------------------------------- Energy --------------------------------------------- */ /** The sentence under the day's kcal: what your body uses at rest, before any movement. */ - fun energy(atRest: Int): String = "Your body uses about ${"%,d".format(atRest)} kcal a day at rest, before any movement." + fun energy(atRest: Int): String = "Your body uses about ${Units.count(atRest.toLong())} kcal a day at rest, before any movement." /* ---------------------------------------------- Distance -------------------------------------------- */ @@ -198,5 +205,6 @@ internal object ActivityCopy { fun distanceSource(fromSteps: Boolean): String = if (fromSteps) "km, from your steps" else "km, from the strap" /** The sentence under the kilometres: the steps they come from. */ - fun distance(steps: Int, today: Boolean): String = "Over ${"%,d".format(steps)} steps ${if (today) "so far today" else "that day"}." + fun distance(steps: Int, today: Boolean): String = + "Over ${Units.count(steps.toLong())} steps ${if (today) "so far today" else "that day"}." } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CaloriesCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CaloriesCopy.kt index f3bc5725..e2f48f03 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CaloriesCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CaloriesCopy.kt @@ -6,9 +6,11 @@ import org.openstrap.app.data.Workouts import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.Locale import kotlin.math.abs +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.presentation.Units /* * The words and sums of Calories (calories spec 4.4 to 4.6): the Activity row, the detail's day and its ranges, where @@ -39,11 +41,17 @@ internal object CaloriesCopy { fun peak(hourly: List, locale: Locale = Locale.getDefault()): String? { val h = hourly.indices.maxByOrNull { hourly[it] } ?: return null val v = Math.round(hourly[h]) - return if (v < 1) null else "Peak %02d:00, %,d".format(locale, h, v) + return if (v < 1) null else "Peak ${hourClock(h, locale)}, ${Units.count(v.toLong(), locale)}" } /** An hour's kcal in the headline while it's picked: "active kcal, 07:00 to 08:00". */ - fun hourLabel(hour: Int): String = "active kcal, %02d:00 to %02d:00".format(hour, (hour + 1) % 24) + fun hourLabel(hour: Int, locale: Locale = Locale.getDefault()): String = + "active kcal, ${hourClock(hour, locale)} to ${hourClock(hour + 1, locale)}" + + /** The start of [hour] on the phone's clock: "07:00", or "7:00 AM". */ + private fun hourClock(hour: Int, locale: Locale) = TimeOfDay.minutes(hour * MINUTES_PER_HOUR, locale = locale) + + private const val MINUTES_PER_HOUR = 60 /** One row of "Where it came from": a workout (opening it, by [workoutStart]), walking or everyday moving. */ data class Source(val name: String, val line: String, val kcal: Int, val workoutStart: Long? = null, val sportType: Int? = null) @@ -57,14 +65,13 @@ internal object CaloriesCopy { workouts: List, date: LocalDate, day: EnergyExpenditure.Day, bmrPerMinute: Float?, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault(), ): List { - val clock = DateTimeFormatter.ofPattern("HH:mm", locale) val onDay = workouts.filter { Instant.ofEpochSecond(it.shownStart).atZone(zone).toLocalDate() == date } val phone = onDay.filter { it.fromPhone } val shown = onDay.filter { w -> w.fromPhone || phone.none { p -> w.workout.start < p.end && p.shownStart < w.workout.end } } .sortedBy { it.shownStart } val rows = shown.mapNotNull { w -> val kcal = Workouts.activeKcal(w, bmrPerMinute) ?: return@mapNotNull null - val at = Instant.ofEpochSecond(w.shownStart).atZone(zone).format(clock) + val at = TimeOfDay.format(w.shownStart, zone, locale = locale) Source(w.sportName, "$at, ${minutes(w.activeSeconds)}", kcal, w.workout.start, w.workout.sportType) } return rows + listOf( @@ -73,11 +80,10 @@ internal object CaloriesCopy { ) } - /** A workout's length on its row: "42 min", "1 h 05". */ - fun minutes(seconds: Long): String { - val m = (seconds + 30) / 60 - return if (m < 60) "$m min" else "%d h %02d".format(m / 60, m % 60) - } + /** A workout's length on its row, to the nearest minute: "42 min", "1 h 05". */ + fun minutes(seconds: Long): String = Units.duration(seconds + HALF_MINUTE_S) + + private const val HALF_MINUTE_S = 30L /** The walking row's line, from its minutes at a walking cadence outside workouts. */ fun walkingLine(minutes: Int): String = when (minutes) { @@ -164,7 +170,8 @@ internal object CaloriesCopy { val busiest = s.busiest ?: return null val share = s.workoutShare return if (share != null && share >= 1) "Workouts made $share% of your active kcal, the rest came from walking and moving about." - else "Your busiest day was ${busiest.date.format(DateTimeFormatter.ofPattern("EEE d MMM", locale))}, with %,d active kcal.".format(locale, Math.round(busiest.active)) + else "Your busiest day was ${DayWords.short(busiest.date, locale)}, " + + "with ${Units.count(Math.round(busiest.active).toLong(), locale)} active kcal." } /* ------------------------------------------- A workout's day ---------------------------------------- */ diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CardLooks.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CardLooks.kt index 6cadba6c..db65d86c 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CardLooks.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/CardLooks.kt @@ -2,7 +2,7 @@ package org.openstrap.app.ui.dashboard import org.openstrap.app.ui.glow.CardSize import org.openstrap.app.ui.glow.GridRow -import org.openstrap.app.ui.glow.Edge +import org.openstrap.app.ui.glow.draw.Edge import org.openstrap.app.ui.glow.Pane import org.openstrap.app.ui.glow.packGrid diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DataStates.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DataStates.kt index f113492f..00a1e3ba 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DataStates.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DataStates.kt @@ -5,6 +5,7 @@ import org.openstrap.app.data.EnergyExpenditure import org.openstrap.app.data.Recovery import org.openstrap.app.data.RecoveryResult import org.openstrap.app.device.ConnectionState +import org.openstrap.app.presentation.Units /* * Data states (spec 4.3, patterns E3 and E4): pairing as one stage, first nights, no sleep last night and a partial @@ -65,7 +66,7 @@ fun wakingLead(firstNights: Boolean, asleepSeconds: Long?, isToday: Boolean): Wa const val FIRST_SCORE = "First score" /** The day remembered for scores from before the app kept a first score, so the marker never shows for them. */ -val BEFORE_FIRST_SCORE: LocalDate = LocalDate.EPOCH +val BEFORE_FIRST_SCORE: LocalDate = LocalDate.of(1970, 1, 1) /** Whether the Recovery hero marks [date]'s score as the first (E4), and the day to remember for it, if any. */ data class FirstScore(val show: Boolean, val remember: LocalDate? = null) @@ -134,8 +135,8 @@ const val PARTIAL_DAY = "Partial day" /** The partial day sheet's line: how much wear the figures cover. */ fun partialLine(w: Wear, isToday: Boolean): String { - val worn = TodayCopy.duration(w.wornMinutes * 60L) - return if (isToday) "The strap recorded $worn of the ${TodayCopy.duration(w.ofMinutes * 60L)} so far today." + val worn = Units.duration(w.wornMinutes * 60L) + return if (isToday) "The strap recorded $worn of the ${Units.duration(w.ofMinutes * 60L)} so far today." else "The strap recorded $worn of the day." } @@ -150,7 +151,8 @@ val PARTIAL_DAY_INFO = Info( val PARTIAL_DAY_ROWS = listOf( "What it lowers" to "Steps, calories and Energy count only the time the strap was on. Charging pauses them too.", "When a day counts as partial" to - "When the strap was worn for under ${TodayCopy.duration(EnergyExpenditure.FULL_DAY_MINUTES * 60L)} of the day, or under that share of the day so far.", + "When the strap was worn for under ${Units.duration(EnergyExpenditure.FULL_DAY_MINUTES * 60L)} of the day, " + + "or under that share of the day so far.", ) /* ------------------------------------------ Debug previews ------------------------------------------ */ diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayCalendar.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayCalendar.kt index a3428e39..bdfcd1e9 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayCalendar.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayCalendar.kt @@ -32,7 +32,7 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -47,10 +47,14 @@ import java.time.LocalDate import java.time.YearMonth import java.time.format.DateTimeFormatter import java.time.format.TextStyle -import java.time.temporal.WeekFields import java.util.Locale +import org.openstrap.app.data.Weeks -/** What the date header's calendar needs: the days that have data, and where a picked day goes. */ +/** + * What the date header's calendar needs: the days that have data, and where a picked day goes. Immutable, so a header + * given an equal picker skips (audit K11). + */ +@androidx.compose.runtime.Immutable data class DayPicker(val available: Set = emptySet(), val onPick: (LocalDate) -> Unit = {}) /** The red full stop from the wordmark marks today. */ @@ -65,7 +69,8 @@ fun DayCalendarSheet(selected: LocalDate, available: Set, onPick: (Lo val today = LocalDate.now() val first = YearMonth.from(available.minOrNull()?.coerceAtMost(today) ?: today) val last = YearMonth.from(today) - var month by remember { mutableStateOf(YearMonth.from(selected)) } + // Opened on another day, the calendar opens on that day's month (audit K8). + var month by rememberSaveable(selected) { mutableStateOf(YearMonth.from(selected)) } // Glass over the blurred page, in the app's overlay. org.openstrap.app.ui.components.GlassSheet(onDismiss) { @@ -85,7 +90,7 @@ fun DayCalendarSheet(selected: LocalDate, available: Set, onPick: (Lo } Spacer(Modifier.height(12.dp)) - val firstDay = WeekFields.of(Locale.getDefault()).firstDayOfWeek + val firstDay = Weeks.firstDay() Row(Modifier.fillMaxWidth()) { for (i in 0 until 7) { SectionLabel( diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayViewModel.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayViewModel.kt index ef27f0b1..01de94e9 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayViewModel.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/DayViewModel.kt @@ -1,59 +1,60 @@ package org.openstrap.app.ui.dashboard +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch -import android.util.Log +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.withContext -import org.openstrap.app.data.RecoveryResult -import org.openstrap.app.data.settings.UserProfile import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn -import org.openstrap.app.data.DayWindow -import org.openstrap.app.data.HealthRepository -import org.openstrap.app.data.LiveSteps -import org.openstrap.app.data.NapTimes +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.openstrap.app.data.DayData +import org.openstrap.app.data.DayRepository +import org.openstrap.app.data.DayValue +import org.openstrap.app.data.RecoveryResult +import org.openstrap.app.data.SleepEdits import org.openstrap.app.data.SleepHistory import org.openstrap.app.data.Summaries +import org.openstrap.app.data.Today +import org.openstrap.app.data.catching import org.openstrap.app.data.db.HealthAlertRow import org.openstrap.app.data.db.OsaEventRow import org.openstrap.app.data.db.OsaResultRow import org.openstrap.app.data.db.PaiSample import org.openstrap.app.data.db.SleepSession import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.data.settings.UserProfile import org.openstrap.app.ui.components.ChartPoint import org.openstrap.app.ui.components.StageSpan import org.openstrap.app.ui.components.TrendPoint -import org.openstrap.protocol.services.StepTotals +import java.time.Clock import java.time.LocalDate -import java.time.ZoneId import java.time.format.TextStyle import java.util.Locale /** Nights of sleep loaded for the week view and the in-sleep vitals' usual values. */ -const val SLEEP_NIGHTS = 14 - -/** Averages over the 7 days before the selected day; null where there's no data. */ -data class Baselines( - val restingHr: Float? = null, - val hrv: Float? = null, - val spo2: Float? = null, - val stress: Float? = null, - val skinTemperature: Float? = null, - val breathRate: Float? = null, -) +const val SLEEP_NIGHTS = SleepHistory.NIGHTS + +/** The data layer's, kept under its old name for the screens. */ +typealias Baselines = org.openstrap.app.data.Baselines + +/** The data layer's, kept under its old name for the screens. */ +typealias SleepNight = org.openstrap.app.data.SleepNight data class DayUiState( val date: LocalDate = LocalDate.now(), @@ -79,7 +80,16 @@ data class DayUiState( val napBreakdowns: Map = emptyMap(), /** Each nap as the strap saw it, keyed by the nap's start as shown (the user's own when they changed its times). */ val napsAsDetected: Map = emptyMap(), + /** The main sleep as the strap saw it, when there's one ([sleep] has the user's times when they changed them). */ + val sleepAsDetected: SleepSession? = null, + /** The night's stages, clipped to [sleep]'s times. */ val sleepStages: List = emptyList(), + /** The strap's whole stages when the user moved the main sleep, so the chart can dim what's left out; else empty. */ + val strapSleepStages: List = emptyList(), + /** The night's main sleep the user deleted, when there's no other ([sleep] is null then), for its Restore. */ + val deletedSleep: SleepEdits.Resolved? = null, + /** Every sleep and nap as shown around the day, from the night before to tonight: what an edit mustn't run into. */ + val sleepNeighbours: List = emptyList(), val sleepBreakdown: Summaries.SleepBreakdown? = null, val spo2: List = emptyList(), val stress: List = emptyList(), @@ -128,24 +138,82 @@ private sealed interface RecoveryOutcome { data class Failed(val failure: RecoveryFailure) : RecoveryOutcome } +/** One day's Heart points: those earned that day and the 7-day total. */ +data class HeartPointsDay(val daily: Float, val total: Float) + +/** One day's stored energy ([org.openstrap.app.data.DailyEnergyHistory]), in kcal. */ +data class EnergyDay(val active: Float, val total: Float, val workout: Float, val partial: Boolean) + +/** + * Each day's Heart points and stored energy up to [date], for the Activity tab and its details, so they're handed + * state rather than a way to read it (audit A11). + */ +data class ActivityHistory( + /** The day these end on, null before the first read. */ + val date: LocalDate? = null, + /** The [HEART_POINTS_DAYS] days ending on [date]. */ + val heartPoints: Map = emptyMap(), + /** The [ENERGY_DAYS] days ending on [date]: the longest range and the one before it. Null while loading. */ + val energy: Map? = null, +) { + /** [heartPoints] when they're [day]'s, else none yet. */ + fun heartPointsOn(day: LocalDate): Map = if (date == day) heartPoints else emptyMap() + + /** [energy] from [from] to [day] when it's [day]'s, else null while it loads. */ + fun energyOn(day: LocalDate, from: LocalDate): Map? = + energy?.takeIf { date == day }?.filterKeys { it in from..day } + + companion object { + /** Heart points' month. */ + const val HEART_POINTS_DAYS = 30 + + /** Calories' year and the year before it, for the change against it. */ + val ENERGY_DAYS = 2 * Range.Year.days + } +} + @OptIn(ExperimentalCoroutinesApi::class) class DayViewModel( - private val repo: HealthRepository, - private val strapSteps: Flow = flowOf(null), + private val repo: DayRepository, profile: Flow = flowOf(UserProfile()), + /** A sleep or nap (by the strap's start) was moved, deleted or restored: Health Connect is written again. */ + private val onSleepEdited: (Long) -> Unit = {}, + /** The clock today is read from, asked afresh each time. Tests pass one they move by hand. */ + private val clock: () -> Clock = { Clock.systemDefaultZone() }, + /** The phone's clock, time zone or date changed ([Today.changes]), so today is read again at once. */ + dateChanges: Flow = emptyFlow(), + /** Where the day's rows are worked into its state, off the main thread (audit C7). */ + private val work: CoroutineDispatcher = Dispatchers.Default, ) : ViewModel() { - private val selected = MutableStateFlow(LocalDate.now()) + /** Today as it moves, so a screen left open past midnight moves on to the new day (audit C10). */ + private val today: StateFlow = Today.dates(clock, dateChanges) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), Today.on(clock())) + + /** The day picked, or null while the screen follows today. */ + private val picked = MutableStateFlow(null) + + /** The day shown: the one picked while it's before today, else today. */ + private val shown: Flow = combine(picked, today) { p, t -> + val date = p?.takeIf { it < t } ?: t + ShownDay(date, date == t) + }.distinctUntilChanged() - fun previousDay() { selected.value = selected.value.minusDays(1) } - fun nextDay() { if (selected.value < LocalDate.now()) selected.value = selected.value.plusDays(1) } - fun select(date: LocalDate) { selected.value = minOf(date, LocalDate.now()) } + private val selected: Flow = shown.map { it.date }.distinctUntilChanged() + + private fun shownNow(): LocalDate = Today.on(clock()).let { t -> picked.value?.takeIf { it < t } ?: t } + + fun previousDay() { picked.value = shownNow().minusDays(1) } + fun nextDay() { picked.value = shownNow().plusDays(1).takeIf { it < Today.on(clock()) } } + fun select(date: LocalDate) { picked.value = date.takeIf { it < Today.on(clock()) } } /** Days the calendar lets you pick. */ val daysWithData: StateFlow> = repo.daysWithData() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet()) - val state: StateFlow = selected.flatMapLatest { date -> stateFor(date) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DayUiState()) + val state: StateFlow = shown.flatMapLatest { (date, isToday) -> repo.day(date, isToday) } + .map(::toUiState) + .flowOn(work) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DayUiState(date = Today.on(clock()))) /** Bumped by [retryRecovery], so the same day and data are worked out again. */ private val recoveryRetries = MutableStateFlow(0) @@ -153,11 +221,25 @@ class DayViewModel( /** Works out the day's Recovery again, after it failed ([recoveryFailure]). */ fun retryRecovery() { recoveryRetries.value++ } - /** Keeps [start] to [end] as the times of the strap's nap [strap]. */ - fun setNapTimes(strap: SleepSession, start: Long, end: Long) { - viewModelScope.launch { repo.setNapTimes(strap, start, end) } + /** Keeps [start] to [end] as the times of the strap's sleep or nap [strap]. */ + fun setSleepTimes(strap: SleepSession, start: Long, end: Long) { + viewModelScope.launch { repo.setSleepTimes(strap, start, end); onSleepEdited(strap.start) } + } + + /** Deletes the strap's sleep or nap [strap]; [restoreSleep] brings it back. */ + fun deleteSleep(strap: SleepSession) { + viewModelScope.launch { repo.deleteSleep(strap); onSleepEdited(strap.start) } } + /** Brings back the strap's deleted sleep or nap [strap], as it was: Undo and Restore. */ + fun restoreSleep(strap: SleepSession) { + viewModelScope.launch { repo.restoreSleep(strap); onSleepEdited(strap.start) } + } + + /** Sleep and naps deleted in the last 7 days, newest first, for You, Your data, Deleted sleep. */ + val deletedSleep: StateFlow> = repo.deletedSleep() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + /** The last Recovery worked out, for yesterday's score when today's fails. */ @Volatile private var lastRecovery: RecoveryResult? = null @@ -169,16 +251,15 @@ class DayViewModel( private val recoveryOutcome: StateFlow = combine(selected, profile, repo.changes().debounce(800), recoveryRetries) { d, p, _, _ -> d to p } .mapLatest { (d, p) -> - withContext(Dispatchers.Default) { - // mapLatest cancels this when newer data arrives; that's not a failure, so let it through. - try { - RecoveryOutcome.Ready(repo.recovery(d, p).also { lastRecovery = it }) - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - Log.w("OpenStrap", "recovery failed", e) - RecoveryOutcome.Failed(RecoveryFailure(d, yesterdayScore(d, p))) - } + withContext(work) { + // mapLatest cancels this when newer data arrives. That's not a failure, and catching lets it through. + catching { repo.recovery(d, p) }.fold( + onSuccess = { RecoveryOutcome.Ready(it.also { r -> lastRecovery = r }) }, + onFailure = { e -> + Log.w("OpenStrap", "recovery failed", e) + RecoveryOutcome.Failed(RecoveryFailure(d, yesterdayScore(d, p))) + }, + ) } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) @@ -201,13 +282,9 @@ class DayViewModel( if (last.date == yesterday) return last.readiness.score last.readinessTrend.firstOrNull { it.first == yesterday }?.let { return it.second } } - return try { - repo.recovery(yesterday, profile).readiness.score - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - null - } + return catching { repo.recovery(yesterday, profile).readiness.score } + .onFailure { Log.w("OpenStrap", "yesterday's recovery failed", it) } + .getOrNull() } /** @@ -217,15 +294,11 @@ class DayViewModel( */ val usualSteps: StateFlow?> = selected .mapLatest { d -> - withContext(Dispatchers.IO) { - try { - org.openstrap.app.data.UsualSteps.byHour(repo.hourlySteps(d.minusDays(org.openstrap.app.data.UsualSteps.DAYS.toLong()), d.minusDays(1)), d) - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - Log.w("OpenStrap", "usual steps failed", e) - null - } + withContext(work) { + catching { + val from = d.minusDays(org.openstrap.app.data.UsualSteps.DAYS.toLong()) + org.openstrap.app.data.UsualSteps.byHour(repo.hourlySteps(from, d.minusDays(1)), d) + }.onFailure { Log.w("OpenStrap", "usual steps failed", it) }.getOrNull() } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) @@ -235,201 +308,90 @@ class DayViewModel( val healthspan: StateFlow = combine(selected, profile, repo.changes().debounce(800)) { d, p, _ -> d to p } .mapLatest { (d, p) -> - withContext(Dispatchers.Default) { - try { - repo.healthspan(d, p) - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - Log.w("OpenStrap", "healthspan failed", e) - null - } + withContext(work) { + catching { repo.healthspan(d, p) }.onFailure { Log.w("OpenStrap", "healthspan failed", it) }.getOrNull() } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - private fun stateFor(date: LocalDate): Flow { - val zone = ZoneId.systemDefault() - val day = DayWindow.of(date) - val night = DayWindow.nightEndingOn(date) - val week = DayWindow(DayWindow.of(date.minusDays(6)).from, day.to) - - val activityAndHr = combine(repo.activity(day), repo.heartRate(day)) { act, hr -> act to hr } - val vitals = combine(repo.spo2(day), repo.stress(day), repo.hrv(day)) { spo2, stress, hrv -> Triple(spo2, stress, hrv) } - // Sessions ending from the start of the night into early tomorrow, so tonight's evening naps are included too. - val nightBefore = DayWindow.nightEndingOn(date.minusDays(1)) - val sessionsAndEdits = combine(repo.sleepSessions(DayWindow(nightBefore.from, day.to + 12 * 3600)), repo.napEdits()) { s, e -> s to e } - val sleep = sessionsAndEdits.flatMapLatest { (sessions, edits) -> - // Main sleep = the longest non-nap session that ended in the night window. - val main = SleepHistory.mainSleep(sessions, night) - val before = SleepHistory.mainSleep(sessions, nightBefore) - // Naps belong to the calendar day they started on, not the night window (which ends at 18:00). - // The strap's start decides the day, so moving a nap's times never moves it to another day. - val detected = sessions.filter { it.nap && it.start in day.from..day.to } - val byStrapStart = edits.associateBy { it.strapStart } - val naps = detected.map { NapTimes.apply(it, byStrapStart) } - val mainStages = if (main == null) flowOf(emptyList()) else repo.sleepStages(main.start) - // Stages stay under the strap's start; they're trimmed to the times shown. - val napStages = if (naps.isEmpty()) flowOf(emptyMap()) - else combine(detected.map { repo.sleepStages(it.start) }) { all -> - naps.zip(all) { n, st -> n.start to NapTimes.clip(st.toList(), n.start, n.end) }.toMap() - } - val asDetected = naps.zip(detected) { n, d -> n.start to d }.toMap() - combine(mainStages, napStages) { st, ns -> SleepParts(main, st, naps, ns, before?.score, asDetected) } - } - val extras = combine(repo.skinTemperature(day), repo.breathRate(night), repo.latestPai(day.to)) { skin, breath, pai -> - Triple(skin, breath, pai?.takeIf { it.timestamp >= day.from }) - } - val weekly = combine(repo.restingHr(week.from, week.to), repo.heartRate(week), repo.activity(week), repo.hrv(week)) { resting, hr, act, hrv -> - val days = (6 downTo 0).map { date.minusDays(it.toLong()) } - val label = { d: LocalDate -> d.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()).take(2) } - val restingTrend = days.map { d -> - val w = DayWindow.of(d, zone) - val stored = resting.firstOrNull { it.day in w.from..w.to }?.bpm - // Our own estimate (lowest 30-min average) only means something with most of a day's data. - val series = Summaries.heartRateSeries(hr.filter { it.timestamp in w.from..w.to }, act.filter { it.timestamp in w.from..w.to }) - val estimated = if (series.size >= MIN_MINUTES_FOR_ESTIMATE) Summaries.estimateRestingHr(series) else null - TrendPoint(label(d), (stored ?: estimated)?.toFloat()) to (stored == null && estimated != null) - } - val hrvTrend = days.map { d -> - val w = DayWindow.of(d, zone) - val v = hrv.filter { it.timestamp in w.from..w.to }.map { it.milliseconds } - TrendPoint(label(d), if (v.isEmpty()) null else v.average().toFloat()) - } - restingTrend to hrvTrend - } - val previous = DayWindow(DayWindow.of(date.minusDays(7)).from, DayWindow.of(date.minusDays(1)).to) - val baselines = combine( - combine(repo.restingHr(previous.from, previous.to), repo.hrv(previous), repo.spo2(previous)) { r, h, s -> Triple(r, h, s) }, - combine(repo.stress(previous), repo.skinTemperature(previous), repo.breathRate(previous)) { st, sk, br -> Triple(st, sk, br) }, - ) { (resting, hrv, spo2), (stress, skin, breath) -> - fun List.mean() = if (isEmpty()) null else map { it.toFloat() }.average().toFloat() - Baselines( - restingHr = resting.map { it.bpm }.mean(), - hrv = hrv.map { it.milliseconds }.mean(), - spo2 = spo2.map { it.percent }.mean(), - stress = stress.map { it.value }.mean(), - skinTemperature = skin.map { it.celsius }.mean(), - breathRate = breath.map { it.perMinute }.mean(), - ) - } - val sleepAndExtras = combine(sleep, extras, baselines) { s, e, b -> Triple(s, e, b) } - val more = combine( - combine(repo.sleepSpo2(night), repo.hrvSdnn(day), repo.osaEvents(night)) { a, b, c -> Triple(a, b, c) }, - repo.osaResult(date), - repo.dailyMaxHr(day), - repo.activityExtended(day), - repo.healthAlerts(day), - ) { (sleepSpo2, sdnn, events), osa, maxHr, extended, alerts -> - MoreParts( - sleepSpo2 = sleepSpo2.map { ChartPoint(it.timestamp, it.percent.toFloat()) }, - hrvSdnn = sdnn.map { ChartPoint(it.timestamp, it.milliseconds) }, - osaResult = osa, - osaEvents = events, - dailyMaxHeartRate = maxHr?.bpm, - distanceMeters = extended.takeIf { it.isNotEmpty() }?.sumOf { it.distanceCm.toLong() }?.let { (it / 100).toInt() }, - activeCalories = extended.takeIf { it.isNotEmpty() }?.sumOf { it.calories }, - alerts = alerts, - ) - } - - val span = DayWindow(DayWindow.nightEndingOn(date.minusDays(SLEEP_NIGHTS - 1L), zone).from, night.to) - val nights = combine( - repo.sleepSessions(span), - combine(repo.hrv(span), repo.skinTemperature(span)) { h, s -> h to s }, - combine(repo.sleepSpo2(span), repo.breathRate(span)) { o, b -> o to b }, - repo.heartRate(night), - ) { sessions, (hrv, skin), (spo2, breath), hr -> - val main = SleepHistory.mainSleeps(sessions, zone) - val list = (SLEEP_NIGHTS - 1 downTo 0).map { ago -> - val d = date.minusDays(ago.toLong()) - val s = main[d] - // Averages over the sleep itself, not the whole night window. - fun during(samples: List, time: (T) -> Long, value: (T) -> Float): Float? = - s?.let { w -> samples.filter { time(it) in w.start..w.end }.map(value).takeIf { it.isNotEmpty() }?.average()?.toFloat() } - SleepNight( - date = d, - session = s, - hrv = during(hrv, { it.timestamp }, { it.milliseconds.toFloat() }), - skinTemperature = during(skin, { it.timestamp }, { it.celsius }), - spo2 = during(spo2, { it.timestamp }, { it.percent.toFloat() }), - breathing = during(breath, { it.timestamp }, { it.perMinute.toFloat() }), - ) - } - // The night's own readings, for the charts of the night-scoped details. - val tonight = main[date] - val sleepHrv = tonight?.let { w -> hrv.filter { it.timestamp in w.start..w.end }.map { ChartPoint(it.timestamp, it.milliseconds.toFloat()) } }.orEmpty() - val sleepSkin = tonight?.let { w -> skin.filter { it.timestamp in w.start..w.end }.map { ChartPoint(it.timestamp, it.celsius) } }.orEmpty() - NightParts( - list, Summaries.bucketAverage(Summaries.heartRateSeries(hr, emptyList())).map { ChartPoint(it.first, it.second.toFloat()) }, - sleepHrv, sleepSkin, + /** Each day's Heart points and stored energy up to the day shown, for the Activity tab and its details. */ + val activityHistory: StateFlow = selected.flatMapLatest { d -> + combine( + repo.dailyPai(d.minusDays(ActivityHistory.HEART_POINTS_DAYS - 1L), d), + repo.dailyEnergy(d.minusDays(ActivityHistory.ENERGY_DAYS - 1L), d), + ) { pai, energy -> + ActivityHistory( + date = d, + heartPoints = pai.mapValues { (_, p) -> HeartPointsDay(p.daily, p.total) }, + energy = energy.mapValues { (_, e) -> EnergyDay(e.activeKcal, e.totalKcal, e.workoutKcal, e.partial) }, ) } - val moreAndNights = combine(more, nights) { m, n -> - m.copy(nights = n.nights, sleepHeartRate = n.heartRate, sleepHrv = n.hrv, sleepSkinTemperature = n.skinTemperature) - } + } + .flowOn(work) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ActivityHistory()) - // The strap's own running total covers steps whose minute records were already taken by another app. - val liveSteps = if (date == LocalDate.now()) strapSteps else flowOf(null) - val liveCounter = LiveSteps() - val actAndLive = combine(activityAndHr, liveSteps) { a, live -> a to live } - - return combine(actAndLive, vitals, sleepAndExtras, weekly, moreAndNights) { (ah, live), (spo2, stress, hrv), (sleepParts, extraParts, base), (restingWithFlag, hrvTrend), m -> - val (session, stages, naps, napStages) = sleepParts - val (skin, breath, pai) = extraParts - val restingTrend = restingWithFlag.map { it.first } - val (act, hr) = ah - val series = Summaries.heartRateSeries(hr, act) - val hourly = Summaries.hourlySteps(act, day.from) - DayUiState( + companion object { + /** + * [data] as the screens show it: the heart rate as a chart, the steps by hour, the trends with their weekday + * labels and the stages as spans. + */ + internal fun toUiState(data: DayData): DayUiState { + val label = { d: LocalDate -> d.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()).take(2) } + fun List.trend() = map { TrendPoint(label(it.date), it.value) } + fun List.spans() = map { StageSpan(it.start, it.end, it.stage) } + val sleep = data.sleep + val series = Summaries.heartRateSeries(data.heartRate, data.minutes) + val hourly = Summaries.hourlySteps(data.minutes, data.day.from) + return DayUiState( loaded = true, - date = date, - steps = liveCounter.today(Summaries.totalSteps(act), live), - minuteSteps = Summaries.totalSteps(act), + date = data.date, + steps = data.steps, + minuteSteps = Summaries.totalSteps(data.minutes), hourlySteps = hourly, activeHours = Summaries.activeHours(hourly), heartRate = Summaries.bucketAverage(series).map { ChartPoint(it.first, it.second.toFloat()) }, latestHeartRate = series.lastOrNull()?.second, - restingHeartRate = restingTrend.last().value?.toInt(), - restingEstimated = restingWithFlag.last().second, - restingTrend = restingTrend, - hrvTrend = hrvTrend, - sleep = session, - previousSleepScore = sleepParts.previousScore, - naps = naps, - napStages = napStages.mapValues { (_, st) -> st.map { StageSpan(it.start, it.end, it.stage) } }, - napBreakdowns = napStages.filterValues { it.isNotEmpty() }.mapValues { (_, st) -> Summaries.sleepBreakdown(st) }, - napsAsDetected = sleepParts.napsAsDetected, - sleepStages = stages.map { StageSpan(it.start, it.end, it.stage) }, - sleepBreakdown = if (stages.isEmpty()) null else Summaries.sleepBreakdown(stages), - spo2 = spo2.map { ChartPoint(it.timestamp, it.percent.toFloat()) }, - stress = stress.map { ChartPoint(it.timestamp, it.value.toFloat()) }, - hrv = hrv.map { ChartPoint(it.timestamp, it.milliseconds.toFloat()) }, - skinTemperature = skin.map { ChartPoint(it.timestamp, it.celsius) }, - breathRate = breath.map { ChartPoint(it.timestamp, it.perMinute.toFloat()) }, - pai = pai, - baselines = base, - sleepSpo2 = m.sleepSpo2, - hrvSdnn = m.hrvSdnn, - osaResult = m.osaResult, - osaEvents = m.osaEvents, - dailyMaxHeartRate = m.dailyMaxHeartRate, - distanceMeters = m.distanceMeters, - activeCalories = m.activeCalories, - alerts = m.alerts, - nights = m.nights, - sleepHeartRate = m.sleepHeartRate, - sleepHrv = m.sleepHrv, - sleepSkinTemperature = m.sleepSkinTemperature, - dayStart = day.from, - dayEnd = day.to, + restingHeartRate = data.restingTrend.lastOrNull()?.value?.toInt(), + restingEstimated = data.restingEstimated.lastOrNull() ?: false, + restingTrend = data.restingTrend.trend(), + hrvTrend = data.hrvTrend.trend(), + sleep = sleep.main, + previousSleepScore = sleep.previousScore, + naps = sleep.naps, + napStages = sleep.napStages.mapValues { (_, st) -> st.spans() }, + napBreakdowns = sleep.napStages.filterValues { it.isNotEmpty() }.mapValues { (_, st) -> Summaries.sleepBreakdown(st) }, + napsAsDetected = sleep.napsAsDetected, + sleepAsDetected = sleep.mainAsDetected, + strapSleepStages = sleep.strapStages.spans(), + deletedSleep = sleep.deletedMain, + sleepNeighbours = sleep.neighbours, + sleepStages = sleep.mainStages.spans(), + sleepBreakdown = if (sleep.mainStages.isEmpty()) null else Summaries.sleepBreakdown(sleep.mainStages), + spo2 = data.spo2.map { ChartPoint(it.timestamp, it.percent.toFloat()) }, + stress = data.stress.map { ChartPoint(it.timestamp, it.value.toFloat()) }, + hrv = data.hrv.map { ChartPoint(it.timestamp, it.milliseconds.toFloat()) }, + skinTemperature = data.skin.map { ChartPoint(it.timestamp, it.celsius) }, + breathRate = data.breath.map { ChartPoint(it.timestamp, it.perMinute.toFloat()) }, + pai = data.pai, + baselines = data.baselines, + sleepSpo2 = data.sleepSpo2.map { ChartPoint(it.timestamp, it.percent.toFloat()) }, + hrvSdnn = data.hrvSdnn.map { ChartPoint(it.timestamp, it.milliseconds) }, + osaResult = data.osaResult, + osaEvents = data.osaEvents, + dailyMaxHeartRate = data.dailyMaxHeartRate, + distanceMeters = data.distanceMeters, + activeCalories = data.activeCalories, + alerts = data.alerts, + nights = data.nights, + sleepHeartRate = Summaries.bucketAverage(Summaries.heartRateSeries(data.nightHeartRate, emptyList())).map { + ChartPoint(it.first, it.second.toFloat()) + }, + sleepHrv = data.sleepHrv.map { ChartPoint(it.timestamp, it.milliseconds.toFloat()) }, + sleepSkinTemperature = data.sleepSkin.map { ChartPoint(it.timestamp, it.celsius) }, + dayStart = data.day.from, + dayEnd = data.day.to, ) } - } - - companion object { - /** Minutes of heart-rate data a day needs before we estimate its resting HR ourselves. */ - private const val MIN_MINUTES_FOR_ESTIMATE = 6 * 60 val SLEEP_STAGE_ROWS = mapOf( SleepStage.AWAKE to 0, @@ -440,45 +402,5 @@ class DayViewModel( } } -private data class SleepParts( - val main: SleepSession?, - val mainStages: List, - val naps: List, - val napStages: Map>, - val previousScore: Int? = null, - val napsAsDetected: Map = emptyMap(), -) - -private data class MoreParts( - val sleepSpo2: List, - val hrvSdnn: List, - val osaResult: OsaResultRow?, - val osaEvents: List, - val dailyMaxHeartRate: Int?, - val distanceMeters: Int?, - val activeCalories: Int?, - val alerts: List, - val nights: List = emptyList(), - val sleepHeartRate: List = emptyList(), - val sleepHrv: List = emptyList(), - val sleepSkinTemperature: List = emptyList(), -) - -private data class NightParts( - val nights: List, - val heartRate: List, - val hrv: List, - val skinTemperature: List, -) - -/** One night's main sleep and the vitals averaged over it. */ -data class SleepNight( - val date: LocalDate, - val session: SleepSession?, - val hrv: Float? = null, - val skinTemperature: Float? = null, - val spo2: Float? = null, - val breathing: Float? = null, -) { - val asleepSeconds: Long? get() = session?.let(SleepHistory::asleepSeconds) -} +/** The day shown, and whether it's today, so a day picked in the past isn't read again at midnight. */ +private data class ShownDay(val date: LocalDate, val isToday: Boolean) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Frame.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Frame.kt index 5bbe2638..8547f804 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Frame.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Frame.kt @@ -1,26 +1,27 @@ package org.openstrap.app.ui.dashboard import java.time.LocalDate -import java.time.format.DateTimeFormatter import java.time.format.TextStyle import java.util.Locale import org.openstrap.app.data.Moment import org.openstrap.app.device.ConnectionState import org.openstrap.app.device.SyncState -import org.openstrap.app.ui.glow.GlowMood -import org.openstrap.app.ui.glow.LightAngle +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.StrapBands +import org.openstrap.app.ui.glow.draw.GlowMood +import org.openstrap.app.ui.glow.draw.LightAngle /* * The frame around every tab (spec 3): what the header says about the day and the strap, and how the tabs answer * taps and back. Plain functions, so the rules are tested without a screen. */ -/** The header's title for [date] on Today: "Today", "Yesterday", or the weekday ("Tuesday"). */ -fun dayTitle(date: LocalDate, today: LocalDate = LocalDate.now(), locale: Locale = Locale.getDefault()): String = when (date) { - today -> "Today" - today.minusDays(1) -> "Yesterday" - else -> date.dayOfWeek.getDisplayName(TextStyle.FULL, locale) -} +/** + * The header's title for [date] on Today, by [DayWords]: "Today", "Yesterday", the weekday within the week ("Tuesday"), + * then the date ("Tue 21 Oct"). + */ +fun dayTitle(date: LocalDate, today: LocalDate = LocalDate.now(), locale: Locale = Locale.getDefault()): String = + DayWords.relative(date, today, locale) /** * The header's title on Today (spec 3.1): the [moment] for today, [dayTitle] for a past day, which keeps its name. @@ -80,8 +81,7 @@ fun heroLightAngle(moment: Moment): Float = when (moment) { } /** The day under the header's title: "Thu 23 Oct". */ -fun dateLine(date: LocalDate, locale: Locale = Locale.getDefault()): String = - date.format(DateTimeFormatter.ofPattern("EEE d MMM", locale)) +fun dateLine(date: LocalDate, locale: Locale = Locale.getDefault()): String = DayWords.short(date, locale) /** A neighbouring day's short name, peeking in beside the title: "Wed". */ fun peekDay(date: LocalDate, locale: Locale = Locale.getDefault()): String = @@ -110,7 +110,7 @@ data class StrapChip(val text: String, val dot: Dot, val description: String) { } companion object { - const val LOW_BATTERY = 15 + const val LOW_BATTERY = StrapBands.LOW_BATTERY } } @@ -146,7 +146,8 @@ fun strapChip(paired: Boolean, connection: ConnectionState, sync: SyncState, las connection.charging && battery != null -> StrapChip("Charging $battery%", StrapChip.Dot.Charging, "Strap charging, battery $battery percent") sync is SyncState.Failed -> StrapChip("Not synced", StrapChip.Dot.Warn, "Strap connected, not synced") battery == null -> StrapChip("", StrapChip.Dot.Fine, "Strap connected") - battery <= StrapChip.LOW_BATTERY -> StrapChip("$battery%", StrapChip.Dot.Low, "Strap connected, battery low, $battery percent") + StrapBands.lowBattery(battery, connection.charging) -> + StrapChip("$battery%", StrapChip.Dot.Low, "Strap connected, battery low, $battery percent") else -> StrapChip("$battery%", StrapChip.Dot.Fine, "Strap connected, battery $battery percent") } } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/InfoCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/InfoCopy.kt index fe3c1729..3bb3294d 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/InfoCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/InfoCopy.kt @@ -4,6 +4,10 @@ import org.openstrap.app.data.Activity import org.openstrap.app.data.History import org.openstrap.app.data.Recovery import org.openstrap.app.data.SleepRegularity +import org.openstrap.app.presentation.RecoveryBands +import org.openstrap.app.presentation.SleepBands +import org.openstrap.app.presentation.Units +import org.openstrap.app.presentation.Vo2maxCopy /* * What every detail's info sheet says (spec 6.2): a bold one-line meaning, the scale its value is read on, and three @@ -26,24 +30,19 @@ object InfoCopy { val NORMAL_SCALE = listOf("Below your normal", "Usual", "Above your normal") /** Recovery's bands, low to high, in the words beside the score ([TodayCopy.recoveryWord]). */ - val RECOVERY_SCALE = listOf("Take it easy", "Moderate", "Ready", "Ready for more") + val RECOVERY_SCALE = RecoveryBands.WORDS /** Charge's bands, low to high. */ val CHARGE_SCALE = listOf("Running low", "Some left", "Good", "Full") /** The Sleep score's bands, low to high, in the words beside it ([TabCopy.sleepScoreWord]). */ - val SLEEP_SCALE = listOf("Poor", "Fair", "Good", "Great") + val SLEEP_SCALE = SleepBands.WORDS /** Sleep regularity's bands, low to high ([SleepRegularity.Band]). */ val REGULARITY_SCALE = SleepRegularity.Band.entries.map { it.label } /** Which bar of [SLEEP_SCALE] a Sleep score is in. */ - fun sleepBar(score: Int): Int = when { - score >= 90 -> 3 - score >= 80 -> 2 - score >= 60 -> 1 - else -> 0 - } + fun sleepBar(score: Int): Int = SleepBands.bar(score) /** Which bar of [REGULARITY_SCALE] a band is. */ fun regularityBar(band: SleepRegularity.Band): Int = band.ordinal @@ -52,12 +51,7 @@ object InfoCopy { fun normalBar(status: Int?): Int? = status?.let { it.coerceIn(-1, 1) + 1 } /** Which bar of [RECOVERY_SCALE] a score is in. */ - fun recoveryBar(score: Int): Int = when { - score >= 85 -> 3 - score >= 70 -> 2 - score >= Recovery.FAIR_FROM -> 1 - else -> 0 - } + fun recoveryBar(score: Int): Int = RecoveryBands.bar(score) /** Which bar of [CHARGE_SCALE] a Charge is in, by quarters. */ fun chargeBar(charge: Int): Int = (charge / 25).coerceIn(0, 3) @@ -350,13 +344,13 @@ object InfoCopy { e.sex?.let { add("That's typical of a ${if (it == org.openstrap.app.data.settings.UserProfile.Sex.Male) "man" else "woman"} aged about ${a.first} to ${a.last}.") } } } - withheld?.let { add("No estimate from your profile yet. ${it.reason}") } + withheld?.let { add("No estimate from your profile yet. ${Vo2maxCopy.reason(it)}") } } /** What's particular about the day's energy: part of the day worn, an unusual total, minutes from heart rate. */ fun energyNotes(e: org.openstrap.app.data.EnergyExpenditure.Day?): List = buildList { e ?: return@buildList - if (e.partial) add("It covers ${TodayCopy.duration(e.minutesWithData * 60L)} of wear, so it's part of the day.") + if (e.partial) add("It covers ${Units.duration(e.minutesWithData * 60L)} of wear, so it's part of the day.") if (e.palImplausible) add("That's an unusual total for a full day. Check your profile and how the strap was worn.") if (e.hrAssistedMinutes > 0) add("${e.hrAssistedMinutes} minutes came from heart rate rather than steps.") } @@ -417,11 +411,11 @@ object InfoCopy { /** The week story's sheet: what each day's numbers are and how far back the comparisons reach. */ val WEEK = Info( "Your week", - "Last Monday to Sunday, day by day: how much you moved, trained and slept.", + "Last week, day by day: how much you moved, trained and slept.", emptyList(), how = "Steps are each day's total from the strap's minutes, and steps a day the average of the days with any. " + "Active minutes are worked out as in their own sheet. A night belongs to the evening it started, so " + - "Sunday's is the night into Monday.", + "the week's last night runs into the next week.", moves = "Every walk, workout and early night shows here a week later.", off = "Steps and workouts are compared with the week before. Active minutes and sleep only go back two weeks, " + "so they're shown without a comparison. A day with the strap off reads low.", @@ -443,12 +437,15 @@ object InfoCopy { if (reg.sri != null) add("This week compares $hours h of wear.") else { add("Not enough wear for the full measure yet: $hours h of the ${SleepRegularity.MIN_VALID_PAIRS / 60} h it needs.") - reg.midpointSdMinutes?.let { add("Meanwhile the middle of your sleep moved about ${TodayCopy.duration(Math.round(it) * 60L)} this week.") } + reg.midpointSdMinutes?.let { + add("Meanwhile the middle of your sleep moved about ${Units.duration(Math.round(it) * 60L)} this week.") + } } } /** What's particular about the night's Sleep score: the sleep need the guidance measures against. */ - fun sleepNotes(needMinutes: Int): List = listOf("Your sleep need here is ${TodayCopy.duration(needMinutes * 60L)}, from your age or your own setting.") + fun sleepNotes(needMinutes: Int): List = + listOf("Your sleep need here is ${Units.duration(needMinutes * 60L)}, from your age or your own setting.") /** * What's particular about today's Recovery, for its info sheet: the strap's own score beside ours, and anything that diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Narrative.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Narrative.kt index 82f9a592..10063f83 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Narrative.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Narrative.kt @@ -1,7 +1,9 @@ package org.openstrap.app.ui.dashboard import org.openstrap.app.data.db.SleepSession -import org.openstrap.app.ui.components.formatDuration +import org.openstrap.app.presentation.RecoveryBands +import org.openstrap.app.presentation.SleepBands +import org.openstrap.app.presentation.Units import kotlin.math.abs /** @@ -61,8 +63,8 @@ object Narrative { short && broken -> "Short, broken night" short -> "Short night" broken -> "Broken sleep" - score >= 90 -> "Solid night" - score >= 80 -> "Good night" + score >= SleepBands.GREAT_FROM -> "Solid night" + score >= SleepBands.GOOD_FROM -> "Good night" light -> "Light sleep" else -> "Okay night" } @@ -84,10 +86,12 @@ object Narrative { ): List = buildList { val asleepMin = (asleepSeconds / 60).toInt() val duration = org.openstrap.app.data.Recovery.durationScore(asleepMin, needMinutes) - add(Contributor("Total sleep", totalSleepRating(asleepMin, duration), asleepMin / needMinutes.toFloat(), formatDuration(asleepSeconds) + " of ${formatDuration(needMinutes * 60L)}")) + val total = Units.duration(asleepSeconds) + " of ${Units.duration(needMinutes * 60L)}" + add(Contributor("Total sleep", totalSleepRating(asleepMin, duration), asleepMin / needMinutes.toFloat(), total)) if (wasoMinutes != null && asleepMin > 0) { val c = org.openstrap.app.data.Recovery.continuityScore(wasoMinutes) - add(Contributor("Continuity", ratingFor(Math.round(c)), c / 100f, "${formatDuration(wasoMinutes * 60L)} awake after falling asleep")) + val awake = "${Units.duration(wasoMinutes * 60L)} awake after falling asleep" + add(Contributor("Continuity", ratingFor(Math.round(c)), c / 100f, awake)) } } @@ -113,9 +117,9 @@ object Narrative { /** A 0-100 contributor score as a rating word. */ fun ratingFor(score: Int): Rating = when { - score >= 85 -> Rating.OPTIMAL - score >= 70 -> Rating.GOOD - score >= org.openstrap.app.data.Recovery.FAIR_FROM -> Rating.FAIR + score >= RecoveryBands.READY_FOR_MORE_FROM -> Rating.OPTIMAL + score >= RecoveryBands.READY_FROM -> Rating.GOOD + score >= RecoveryBands.MODERATE_FROM -> Rating.FAIR else -> Rating.LOW } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Routes.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Routes.kt index 9976ec59..0f198b47 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Routes.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/Routes.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.saveable.Saver import org.openstrap.app.data.settings.Goals import org.openstrap.app.data.DailyMetric +import org.openstrap.app.presentation.Units +import java.util.Locale /** A metric with its own detail screen: its title, unit, decimals shown, and the per-day series behind it. */ enum class Metric(val title: String, val unit: String, val decimals: Int, val daily: DailyMetric) { @@ -17,11 +19,16 @@ enum class Metric(val title: String, val unit: String, val decimals: Int, val da Steps("Steps", "steps", 0, DailyMetric.Steps), ; - fun format(v: Float): String = if (this == Steps) "%,d".format(Math.round(v)) else "%.${decimals}f".format(v) + /** + * [v] as this metric shows it: "13,860" steps, "36.5" °C, in [locale]'s digits and marks. A figure drawn in the dot + * font passes [Units.DOT_LOCALE], since the font has only ASCII digits and a comma would read as a thousands mark. + */ + fun format(v: Float, locale: Locale = Locale.getDefault()): String = + if (this == Steps) Units.count(Math.round(v).toLong(), locale) else String.format(locale, "%.${decimals}f", v) /** A change from your normal, signed with a true minus (spec 2.3): "+0.2", "−0.3"; no sign is lost to rounding. */ - fun formatChange(d: Float): String { - val shown = format(kotlin.math.abs(d)) + fun formatChange(d: Float, locale: Locale = Locale.getDefault()): String { + val shown = format(kotlin.math.abs(d), locale) return (if (d < 0 && shown.any { it in '1'..'9' }) "\u2212" else "+") + shown } } @@ -63,7 +70,7 @@ sealed interface Detail { data object Recovery : Detail /** The Sleep tab's screen, opened from a card on another tab. */ data object Sleep : Detail - /** The week story: last Monday to Sunday day by day, opened from Your week on Today. */ + /** The week story: last week day by day, opened from Your week on Today. */ data object Week : Detail companion object { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SleepCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SleepCopy.kt index 8b871330..75563007 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SleepCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SleepCopy.kt @@ -7,6 +7,8 @@ import org.openstrap.app.data.SleepRegularity import org.openstrap.app.ui.components.ChartPoint import kotlin.math.abs import kotlin.math.roundToInt +import org.openstrap.app.presentation.Units +import org.openstrap.app.presentation.TimeOfDay /* * The numbers and words of the Sleep tab's details (spec 5.2 and 6.1): the night-scoped vitals, the Sleep score, Sleep @@ -161,8 +163,8 @@ internal object SleepCopy { fun factors(contributors: List, asleepSeconds: Long, needMinutes: Int, wasoMinutes: Int?): List = contributors.map { c -> val detail = when (c.label) { - "Total sleep" -> "${TodayCopy.duration(asleepSeconds)} of ${TodayCopy.duration(needMinutes * 60L)}" - else -> "${TodayCopy.duration((wasoMinutes ?: 0) * 60L)} awake after falling asleep" + "Total sleep" -> "${Units.duration(asleepSeconds)} of ${Units.duration(needMinutes * 60L)}" + else -> "${Units.duration((wasoMinutes ?: 0) * 60L)} awake after falling asleep" } val name = if (c.label == "Total sleep") "Time asleep" else "Staying asleep" Factor(name, detail, factorWord(c.rating), c.fraction, c.rating != Narrative.Rating.GOOD) @@ -198,7 +200,8 @@ internal object SleepCopy { band == SleepRegularity.Band.Regular -> "Regular this week. Most days kept similar sleep times." band == SleepRegularity.Band.Fair -> "Fair this week. Your sleep times moved on some days." band == SleepRegularity.Band.Irregular -> "Irregular this week. Your sleep times moved a lot from day to day." - sd != null -> "The middle of your sleep moved about ${TodayCopy.duration(sd.roundToInt() * 60L)} this week. The full measure needs more wear." + sd != null -> + "The middle of your sleep moved about ${Units.duration(sd.roundToInt() * 60L)} this week. The full measure needs more wear." else -> "Regularity needs about 5 days of wear around the clock." } } @@ -213,8 +216,8 @@ internal object SleepCopy { val diff = Math.floorMod(bedMinutes - usualBed + 12 * 60, 24 * 60) - 12 * 60 return when { abs(diff) < USUAL_MINUTES -> "$base, close to your usual." - diff > 0 -> "$base, ${TodayCopy.duration(diff * 60L)} later than usual." - else -> "$base, ${TodayCopy.duration(-diff * 60L)} earlier than usual." + diff > 0 -> "$base, ${Units.duration(diff * 60L)} later than usual." + else -> "$base, ${Units.duration(-diff * 60L)} earlier than usual." } } @@ -224,7 +227,7 @@ internal object SleepCopy { /** The night shown, without a sleep. */ fun noNight(today: Boolean): String = if (today) "No sleep recorded last night." else "No sleep recorded that night." - private fun clock(m: Int) = Math.floorMod(m, 1440).let { "%02d:%02d".format(it / 60, it % 60) } + private fun clock(m: Int) = TimeOfDay.minutes(m) /* ----------------------------------------------- Naps ----------------------------------------------- */ @@ -236,11 +239,90 @@ internal object SleepCopy { /** The day's naps in a sentence: "One nap, 45 min." or "Two naps, 1 h 10 in all." */ fun naps(count: Int, totalSeconds: Long): String = - if (count == 1) "One nap, ${TodayCopy.duration(totalSeconds)}." else "${DetailCopy.count(count, "nap").replaceFirstChar { it.uppercase() }}, ${TodayCopy.duration(totalSeconds)} in all." + if (count == 1) "One nap, ${Units.duration(totalSeconds)}." + else "${DetailCopy.count(count, "nap").replaceFirstChar { it.uppercase() }}, ${Units.duration(totalSeconds)} in all." /** Under a nap's heart rate line. */ fun napHeart(lowest: Int, average: Int): String = "Lowest $lowest bpm, $average on average." /** A nap the strap recorded without stages. */ const val NAP_NO_STAGES = "The strap didn't record stages for this nap." + + /* --------------------------------------- Adjust times (edit spec 4.2) ------------------------------- */ + + /** A span of clock times in text: "23:20 to 07:21". */ + fun span(from: String, to: String): String = "$from to $to" + + /** The times sheet's title. */ + fun timesTitle(nap: Boolean): String = if (nap) "Nap times" else "Sleep times" + + /** Under the sheet's title. */ + fun strapSaw(from: String, to: String): String = "The strap saw ${span(from, to)}." + + /** How to read the sheet's heart rate. */ + const val TIMES_HINT = "Your heart rate drops as you fall asleep and rises as you wake." + + /** The sheet's result line for times that can be saved. */ + fun timesResult(nap: Boolean, seconds: Long): String = + if (nap) "A nap of ${Units.duration(seconds)}." else "Asleep for ${Units.duration(seconds)}." + + /** On a detail whose sleep or nap has the user's times. */ + fun yourTimes(from: String, to: String): String = "Your times. ${strapSaw(from, to)}" + + /** The score's info sheet, when the strap's score was moved for the user's times ([org.openstrap.app.data.SleepEdits.anchored]). */ + const val SCORE_FROM_YOUR_TIMES = "The strap's score, adjusted for your times." + + /* ------------------------------------------ Delete (edit spec 4.3) ---------------------------------- */ + + /** The delete sheet's question. */ + fun deleteTitle(nap: Boolean, today: Boolean): String = when { + nap -> "Delete this nap?" + today -> "Delete last night's sleep?" + else -> "Delete that night's sleep?" + } + + /** What a delete does, after the times. */ + const val DELETE_WHAT = "It won't count toward your totals, scores or Health Connect. Heart rate and the other readings stay." + + /** Where to undo it later. */ + const val DELETE_RESTORE = "You can bring it back from You, Your data, Deleted sleep for 7 days." + + /** The snackbar after a delete, with Undo. */ + fun deleted(nap: Boolean, today: Boolean, start: String): String = when { + nap -> "Nap at $start deleted" + today -> "Last night's sleep deleted" + else -> "That night's sleep deleted" + } + + /** The card in a deleted night's place. */ + const val NO_SLEEP_TITLE = "No sleep recorded" + + fun deletedNight(from: String, to: String, today: Boolean): String = + "You deleted ${span(from, to)}. " + + if (today) "Recovery this morning works without last night, as on a night the strap was off." + else "Recovery that morning works without that night, as on a night the strap was off." + + /** The headline's label on a deleted night. */ + fun noScoreDeleted(today: Boolean): String = if (today) "Last night" else "That night" + + /* ---------------------------------- Deleted sleep page (edit spec 4.4) ------------------------------ */ + + const val DELETED_INTRO = "Sleep and naps you deleted in the last 7 days. Restoring puts it back in your totals, scores and Health Connect." + const val DELETED_FOOT = "After 7 days they are deleted for good, even if the strap syncs them again." + + /** The Deleted sleep page once nothing is left in it. */ + const val DELETED_NONE = "Nothing deleted in the last 7 days." + + /** A deleted entry's name: "Night of Wed 29 Oct" or "Nap, Thu 30 Oct". */ + fun deletedName(nap: Boolean, day: String): String = if (nap) "Nap, $day" else "Night of $day" + + /** Under it, on two lines so neither wraps beside Restore: "23:20 to 07:21", then "6 days left". */ + fun deletedLine(from: String, to: String, daysLeft: Int): String = + "${span(from, to)}\n${if (daysLeft == 1) "1 day" else "$daysLeft days"} left" + + /** Under a nap whose heart rate stayed as when sitting still (edit spec 4.5). */ + fun stillNap(bpm: Int): String = "Heart rate stayed at $bpm, as when you sit still. Might not be a nap." + + /** The You page's row value. */ + fun deletedCount(n: Int): String = if (n == 1) "1 you can restore" else "$n you can restore" } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SleepPanels.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SleepPanels.kt index 68877241..66bf253a 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SleepPanels.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SleepPanels.kt @@ -21,7 +21,8 @@ import org.openstrap.app.data.DayStat import org.openstrap.app.data.History import org.openstrap.app.data.SleepHistory import org.openstrap.app.data.SleepRegularity -import org.openstrap.app.ui.components.formatDuration +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.presentation.Units import org.openstrap.app.ui.theme.MetricColors import java.time.Instant import java.time.ZoneId @@ -113,7 +114,7 @@ internal fun sleepWeek( slept = slept.size, usual = SleepHistory.usualTimes(state.nights.mapNotNull { n -> n.session?.let { n.date to it } }.toMap(), state.date, zone), regularity = regularity, - averageAsleep = formatDuration(asleep.average().toLong()), + averageAsleep = Units.duration(asleep.average().toLong()), goalNights = asleep.count { it >= goalSeconds }, ) } @@ -188,7 +189,7 @@ internal fun SleepWindows( } } -internal fun minutesClock(minutes: Int): String = SleepHistory.clockMinutes(minutes).let { "%02d:%02d".format(it / 60, it % 60) } +/** Minutes since midnight on the phone's clock, wrapping past either end of the day: "23:30", or "11:30 PM". */ +internal fun minutesClock(minutes: Int): String = TimeOfDay.minutes(minutes) -private fun clockOf(epochSeconds: Long): String = - Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault()).let { "%02d:%02d".format(it.hour, it.minute) } +private fun clockOf(epochSeconds: Long): String = TimeOfDay.format(epochSeconds, ZoneId.systemDefault()) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SyncStates.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SyncStates.kt index de8fa064..4513a29e 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SyncStates.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/SyncStates.kt @@ -3,11 +3,13 @@ package org.openstrap.app.ui.dashboard import java.time.Instant import java.time.LocalTime import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.Locale import org.openstrap.app.device.ConnectionState import org.openstrap.app.device.SyncRecord import org.openstrap.app.device.SyncState +import org.openstrap.app.presentation.ClockSetting +import org.openstrap.app.presentation.StrapBands +import org.openstrap.app.presentation.TimeOfDay /* * Sync and strap states (spec 4.3, patterns E1, E2 and E5): quiet until they matter. A stopped sync turns the chip @@ -16,10 +18,10 @@ import org.openstrap.app.device.SyncState */ /** - * Data older than this, while syncing has stopped, is stale. Connected, the strap syncs at least hourly by itself, so - * an hour old is normal; the half hour on top covers a sync that's slow to start. + * Data older than this, while syncing has stopped, is stale: the one threshold the widgets use too + * ([StrapBands.STALE_AFTER_S]), so one missed sync in normal wear doesn't mark the cards. */ -const val STALE_AFTER_S = 90 * 60L +const val STALE_AFTER_S = StrapBands.STALE_AFTER_S /** A sync still failing after this long takes Today's hero (spec 4.3). To be tuned on hardware (spec 9). */ const val STUCK_AFTER_S = 3 * 3600L @@ -30,15 +32,9 @@ val MORNING_START: LocalTime = LocalTime.of(4, 0) /** And ends at noon, where the rest of Today stops calling it the morning. */ val MORNING_END: LocalTime = LocalTime.NOON -/** - * The app writes times in 24 h everywhere ("HH:mm"), whatever the phone is set to. This is the one switch for the - * times these states show, so they change with the rest of the app when it follows the phone's setting. - */ -const val APP_24_HOUR = true - -/** A clock time: "09:40", or "9:40 AM" in 12 h. */ -fun clockText(epoch: Long, zone: ZoneId, use24Hour: Boolean = APP_24_HOUR, locale: Locale = Locale.getDefault()): String = - Instant.ofEpochSecond(epoch).atZone(zone).format(DateTimeFormatter.ofPattern(if (use24Hour) "HH:mm" else "h:mm a", locale)) +/** A clock time on the phone's clock ([TimeOfDay]): "09:40", or "9:40 AM" in 12 h. Kept for callers that still name it. */ +fun clockText(epoch: Long, zone: ZoneId, use24Hour: Boolean = ClockSetting.use24Hour, locale: Locale = Locale.getDefault()): String = + TimeOfDay.format(epoch, zone, use24Hour, locale) /** * Whether syncing has stopped: the last sync failed, or the strap isn't connected so nothing can sync. A sync @@ -53,10 +49,10 @@ fun syncStopped(paired: Boolean, connection: ConnectionState, sync: SyncState): */ fun upToMarker( newest: Long?, now: Long, stopped: Boolean, isToday: Boolean, zone: ZoneId, - staleAfter: Long = STALE_AFTER_S, use24Hour: Boolean = APP_24_HOUR, + staleAfter: Long = STALE_AFTER_S, use24Hour: Boolean = ClockSetting.use24Hour, ): String? = if (!isToday || !stopped || newest == null || now - newest < staleAfter) null - else "Up to ${clockText(newest, zone, use24Hour)}" + else "Up to ${TimeOfDay.format(newest, zone, use24Hour)}" /** * Whether a failing sync takes Today's hero (spec 4.3): on today, still failing [STUCK_AFTER_S] after the first @@ -85,8 +81,8 @@ fun stuckCopy(hasLastNight: Boolean, upTo: String?): StuckCopy = ) /** "Last tried 07:42", for the stuck hero; null when no attempt was seen. */ -fun lastTried(record: SyncRecord, zone: ZoneId, use24Hour: Boolean = APP_24_HOUR): String? = - record.lastAttempt?.let { "Last tried ${clockText(it, zone, use24Hour)}" } +fun lastTried(record: SyncRecord, zone: ZoneId, use24Hour: Boolean = ClockSetting.use24Hour): String? = + record.lastAttempt?.let { "Last tried ${TimeOfDay.format(it, zone, use24Hour)}" } /** Recovery couldn't be worked out (spec 4.3, E5): yesterday's score stays, dimmed, with this line and [TRY_AGAIN]. */ const val RECOVERY_FAILED = "Couldn't work out today's score." diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabCopy.kt index a75d2121..ce310ba8 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabCopy.kt @@ -2,10 +2,15 @@ package org.openstrap.app.ui.dashboard import org.openstrap.app.data.Recovery import org.openstrap.app.data.SleepRegularity -import org.openstrap.app.data.Workouts +import org.openstrap.app.data.SportTypes import java.time.LocalDate -import java.time.format.DateTimeFormatter import java.util.Locale +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.Pace +import org.openstrap.app.presentation.RecoveryBands +import org.openstrap.app.presentation.SleepBands +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.presentation.Units /* * The words on the Recovery, Sleep and Activity tabs (spec 5 and 7). Like [TodayCopy], built only from numbers the @@ -56,7 +61,7 @@ object TabCopy { private const val SENTENCE_WEIGHT = 0.06f /** Below this a contributor holds the score back ([org.openstrap.app.ui.dashboard.Narrative.ratingFor]'s Good). */ - private const val HELD_BACK_BELOW = 70 + private const val HELD_BACK_BELOW = RecoveryBands.READY_FROM /** * The contributor holding Recovery back the most: the lowest scored of those that weigh enough, when it's under @@ -71,7 +76,7 @@ object TabCopy { */ fun recovery(score: Int, alert: Boolean, contributors: List): String { val meaning = TodayCopy.recoveryMeaning(score, alert) - val by = heldBackBy(contributors)?.takeIf { score < 85 && !alert } ?: return meaning + val by = heldBackBy(contributors)?.takeIf { score < RecoveryBands.READY_FOR_MORE_FROM && !alert } ?: return meaning return "$meaning Mostly held back by ${factorPhrase(by)}." } @@ -81,16 +86,19 @@ object TabCopy { /** The Recovery hero with no sleep recorded last night: no night to score (spec 4.3). */ const val NO_SLEEP_SCORE = "No sleep recorded last night, so there's no score today." + /** The same when the user deleted last night's sleep. */ + const val DELETED_SLEEP_SCORE = "You deleted last night's sleep, so there's no score today." + /** A contributor's status word, by the score's own bands. */ fun contributorWord(score: Int): String = when { - score >= 85 -> "Helping" - score >= 70 -> "Usual" - score >= Recovery.FAIR_FROM -> "Holding back a little" + score >= RecoveryBands.READY_FOR_MORE_FROM -> "Helping" + score >= RecoveryBands.READY_FROM -> "Usual" + score >= RecoveryBands.MODERATE_FROM -> "Holding back a little" else -> "Holding you back" } /** Whether a contributor is far enough from usual to take its colour. */ - fun contributorOff(score: Int): Boolean = score < 70 || score >= 85 + fun contributorOff(score: Int): Boolean = score < RecoveryBands.READY_FROM || score >= RecoveryBands.READY_FOR_MORE_FROM /** The [n] contributors that weigh most, heaviest first: the ones "What drove it" shows. */ fun topDrivers(contributors: List, n: Int = 3): List = @@ -139,7 +147,7 @@ object TabCopy { * are the week's other nights, asleep seconds). "7 h 40, your longest this week." */ fun night(asleepSeconds: Long, others: List): String { - val d = TodayCopy.duration(asleepSeconds) + val d = Units.duration(asleepSeconds) return when { others.size < WEEK_COMPARE_NIGHTS -> "$d asleep." asleepSeconds > others.max() -> "$d, your longest this week." @@ -152,19 +160,16 @@ object TabCopy { /** The Sleep tab's night before the strap has seen one (spec 4.3). */ const val FIRST_NIGHT_SLEEP = "Your first night with the strap shows here in the morning." - fun noNight(naps: Boolean, isToday: Boolean): String = when { + /** [deleted]: the user deleted the night, so it says so rather than ask whether the strap was on. */ + fun noNight(naps: Boolean, isToday: Boolean, deleted: Boolean = false): String = when { + deleted -> TodayCopy.deletedNight(isToday) naps -> "No night sleep recorded, only naps." isToday -> TodayCopy.NO_SLEEP else -> "No sleep recorded that night." } /** The sleep score's word, in the bands the strap's score is built for. */ - fun sleepScoreWord(score: Int): String = when { - score >= 90 -> "Great" - score >= 80 -> "Good" - score >= 60 -> "Fair" - else -> "Poor" - } + fun sleepScoreWord(score: Int): String = SleepBands.word(score) /** The score's line beside the night's dotted score: "Sleep score, good", or why there's no score. */ fun sleepScoreLine(score: Int?): String = score?.let { "Sleep score, ${sleepScoreWord(it).lowercase()}" } ?: NO_STRAP_SCORE @@ -173,16 +178,14 @@ object TabCopy { const val NO_STRAP_SCORE = "No score from the strap" /** A stage's total on the stage bar's legend: "Deep 1 h 32", "Awake 18 min". */ - fun stageTotal(stage: Int, seconds: Long): String = "${SLEEP_NAMES[stage].orEmpty()} ${TodayCopy.duration(seconds)}" + fun stageTotal(stage: Int, seconds: Long): String = "${SLEEP_NAMES[stage].orEmpty()} ${Units.duration(seconds)}" /** "7 h 40 asleep". */ - fun asleep(seconds: Long): String = "${TodayCopy.duration(seconds)} asleep" + fun asleep(seconds: Long): String = "${Units.duration(seconds)} asleep" - /** When the night ran, bedtime to waking: "22:58–06:56" (an en dash, a time range). */ - fun nightTimes(start: Long, end: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): String { - fun clock(epoch: Long) = java.time.Instant.ofEpochSecond(epoch).atZone(zone).format(DateTimeFormatter.ofPattern("HH:mm")) - return "${clock(start)}–${clock(end)}" - } + /** When the night ran, bedtime to waking: "22:58–06:56" (an en dash, a time range), in the phone's clock. */ + fun nightTimes(start: Long, end: Long, zone: java.time.ZoneId = java.time.ZoneId.systemDefault()): String = + "${TimeOfDay.format(start, zone)}–${TimeOfDay.format(end, zone)}" /** "Woke 3 times", "Woke once", "Slept through"; null when the strap didn't say. */ fun woke(count: Int?): String? = when (count) { @@ -202,9 +205,7 @@ object TabCopy { /** "Usually asleep by 23:10, up at 06:50." from minutes since midnight. */ fun usualTimes(bedMinutes: Int, wakeMinutes: Int): String = - "Usually asleep by ${minutes(bedMinutes)}, up at ${minutes(wakeMinutes)}." - - private fun minutes(m: Int) = Math.floorMod(m, 1440).let { "%02d:%02d".format(it / 60, it % 60) } + "Usually asleep by ${TimeOfDay.minutes(bedMinutes)}, up at ${TimeOfDay.minutes(wakeMinutes)}." /** Breathing disturbances against your usual, or how many nights it still needs. */ fun breathing(b: Recovery.BreathingDisturbances): String = when (val usual = b.usual) { @@ -222,6 +223,9 @@ object TabCopy { * The week's balance from fatigue (the last 7 days) against fitness (the last 6 weeks): "Building" when the week * is well above your usual, "Easing off" when well below, "Steady" between, with the band Today uses for a usual * Load ([TodayCopy.LOAD_BAND]). Null before there's a fitness to compare with. + * + * Not a [org.openstrap.app.presentation.LoadBands] rule: it weighs the week's ATL against the CTL, not a day's Load + * against your usual day, so it keeps its own words and only borrows the band's width. */ fun loadBalance(trend: Recovery.LoadTrend?): String? { val t = trend ?: return null @@ -283,26 +287,23 @@ object TabCopy { enum class SportFact { Run, Walk, Strength, Other } fun sportFact(sportType: Int): SportFact = when (sportType) { - in Workouts.RUNS -> SportFact.Run - in Workouts.WALKS -> SportFact.Walk - in org.openstrap.app.data.Strength.SPORTS -> SportFact.Strength + in SportTypes.RUNS -> SportFact.Run + in SportTypes.WALKS -> SportFact.Walk + in SportTypes.STRENGTH -> SportFact.Strength else -> SportFact.Other } - /** Shorter than this and a pace says more about GPS noise than the run. */ - private const val PACE_MIN_M = 500f - /** - * A workout's one fact, by [sport]: distance and pace for a run ("5.2 km, 5:24 a km"), distance for a walk, sets + * A workout's one fact, by [sport]: distance and pace for a run ("5.2 km, 5:24 /km"), distance for a walk, sets * for strength ("14 sets"), average heart rate otherwise ("Average 128 bpm"). Each falls back to the next the * workout has: a run without a GPS pace to its distance, a run or walk without one to heart rate, strength without * sets to heart rate, anything else without heart rate to distance. Distance from steps is approximate ("≈ 3.4 * km") and gets no pace. Only phone workouts carry distance and sets; the strap's fall back to heart rate. */ fun workoutFact(sport: SportFact, distanceMeters: Float?, fromSteps: Boolean, activeSeconds: Long?, sets: Int?, averageHeartRate: Int?): String? { - val distance = distanceMeters?.takeIf { it >= 10f }?.let { distanceText(it, fromSteps) } - val pace = distanceMeters?.takeIf { !fromSteps && it >= PACE_MIN_M }?.let { m -> - activeSeconds?.takeIf { it > 0 }?.let { paceText(it / (m / 1000f)) } + val distance = distanceMeters?.let { Units.distance(it, Units.Precision.Day, fromSteps) } + val pace = distanceMeters?.takeIf { !fromSteps }?.let { m -> + activeSeconds?.takeIf { it > 0 }?.let { Pace.text(Pace.of(m, it)) } } val heart = averageHeartRate?.takeIf { it > 0 }?.let { "Average $it bpm" } val counted = sets?.takeIf { it > 0 }?.let { if (it == 1) "1 set" else "$it sets" } @@ -314,16 +315,11 @@ object TabCopy { } } - /** "850 m" under a kilometre, "5.2 km" from one; "≈ " in front when estimated from steps. */ - fun distanceText(meters: Float, fromSteps: Boolean): String = - (if (fromSteps) "≈ " else "") + if (meters < 1000f) "${meters.toInt()} m" else "%.1f km".format(meters / 1000f) + /** "850 m" under a kilometre, "5.2 km" from one, "≈ " in front when estimated from steps. Empty under 10 m. */ + fun distanceText(meters: Float, fromSteps: Boolean): String = Units.distance(meters, Units.Precision.Day, fromSteps).orEmpty() - /** "5:24 a km"; null for a pace slower than an hour a kilometre, which is standing still. */ - fun paceText(secondsPerKm: Float): String? { - if (secondsPerKm <= 0f || secondsPerKm > 3600f) return null - val s = Math.round(secondsPerKm) - return "%d:%02d a km".format(s / 60, s % 60) - } + /** "5:24 /km", null for a pace slower than an hour a kilometre, which is standing still. */ + fun paceText(secondsPerKm: Float): String? = Pace.text(secondsPerKm.toDouble()) /** * A workout row's second line: its fact, then when it started ("3.4 km, 17:35"), or the time alone. @@ -332,22 +328,18 @@ object TabCopy { */ fun workoutLine(fact: String?, startClock: String): String = listOfNotNull(fact, startClock).joinToString(", ") - /** The header over a day's workouts: "Today", "Yesterday", then "Wed 21 Oct", with the year when it isn't this one. */ - fun workoutDay(date: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String = when (date) { - today -> "Today" - today.minusDays(1) -> "Yesterday" - else -> date.format(DateTimeFormatter.ofPattern(if (date.year == today.year) "EEE d MMM" else "EEE d MMM yyyy", locale)) - } + /** The header over a day's workouts, by [DayWords]: "Today", "Yesterday", "Tuesday", then "Wed 21 Oct". */ + fun workoutDay(date: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String = DayWords.relative(date, today, locale) /* --------------------------------------------- Habit Age --------------------------------------------- */ /** "Updated Sun 18 Oct", for the Sunday the estimate is for. */ fun habitAgeUpdated(sunday: LocalDate, locale: Locale = Locale.getDefault()): String = - "Updated " + sunday.format(DateTimeFormatter.ofPattern("EEE d MMM", locale)) + "Updated " + DayWords.short(sunday, locale) /** "First estimate Sunday 22 Nov", before there's a number. */ fun firstEstimate(sunday: LocalDate, locale: Locale = Locale.getDefault()): String = - "First estimate " + sunday.format(DateTimeFormatter.ofPattern("EEEE d MMM", locale)) + "First estimate " + sunday.format(org.openstrap.app.presentation.Formatters.of("EEEE d MMM", locale)) /** Habit Age before its first estimate, under your own age in its place: "Your age for now", then what it waits for. */ fun habitAgeForNow(waiting: String?): String = if (waiting.isNullOrBlank()) "Your age for now" else "Your age for now. $waiting" diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabPlans.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabPlans.kt index b8480481..b23d8434 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabPlans.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TabPlans.kt @@ -1,7 +1,7 @@ package org.openstrap.app.ui.dashboard import org.openstrap.app.ui.glow.CardSize -import org.openstrap.app.ui.glow.Edge +import org.openstrap.app.ui.glow.draw.Edge import org.openstrap.app.ui.glow.Pane /* diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayCopy.kt index 0140849c..821beeeb 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayCopy.kt @@ -1,32 +1,27 @@ package org.openstrap.app.ui.dashboard -import java.time.DayOfWeek import java.time.Instant import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.temporal.TemporalAdjusters +import java.time.format.TextStyle import java.util.Locale import kotlin.math.abs import org.openstrap.app.data.Recovery +import org.openstrap.app.presentation.LoadBand +import org.openstrap.app.presentation.LoadBands +import org.openstrap.app.presentation.RecoveryBands +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.presentation.Units +import org.openstrap.app.data.Weeks /* * The words on Today's hero and its small facts (spec 4.2 and 7). Built only from numbers the app already has: where * a number is missing the sentence says what it waits for, and nothing is guessed. */ object TodayCopy { - /** A length of time the way Today says it: "40 min", "7 h 40", "8 h". */ - fun duration(seconds: Long): String { - val minutes = seconds.coerceAtLeast(0) / 60 - val h = minutes / 60 - val m = minutes % 60 - return when { - h == 0L -> "$m min" - m == 0L -> "$h h" - else -> "$h h %02d".format(m) - } - } + /** A length of time the way the app says it: "40 min", "7 h 40", "8 h". Kept for callers that still name it here. */ + fun duration(seconds: Long): String = Units.duration(seconds) /** A value against your normal, from [org.openstrap.app.data.History.status]: null is no normal yet. */ fun normalWord(direction: Int?): String = when { @@ -39,20 +34,14 @@ object TodayCopy { /** What a Recovery score means for the day, in a sentence. */ fun recoveryMeaning(score: Int, alert: Boolean): String = when { alert -> "Some overnight signals are off your normal. An easy day suits." - score >= 85 -> "Well recovered. A good day for a hard session." - score >= 70 -> "Recovered enough for your usual training." - score >= Recovery.FAIR_FROM -> "Not fully recovered. Keep the effort moderate." + score >= RecoveryBands.READY_FOR_MORE_FROM -> "Well recovered. A good day for a hard session." + score >= RecoveryBands.READY_FROM -> "Recovered enough for your usual training." + score >= RecoveryBands.MODERATE_FROM -> "Not fully recovered. Keep the effort moderate." else -> "Still catching up. An easy day helps." } /** The status word beside the score. */ - fun recoveryWord(score: Int, alert: Boolean): String = when { - alert -> "Take it easy" - score >= 85 -> "Ready for more" - score >= 70 -> "Ready" - score >= Recovery.FAIR_FROM -> "Moderate" - else -> "Take it easy" - } + fun recoveryWord(score: Int, alert: Boolean): String = RecoveryBands.word(score, alert) /** * Why there's no Recovery score yet. [Learning] counts the nights OpenStrap has toward its first score ([nights] of @@ -71,12 +60,14 @@ object TodayCopy { /** * The waking hero (spec 4.2, 4.3): how long you slept, then what Recovery says, or why there's no score yet and the - * strap's own [estimate] when there is one. [firstNights] is before the strap has seen any sleep. + * strap's own [estimate] when there is one. [firstNights] is before the strap has seen any sleep. [deleted] is a + * night the user deleted, which never asks whether the strap was on. */ - fun waking(asleepSeconds: Long?, score: Int?, alert: Boolean, noScore: NoScore?, firstNights: Boolean, isToday: Boolean): String { + fun waking(asleepSeconds: Long?, score: Int?, alert: Boolean, noScore: NoScore?, firstNights: Boolean, isToday: Boolean, deleted: Boolean = false): String { if (firstNights) return RECOVERY_FIRST_NIGHT + if (asleepSeconds == null && deleted) return deletedNight(isToday) if (asleepSeconds == null) return if (isToday) NO_SLEEP else "No sleep recorded that night." - val slept = "Slept ${duration(asleepSeconds)}." + val slept = "Slept ${Units.duration(asleepSeconds)}." return when { score != null -> "$slept ${recoveryMeaning(score, alert)}" // The strap's estimate stands under the sentence with its label, so the sentence doesn't repeat it. @@ -91,6 +82,9 @@ object TodayCopy { /** Today's waking hero with no sleep recorded last night, and the Sleep tab's night (spec 4.3). */ const val NO_SLEEP = "No sleep recorded last night. Was the strap on?" + /** In [NO_SLEEP]'s place when the user deleted the night themselves (sleep edit spec 4.3). */ + fun deletedNight(isToday: Boolean): String = if (isToday) "You deleted last night's sleep." else "You deleted that night's sleep." + /** What a Recovery card shows: the number (null for a dash), the word beside it and the line under it. */ data class RecoveryShown(val value: Int?, val word: String?, val line: String, val estimate: Boolean = false) @@ -146,8 +140,8 @@ object TodayCopy { /** The workout window hero's title: what Recovery says about effort now. */ fun windowTitle(score: Int?): String = when { score == null -> "Your usual workout time" - score >= 70 -> "Ready for effort" - score >= Recovery.FAIR_FROM -> "Fine for a steady session" + score >= RecoveryBands.READY_FROM -> "Ready for effort" + score >= RecoveryBands.MODERATE_FROM -> "Fine for a steady session" else -> "An easy session suits today" } @@ -175,27 +169,23 @@ object TodayCopy { /** Within this many bpm of resting, the heart has settled. */ const val SETTLED_BPM = 10 - /** How a Load compares with your usual. */ + /** + * How a Load compares with your usual, one for one with [LoadBand]. It stays here while screens outside the + * dashboard still name it, and its words are [LoadBand]'s. + */ enum class LoadStatus(val text: String) { - Above("Above your usual"), Usual("Usual"), Below("Below your usual"), SoFar("So far today") + Above(LoadBand.Above.text), Usual(LoadBand.Usual.text), Below(LoadBand.Below.text), SoFar(LoadBand.SoFar.text) } /** - * [load] against your [usual] daily Load. Before the evening a low Load is only "so far today": compared with a - * whole usual day it would always look low (spec 2.7, compare by pace). Null before there's a usual. + * [load] against your [usual] daily Load by [LoadBands]. Before the evening a low Load is only "so far today": + * compared with a whole usual day it would always look low (spec 2.7, compare by pace). Null before there's a usual. */ - fun loadStatus(load: Float, usual: Float?, endOfDay: Boolean): LoadStatus? { - if (usual == null || usual <= 0f) return null - return when { - load > usual * (1 + LOAD_BAND) -> LoadStatus.Above - !endOfDay -> LoadStatus.SoFar - load < usual * (1 - LOAD_BAND) -> LoadStatus.Below - else -> LoadStatus.Usual - } - } + fun loadStatus(load: Float, usual: Float?, endOfDay: Boolean): LoadStatus? = + LoadBands.of(load, usual, endOfDay)?.let { LoadStatus.valueOf(it.name) } - /** A Load within this share of your usual counts as usual. */ - const val LOAD_BAND = 0.25f + /** A Load within this share of your usual counts as usual ([LoadBands.BAND]). */ + const val LOAD_BAND = LoadBands.BAND /** * The evening hero: what kind of day it was, then what made it. [workouts] are the day's workouts as short nouns @@ -216,7 +206,7 @@ object TodayCopy { 1 -> "a ${workouts[0] ?: "workout"}" else -> "${workouts.size} workouts" }, - if (stepCount || stepGoal <= 0) "%,d steps".format(locale, steps) else stepsAgainstGoal(steps, stepGoal), + if (stepCount || stepGoal <= 0) "${Units.count(steps.toLong(), locale)} steps" else stepsAgainstGoal(steps, stepGoal), when (load) { LoadStatus.Above -> "a Load above your usual" LoadStatus.Below -> "a Load below your usual" @@ -240,7 +230,7 @@ object TodayCopy { fun bedtimeIn(now: LocalTime, bedtime: LocalTime?): String? { bedtime ?: return null val minutes = afterNoon(bedtime) - afterNoon(now) - return if (minutes <= 0) null else "Bedtime in ${duration(minutes * 60L)}" + return if (minutes <= 0) null else "Bedtime in ${Units.duration(minutes * 60L)}" } private fun afterNoon(t: LocalTime): Int = Math.floorMod(t.toSecondOfDay() / 60 - 720, 1440) @@ -252,16 +242,16 @@ object TodayCopy { * Time to recover as a value and a status: "14" and "Hours left, ready tomorrow", "2" and "Hours left, ready by * 20:30". Recovered has no number, only the word (null value). */ - fun recoverText(left: Long, now: Long, zone: ZoneId): Pair { + fun recoverText(left: Long, now: Long, zone: ZoneId, locale: Locale = Locale.getDefault()): Pair { if (left <= 0) return null to "Recovered" val hours = (left + 3599) / 3600 val ready = Instant.ofEpochSecond(now + left).atZone(zone) val today = Instant.ofEpochSecond(now).atZone(zone).toLocalDate() val unit = if (hours == 1L) "Hour left" else "Hours left" val status = when (ready.toLocalDate()) { - today -> "ready by ${ready.format(DateTimeFormatter.ofPattern("HH:mm"))}" + today -> "ready by ${TimeOfDay.format(now + left, zone, locale = locale)}" today.plusDays(1) -> "ready tomorrow" - else -> "ready ${ready.dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, Locale.getDefault())}" + else -> "ready ${ready.dayOfWeek.getDisplayName(TextStyle.FULL, locale)}" } return "$hours" to "$unit, $status" } @@ -275,13 +265,17 @@ object TodayCopy { LoadStatus.SoFar -> "Load so far today" } - /** Under Your week before there's a week before it to compare with. */ - const val WEEK_FIRST = "Week on week comparisons start next Monday." + /** Under Your week before there's a week before it to compare with: the week starts on the locale's first day. */ + val WEEK_FIRST: String get() = weekFirst() + + /** [WEEK_FIRST] in [locale]: "Week on week comparisons start next Monday." */ + fun weekFirst(locale: Locale = Locale.getDefault()): String = + "Week on week comparisons start next ${Weeks.firstDay(locale).getDisplayName(TextStyle.FULL, locale)}." - /** The week Your week tells (spec 4.2): Monday to Sunday, the last full week before [date]. */ - fun lastWeek(date: LocalDate): ClosedRange { - val start = date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).minusWeeks(1) - return start..start.plusDays(6) + /** The week Your week tells (spec 4.2): the last full week before [date], from the locale's first day ([Weeks]). */ + fun lastWeek(date: LocalDate, firstDay: java.time.DayOfWeek = Weeks.firstDay()): ClosedRange { + val start = Weeks.start(date, firstDay).minusWeeks(1) + return start..Weeks.end(start, firstDay) } /** "Steps up 12%" and the like, comparing two weeks; null without both. */ @@ -293,7 +287,7 @@ object TodayCopy { /** The Last night strip: "7 h 40, score 83". */ fun lastNight(asleepSeconds: Long?, score: Int?): String? = asleepSeconds?.let { - duration(it) + (score?.let { s -> ", score $s" } ?: "") + Units.duration(it) + (score?.let { s -> ", score $s" } ?: "") } /** A Small's word for Load against your usual: the [LoadStatus]'s, or that the usual is still being learned. */ diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayLooks.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayLooks.kt index 93723443..5e81c8af 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayLooks.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/TodayLooks.kt @@ -2,7 +2,7 @@ package org.openstrap.app.ui.dashboard import org.openstrap.app.data.Moment import org.openstrap.app.ui.glow.CardSize -import org.openstrap.app.ui.glow.Edge +import org.openstrap.app.ui.glow.draw.Edge import org.openstrap.app.ui.glow.Pane /* diff --git a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/WeekStory.kt b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/WeekStory.kt index 94f5a5ca..229960eb 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/dashboard/WeekStory.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/dashboard/WeekStory.kt @@ -3,9 +3,10 @@ package org.openstrap.app.ui.dashboard import java.time.LocalDate import java.time.format.TextStyle import java.util.Locale +import org.openstrap.app.presentation.Units /* - * The week story (spec 4.2), opened from Your week on Today: last Monday to Sunday day by day, from what the app keeps. + * The week story (spec 4.2), opened from Your week on Today: last week day by day, from what the app keeps. * Steps a day, active minutes, the workouts done and the night's sleep after each day. It compares with the week * before only where that week is kept: steps (stored per day) and workouts. Active minutes are worked out for the * 14 days ending on the day shown and nights load 14 back, so neither reaches the week before. Pure, like [TodayCopy]. @@ -21,7 +22,7 @@ internal object WeekStory { data class Totals(val stepsADay: Int?, val activeMinutes: Int?, val workouts: Int, val asleepANight: Long?) /** - * The days of [week], Monday first. [steps] and [active] are keyed by day, [workouts] by the day they started, + * The days of [week], its first day first. [steps] and [active] are keyed by day, [workouts] by the day they started, * [asleepByMorning] by the morning a night ended on, so each day gets the night after it. */ fun days( @@ -42,7 +43,7 @@ internal object WeekStory { fun dayLine(d: Day): String? = listOfNotNull( d.activeMinutes?.takeIf { it > 0 }?.let { if (it == 1) "1 active minute" else "$it active minutes" }, d.workouts.takeIf { it.isNotEmpty() }?.joinToString(", ") { it.lowercase() }, - d.asleepSeconds?.let { "${TodayCopy.duration(it)} asleep" }, + d.asleepSeconds?.let { "${Units.duration(it)} asleep" }, ).joinToString(", ").replaceFirstChar { it.uppercase() }.ifEmpty { null } /** "Busiest on Thursday, 12,400 steps." Null with fewer than two days of steps, where there's nothing to pick. */ diff --git a/app/src/main/kotlin/org/openstrap/app/ui/device/BackupCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/device/BackupCopy.kt index f7ec7b51..fd764c9f 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/device/BackupCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/device/BackupCopy.kt @@ -3,10 +3,10 @@ package org.openstrap.app.ui.device import org.openstrap.app.data.settings.BackupState import org.openstrap.app.export.AutoBackup import org.openstrap.app.export.BackupFiles -import java.time.Instant import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.Locale +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.Units /** The words of backup on the You and Backup pages and in its messages. */ object BackupCopy { @@ -35,17 +35,9 @@ object BackupCopy { BackupFiles.Failure.Other -> "It tries again tomorrow." } - /** "today at 03:12", "yesterday at 03:12", "on Tue 20 Oct". */ - fun whenDone(epochSeconds: Long, now: Long, zone: ZoneId, locale: Locale = Locale.getDefault()): String { - val at = Instant.ofEpochSecond(epochSeconds).atZone(zone) - val today = Instant.ofEpochSecond(now).atZone(zone).toLocalDate() - val clock = at.format(DateTimeFormatter.ofPattern("HH:mm", locale)) - return when (at.toLocalDate()) { - today -> "today at $clock" - today.minusDays(1) -> "yesterday at $clock" - else -> "on ${at.format(DateTimeFormatter.ofPattern("EEE d MMM", locale))}" - } - } + /** "today at 03:12", "yesterday at 03:12", "on Tue 20 Oct": [DayWords.whenDone], on the phone's clock. */ + fun whenDone(epochSeconds: Long, now: Long, zone: ZoneId, locale: Locale = Locale.getDefault()): String = + DayWords.whenDone(epochSeconds, now, zone, locale = locale) /** * Where the folder picker opens when backup is first turned on. The top of the phone's storage can't be chosen @@ -58,14 +50,14 @@ object BackupCopy { * What a restore says, short enough for a toast: what came back, and rows that couldn't be read. Records already on * this phone aren't counted, since restoring only fills gaps. */ - fun restored(records: Int, settings: Boolean, skipped: Int): String { + fun restored(records: Int, settings: Boolean, skipped: Int, locale: Locale = Locale.getDefault()): String { val what = when { - records > 0 && settings -> "Restored %,d records and your settings.".format(records) - records > 0 -> "Restored %,d records.".format(records) + records > 0 && settings -> "Restored ${Units.count(records.toLong(), locale)} records and your settings." + records > 0 -> "Restored ${Units.count(records.toLong(), locale)} records." settings -> "Restored your settings. Your records were already here." else -> "Nothing to restore. It's all already here." } - return if (skipped > 0) "$what %,d rows couldn't be read.".format(skipped) else what + return if (skipped > 0) "$what ${Units.count(skipped.toLong(), locale)} rows couldn't be read." else what } /** The Backup folder row's value. */ diff --git a/app/src/main/kotlin/org/openstrap/app/ui/device/DeletedSleepPage.kt b/app/src/main/kotlin/org/openstrap/app/ui/device/DeletedSleepPage.kt new file mode 100644 index 00000000..0a82e33c --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/device/DeletedSleepPage.kt @@ -0,0 +1,65 @@ +package org.openstrap.app.ui.device + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.SleepEdits +import org.openstrap.app.data.SleepHistory +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.ui.dashboard.ScreenList +import org.openstrap.app.ui.dashboard.SleepCopy +import org.openstrap.app.ui.glow.GlowLayout +import org.openstrap.app.ui.glow.GlowType +import org.openstrap.app.ui.glow.PageButton +import org.openstrap.app.ui.glow.PageHeader +import org.openstrap.app.ui.glow.PageNote +import org.openstrap.app.ui.glow.PageRow +import org.openstrap.app.ui.glow.PageRows +import org.openstrap.app.ui.theme.AppIcons +import java.time.Instant +import java.time.ZoneId +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.TimeOfDay + +/** + * Deleted sleep (edit spec 4.4), from the You page's Your data while there's something in it: each night or nap deleted + * in the last 7 days, its day, its times and the days left, with Restore. After 7 days they go for good. + */ +@Composable +fun DeletedSleepPage( + deleted: List, + contentPadding: PaddingValues, + onRestore: (SleepSession) -> Unit, + onBack: () -> Unit, +) { + BackHandler(onBack = onBack) + val zone = ZoneId.systemDefault() + val now = System.currentTimeMillis() / 1000 + fun clock(t: Long) = TimeOfDay.format(t, zone) + ScreenList(contentPadding, gutter = GlowLayout.Gutter) { + item { PageHeader("Deleted sleep", onBack) } + item { Text(SleepCopy.DELETED_INTRO, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(horizontal = 4.dp)) } + // Once the last one is restored the page stays open, saying so, with no empty panel under it. + if (deleted.isEmpty()) item { Text(SleepCopy.DELETED_NONE, color = Color.White, style = GlowType.body, modifier = Modifier.padding(horizontal = 4.dp)) } + else item { + PageRows { + deleted.forEachIndexed { i, r -> + // A night is named by the evening it began, a nap by its own day. + val day = if (r.strap.nap) Instant.ofEpochSecond(r.strap.start).atZone(zone).toLocalDate() + else SleepHistory.nightOf(r.strap.end, zone).minusDays(1) + PageRow( + SleepCopy.deletedName(r.strap.nap, DayWords.short(day)), + SleepCopy.deletedLine(clock(r.start), clock(r.end), r.edit?.let { SleepEdits.daysLeft(it, now) } ?: SleepEdits.KEEP_DAYS), + below = true, divider = i > 0, icon = AppIcons.Moon, + ) { PageButton("Restore", { onRestore(r.strap) }, icon = AppIcons.ArrowCounterClockwise) } + } + } + } + if (deleted.isNotEmpty()) item { PageNote(SleepCopy.DELETED_FOOT) } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceScreen.kt index 3c6ac240..c22e07bf 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -47,24 +47,21 @@ import androidx.compose.ui.unit.dp import org.openstrap.app.device.ConnectionState import org.openstrap.app.device.SyncState import androidx.compose.foundation.layout.height -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.graphics.Color -import kotlinx.coroutines.launch -import org.openstrap.app.data.settings.SettingsStore import org.openstrap.app.ui.alarms.AlarmsUiState import org.openstrap.app.ui.alarms.alarmsSummary import org.openstrap.app.ui.alarms.healthAlertsSummary import org.openstrap.app.ui.alarms.phoneAlertsSummary import org.openstrap.app.ui.dashboard.agoText -import org.openstrap.app.ui.glow.CoreShape +import org.openstrap.app.ui.glow.draw.CoreShape import org.openstrap.app.ui.glow.DestructiveInk import org.openstrap.app.ui.glow.DotMatrixText import org.openstrap.app.ui.glow.GlowCard import org.openstrap.app.ui.glow.GlowLayout -import org.openstrap.app.ui.glow.GlowStyle +import org.openstrap.app.ui.glow.draw.GlowStyle import org.openstrap.app.ui.glow.GlowSwitch import org.openstrap.app.ui.glow.GlowType -import org.openstrap.app.ui.glow.LightAngle +import org.openstrap.app.ui.glow.draw.LightAngle import org.openstrap.app.ui.glow.PageButton import org.openstrap.app.ui.glow.PageConfirm import org.openstrap.app.ui.glow.PageField @@ -115,7 +112,6 @@ fun DeviceScreen( vm: DeviceViewModel, contentPadding: PaddingValues, onRequestBluetooth: (then: () -> Unit) -> Unit, - settings: SettingsStore, /** The strap's alarms and health settings as last read, for the rows' values. */ alarms: AlarmsUiState, zeppSignedIn: Boolean, @@ -133,21 +129,20 @@ fun DeviceScreen( selected: String? = null, ) { BackHandler(onBack = onBack) - val scope = rememberCoroutineScope() - val paired by vm.paired.collectAsState() - val connection by vm.strap.connection.collectAsState() - val sync by vm.strap.sync.collectAsState() - val scan by vm.scan.collectAsState() - val info by vm.strap.info.collectAsState() - val calls by settings.buzzOnCalls.collectAsState(false) - val notifications by settings.buzzOnNotifications.collectAsState(false) - val wearReminder by settings.wearReminder.collectAsState(true) - val autoStart by settings.autoStartWorkouts.collectAsState(true) + val paired by vm.paired.collectAsStateWithLifecycle() + val connection by vm.connection.collectAsStateWithLifecycle() + val sync by vm.sync.collectAsStateWithLifecycle() + val scan by vm.scan.collectAsStateWithLifecycle() + val info by vm.info.collectAsStateWithLifecycle() + val calls by vm.buzzOnCalls.collectAsStateWithLifecycle() + val notifications by vm.buzzOnNotifications.collectAsStateWithLifecycle() + val wearReminder by vm.wearReminder.collectAsStateWithLifecycle() + val autoStart by vm.autoStartWorkouts.collectAsStateWithLifecycle() var confirmForget by rememberSaveable { mutableStateOf(false) } var confirmDisconnect by rememberSaveable { mutableStateOf(false) } var confirmRestart by rememberSaveable { mutableStateOf(false) } val keepConnected = rememberKeepConnected(vm) - val offerBackground by vm.offerBackground.collectAsState() + val offerBackground by vm.offerBackground.collectAsStateWithLifecycle() LaunchedEffect(offerBackground, paired) { val address = paired?.address if (offerBackground && address != null) { vm.backgroundOffered(); keepConnected.associate(address) } @@ -158,7 +153,7 @@ fun DeviceScreen( "Disconnect the strap?", (if (sync is SyncState.Running) "The sync in progress will stop. " else "") + "The strap keeps recording on its own, but OpenStrap won't sync or show live heart rate until you connect again.", - confirm = "Disconnect", onConfirm = { confirmDisconnect = false; vm.strap.disconnect() }, onDismiss = { confirmDisconnect = false }, + confirm = "Disconnect", onConfirm = { confirmDisconnect = false; vm.disconnect() }, onDismiss = { confirmDisconnect = false }, ) } @@ -197,14 +192,16 @@ fun DeviceScreen( when (connection) { is ConnectionState.Connected -> { PageButton( - "Sync now", vm.strap::syncNow, Modifier.weight(1f).height(56.dp), enabled = sync !is SyncState.Running, + "Sync now", vm::syncNow, Modifier.weight(1f).height(56.dp), enabled = sync !is SyncState.Running, icon = AppIcons.Refresh04, ) if (info?.findDevice == true) FindStrapButton(vm, Modifier.weight(1f).height(56.dp)) } ConnectionState.Connecting, ConnectionState.Authenticating -> - PageButton("Cancel", vm.strap::disconnect, Modifier.fillMaxWidth().height(56.dp)) - else -> PageButton("Connect", { onRequestBluetooth { vm.strap.connect() } }, Modifier.fillMaxWidth().height(56.dp), primary = true) + PageButton("Cancel", vm::disconnect, Modifier.fillMaxWidth().height(56.dp)) + else -> PageButton( + "Connect", { onRequestBluetooth { vm.connect() } }, Modifier.fillMaxWidth().height(56.dp), primary = true, + ) } } } @@ -216,11 +213,11 @@ fun DeviceScreen( PageRow("Alarms", alarmsSummary(alarms) ?: waiting, onClick = onAlarms, divider = false, icon = AppIcons.Alarm, selected = selected == "Alarms") PageRow("Phone alerts", phoneAlertsSummary(calls, notifications), onClick = onPhoneAlerts, icon = AppIcons.Vibrate, selected = selected == "Phone alerts") PageRow("Health alerts", healthAlertsSummary(alarms) ?: waiting, onClick = onHealthAlerts, icon = AppIcons.Heartbeat, selected = selected == "Health alerts") - PageRow("Off-body reminder", onClick = { scope.launch { settings.setWearReminder(!wearReminder) } }, icon = AppIcons.HandPalm) { - GlowSwitch(wearReminder, { on -> scope.launch { settings.setWearReminder(on) } }) + PageRow("Off-body reminder", onClick = { vm.setWearReminder(!wearReminder) }, icon = AppIcons.HandPalm) { + GlowSwitch(wearReminder, vm::setWearReminder) } - PageRow("Auto-detect workouts", onClick = { scope.launch { settings.setAutoStartWorkouts(!autoStart) } }, icon = AppIcons.PersonSimpleRun) { - GlowSwitch(autoStart, { on -> scope.launch { settings.setAutoStartWorkouts(on) } }) + PageRow("Auto-detect workouts", onClick = { vm.setAutoStartWorkouts(!autoStart) }, icon = AppIcons.PersonSimpleRun) { + GlowSwitch(autoStart, vm::setAutoStartWorkouts) } KeepConnectedRow(vm, keepConnected, device.address) PageRow("Firmware", info?.firmware ?: (connection as? ConnectionState.Connected)?.firmware ?: "Check for updates", onClick = onFirmware, icon = AppIcons.Cpu, selected = selected == "Firmware") @@ -247,7 +244,7 @@ fun DeviceScreen( /** Find strap: buzzes the strap and becomes Stop while it runs; it stops by itself after 60 s, as Zepp's does. */ @Composable private fun FindStrapButton(vm: DeviceViewModel, modifier: Modifier) { - val finding by vm.strap.findingStrap.collectAsState() + val finding by vm.findingStrap.collectAsStateWithLifecycle() PageButton( if (finding) "Stop buzzing" else "Find strap", { if (finding) vm.stopFindingStrap() else vm.findStrap() }, modifier, icon = if (finding) AppIcons.StopFill else AppIcons.SearchArea, @@ -260,18 +257,13 @@ private class KeepConnected(val associate: (address: String) -> Unit) @Composable private fun rememberKeepConnected(vm: DeviceViewModel): KeepConnected { val context = LocalContext.current - val companion = vm.companion val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> - if (!companion.onResult(result.resultCode)) vm.showMessage("Not linked. You can turn off battery optimization for OpenStrap instead.") + vm.associationResult(result.resultCode) } - return remember(companion, launcher) { + return remember(vm, launcher) { KeepConnected { address -> val activity = context.findActivity() ?: return@KeepConnected - companion.associate( - activity, address, - launch = { launcher.launch(IntentSenderRequest.Builder(it).build()) }, - onError = vm::showMessage, - ) + vm.associate(activity, address, launch = { launcher.launch(IntentSenderRequest.Builder(it).build()) }) } } } @@ -290,14 +282,14 @@ private tailrec fun android.content.Context.findActivity(): android.app.Activity @Composable private fun KeepConnectedRow(vm: DeviceViewModel, keepConnected: KeepConnected, address: String) { val context = LocalContext.current - val associated by vm.companion.associated.collectAsState() - val ignoringBattery by vm.companion.ignoringBatteryOptimizations.collectAsState() - val declined by vm.companionDeclined.collectAsState() + val associated by vm.companionAssociated.collectAsStateWithLifecycle() + val ignoringBattery by vm.ignoringBatteryOptimizations.collectAsStateWithLifecycle() + val declined by vm.companionDeclined.collectAsStateWithLifecycle() val title = "Stay connected in background" val icon = AppIcons.Bluetooth when { associated -> PageRow(title, "On", icon = icon) - vm.companion.supported && !declined -> PageRow(title, "Off", onClick = { keepConnected.associate(address) }, icon = icon) + vm.companionSupported && !declined -> PageRow(title, "Off", onClick = { keepConnected.associate(address) }, icon = icon) !ignoringBattery -> PageRow(title, "Off", icon = icon, onClick = { runCatching { context.startActivity( @@ -339,7 +331,7 @@ private fun StrapHero(name: String, connection: ConnectionState, sync: SyncState Column(Modifier.fillMaxWidth().padding(top = 128.dp, bottom = 5.dp, end = 112.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { // The card's one dotted number (spec 2.3); the word after it sits on the digits' baseline in Body at 72%. if (battery != null) Row(verticalAlignment = Alignment.Bottom) { - DotMatrixText("$battery", dot = org.openstrap.app.ui.glow.DotSize.Hero) + DotMatrixText("$battery", dot = org.openstrap.app.ui.glow.draw.DotSize.Hero) Text("% battery", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) } if (sync is SyncState.Running) { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceViewModel.kt b/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceViewModel.kt index ff18c3da..49b38f71 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceViewModel.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/device/DeviceViewModel.kt @@ -1,10 +1,13 @@ package org.openstrap.app.ui.device +import android.app.Activity +import android.content.IntentSender import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -14,10 +17,22 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.openstrap.app.AppContainer import org.openstrap.app.ble.FoundDevice +import org.openstrap.app.data.BackupRepository +import org.openstrap.app.data.BackupResult +import org.openstrap.app.data.catching import org.openstrap.app.data.settings.PairedDevice +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.device.CompanionLink +import org.openstrap.app.device.ConnectionState import org.openstrap.app.device.StrapController +import org.openstrap.app.device.StrapInfo +import org.openstrap.app.device.StrapManager +import org.openstrap.app.device.SyncState +import org.openstrap.app.export.AutoBackup +import org.openstrap.app.health.HealthConnectAutoSync +import org.openstrap.app.health.HealthConnectExporter +import org.openstrap.app.presentation.Units import org.openstrap.app.zepp.BoundDevice import org.openstrap.app.zepp.DeviceListing @@ -27,16 +42,49 @@ data class ScanUiState( val error: String? = null, ) -class DeviceViewModel(private val container: AppContainer) : ViewModel() { - val strap: StrapController get() = container.strap - val companion: org.openstrap.app.device.CompanionLink get() = container.companion +/** + * The Strap page and the You page's data rows: pairing, the strap's commands, its phone settings, backup and Health + * Connect. The screens only read state and call functions here, so nothing they start dies with the composition + * (audit A2), and the strap itself stays private. + */ +// Each parameter is one area of the two pages. Wave 5 splits the pages' view models, and with them this list. +@Suppress("LongParameterList") +class DeviceViewModel( + private val settings: SettingsStore, + private val strap: StrapController, + private val companion: CompanionLink, + private val scanStraps: () -> Flow, + private val backups: BackupRepository, + private val autoBackup: AutoBackup, + private val healthConnect: HealthConnectExporter, + private val autoHealthConnect: HealthConnectAutoSync, + private val sampleData: suspend () -> Unit, +) : ViewModel() { val paired: StateFlow = - container.settings.pairedDevice.stateIn(viewModelScope, SharingStarted.Eagerly, null) + settings.pairedDevice.stateIn(viewModelScope, SharingStarted.Eagerly, null) val profile: StateFlow = - container.settings.profile.stateIn(viewModelScope, SharingStarted.Eagerly, org.openstrap.app.data.settings.UserProfile()) + settings.profile.stateIn(viewModelScope, SharingStarted.Eagerly, org.openstrap.app.data.settings.UserProfile()) - fun saveProfile(p: org.openstrap.app.data.settings.UserProfile) = viewModelScope.launch { container.settings.saveProfile(p) } + fun saveProfile(p: org.openstrap.app.data.settings.UserProfile) = viewModelScope.launch { settings.saveProfile(p) } + + val connection: StateFlow get() = strap.connection + val sync: StateFlow get() = strap.sync + val info: StateFlow get() = strap.info + val findingStrap: StateFlow get() = strap.findingStrap + + fun connect() = strap.connect() + fun disconnect() = strap.disconnect() + fun syncNow() = strap.syncNow() + + /** The strap's settings kept on the phone, as the Strap page's rows show them. */ + val buzzOnCalls: StateFlow = settings.buzzOnCalls.stateIn(viewModelScope, SharingStarted.Eagerly, false) + val buzzOnNotifications: StateFlow = settings.buzzOnNotifications.stateIn(viewModelScope, SharingStarted.Eagerly, false) + val wearReminder: StateFlow = settings.wearReminder.stateIn(viewModelScope, SharingStarted.Eagerly, true) + val autoStartWorkouts: StateFlow = settings.autoStartWorkouts.stateIn(viewModelScope, SharingStarted.Eagerly, true) + + fun setWearReminder(on: Boolean) = viewModelScope.launch { settings.setWearReminder(on) } + fun setAutoStartWorkouts(on: Boolean) = viewModelScope.launch { settings.setAutoStartWorkouts(on) } private val _scan = MutableStateFlow(ScanUiState()) val scan = _scan.asStateFlow() @@ -51,7 +99,7 @@ class DeviceViewModel(private val container: AppContainer) : ViewModel() { scanJob?.cancel() _scan.value = ScanUiState(scanning = true) scanJob = viewModelScope.launch { - container.scanner.scan() + scanStraps() .catch { e -> _scan.update { it.copy(scanning = false, error = e.message) } } .collect { d -> _scan.update { s -> s.copy(found = (s.found.filter { it.address != d.address } + d).sortedByDescending { it.rssi }) } } } @@ -66,32 +114,34 @@ class DeviceViewModel(private val container: AppContainer) : ViewModel() { val key = normalizeAuthKey(authKey) if (key == null) { val n = authKey.trim().removePrefix("0x").removePrefix("0X").count { it.isLetterOrDigit() } - _message.value = "The auth key must be 32 characters of 0–9 and a–f. You entered $n." + _message.value = "The auth key must be 32 characters of 0 to 9 and a to f. You entered $n." return } - if (!MAC.matches(address.uppercase())) { + // Trimmed once, as the sheet's own check does, so a pasted address with a trailing space pairs (audit E7). + val mac = pairAddress(address) + if (mac == null) { _message.value = "The Bluetooth address should look like C8:0F:10:AB:CD:EF. Scan to fill it in." return } stopScan() viewModelScope.launch { - container.settings.savePairedDevice(PairedDevice(address.uppercase(), key, name)) - container.strap.connect() - container.companion.refresh().join() - if (container.companion.supported && !container.companion.associated.value) _offerBackground.value = true + settings.savePairedDevice(PairedDevice(mac, key, name)) + strap.connect() + companion.refresh().join() + if (companion.supported && !companion.associated.value) _offerBackground.value = true } } /** Keeps the Zepp sign-in for firmware checks and, when Zepp returned the strap's key, pairs with it straight away. */ fun signInAndPair(listing: DeviceListing, strap: BoundDevice?) = viewModelScope.launch { - container.settings.saveZeppAccount(listing.session.toAccount(listing.apiBase, strap?.china == true, System.currentTimeMillis())) + settings.saveZeppAccount(listing.session.toAccount(listing.apiBase, strap?.china == true, System.currentTimeMillis())) val key = strap?.authKey if (strap != null && key != null) pair(strap.macAddress, DEFAULT_NAME, key) else _message.value = "Zepp sign-in saved, but no auth key came with it. Get the key on a computer and enter it by hand." } fun loadSampleData() = viewModelScope.launch { - org.openstrap.app.data.SampleData.load(container.database.healthDao()) + sampleData() _message.value = "Loaded a week of sample data." } @@ -101,59 +151,69 @@ class DeviceViewModel(private val container: AppContainer) : ViewModel() { fun backgroundOffered() { _offerBackground.value = false } val companionDeclined: StateFlow = - container.settings.companionDeclined.stateIn(viewModelScope, SharingStarted.Eagerly, false) + settings.companionDeclined.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + /** "Stay connected in background": whether Android can associate the strap, and whether it is, or battery optimization is off. */ + val companionSupported: Boolean get() = companion.supported + val companionAssociated: StateFlow get() = companion.associated + val ignoringBatteryOptimizations: StateFlow get() = companion.ignoringBatteryOptimizations + + /** Asks Android to associate the strap at [address]. [launch] shows its prompt from [activity]. */ + fun associate(activity: Activity, address: String, launch: (IntentSender) -> Unit) = + companion.associate(activity, address, launch, onError = ::showMessage) + + /** The association prompt's answer. Turned down, the page says what else keeps the strap connected. */ + fun associationResult(resultCode: Int) { + if (!companion.onResult(resultCode)) showMessage("Not linked. You can turn off battery optimization for OpenStrap instead.") + } fun showMessage(text: String) { _message.value = text } /** Find strap: buzzes it until stopped or Zepp's 60 s are up. */ fun findStrap() = viewModelScope.launch { - runCatching { container.strap.findStrap() }.onFailure { _message.value = it.message ?: "Couldn't buzz the strap" } + catching { strap.findStrap() }.onFailure { failed("find strap", it, ErrorCopy.strap(it, "Couldn't buzz the strap")) } } fun stopFindingStrap() = viewModelScope.launch { - runCatching { container.strap.stopFindingStrap() }.onFailure { _message.value = it.message ?: "Couldn't stop the strap buzzing" } + catching { strap.stopFindingStrap() } + .onFailure { failed("stop finding strap", it, ErrorCopy.strap(it, "Couldn't stop the strap buzzing")) } } /** Restarts the strap; the link drops and the usual reconnect brings it back. */ fun restartStrap() = viewModelScope.launch { - _message.value = runCatching { container.strap.restartStrap(); "Restarting the strap. OpenStrap reconnects when it's back." } - .getOrElse { it.message ?: "Couldn't restart the strap" } + _message.value = catching { + strap.restartStrap() + "Restarting the strap. OpenStrap reconnects when it's back." + } + .getOrElse { failed("restart strap", it, ErrorCopy.strap(it, "Couldn't restart the strap")) } } fun forget() = viewModelScope.launch { - container.strap.disconnect() - paired.value?.let { container.companion.disassociate(it.address) } - container.settings.forgetDevice() + strap.disconnect() + paired.value?.let { companion.disassociate(it.address) } + settings.forgetDevice() } fun exportCsv(uri: Uri) = viewModelScope.launch { - _message.value = runCatching { - withContext(Dispatchers.IO) { - val settings = org.openstrap.app.export.SettingsBackup.toJson(container.settings.backupValues()) - container.app.contentResolver.openOutputStream(uri)!!.use { container.csv.exportZip(it, settings = settings) } - } - "Saved a copy." - }.getOrElse { "Couldn't save the copy: ${it.message}" } + _message.value = when (val result = backups.exportCsv(uri)) { + is BackupResult.Failed -> failed("save a copy", result.error, ErrorCopy.saveCopy(result.error)) + else -> "Saved a copy." + } } /** - * Restores a zip from [exportCsv] or automatic backup into what this phone is missing ([CsvImporter]), and the settings in - * it that this phone hasn't set. Safe to run twice. + * Restores a zip from [exportCsv] or automatic backup into what this phone is missing, and the settings in it that + * this phone hasn't set. Safe to run twice. */ fun importCsv(uri: Uri) = viewModelScope.launch { - _message.value = runCatching { - val result = withContext(Dispatchers.IO) { - container.app.contentResolver.openInputStream(uri)!!.use { org.openstrap.app.export.CsvImporter(container.database).importZip(it) } - } - // A damaged settings file costs the settings only, not the records already restored. - val settings = result.settings?.let { json -> - runCatching { container.settings.restoreValues(org.openstrap.app.export.SettingsBackup.fromJson(json)) }.getOrDefault(0) - } ?: 0 - BackupCopy.restored(result.total, settings > 0, result.skipped) - }.getOrElse { "Couldn't restore the copy: ${it.message}" } - } - - val backup: StateFlow = container.settings.backup + _message.value = when (val result = backups.importCsv(uri)) { + is BackupResult.Restored -> BackupCopy.restored(result.total, result.settings, result.skipped) + is BackupResult.Failed -> failed("restore a copy", result.error, ErrorCopy.restoreCopy(result.error)) + BackupResult.Saved -> null + } + } + + val backup: StateFlow = settings.backup .stateIn(viewModelScope, SharingStarted.Eagerly, org.openstrap.app.data.settings.BackupState(null, null, null, null, null)) /** True while Back up now runs. */ @@ -165,41 +225,23 @@ class DeviceViewModel(private val container: AppContainer) : ViewModel() { * go of the folder before, starts the daily backup and makes the first one now. */ fun chooseBackupFolder(uri: Uri) = viewModelScope.launch { - val resolver = container.app.contentResolver - val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION - val kept = runCatching { resolver.takePersistableUriPermission(uri, flags) } - if (kept.isFailure) { + if (!autoBackup.chooseFolder(uri)) { _message.value = "OpenStrap can't keep writing to that folder. Choose another one." return@launch } - val before = backup.value.folder - if (before != null && before != uri.toString()) releaseFolder(before) - val name = withContext(Dispatchers.IO) { runCatching { androidx.documentfile.provider.DocumentFile.fromTreeUri(container.app, uri)?.name }.getOrNull() } - container.settings.setBackupFolder(uri.toString(), name) - org.openstrap.app.export.AutoBackup.schedule(container.app) backUpNow(quiet = true) } /** Stops automatic backup and lets go of its folder. The backups already there stay. */ - fun turnOffBackup() = viewModelScope.launch { - org.openstrap.app.export.AutoBackup.cancel(container.app) - backup.value.folder?.let(::releaseFolder) - container.settings.clearBackup() - } - - private fun releaseFolder(folder: String) { - val flags = android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION - runCatching { container.app.contentResolver.releasePersistableUriPermission(Uri.parse(folder), flags) } - } + fun turnOffBackup() = viewModelScope.launch { autoBackup.turnOff() } /** One backup now, the daily one's way. During a workout it waits for the end instead. [quiet] says only a failure. */ fun backUpNow(quiet: Boolean = false) = viewModelScope.launch { if (_backingUp.value) return@launch _backingUp.value = true try { - val outcome = org.openstrap.app.export.AutoBackup(container.app, container).run() - if (outcome == org.openstrap.app.export.AutoBackup.Outcome.Recording) org.openstrap.app.export.AutoBackup.afterWorkout(container.app) - if (!quiet || outcome !is org.openstrap.app.export.AutoBackup.Outcome.Done) _message.value = BackupCopy.outcome(outcome) + val outcome = autoBackup.runNow() + if (!quiet || outcome !is AutoBackup.Outcome.Done) _message.value = BackupCopy.outcome(outcome) } finally { _backingUp.value = false } @@ -207,44 +249,61 @@ class DeviceViewModel(private val container: AppContainer) : ViewModel() { /** Brings back what OpenStrap exported to Health Connect, filling gaps only. */ fun importHealthConnect() = viewModelScope.launch { - _message.value = runCatching { - val n = withContext(Dispatchers.IO) { container.healthConnect.import() } - if (n == 0) "Health Connect has no OpenStrap data to restore." else "Restored %,d records from Health Connect.".format(n) - }.getOrElse { "Couldn't restore from Health Connect: ${it.message}" } + _message.value = catching { + val n = withContext(Dispatchers.IO) { healthConnect.import() } + if (n == 0) "Health Connect has no OpenStrap data to restore." + else "Restored ${Units.count(n.toLong())} records from Health Connect." + }.getOrElse { failed("health connect import", it, ErrorCopy.healthConnect("Couldn't restore from Health Connect", it)) } } fun exportHealthConnect() = viewModelScope.launch { - _message.value = runCatching { + _message.value = catching { val now = System.currentTimeMillis() / 1000 - val n = container.healthConnect.export(now - 30L * 24 * 3600, now) - container.settings.setHealthConnectSynced(now) + val n = healthConnect.export(now - 30L * 24 * 3600, now) + settings.setHealthConnectSynced(now) "Wrote $n records to Health Connect (last 30 days)." - }.getOrElse { "Couldn't write to Health Connect: ${it.message}" } + }.getOrElse { failed("health connect export", it, ErrorCopy.healthConnect("Couldn't write to Health Connect", it)) } } /** Auto sync to Health Connect, for the You page's row. */ val healthConnectAuto: StateFlow = - container.settings.healthConnectAuto.stateIn(viewModelScope, SharingStarted.Eagerly, org.openstrap.app.data.settings.HealthConnectAuto()) + settings.healthConnectAuto.stateIn(viewModelScope, SharingStarted.Eagerly, org.openstrap.app.data.settings.HealthConnectAuto()) /** The auto sync switch. On is called once the write permissions are granted, and writes the last 30 days at once. */ fun setAutoHealthConnect(on: Boolean) = viewModelScope.launch { - if (!on) { container.settings.setHealthConnectAuto(false); return@launch } - _message.value = runCatching { - val n = container.autoHealthConnect.turnOn() + if (!on) { settings.setHealthConnectAuto(false); return@launch } + _message.value = catching { + val n = autoHealthConnect.turnOn() "Auto sync is on. Wrote $n records to Health Connect (last 30 days)." - }.getOrElse { "Auto sync is on, but the first copy didn't work: ${it.message}" } + }.getOrElse { + failed("health connect auto sync", it, ErrorCopy.healthConnect("Auto sync is on, but the first copy didn't work", it)) + } } /** The You page opened: auto sync turns off, saying why, if Health Connect or its permissions went away. */ fun checkAutoHealthConnect() = viewModelScope.launch { - runCatching { container.autoHealthConnect.check() } + catching { autoHealthConnect.check() }.onFailure { log("health connect: auto sync check failed: $it") } + } + + /** The raw failure goes to Diagnostics, and [copy], what the screen says, comes back. */ + private fun failed(what: String, e: Throwable, copy: String): String { + log("$what failed: $e") + return copy + } + + private fun log(message: String) { + android.util.Log.w("OpenStrap", message) + (strap as? StrapManager)?.log(message) } companion object { const val DEFAULT_NAME = "Amazfit Helio Strap" private val MAC = Regex("^([0-9A-F]{2}:){5}[0-9A-F]{2}$") - fun isAddress(input: String) = MAC.matches(input.trim().uppercase()) + fun isAddress(input: String) = pairAddress(input) != null + + /** [input] as the address to pair with, trimmed and upper case, or null when it isn't a Bluetooth address. */ + fun pairAddress(input: String): String? = input.trim().uppercase().takeIf { MAC.matches(it) } /** Accepts "0x…", spaces or colons; returns 32 lowercase hex chars or null. */ fun normalizeAuthKey(input: String): String? { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/device/ErrorCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/device/ErrorCopy.kt new file mode 100644 index 00000000..dbfbc167 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/device/ErrorCopy.kt @@ -0,0 +1,60 @@ +package org.openstrap.app.ui.device + +import org.openstrap.app.ble.GattTimeoutException +import org.openstrap.app.device.StrapCommandException +import org.openstrap.app.export.BackupFiles +import org.openstrap.app.firmware.FirmwareCloudException +import org.openstrap.app.zepp.ZeppCloudException +import org.openstrap.protocol.firmware.DfuException +import java.io.IOException +import java.util.zip.ZipException + +/** + * What a failed action says, as [BackupCopy.failure] does for backups. An exception's own text is shown only when it + * was written for the screen (a strap, firmware or Zepp reply). Anything else, like a null stream or a lost + * permission, would show a class name or "null", so it gets plain copy and the raw text goes to Diagnostics (audit E6). + */ +object ErrorCopy { + /** A strap command that failed: the strap's own reason when it gave one, else [fallback]. */ + fun strap(e: Throwable, fallback: String): String = when (e) { + is StrapCommandException -> e.message ?: fallback + is GattTimeoutException -> NO_ANSWER + else -> fallback + } + + /** Save a copy failed while writing the zip to the place the user picked. */ + fun saveCopy(e: Throwable): String = "Couldn't save the copy. " + when (BackupFiles.classify(e)) { + BackupFiles.Failure.NoSpace -> "There isn't enough space for it." + BackupFiles.Failure.PermissionLost -> "OpenStrap wasn't allowed to write there. Choose another place." + BackupFiles.Failure.FolderGone -> "That place is no longer available. Choose another one." + BackupFiles.Failure.CantWrite, BackupFiles.Failure.Other -> "Try again, or choose another place." + } + + /** Restore failed while reading the zip the user picked. */ + fun restoreCopy(e: Throwable): String = "Couldn't restore the copy. " + when { + // CsvImporter says a zip without OpenStrap tables is not an export, and a broken zip can't be read at all. + e is ZipException || e is IllegalArgumentException -> "That file isn't an OpenStrap copy." + e is SecurityException || e is java.io.FileNotFoundException -> "OpenStrap can't open that file. Choose it again." + else -> "Try again." + } + + /** A Health Connect read or write that failed. [what] says which, "Couldn't write to Health Connect". */ + fun healthConnect(what: String, e: Throwable): String = "$what. " + when (e) { + is SecurityException -> "OpenStrap no longer has permission. Allow it in Health Connect." + is IOException, is android.os.RemoteException -> "Health Connect didn't answer. Try again." + else -> "Try again." + } + + /** + * A firmware check, download or install that failed. The update server, the Zepp sign-in and the strap's update + * service word their own reasons. A network failure says so, and anything else gets [fallback]. + */ + fun firmware(e: Throwable, fallback: String): String = when (e) { + is FirmwareCloudException, is ZeppCloudException, is DfuException, is StrapCommandException -> e.message ?: fallback + is GattTimeoutException -> NO_ANSWER + is IOException -> "Couldn't reach the update server. Check your internet connection and try again." + else -> fallback + } + + private const val NO_ANSWER = "The strap didn't answer. Try again." +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/device/GoalsSheet.kt b/app/src/main/kotlin/org/openstrap/app/ui/device/GoalsSheet.kt index 8155304e..6145312c 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/device/GoalsSheet.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/device/GoalsSheet.kt @@ -35,7 +35,9 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import org.openstrap.app.data.settings.CalorieGoal import org.openstrap.app.data.settings.Goals -import org.openstrap.app.ui.glow.ControlSurface +import org.openstrap.app.presentation.GoalCopy +import org.openstrap.app.presentation.Units +import org.openstrap.app.ui.theme.ControlSurface import org.openstrap.app.ui.glow.GlowLayout import org.openstrap.app.ui.glow.GlowType import org.openstrap.app.ui.glow.PageButton @@ -46,7 +48,7 @@ import org.openstrap.app.ui.theme.AppIcons import org.openstrap.app.ui.theme.MetricColors /** - * Steps, calories, sleep and active hours to aim for each day, in a sheet over the You page (spec 3.4, calories spec + * Steps, calories, sleep and active hours to aim for each day, and workouts a week, in a sheet over the You page (spec 3.4, calories spec * 4.6). Every goal ring and "goal met" uses these, and the calorie goal goes to the strap. [suggestedCalories] is the * calorie goal your last four weeks suggest ([CalorieGoal.suggested]), null while it loads or under a week of full days. Cancel, or pulling the * sheet down, leaves them as they were. @@ -58,13 +60,14 @@ fun GoalsSheet(goals: Goals, onSave: (Goals) -> Unit, onDismiss: () -> Unit, sug var sleepH by rememberSaveable { mutableStateOf((goals.sleepMinutes / 60).toString()) } var sleepM by rememberSaveable { mutableStateOf((goals.sleepMinutes % 60).toString()) } var active by rememberSaveable { mutableStateOf(goals.activeHours.toString()) } + var workouts by rememberSaveable { mutableIntStateOf(goals.workoutsPerWeek) } val stepsValue = steps.toIntOrNull()?.takeIf { it in Goals.STEPS } val sleepValue = sleepH.toIntOrNull()?.let { h -> sleepM.ifEmpty { "0" }.toIntOrNull()?.takeIf { it in 0..59 }?.let { h * 60 + it } } ?.takeIf { it in Goals.SLEEP_MINUTES } val activeValue = active.toIntOrNull()?.takeIf { it in Goals.ACTIVE_HOURS } - PageSheet(onDismiss, "Your goals", note = "What you aim for each day. Change them whenever they stop fitting.") { + PageSheet(onDismiss, "Your goals", note = "What you aim for each day and week. Change them whenever they stop fitting.") { NumberField(steps, { steps = it }, "Steps", "steps", stepsValue == null) CalorieGoalField(calories, { calories = it }, suggestedCalories) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -76,16 +79,33 @@ fun GoalsSheet(goals: Goals, onSave: (Goals) -> Unit, onDismiss: () -> Unit, sug } NumberField(active, { active = it.take(2) }, "Active hours", "of 24", activeValue == null) PageNote("An active hour is one with at least 250 steps.") + WorkoutGoalField(workouts) { workouts = it } Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { PageButton("Cancel", onDismiss, Modifier.weight(1f)) PageButton( - "Save", { onSave(Goals(stepsValue!!, sleepValue!!, activeValue!!, calories)) }, Modifier.weight(1f), + "Save", { onSave(goals.copy(steps = stepsValue!!, sleepMinutes = sleepValue!!, activeHours = activeValue!!, calories = calories, workoutsPerWeek = workouts)) }, Modifier.weight(1f), enabled = stepsValue != null && sleepValue != null && activeValue != null, primary = true, ) } } } +/** Workouts a week, between minus and plus (workouts spec 3.1). */ +@Composable +internal fun WorkoutGoalField(value: Int, onChange: (Int) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("Workouts", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + RoundButton(AppIcons.Minus, "Fewer workouts a week", { onChange((value - 1).coerceIn(Goals.WORKOUTS_PER_WEEK)) }, size = 44.dp) + Box(Modifier.weight(1f).height(52.dp).clip(GlowLayout.TileShape).background(ControlSurface), contentAlignment = Alignment.Center) { + Text("$value a week", color = Color.White, style = GlowType.body, maxLines = 1) + } + RoundButton(AppIcons.Plus, "More workouts a week", { onChange((value + 1).coerceIn(Goals.WORKOUTS_PER_WEEK)) }, size = 44.dp) + } + PageNote("Workouts of 10 minutes or more count, two a day at most.") + } +} + /** * The calorie goal: active kcal between minus and plus, which step it by [Goals.CALORIES_STEP]. Tapping the number * opens it for typing; Enter or leaving the field keeps a value in range, anything else keeps the goal as it was and @@ -127,14 +147,14 @@ private fun CalorieGoalField(value: Int, onChange: (Int) -> Unit, suggested: Int ) LaunchedEffect(Unit) { focus.requestFocus() } } else { - Text("%,d active kcal".format(value), color = Color.White, style = GlowType.body, maxLines = 1) + Text("${Units.count(value.toLong())} active kcal", color = Color.White, style = GlowType.body, maxLines = 1) } } RoundButton(AppIcons.Plus, "Raise the calorie goal", { typing = false; refused = false; onChange(CalorieGoal.step(value, Goals.CALORIES_STEP)) }, size = 44.dp) } - if (refused) PageNote(CalorieGoal.OUT_OF_RANGE, MetricColors.warn) + if (refused) PageNote(GoalCopy.outOfRange(Goals.CALORIES), MetricColors.warn) if (suggested != null) { - val line = "Suggested %,d, from your last 4 weeks".format(suggested) + val line = "Suggested ${Units.count(suggested.toLong())}, from your last 4 weeks" if (suggested == value) PageNote("$line. Your strap's goal is set to match.") else Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { Box(Modifier.weight(1f)) { PageNote(line) } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/device/ProfileScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/device/ProfileScreen.kt index 159ad783..99578777 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/device/ProfileScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/device/ProfileScreen.kt @@ -42,6 +42,7 @@ import org.openstrap.app.ui.glow.PageField import org.openstrap.app.ui.glow.PageHeader import org.openstrap.app.ui.glow.PageNote import org.openstrap.app.ui.glow.PagePanel +import org.openstrap.app.ui.glow.PageRow import org.openstrap.app.ui.glow.PageSwitchRow import java.time.LocalDate @@ -61,7 +62,7 @@ fun UserProfile.summary(): String = * wide); back leaves without saving, asking first if anything was changed. */ @Composable -fun ProfileScreen(profile: UserProfile, contentPadding: PaddingValues, onSave: (UserProfile) -> Unit, onBack: () -> Unit) { +fun ProfileScreen(profile: UserProfile, contentPadding: PaddingValues, onSave: (UserProfile) -> Unit, onBack: () -> Unit, onHeartZones: () -> Unit = {}) { var age by rememberSaveable { mutableStateOf(profile.age()?.toString().orEmpty()) } var sex by rememberSaveable { mutableIntStateOf(when (profile.sex) { UserProfile.Sex.Female -> 0; UserProfile.Sex.Male -> 1; null -> 2 }) } var imperial by rememberSaveable { mutableStateOf(profile.usesImperial) } @@ -74,7 +75,6 @@ fun ProfileScreen(profile: UserProfile, contentPadding: PaddingValues, onSave: ( var activity by rememberSaveable { mutableIntStateOf(profile.activityLevel ?: -1) } var betaBlocker by rememberSaveable { mutableStateOf(profile.betaBlocker) } var pregnant by rememberSaveable { mutableStateOf(profile.pregnant) } - var maxHr by rememberSaveable { mutableStateOf(profile.maxHeartRate?.toString().orEmpty()) } var sleepNeed by rememberSaveable { mutableStateOf(profile.sleepNeedMinutes?.let { formatNumber(it / 60f) }.orEmpty()) } val ageValue = age.trim().toIntOrNull()?.takeIf { it in 10..100 } @@ -86,12 +86,11 @@ fun ProfileScreen(profile: UserProfile, contentPadding: PaddingValues, onSave: ( if (f != null && i != null) Units.feetInchesToCm(f, i).takeIf { it in 100..250 } else null } else heightCm.trim().toIntOrNull()?.takeIf { it in 100..250 } val heightBlank = if (imperial) feet.isBlank() && inches.isBlank() else heightCm.isBlank() - val maxHrValue = maxHr.trim().toIntOrNull()?.takeIf { it in 120..230 } // Hours, so "7.5" is 450 min; only 7-9 h is accepted (Recovery.SLEEP_NEED_MIN..MAX). val sleepNeedValue = sleepNeed.trim().replace(',', '.').toFloatOrNull()?.let { Math.round(it * 60) } ?.takeIf { it in org.openstrap.app.data.Recovery.SLEEP_NEED_MIN..org.openstrap.app.data.Recovery.SLEEP_NEED_MAX } val valid = (age.isBlank() || ageValue != null) && (weight.isBlank() || weightValue != null) && (heightBlank || heightValue != null) && - (sleepNeed.isBlank() || sleepNeedValue != null) && (maxHr.isBlank() || maxHrValue != null) + (sleepNeed.isBlank() || sleepNeedValue != null) fun switchUnits(toImperial: Boolean) { if (toImperial == imperial) return @@ -105,8 +104,8 @@ fun ProfileScreen(profile: UserProfile, contentPadding: PaddingValues, onSave: ( } // What the fields held when the page opened, so back knows whether there is anything to lose. - val initial = rememberSaveable { listOf(age, sex, imperial, weight, heightCm, feet, inches, activity, betaBlocker, pregnant, maxHr, sleepNeed).joinToString("\u0000") } - val dirty = listOf(age, sex, imperial, weight, heightCm, feet, inches, activity, betaBlocker, pregnant, maxHr, sleepNeed).joinToString("\u0000") != initial + val initial = rememberSaveable { listOf(age, sex, imperial, weight, heightCm, feet, inches, activity, betaBlocker, pregnant, sleepNeed).joinToString("\u0000") } + val dirty = listOf(age, sex, imperial, weight, heightCm, feet, inches, activity, betaBlocker, pregnant, sleepNeed).joinToString("\u0000") != initial var confirmDiscard by rememberSaveable { mutableStateOf(false) } val back: () -> Unit = { if (dirty) confirmDiscard = true else onBack() } BackHandler(onBack = back) @@ -127,7 +126,6 @@ fun ProfileScreen(profile: UserProfile, contentPadding: PaddingValues, onSave: ( heightCm = heightValue, imperial = imperial, sleepNeedMinutes = sleepNeedValue, - maxHeartRate = maxHrValue, activityLevel = activity.takeIf { it in 0..4 }, // Saving confirms the answers, so the fitness estimate's freshness checks restart. activityAnsweredAt = if (activity in 0..4) System.currentTimeMillis() / 1000 else null, @@ -188,7 +186,11 @@ fun ProfileScreen(profile: UserProfile, contentPadding: PaddingValues, onSave: ( } PageSwitchRow("Taking beta-blockers", null, betaBlocker, { betaBlocker = it }, divider = false) PageSwitchRow("Pregnant", null, pregnant, { pregnant = it }, divider = false) - NumberField(maxHr, { maxHr = it }, "Max heart rate from a test (optional)", "bpm", maxHr.isNotBlank() && maxHrValue == null) + // Your max is set on the Heart rate zones page, with the zones that follow it. + PageRow( + org.openstrap.app.ui.workout.HeartZonesCopy.MAX_ROW, org.openstrap.app.ui.workout.HeartZonesCopy.profileLine(profile.maxHeartRate), + onClick = onHeartZones, below = true, divider = false, + ) NumberField(sleepNeed, { sleepNeed = it }, "Sleep need (7–9, blank for your age's default)", "h", sleepNeed.isNotBlank() && sleepNeedValue == null, decimal = true) } } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/device/UpdatesPage.kt b/app/src/main/kotlin/org/openstrap/app/ui/device/UpdatesPage.kt index 9c9edc3f..a2f44d01 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/device/UpdatesPage.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/device/UpdatesPage.kt @@ -38,6 +38,7 @@ import org.openstrap.app.update.NoteBlock import org.openstrap.app.update.NoteSpan import org.openstrap.app.update.Release import org.openstrap.app.update.ReleaseNotes +import org.openstrap.app.presentation.UpdateCopy import org.openstrap.app.update.Releases import org.openstrap.app.update.UpdateState diff --git a/app/src/main/kotlin/org/openstrap/app/ui/device/YouPage.kt b/app/src/main/kotlin/org/openstrap/app/ui/device/YouPage.kt index 387329ae..7e8c8c43 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/device/YouPage.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/device/YouPage.kt @@ -16,27 +16,31 @@ import org.openstrap.app.data.settings.BackupState import org.openstrap.app.data.settings.Goals import org.openstrap.app.data.settings.HealthConnectAuto import org.openstrap.app.data.settings.UserProfile +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.Units +import java.util.concurrent.TimeUnit.MINUTES import org.openstrap.app.ui.dashboard.ScreenList import org.openstrap.app.ui.glow.GlowLayout import org.openstrap.app.ui.glow.GlowType import org.openstrap.app.ui.glow.PageHeader +import org.openstrap.app.presentation.UpdateCopy import org.openstrap.app.ui.glow.PageRow import org.openstrap.app.ui.glow.PageRows import org.openstrap.app.ui.theme.AppIcons -/** "7 h 30", or "8 h" on the hour. */ -fun hoursMinutes(minutes: Int): String = if (minutes % 60 == 0) "${minutes / 60} h" else "${minutes / 60} h ${"%02d".format(minutes % 60)}" - /** The Goals row's value: "8,000 steps, 300 active kcal, 8 active hours". */ -fun goalsLine(goals: Goals): String = "%,d steps, %,d active kcal, %d active hours".format(goals.steps, goals.calories, goals.activeHours) +fun goalsLine(goals: Goals, locale: java.util.Locale = java.util.Locale.getDefault()): String = + "${Units.count(goals.steps.toLong(), locale)} steps, ${Units.count(goals.calories.toLong(), locale)} active kcal, " + + "${goals.activeHours} active hours" /** The Sleep goal row's value, with the sleep need Recovery uses beside it: "7 h 30. Your sleep need is 8 h". */ fun sleepGoalLine(goals: Goals, needMinutes: Int): String = - "${hoursMinutes(goals.sleepMinutes)}. Your sleep need is ${hoursMinutes(needMinutes)}" + "${Units.duration(MINUTES.toSeconds(goals.sleepMinutes.toLong()))}. " + + "Your sleep need is ${Units.duration(MINUTES.toSeconds(needMinutes.toLong()))}" /** - * The auto sync row's value: on and when it last wrote ("On. Last synced 10:42", "yesterday 10:42", "12 Oct"), off, - * or stopped by itself and why. [now] in epoch seconds. + * The auto sync row's value: on and when it last wrote, by [DayWords.at] ("On. Last synced 10:42", "yesterday 10:42", + * "Tuesday 10:42", "Tue 12 Oct 10:42"), off, or stopped by itself and why. [now] in epoch seconds. */ fun healthConnectLine(auto: HealthConnectAuto, now: Long, zone: java.time.ZoneId, locale: java.util.Locale = java.util.Locale.getDefault()): String { if (!auto.on) return when (auto.stopped) { @@ -45,14 +49,7 @@ fun healthConnectLine(auto: HealthConnectAuto, now: Long, zone: java.time.ZoneId null -> "Off. Copies new data after each strap sync" } val last = auto.lastSynced ?: return "On. Copies new data after each strap sync" - val today = java.time.Instant.ofEpochSecond(now).atZone(zone).toLocalDate() - val day = java.time.Instant.ofEpochSecond(last).atZone(zone).toLocalDate() - val clock = org.openstrap.app.ui.dashboard.clockText(last, zone, locale = locale) - return "On. Last synced " + when (day) { - today -> clock - today.minusDays(1) -> "yesterday $clock" - else -> day.format(java.time.format.DateTimeFormatter.ofPattern("d MMM", locale)) - } + return "On. Last synced " + DayWords.at(last, now, zone, locale = locale) } /** The You page's Health Connect row: on and when it last wrote, off, or stopped and why, in a few words. */ @@ -85,13 +82,21 @@ fun YouPage( logLines: Int, contentPadding: PaddingValues, onProfile: () -> Unit, + /** Your max heart rate once worked out, said on the Heart rate zones row, which opens that page. */ + maxHeartRate: Int?, + onHeartZones: () -> Unit, onGoals: () -> Unit, + /** The weekly goal, personal bests and badges (workouts spec 3.5). */ + onGoalsAndBests: () -> Unit, /** Auto sync to Health Connect, said on its row; the row opens the Health Connect page. */ healthConnectAuto: HealthConnectAuto, onHealthConnect: () -> Unit, /** Automatic backup, said on its row; the row opens the Backup page. */ backup: BackupState, onBackup: () -> Unit, + /** Sleep and naps deleted in the last 7 days; the Deleted sleep row shows only when there are any. */ + deletedSleep: Int, + onDeletedSleep: () -> Unit, /** New versions from GitHub, said on its row; the row opens the Updates page. */ updates: org.openstrap.app.update.UpdateState, installedVersion: String, @@ -110,8 +115,16 @@ fun YouPage( item { PageRows { PageRow("Profile", profile.summary(), onClick = onProfile, below = true, divider = false, icon = AppIcons.User, selected = selected == "Profile") + PageRow( + org.openstrap.app.ui.workout.HeartZonesCopy.TITLE, org.openstrap.app.ui.workout.HeartZonesCopy.youLine(maxHeartRate), onClick = onHeartZones, + below = true, icon = AppIcons.Heartbeat, selected = selected == org.openstrap.app.ui.workout.HeartZonesCopy.TITLE, + ) PageRow("Goals", goalsLine(goals), onClick = onGoals, below = true, icon = AppIcons.Target) PageRow("Sleep goal", sleepGoalLine(goals, sleepNeedMinutes), onClick = onGoals, below = true, icon = AppIcons.Moon) + PageRow( + "Goals and bests", "${goals.workoutsPerWeek} workouts a week, personal bests and badges", onClick = onGoalsAndBests, below = true, + icon = AppIcons.FlagCheckered, selected = selected == "Goals and bests", + ) } } item { PanelHeading("Your data") } @@ -125,6 +138,10 @@ fun YouPage( icon = AppIcons.Heartbeat, selected = selected == "Health Connect", ) PageRow("Backup", BackupCopy.summary(backup, now, zone), onClick = onBackup, below = true, icon = AppIcons.ShieldCheck, selected = selected == "Backup") + if (deletedSleep > 0) PageRow( + "Deleted sleep", org.openstrap.app.ui.dashboard.SleepCopy.deletedCount(deletedSleep), onClick = onDeletedSleep, below = true, + icon = AppIcons.Trash, selected = selected == "Deleted sleep", + ) } } item { PanelHeading("Help") } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/firmware/FirmwareScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/firmware/FirmwareScreen.kt index e2364331..4555e86b 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/firmware/FirmwareScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/firmware/FirmwareScreen.kt @@ -19,7 +19,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -63,10 +63,10 @@ import org.openstrap.app.zepp.DeviceListing */ @Composable fun FirmwareScreen(vm: FirmwareViewModel, contentPadding: PaddingValues, onBack: () -> Unit, onZeppAccount: () -> Unit) { - val step by vm.step.collectAsState() - val account by vm.account.collectAsState() - val connection by vm.strap.connection.collectAsState() - val info by vm.strap.info.collectAsState() + val step by vm.step.collectAsStateWithLifecycle() + val account by vm.account.collectAsStateWithLifecycle() + val connection by vm.connection.collectAsStateWithLifecycle() + val info by vm.info.collectAsStateWithLifecycle() val installing = step is FirmwareStep.Installing // Leaving mid-install would be harmless for the strap, but keep the user here and the screen on. @@ -191,8 +191,8 @@ private fun CheckResult(result: UpdateCheck, vm: FirmwareViewModel) { @Composable fun ZeppAccountScreen(vm: FirmwareViewModel, contentPadding: PaddingValues, onBack: () -> Unit) { BackHandler(onBack = onBack) - val account by vm.account.collectAsState() - val installing = vm.step.collectAsState().value is FirmwareStep.Installing + val account by vm.account.collectAsStateWithLifecycle() + val installing = vm.step.collectAsStateWithLifecycle().value is FirmwareStep.Installing ScreenList(contentPadding, gutter = GlowLayout.Gutter) { item { PageHeader("Zepp account", onBack) } item { AccountPanel(account, enabled = !installing, onSave = vm::saveAccount, onClear = vm::clearAccount, onSignIn = vm::saveSignIn) } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/firmware/FirmwareViewModel.kt b/app/src/main/kotlin/org/openstrap/app/ui/firmware/FirmwareViewModel.kt index eecabe77..f5c81b3b 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/firmware/FirmwareViewModel.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/firmware/FirmwareViewModel.kt @@ -11,15 +11,23 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.openstrap.app.AppContainer +import java.io.File +import java.io.FileNotFoundException +import java.io.InputStream +import org.openstrap.app.data.catching +import org.openstrap.app.data.settings.SettingsStore import org.openstrap.app.data.settings.ZeppAccount import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.StrapController +import org.openstrap.app.device.StrapInfo +import org.openstrap.app.device.StrapManager import org.openstrap.app.firmware.FirmwareCloud import org.openstrap.app.firmware.FirmwareCloudException import org.openstrap.app.firmware.FirmwarePart import org.openstrap.app.firmware.StrapIdentity import org.openstrap.app.firmware.StrapResources import org.openstrap.app.firmware.UpdateCheck +import org.openstrap.app.ui.device.ErrorCopy import org.openstrap.app.zepp.BoundDevice import org.openstrap.app.zepp.DeviceListing import org.openstrap.app.zepp.ZeppCloud @@ -50,29 +58,43 @@ sealed interface FirmwareStep { data class Failed(val message: String) : FirmwareStep } -class FirmwareViewModel(private val container: AppContainer) : ViewModel() { - private val cloud = FirmwareCloud(container.app.cacheDir) - val strap get() = container.strap +/** + * The Firmware and Zepp account pages: checking for, downloading and installing strap firmware, and the Zepp sign-in + * the check needs. [cacheDir] holds downloads, and [openFile] reads a file the user picked. + */ +class FirmwareViewModel( + private val strap: StrapController, + private val settings: SettingsStore, + cacheDir: File, + private val openFile: (Uri) -> InputStream?, +) : ViewModel() { + private val cloud = FirmwareCloud(cacheDir) + + /** The strap's link, for what the page offers. */ + val connection: StateFlow get() = strap.connection + + /** What the strap said about itself, its firmware among it, once connected. */ + val info: StateFlow get() = strap.info val account: StateFlow = - container.settings.zeppAccount.stateIn(viewModelScope, SharingStarted.Eagerly, null) + settings.zeppAccount.stateIn(viewModelScope, SharingStarted.Eagerly, null) private val _step = MutableStateFlow(FirmwareStep.Idle) val step = _step.asStateFlow() fun saveAccount(token: String, userId: String, apiBase: String, china: Boolean) = viewModelScope.launch { - container.settings.saveZeppAccount( + settings.saveZeppAccount( ZeppAccount(token.trim(), userId.trim(), apiBase.trim().ifEmpty { ZeppAccount.DEFAULT_API_BASE }, china), ) } fun saveSignIn(listing: DeviceListing, strap: BoundDevice?) = viewModelScope.launch { - container.settings.saveZeppAccount( + settings.saveZeppAccount( listing.session.toAccount(listing.apiBase, strap?.china ?: account.value?.china ?: false, System.currentTimeMillis()), ) } - fun clearAccount() = viewModelScope.launch { container.settings.clearZeppAccount() } + fun clearAccount() = viewModelScope.launch { settings.clearZeppAccount() } fun reset() { if (!busy) _step.value = FirmwareStep.Idle } @@ -84,7 +106,7 @@ class FirmwareViewModel(private val container: AppContainer) : ViewModel() { val firmware = info?.firmware ?: return fail("Connect the strap first so its firmware version is known.") _step.value = FirmwareStep.Checking viewModelScope.launch { - _step.value = runCatching { + _step.value = catching { val identity = StrapIdentity( firmwareVersion = firmware, hardwareVersion = info.hardware, @@ -101,7 +123,7 @@ class FirmwareViewModel(private val container: AppContainer) : ViewModel() { cloud.check(current.apiBase, current.appToken, current.userId, identity) } FirmwareStep.Checked(result) - }.getOrElse { FirmwareStep.Failed(it.message ?: "Update check failed") } + }.getOrElse { failed("firmware check", it, "Update check failed") } } } @@ -111,8 +133,10 @@ class FirmwareViewModel(private val container: AppContainer) : ViewModel() { */ private suspend fun renewed(acc: ZeppAccount, rejected: Boolean): ZeppAccount { if (!ZeppCloud.needsAppTokenRefresh(acc, System.currentTimeMillis(), rejected)) return acc - val fresh = if (rejected) ZeppCloud.refreshAppToken(acc) else runCatching { ZeppCloud.refreshAppToken(acc) }.getOrNull() ?: return acc - container.settings.saveZeppAccount(fresh) + val fresh = if (rejected) ZeppCloud.refreshAppToken(acc) else catching { ZeppCloud.refreshAppToken(acc) } + .onFailure { log("firmware: early token renewal failed, keeping the old one: $it") } + .getOrNull() ?: return acc + settings.saveZeppAccount(fresh) return fresh } @@ -121,7 +145,7 @@ class FirmwareViewModel(private val container: AppContainer) : ViewModel() { val type = part.type ?: return fail("Unknown part type ${part.prefix}") _step.value = FirmwareStep.Downloading(part, 0f) viewModelScope.launch { - _step.value = runCatching { + _step.value = catching { val bytes = cloud.download(part) { done, total -> if (total > 0) _step.value = FirmwareStep.Downloading(part, done.toFloat() / total) } @@ -129,17 +153,20 @@ class FirmwareViewModel(private val container: AppContainer) : ViewModel() { FirmwareStep.Ready( ReadyImage(bytes, type, "${part.label} ${part.version}", part.version, fromCloud = true, trailer, trailerWarnings(bytes, trailer, type)), ) - }.getOrElse { FirmwareStep.Failed(it.message ?: "Download failed") } + }.getOrElse { failed("firmware download", it, "Download failed") } } } /** Stages a file the user picked. Only plain firmware images are accepted from files. */ fun loadFile(uri: Uri) = viewModelScope.launch { - _step.value = runCatching { + _step.value = catching { val bytes = withContext(Dispatchers.IO) { - container.app.contentResolver.openInputStream(uri)!!.use { it.readBytes() } + val input = openFile(uri) ?: throw FileNotFoundException("no input stream for $uri") + input.use { it.readBytes() } + } + if (bytes.size !in 1024..16 * 1024 * 1024) { + return@catching FirmwareStep.Failed("That file is ${bytes.size} bytes, which isn't a plausible firmware image.") } - require(bytes.size in 1024..16 * 1024 * 1024) { "That file is ${bytes.size} bytes, which isn't a plausible firmware image." } val trailer = HmfwTrailer.read(bytes) val warnings = buildList { add("This file didn't come from Zepp's update server for this strap. Only install files you trust.") @@ -147,7 +174,10 @@ class FirmwareViewModel(private val container: AppContainer) : ViewModel() { addAll(trailerWarnings(bytes, trailer, FirmwareType.FIRMWARE)) } FirmwareStep.Ready(ReadyImage(bytes, FirmwareType.FIRMWARE, "Firmware from file", null, fromCloud = false, trailer, warnings)) - }.getOrElse { FirmwareStep.Failed(it.message ?: "Couldn't read the file") } + }.getOrElse { + log("firmware: file not read: $it") + FirmwareStep.Failed("Couldn't read the file. Choose it again.") + } } /** Problems that make an image look wrong for this strap (docs/protocol/03 §2.2, advisory). */ @@ -176,7 +206,7 @@ class FirmwareViewModel(private val container: AppContainer) : ViewModel() { if (blocking.isNotEmpty()) return fail(blocking.joinToString(" ")) _step.value = FirmwareStep.Installing("Starting…", null) viewModelScope.launch { - _step.value = runCatching { + _step.value = catching { strap.installFirmware(image.bytes, image.type) { p -> _step.value = when (p) { is DfuProgress.Transferring -> FirmwareStep.Installing("Sending to strap", p.sent.toFloat() / p.total) @@ -186,12 +216,23 @@ class FirmwareViewModel(private val container: AppContainer) : ViewModel() { } } FirmwareStep.Installed - }.getOrElse { FirmwareStep.Failed(it.message ?: "Install failed") } + }.getOrElse { failed("firmware install", it, "Install failed") } } } private fun fail(message: String) { _step.value = FirmwareStep.Failed(message) } + /** The raw failure goes to Diagnostics, and the screen says it in plain words ([ErrorCopy.firmware]). */ + private fun failed(what: String, e: Throwable, fallback: String): FirmwareStep.Failed { + log("$what failed: $e") + return FirmwareStep.Failed(ErrorCopy.firmware(e, fallback)) + } + + private fun log(message: String) { + android.util.Log.w("OpenStrap", message) + (strap as? StrapManager)?.log(message) + } + companion object { /** Zepp's upgrade page refuses at this level or below (07 §1.1). */ const val MIN_BATTERY = 30 diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/ActivityDetail.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/ActivityDetail.kt index ee9dc94e..3b9a4bb6 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/ActivityDetail.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/ActivityDetail.kt @@ -1,5 +1,11 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.dotFormat +import org.openstrap.app.data.Weeks +import org.openstrap.app.presentation.Units +import org.openstrap.app.presentation.Vo2maxCopy +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.presentation.DayWords import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -31,7 +37,6 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import kotlinx.coroutines.flow.flowOf import org.openstrap.app.data.Activity import org.openstrap.app.data.DailyMetric import org.openstrap.app.data.RecoveryResult @@ -39,6 +44,7 @@ import org.openstrap.app.data.WorkoutAnalysis import org.openstrap.app.ui.components.rememberScrubHaptics import org.openstrap.app.ui.components.scrub import org.openstrap.app.ui.dashboard.ActivityCopy +import org.openstrap.app.ui.dashboard.ActivityHistory import org.openstrap.app.ui.dashboard.CaloriesCopy import org.openstrap.app.ui.dashboard.DailyHistory import org.openstrap.app.ui.dashboard.DayUiState @@ -56,9 +62,7 @@ import org.openstrap.app.ui.theme.MetricColors import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.TextStyle -import java.util.Locale +import org.openstrap.app.ui.glow.draw.Edge /* * The Activity tab's details (spec 5.3 and 6.1) in the metric template: Heart points, active minutes, Load, VO2 max, @@ -73,7 +77,8 @@ internal class ActivityDetailContext( val state: DayUiState, val r: RecoveryResult?, val workouts: List, - val paiHistory: PaiHistory?, + /** Each day's Heart points, and stored energy for Calories' week, month and year. */ + val activity: ActivityHistory, val history: DailyHistory, val onOpen: (Detail) -> Unit, val onSwitch: (Detail) -> Unit, @@ -81,8 +86,6 @@ internal class ActivityDetailContext( val onEditProfile: () -> Unit, val contentPadding: PaddingValues, val onBack: () -> Unit, - /** Each day's stored energy, for Calories' week, month and year. */ - val energyHistory: EnergyHistory? = null, ) { val today: LocalDate get() = LocalDate.now() val isToday: Boolean get() = state.date == today @@ -103,10 +106,8 @@ internal fun ActivityScoreDetail(score: Score, ctx: ActivityDetailContext) { } } -private val DAY = DateTimeFormatter.ofPattern("EEE d MMM") -private val CLOCK = DateTimeFormatter.ofPattern("HH:mm") - -private fun shortDay(d: LocalDate) = d.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) +/** A time of day at [zone], following the phone's 12 or 24 hour setting. */ +private fun clockOf(t: java.time.ZonedDateTime): String = TimeOfDay.format(t.toLocalTime()) /** The seven days ending on [end], oldest first. */ private fun weekOf(end: LocalDate): List = (6 downTo 0).map { end.minusDays(it.toLong()) } @@ -135,37 +136,38 @@ private fun HeartPointsDetail(ctx: ActivityDetailContext) { val range = ranges[rangeIndex.coerceIn(ranges.indices)] var scrub by remember(range, date) { mutableStateOf(null) } var info by rememberSaveable { mutableStateOf(false) } - val stored by remember(date, ctx.paiHistory) { - ctx.paiHistory?.invoke(date.minusDays(29), maxOf(date, ctx.today)) ?: flowOf(emptyMap()) - }.collectAsState(emptyMap()) - val week = weekOf(date) - val daily = week.map { stored[it]?.daily } + val stored = ctx.activity.heartPointsOn(date) + // Worked out once per load, not on every pass of the screen (audit K4). + val week = remember(date) { weekOf(date) } + val daily = remember(week, stored) { week.map { stored[it]?.daily } } val total = (ctx.state.pai?.total ?: stored[date]?.total)?.let(Math::round) - val month = (29 downTo 0).map { date.minusDays(it.toLong()) } - val totals = month.map { stored[it]?.total?.let(Math::round) } + val month = remember(date) { (29 downTo 0).map { date.minusDays(it.toLong()) } } + val totals = remember(month, stored) { month.map { stored[it]?.total?.let(Math::round) } } - val headline = scrub ?: when { + val headline = when { range == Range.Month && totals.any { it != null } -> DetailHeadline(Math.round(totals.filterNotNull().average()).toString(), "points, 7-day total on average", null) range == Range.Month -> DetailHeadline.none("No points", "In the last 30 days") total != null -> DetailHeadline(total.toString(), "points, last 7 days", ActivityCopy.heartPointsWord(total)) - else -> DetailHeadline.none("No points yet", "After the strap's next sync") + else -> DetailHeadline.none("No points yet", AFTER_NEXT_SYNC) } val strip = DetailCopy.strip(date, ctx.today, stored.keys) DetailTemplate( Score.HeartPoints.title, color, siblings(Detail.OfScore(Score.HeartPoints), ctx.onSwitch), onInfo = { info = true }, onBack = ctx.onBack, contentPadding = ctx.contentPadding, headline = headline, strip = strip, onDay = ctx.onSelectDay, - ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, + ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, scrubbed = { scrub }, ) { if (range == Range.Week) { item { if (daily.all { it == null }) DetailEmpty("No Heart points from the strap for these days yet.") else StackedDayBars( - daily.map { it ?: 0f }, ActivityCopy.HEART_POINTS_TARGET.toFloat(), color, week.map(::shortDay), + daily.map { it ?: 0f }, ActivityCopy.HEART_POINTS_TARGET.toFloat(), color, week.map { DayWords.weekdayShort(it) }, Modifier.padding(top = 8.dp), ) { i -> - scrub = i?.let { DetailHeadline(Math.round(daily[it] ?: 0f).toString(), ActivityCopy.earnedLabel(week[it].format(DAY)), null) } + scrub = i?.let { + DetailHeadline(Math.round(daily[it] ?: 0f).toString(), ActivityCopy.earnedLabel(DayWords.short(week[it])), null) + } } } if (total != null) item { @@ -181,8 +183,12 @@ private fun HeartPointsDetail(ctx: ActivityDetailContext) { val values = totals.map { it?.toFloat() } NormalTrend( values, DetailCopy.levels(values, null), color, - labels = listOf(0f, 1 / 3f, 2 / 3f, 1f).map { month[((month.size - 1) * it).toInt()].format(DateTimeFormatter.ofPattern("d MMM")) }, - onScrub = { i -> scrub = i?.let { totals[it] }?.let { v -> DetailHeadline(v.toString(), "7-day total, ${month[i].format(DAY)}", ActivityCopy.heartPointsWord(v)) } }, + labels = listOf(0f, 1 / 3f, 2 / 3f, 1f).map { DayWords.dayMonth(month[((month.size - 1) * it).toInt()]) }, + onScrub = { i -> + scrub = i?.let { totals[it] }?.let { v -> + DetailHeadline(v.toString(), "7-day total, ${DayWords.short(month[i])}", ActivityCopy.heartPointsWord(v)) + } + }, ) } } @@ -274,7 +280,7 @@ private fun ActiveMinutesDetail(ctx: ActivityDetailContext) { val week = weekOf(date) val byDay = a?.byDay.orEmpty() val day = byDay[date] ?: a?.mvpa - val headline = scrub ?: when { + val headline = when { a == null -> DetailHeadline.none("No reading", "Needs the strap's steps and heart rate") range == Range.Week -> DetailHeadline(a.weekEquivalent.toString(), "minutes, last 7 days", ActivityCopy.activeWord(a.weekEquivalent)) // The sentence says when ("So far today"), and which minutes. @@ -284,14 +290,16 @@ private fun ActiveMinutesDetail(ctx: ActivityDetailContext) { DetailTemplate( Score.ActiveMinutes.title, color, siblings(Detail.OfScore(Score.ActiveMinutes), ctx.onSwitch), onInfo = { info = true }, onBack = ctx.onBack, contentPadding = ctx.contentPadding, headline = headline, strip = stepsStrip(ctx), onDay = ctx.onSelectDay, - ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, + ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, scrubbed = { scrub }, ) { if (a != null && range == Range.Week) { val values = week.map { (byDay[it]?.equivalent ?: 0).toFloat() } item { GlowBars( - values, height = 200.dp, labels = week.map(::shortDay), accent = color, modifier = Modifier.padding(top = 8.dp), - onScrub = { i -> scrub = i?.let { DetailHeadline(Math.round(values[it]).toString(), "minutes, ${week[it].format(DAY)}", null) } }, + values, height = 200.dp, labels = week.map { DayWords.weekdayShort(it) }, accent = color, modifier = Modifier.padding(top = 8.dp), + onScrub = { i -> + scrub = i?.let { DetailHeadline(Math.round(values[it]).toString(), "minutes, ${DayWords.short(week[it])}", null) } + }, ) } item { DetailSentence(ActivityCopy.activeWeek(a.weekEquivalent)) } @@ -331,7 +339,7 @@ private fun LoadDetail(ctx: ActivityDetailContext) { val status = r?.let { TodayCopy.loadStatus(it.load, usual, endOfDay = !ctx.isToday) } val balance = TabCopy.loadBalance(r?.loadTrend) val known = values.filterNotNull() - val headline = scrub ?: when { + val headline = when { r == null -> DetailHeadline.none("No reading", "Needs a day of heart rate") // "So far today" and "learning your usual" are the sentence's to say, so the word is only a comparison. range == Range.Day -> DetailHeadline(Math.round(r.load).toString(), if (ctx.isToday) "today" else "that day", status?.takeIf { it != TodayCopy.LoadStatus.SoFar }?.text) @@ -346,7 +354,7 @@ private fun LoadDetail(ctx: ActivityDetailContext) { DetailTemplate( Score.Load.title, color, siblings(Detail.OfScore(Score.Load), ctx.onSwitch), onInfo = { info = true }, onBack = ctx.onBack, contentPadding = ctx.contentPadding, headline = headline, strip = stepsStrip(ctx, DailyMetric.HeartRate), onDay = ctx.onSelectDay, - ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, + ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, scrubbed = { scrub }, ) { if (r != null && range == Range.Day) { item { @@ -355,7 +363,7 @@ private fun LoadDetail(ctx: ActivityDetailContext) { listOf( Math.round(r.workoutLoad).toString() to "From workouts", Math.round(r.everydayLoad).toString() to "Moving about", - "%.1f".format(r.strain) to "Strain", + Units.decimal(r.strain, 1) to "Strain", ), ) DetailSentence(ActivityCopy.loadDay(r.strainPercentile, ctx.isToday)) @@ -366,10 +374,14 @@ private fun LoadDetail(ctx: ActivityDetailContext) { if (known.isEmpty()) DetailEmpty("No Load in the last ${range.days} days yet.") else NormalTrend( values, levels(values, band), color, - labels = if (range == Range.Week) days.map(::shortDay) - else listOf(0f, 1 / 3f, 2 / 3f, 1f).map { days[((days.size - 1) * it).toInt()].format(DateTimeFormatter.ofPattern("d MMM")) }, + labels = if (range == Range.Week) days.map { DayWords.weekdayShort(it) } + else listOf(0f, 1 / 3f, 2 / 3f, 1f).map { DayWords.dayMonth(days[((days.size - 1) * it).toInt()]) }, band = band, bandLabel = band?.let(ActivityCopy::usualBandLabel), - onScrub = { i -> scrub = i?.let { values[it] }?.let { v -> DetailHeadline(Math.round(v).toString(), days[i].format(DAY), loadWordOf(v, usual)) } }, + onScrub = { i -> + scrub = i?.let { values[it] }?.let { v -> + DetailHeadline(Math.round(v).toString(), DayWords.short(days[i]), loadWordOf(v, usual)) + } + }, ) } val sentence = if (range == Range.Week) ActivityCopy.balance(balance) ?: ActivityCopy.loadSpan(levels(values, band), range) @@ -424,7 +436,7 @@ private fun Vo2Detail(ctx: ActivityDetailContext) { .mapNotNull { w -> (w.workout.vo2max ?: w.effect?.vo2max?.let(Math::round))?.takeIf { it > 0 }?.let { w to it } } val latest = readings.firstOrNull() val est = ctx.r?.vo2max - val band = est?.band?.label + val band = est?.band?.let(Vo2maxCopy::band) // The band is the profile estimate's, so it stands beside that number only. val headline = when { latest != null -> DetailHeadline(latest.second.toString(), "ml/kg/min", null) @@ -444,7 +456,7 @@ private fun Vo2Detail(ctx: ActivityDetailContext) { readings.take(5).forEach { (w, v) -> val start = Instant.ofEpochSecond(w.shownStart).atZone(zone) ListRow( - w.sportName, color, v.toString(), start.format(DAY) + ", " + start.format(CLOCK), divider = !first, + w.sportName, color, v.toString(), DayWords.short(start.toLocalDate()) + ", " + clockOf(start), divider = !first, onClick = { ctx.onOpen(Detail.Workout(w.workout.start)) }, icon = org.openstrap.app.ui.workout.SportIcons.of(w.workout.sportType), iconTint = SportIconTint, ) first = false @@ -473,11 +485,13 @@ private fun RecoverDetail(ctx: ActivityDetailContext) { val zone = ZoneId.systemDefault() val now = System.currentTimeMillis() / 1000 val rated = ctx.workouts.filter { it.recoveryMinutes != null }.sortedByDescending { it.end } - val last = rated.firstOrNull() + val last = latestRecoveryEstimate(ctx.workouts) val left = last?.let { TodayCopy.recoverLeft(it.end, it.recoveryMinutes ?: 0, now) } val shown = left?.let { TodayCopy.recoverShort(it, now, zone) } val source = org.openstrap.app.data.Workouts.recoverySource(rated) - val ended = source?.let { w -> Instant.ofEpochSecond(w.end).atZone(zone).let { ActivityCopy.ended(it.toLocalDate(), it.format(CLOCK), ctx.today) } } + val ended = source?.let { w -> + Instant.ofEpochSecond(w.end).atZone(zone).let { ActivityCopy.ended(it.toLocalDate(), clockOf(it), ctx.today) } + } val headline = when { last == null -> DetailHeadline.none("No estimate", "After a workout the strap rates") shown?.first == null -> DetailHeadline.none("Recovered", "After your ${(source ?: last).sportName.lowercase()}, $ended") @@ -498,7 +512,7 @@ private fun RecoverDetail(ctx: ActivityDetailContext) { // Its own, not what earlier workouts left on top ([SummaryCopy.carriedOver]). val own = org.openstrap.app.ui.workout.SummaryCopy.recoverSpan(w.ownRecoveryMinutes ?: w.recoveryMinutes) ?: "None" ListRow( - w.sportName, color, own, "On its own, ${start.format(DAY)}, ${start.format(CLOCK)}", + w.sportName, color, own, "On its own, ${DayWords.short(start.toLocalDate())}, ${clockOf(start)}", divider = i > 0, onClick = { ctx.onOpen(Detail.Workout(w.workout.start)) }, icon = org.openstrap.app.ui.workout.SportIcons.of(w.workout.sportType), iconTint = SportIconTint, ) } @@ -514,7 +528,7 @@ private fun RecoverDetail(ctx: ActivityDetailContext) { /** * Calories (calories spec 4.5): active kcal as the headline with the goal under it. Day has the hours split by where the * kcal came from, the total and your use at rest, and a row per workout, walking and everyday moving. Week, Month and - * Year read each day's stored row ([EnergyHistory]): the active kcal a day, the goal days, day bars split into + * Year read each day's stored row ([ActivityHistory.energy]): the active kcal a day, the goal days, day bars split into * workouts and the rest against the goal (weekly over a year), and the change against the range before. The averages * read full days only; the total and the busiest day take today too ([CaloriesCopy.summary]). No profile: as before, * the way to add one. @@ -533,43 +547,55 @@ private fun CaloriesDetail(ctx: ActivityDetailContext) { val active = e?.activeKcal?.let(Math::round) val start = date.minusDays(range.days - 1L) // A range reads twice its length, for the change against the one before. - val stored by remember(range, date, ctx.energyHistory) { - if (range == Range.Day) flowOf(null) else ctx.energyHistory?.invoke(start.minusDays(range.days.toLong()), date) ?: flowOf(emptyMap()) - }.collectAsState(null) + val stored = remember(range, date, ctx.activity) { + if (range == Range.Day) null else ctx.activity.energyOn(date, start.minusDays(range.days.toLong())) + } // The day shown is worked out afresh, so it stands in for its row. - val rows = stored?.let { m -> - m.mapValues { (d, r) -> CaloriesCopy.DayKcal(d, r.activeKcal, r.totalKcal, r.workoutKcal, r.partial) } + + // Worked out once per load, not on every pass of the screen (audit K4). + val rows = remember(stored, e, date) { + stored?.let { m -> + m.mapValues { (d, r) -> CaloriesCopy.DayKcal(d, r.active, r.total, r.workout, r.partial) } + (e?.let { mapOf(date to CaloriesCopy.DayKcal(date, it.activeKcal, it.totalKcal, it.workoutKcal, it.partial)) } ?: emptyMap()) + } + } + val summary = remember(rows, start, date, goal, ctx.isToday) { + rows?.let { CaloriesCopy.summary(it, start, date, goal, inProgress = date.takeIf { ctx.isToday }) } } - val summary = rows?.let { CaloriesCopy.summary(it, start, date, goal, inProgress = date.takeIf { ctx.isToday }) } - val headline = scrub ?: when { + val headline = when { ctx.r == null -> DetailHeadline.none("No reading", DetailCopy.noValue(Metric.Steps, Range.Day, ctx.isToday)) e == null || active == null -> DetailHeadline.none("No estimate", "Needs your age, weight and height") - range == Range.Day -> DetailHeadline("%,d".format(active), CaloriesCopy.dayLabel(ctx.isToday), "Goal met".takeIf { active >= goal }) + range == Range.Day -> + DetailHeadline(dotFormat("%,d", active), CaloriesCopy.dayLabel(ctx.isToday), "Goal met".takeIf { active >= goal }) summary == null -> DetailHeadline.none("Loading", DetailCopy.noValue(Metric.Steps, range, ctx.isToday)) - summary.activeADay != null -> DetailHeadline("%,d".format(summary.activeADay), CaloriesCopy.rangeLabel(range), CaloriesCopy.goalDays(summary)) + summary.activeADay != null -> + DetailHeadline(dotFormat("%,d", summary.activeADay), CaloriesCopy.rangeLabel(range), CaloriesCopy.goalDays(summary)) else -> DetailHeadline.none("No full days yet", DetailCopy.noValue(Metric.Steps, range, ctx.isToday)) } DetailTemplate( Score.Energy.title, color, siblings(Detail.OfScore(Score.Energy), ctx.onSwitch), onInfo = { info = true }, onBack = ctx.onBack, contentPadding = ctx.contentPadding, headline = headline, strip = stepsStrip(ctx, DailyMetric.HeartRate), onDay = ctx.onSelectDay, - ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, + ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, scrubbed = { scrub }, ) { if (e == null || active == null) { if (ctx.r != null) item { - ListPanel(Modifier.padding(top = 8.dp)) { LinkRow("Add age, weight and height", ctx.onEditProfile, divider = false) } + ListPanel(Modifier.padding(top = 8.dp)) { LinkRow(ADD_PROFILE, ctx.onEditProfile, divider = false) } } } else if (range == Range.Day) { item { GoalBar(CaloriesCopy.share(active, goal), CaloriesCopy.toGoal(active, goal), color) } - item { HourlyKcal(e, color) { h -> scrub = h?.let { DetailHeadline("%,d".format(Math.round(e.hourly.total[it])), CaloriesCopy.hourLabel(it), null) } } } + item { + HourlyKcal(e, color) { h -> + scrub = h?.let { DetailHeadline(dotFormat("%,d", Math.round(e.hourly.total[it])), CaloriesCopy.hourLabel(it), null) } + } + } item { Column { // So far today both are; the activity level only means something for a whole day, in the goal's place. DetailFigures( listOf( - "%,d".format(Math.round(e.totalKcal)) to "Total kcal", - "%,d".format(Math.round(e.totalKcal - e.activeKcal)) to "At rest", - (e.pal?.takeIf { !ctx.isToday }?.let { "%.2f".format(it) to "Activity level" } ?: ("%,d".format(goal) to "Goal")), + Units.count(Math.round(e.totalKcal)) to "Total kcal", + Units.count(Math.round(e.totalKcal - e.activeKcal)) to "At rest", + e.pal?.takeIf { !ctx.isToday }?.let { Units.decimal(it, 2) to "Activity level" } + ?: (Units.count(goal) to "Goal"), ), ) DetailSentence(ActivityCopy.energy(Math.round(e.bmrKcal))) @@ -585,10 +611,10 @@ private fun CaloriesDetail(ctx: ActivityDetailContext) { sources.forEachIndexed { i, s -> val start = s.workoutStart if (start != null) ListRow( - s.name, color, "%,d".format(s.kcal), s.line, divider = i > 0, onClick = { ctx.onOpen(Detail.Workout(start)) }, + s.name, color, Units.count(s.kcal), s.line, divider = i > 0, onClick = { ctx.onOpen(Detail.Workout(start)) }, icon = org.openstrap.app.ui.workout.SportIcons.of(s.sportType ?: 0), iconTint = SportIconTint, ) else ListRow( - s.name, color, "%,d".format(s.kcal), s.line, divider = i > 0, + s.name, color, Units.count(s.kcal), s.line, divider = i > 0, icon = if (s.name == "Walking") Metric.Steps.rowIcon() else AppIcons.PersonArmsSpread, ) } @@ -606,8 +632,8 @@ private fun CaloriesDetail(ctx: ActivityDetailContext) { item { DetailFigures( listOfNotNull( - "%,d".format(summary.activeTotal) to "Active kcal in all", - summary.totalADay?.let { "%,d".format(it) to "Total a day" }, + Units.count(summary.activeTotal) to "Active kcal in all", + summary.totalADay?.let { Units.count(it) to "Total a day" }, summary.vsBefore?.let { CaloriesCopy.signed(it) to CaloriesCopy.vsLabel(range) }, ), Modifier.padding(top = 18.dp), @@ -670,18 +696,18 @@ private fun RangeKcal( val bars = remember(rows, range, start, end) { val days = generateSequence(start) { it.plusDays(1) }.takeWhile { !it.isAfter(end) }.toList() if (range != Range.Year) days.map { d -> d to rows[d]?.let { listOf(it.workout, (it.active - it.workout).coerceAtLeast(0f)) } } - else days.groupBy { it.with(java.time.temporal.TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) }.map { (monday, week) -> + else days.groupBy { Weeks.start(it) }.map { (first, week) -> val have = week.mapNotNull { rows[it] } - monday to have.takeIf { it.isNotEmpty() }?.let { w -> + first to have.takeIf { it.isNotEmpty() }?.let { w -> val workout = w.sumOf { it.workout.toDouble() }.toFloat() / w.size listOf(workout, (w.sumOf { it.active.toDouble() }.toFloat() / w.size - workout).coerceAtLeast(0f)) } } } val labels = when (range) { - Range.Week -> bars.map { shortDay(it.first) } - Range.Month -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { bars[((bars.size - 1) * it).toInt()].first.format(DateTimeFormatter.ofPattern("d MMM")) } - else -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { bars[((bars.size - 1) * it).toInt()].first.format(DateTimeFormatter.ofPattern("MMM")) } + Range.Week -> bars.map { DayWords.weekdayShort(it.first) } + Range.Month -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { DayWords.dayMonth(bars[((bars.size - 1) * it).toInt()].first) } + else -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { DayWords.monthShort(bars[((bars.size - 1) * it).toInt()].first) } } Column(Modifier.padding(top = 8.dp)) { KcalBars( @@ -689,8 +715,9 @@ private fun RangeKcal( onPick = { i -> onScrub(i?.let { bars[it] }?.let { (d, parts) -> parts?.let { - val label = if (range == Range.Year) "active kcal a day, week of ${d.format(DateTimeFormatter.ofPattern("d MMM"))}" else "active kcal, ${d.format(DAY)}" - DetailHeadline("%,d".format(Math.round(it.sum())), label, null) + val label = if (range == Range.Year) "active kcal a day, week of ${DayWords.dayMonth(d)}" + else "active kcal, ${DayWords.short(d)}" + DetailHeadline(dotFormat("%,d", Math.round(it.sum())), label, null) } }) }, @@ -792,8 +819,8 @@ private fun DistanceDetail(ctx: ActivityDetailContext) { // TODO(step 6): Week and longer need each day's distance, which the engine works out only for the day shown. val ours = ctx.r?.activity?.distanceMeters val meters = ours ?: ctx.state.distanceMeters?.toFloat() - val km = meters?.div(1000f) - val headline = if (km != null) DetailHeadline("%.1f".format(km), ActivityCopy.distanceSource(ours != null), null) + val km = meters?.let { Units.km(it, Units.Precision.Day, Units.DOT_LOCALE) } + val headline = if (km != null) DetailHeadline(km, ActivityCopy.distanceSource(ours != null), null) else DetailHeadline.none("No reading", DetailCopy.noValue(Metric.Steps, Range.Day, ctx.isToday)) DetailTemplate( Score.Distance.title, color, siblings(Detail.OfScore(Score.Distance), ctx.onSwitch), onInfo = { info = true }, onBack = ctx.onBack, @@ -822,23 +849,23 @@ private fun ActivityRelated(current: Score, ctx: ActivityDetailContext, heartPoi var first = true fun divider() = !first.also { first = false } ListRow( - Metric.Steps.title, MetricColors.steps, ctx.state.steps.takeIf { it > 0 }?.let { "%,d".format(it) }, + Metric.Steps.title, MetricColors.steps, ctx.state.steps.takeIf { it > 0 }?.let { Units.count(it) }, ActivityCopy.goalShare(ctx.state.steps, goal), onClick = { ctx.onOpen(Detail.OfMetric(Metric.Steps)) }, divider = divider(), icon = Metric.Steps.rowIcon(), ) if (current != Score.ActiveMinutes) ListRow( Score.ActiveMinutes.title, MetricColors.steps, a?.weekEquivalent?.toString(), - a?.let { TabCopy.activeMinutes(it.weekEquivalent, Activity.MVPA_WEEKLY_TARGET) } ?: "Nothing for this day", + a?.let { TabCopy.activeMinutes(it.weekEquivalent, Activity.MVPA_WEEKLY_TARGET) } ?: NO_VALUE, onClick = { ctx.onOpen(Detail.OfScore(Score.ActiveMinutes)) }, divider = divider(), icon = Score.ActiveMinutes.rowIcon(), ) if (current != Score.HeartPoints) ListRow( Score.HeartPoints.title, MetricColors.heart, points?.toString(), - points?.let { TabCopy.heartPoints(it, ActivityCopy.HEART_POINTS_TARGET) } ?: "After the strap's next sync", + points?.let { TabCopy.heartPoints(it, ActivityCopy.HEART_POINTS_TARGET) } ?: AFTER_NEXT_SYNC, onClick = { ctx.onOpen(Detail.OfScore(Score.HeartPoints)) }, divider = divider(), icon = Score.HeartPoints.rowIcon(), ) if (current != Score.Load) ListRow( Score.Load.title, MetricColors.load, ctx.r?.load?.let(Math::round)?.toString(), - ctx.r?.let { TabCopy.loadUsual(it.chronicLoad) } ?: "Nothing for this day", + ctx.r?.let { TabCopy.loadUsual(it.chronicLoad) } ?: NO_VALUE, onClick = { ctx.onOpen(Detail.OfScore(Score.Load)) }, divider = divider(), icon = Score.Load.rowIcon(), ) } @@ -878,20 +905,23 @@ internal fun WeekDetail(ctx: ActivityDetailContext) { val workoutsBefore = if (ctx.workouts.any { dayOf(it) < start }) ctx.workouts.count { dayOf(it) in before..start.minusDays(1) && it.workout.sportType != org.openstrap.app.data.Workouts.GENERIC_ACTIVITY } else null - val headline = scrub ?: when { - totals.stepsADay != null -> DetailHeadline("%,d".format(totals.stepsADay), WeekStory.label(week), change) + val headline = when { + totals.stepsADay != null -> DetailHeadline(dotFormat("%,d", totals.stepsADay), WeekStory.label(week), change) steps == null -> DetailHeadline.none("Loading", WeekStory.label(week).replaceFirstChar { it.uppercase() }) else -> DetailHeadline.none("No steps", "Monday to Sunday last week") } DetailTemplate( "Your week", color, emptyList(), onInfo = { info = true }, onBack = ctx.onBack, contentPadding = ctx.contentPadding, headline = headline, strip = emptyList(), onDay = {}, ranges = listOf(Range.Week), range = Range.Week, onRange = {}, + scrubbed = { scrub }, ) { val values = days.map { (it.steps ?: 0).toFloat() } if (values.any { it > 0f }) item { GlowBars( - values, height = 180.dp, labels = days.map { shortDay(it.date) }, accent = MetricColors.steps, modifier = Modifier.padding(top = 8.dp), - onScrub = { i -> scrub = i?.let { DetailHeadline(Math.round(values[it]).toString(), "steps, ${days[it].date.format(DAY)}", null) } }, + values, height = 180.dp, labels = days.map { DayWords.weekdayShort(it.date) }, accent = MetricColors.steps, modifier = Modifier.padding(top = 8.dp), + onScrub = { i -> + scrub = i?.let { DetailHeadline(Math.round(values[it]).toString(), "steps, ${DayWords.short(days[it].date)}", null) } + }, ) } item { @@ -899,7 +929,7 @@ internal fun WeekDetail(ctx: ActivityDetailContext) { listOf( totals.activeMinutes?.toString() to "Active minutes", totals.workouts.toString() to "Workouts", - totals.asleepANight?.let(::clockDuration) to "Asleep a night", + totals.asleepANight?.let { Units.duration(it) } to "Asleep a night", ).filter { it.first != null }, Modifier.padding(top = 18.dp), ) @@ -909,7 +939,7 @@ internal fun WeekDetail(ctx: ActivityDetailContext) { item { ListPanel(Modifier.padding(top = 8.dp), title = "Day by day") { WeekStory.rows(days).forEachIndexed { i, r -> - ListRow(r.title, MetricColors.steps, r.steps?.let { "%,d".format(it) }, r.line, divider = i > 0, dash = false) + ListRow(r.title, MetricColors.steps, r.steps?.let { Units.count(it) }, r.line, divider = i > 0, dash = false) } } } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/AllMetricsScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/AllMetricsScreen.kt index ef328bb4..ec35a392 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/AllMetricsScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/AllMetricsScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import java.time.LocalDate import kotlin.math.roundToInt +import org.openstrap.app.presentation.chargeShown import org.openstrap.app.data.Healthspan import org.openstrap.app.data.RecoveryResult import org.openstrap.app.data.WorkoutAnalysis @@ -60,9 +61,6 @@ data class IndexRow(val name: String, val color: Color, val value: String?, val data class IndexGroup(val title: String, val rows: List) -/** Shown under a row's name when the day has no value for it. */ -internal const val NO_VALUE = "Nothing for this day" - /** * What the Recovery row says under its name: the learning state while the baselines fill, as on the Recovery card * ("Learning, 5 of 7 nights"); otherwise that it's the day's score, or null when there's nothing to say. diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/CardLight.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/CardLight.kt new file mode 100644 index 00000000..e60a255b --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/CardLight.kt @@ -0,0 +1,23 @@ +package org.openstrap.app.ui.glow + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.scale + +/** + * A card lit by a soft light off its edge, as Ready's heart and the share cards are from below and the Heart rate zones + * hero from the left: an ellipse [radiusX] across and [radiusY] tall around [center] in [color], through [stops] + * (distance out from the centre, 0 to 1, to the colour's alpha there) to nothing at its rim. Written once so the three + * lights stay one light. + */ +internal fun DrawScope.drawCardLight(color: Color, center: Offset, radiusX: Float, radiusY: Float, stops: List>) { + val colors = stops.map { (at, alpha) -> at to color.copy(alpha = alpha) } + (1f to Color.Transparent) + // A circle as tall as the ellipse, stretched across to its width: the gradient stretches with it. + scale(radiusX / radiusY, 1f, center) { + @Suppress("SpreadOperator") // Brush takes its stops only as varargs, and there are three or four of them. + val brush = Brush.radialGradient(*colors.toTypedArray(), center = center, radius = radiusY) + drawCircle(brush, radiusY, center) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/Celebrations.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/Celebrations.kt new file mode 100644 index 00000000..39c9789b --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/Celebrations.kt @@ -0,0 +1,229 @@ +package org.openstrap.app.ui.glow + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.CircleShape +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.alpha +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.Rewards +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.presentation.RewardsCopy +import org.openstrap.app.presentation.dotFormat +import org.openstrap.app.ui.glow.draw.DotSize + +/* + * The celebrations (workouts spec 3.4, boards GoalMoment and BadgeUnlocked): the full screen goal moment, once a week, + * the full screen card for a personal best (never for a milestone), the badges past history earned, shown once on + * one screen, and the two switches that turn the goal moment and the weekly recap off. No other pop ups. + */ + +/** Each group of badges in its own colour, so the art tells them apart. */ +internal fun badgeColor(group: Rewards.Group): Color = when (group) { + Rewards.Group.Firsts -> MetricColors.steps + Rewards.Group.Milestones -> MetricColors.calories + Rewards.Group.Weekly -> MetricColors.good + Rewards.Group.Kinds -> MetricColors.load +} + +/** A personal best's colour: the calories' gold. */ +internal val BestColor = MetricColors.calories + +/** + * A badge's art in the glow language: a round glow in [color] lit from below, a hairline rim, and its [short] text. + * [dots] puts the text in dots (the one dotted number on its screen); a [locked] badge is dim and unlit. + */ +@Composable +internal fun BadgeArt( + short: String, color: Color, size: Dp, modifier: Modifier = Modifier, locked: Boolean = false, dots: Boolean = false, under: String? = null, + /** Just a ring in the colour with the text in it (board AchievementsV1's row), no fill. */ + ring: Boolean = false, +) { + Box( + modifier.size(size).alpha(if (locked) 0.35f else 1f) + .let { m -> + if (ring) m else m.background( + if (locked) Brush.radialGradient(listOf(Color.White.copy(alpha = 0.06f), Color.White.copy(alpha = 0.02f))) + else Brush.verticalGradient(listOf(Color.Black, color.copy(alpha = 0.55f))), + CircleShape, + ) + } + .border(if (ring) 2.dp else 1.dp, if (locked) Color.White.copy(alpha = if (ring) 0.5f else 0.12f) else color.copy(alpha = if (ring) 1f else 0.8f), CircleShape), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (dots) DotMatrixText(short, dot = DotSize.Hero) + else Text( + short, color = if (ring && !locked) color else Color.White, maxLines = 1, + style = if (size >= 80.dp) GlowType.pageTitle else GlowType.cardTitle, + ) + under?.let { Text(it, color = Color.White, style = GlowType.body, modifier = Modifier.padding(top = 6.dp)) } + } + } +} + +/** The close button in a celebration's top corner. */ +@Composable +private fun CloseRow(title: String?, onClose: () -> Unit) { + Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(title.orEmpty(), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.weight(1f).padding(start = 4.dp)) + RoundButton(AppIcons.X, "Close", onClose, size = 40.dp) + } +} + +/** + * The full screen card for a personal best (board BadgeUnlocked): the best's art with its value, what it is, how far it + * beat the last one and when it was set. Share comes with the share screen; Continue goes on. + */ +@Composable +fun BestCardScreen(best: Rewards.NewBest, sportName: String, contentPadding: PaddingValues, onContinue: () -> Unit) { + BackHandler(onBack = onContinue) + val now = System.currentTimeMillis() / 1000 + WorkoutPage(BestColor, contentPadding, pinned = { WorkoutButton("Continue", Color.White, Color.Black, onContinue) }) { + CloseRow("Personal best", onContinue) + Spacer(Modifier.height(24.dp)) + BadgeArt( + RewardsCopy.bestShort(best.best.kind), BestColor, 200.dp, Modifier.align(Alignment.CenterHorizontally), dots = true, + under = RewardsCopy.bestValue(best.best.kind, best.best.value), + ) + Spacer(Modifier.height(12.dp)) + Text(RewardsCopy.bestTitle(best.best.kind), color = Color.White, style = GlowType.pageTitle, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + Text(RewardsCopy.beatLine(best, now), color = Color.White, style = GlowType.sentence, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + Text( + RewardsCopy.whenSet(best.best.date, sportName), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, + textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(), + ) + } +} + +/** + * The goal moment (board GoalMoment), once a week after the summary of the workout that met the goal: "4/4" in dots, + * the weeks in a row, the week's days, and Continue, with Celebrations settings under it. + */ +@Composable +fun GoalMomentScreen(count: Int, goal: Int, inARow: Int, weekStarts: List, contentPadding: PaddingValues, onSettings: () -> Unit, onContinue: () -> Unit) { + BackHandler(onBack = onContinue) + WorkoutPage( + MetricColors.good, contentPadding, + pinned = { + WorkoutButton("Continue", Color.White, Color.Black, onContinue) + PageTextButton("Celebrations settings", onSettings, Modifier.align(Alignment.CenterHorizontally)) + }, + ) { + CloseRow(null, onContinue) + Spacer(Modifier.height(32.dp)) + DotMatrixText( + dotFormat("%d/%d", count, goal), Modifier.align(Alignment.CenterHorizontally), dot = DotSize.Hero, color = MetricColors.good, + ) + Text("Weekly goal met", color = Color.White, style = GlowType.pageTitle, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) + Text(RewardsCopy.momentLine(inARow), color = Color.White, style = GlowType.sentence, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(8.dp)) + WeekDaysPanel(weekStarts, note = "Shown once a week, when you reach your goal.") + } +} + +/** + * The celebration settings and the week the goal moment last showed for, with what changes them, handed to the + * summary so it can follow itself with the goal moment. + */ +class Celebrations( + val goalMoment: Boolean, + val weeklyRecap: Boolean, + /** The week (epoch day) the goal moment last showed for. */ + val momentWeek: Long?, + val setGoalMoment: (Boolean) -> Unit, + val setWeeklyRecap: (Boolean) -> Unit, + val markMoment: (Long) -> Unit, +) + +/** The two celebration switches (workouts spec 3.4), in a sheet. */ +@Composable +fun CelebrationsSheet(goalMoment: Boolean, weeklyRecap: Boolean, onGoalMoment: (Boolean) -> Unit, onWeeklyRecap: (Boolean) -> Unit, onDismiss: () -> Unit) { + PageSheet(onDismiss, "Celebrations", note = "No streaks to keep and no nudges. Just these two.") { + PageRows { + PageSwitchRow("Weekly goal moment", "A full screen once a week, when you reach your goal", goalMoment, onGoalMoment, divider = false) + PageSwitchRow("Weekly recap", "One notification on Monday morning about your week", weeklyRecap, onWeeklyRecap) + } + PageButton("Done", onDismiss, Modifier.fillMaxWidth(), primary = true) + } +} + +/** + * The badges past history earned, shown once on one screen when the app first works them out (requests spec 7.2), + * rather than one by one. + */ +@Composable +fun HistoryBadgesScreen(badges: List, contentPadding: PaddingValues, onContinue: () -> Unit, face: (Rewards.Badge) -> String = { it.short }) { + BackHandler(onBack = onContinue) + WorkoutPage(MetricColors.good, contentPadding, pinned = { WorkoutButton("Continue", Color.White, Color.Black, onContinue) }) { + CloseRow(null, onContinue) + Text("From your workouts so far", color = Color.White, style = GlowType.pageTitle, modifier = Modifier.padding(start = 4.dp, top = 8.dp)) + Text( + "${badges.size} ${if (badges.size == 1) "badge" else "badges"} already earned. New ones show on the summary after a workout.", + color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 4.dp), + ) + BadgeGrid(badges.map { it to null }, face = face) + } +} + +/** + * The badges on Goals and bests (board AchievementsV1): up to four rings in a row, no panel, the badge's colour as the + * ring and its text, the name under and its date, or for the next one dim with how far along it is. + */ +@Composable +internal fun BadgeRow(badges: List>, locked: (Rewards.Badge) -> Boolean, face: (Rewards.Badge) -> String) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top) { + badges.take(4).forEach { (b, note) -> + val dim = locked(b) + Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + BadgeArt(face(b), badgeColor(b.group), 64.dp, locked = dim, ring = true) + Text( + if (dim && note != null) note else b.title, color = Color.White.copy(alpha = if (dim) 0.5f else 1f), + style = GlowType.caption, textAlign = TextAlign.Center, maxLines = 2, + ) + } + } + repeat((4 - badges.size).coerceAtLeast(0)) { Box(Modifier.weight(1f)) } + } +} + +/** + * Badges three to a row, each its art and title; [note] under the title is the date earned or what it needs, [face] + * what its art reads ([RewardsCopy.face]). + */ +@Composable +internal fun BadgeGrid(badges: List>, locked: (Rewards.Badge) -> Boolean = { false }, face: (Rewards.Badge) -> String = { it.short }) { + QuietPanel { + badges.chunked(3).forEach { row -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + row.forEach { (b, note) -> + Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + BadgeArt(face(b), badgeColor(b.group), 64.dp, locked = locked(b)) + Text(b.title, color = Color.White, style = GlowType.secondary, textAlign = TextAlign.Center, maxLines = 2) + note?.let { Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.caption, textAlign = TextAlign.Center) } + } + } + repeat(3 - row.size) { Box(Modifier.weight(1f)) } + } + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/ChooseScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/ChooseScreen.kt new file mode 100644 index 00000000..cd0e7522 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/ChooseScreen.kt @@ -0,0 +1,416 @@ +package org.openstrap.app.ui.glow + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.RecentChip +import org.openstrap.app.data.Workouts +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.ui.components.pressClickable +import org.openstrap.app.ui.dashboard.ScreenList +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.workout.SportIcons +import org.openstrap.app.ui.workout.familyColor +import org.openstrap.app.ui.glow.draw.Edge + +/* ---------------------------------------------- Choose ----------------------------------------------- */ + +/** Your usual sport on Choose, with its one line: what you did last time. */ +data class ChooseSport(val type: Int, val line: String) + +/** + * Choose (workouts spec 2.1), the first screen of a workout and every sport on it, so one tap from Activity's All + * sports reaches them all: "Start a workout" and the strap in one line; your usual as a slim row lit from its left + * edge in the sport's colour, with Start, which starts with last time's settings and skips Ready ([onStartUsual]), + * while the row elsewhere opens Ready ([onReady]); the search over every sport; Recent as chips, saved workouts among + * them ([onSaved]); then all sports as tiles and More sports, as the picker has them. A new user has no usual, and + * Run, Walk and Strength as recent. + */ +@Composable +fun GlowChooseScreen( + usual: ChooseSport?, + recent: List, + connection: ConnectionState, + liveHeartRate: Int?, + contentPadding: PaddingValues, + onStartUsual: (Int) -> Unit, + onReady: (Int) -> Unit, + onBack: () -> Unit, + onSaved: (RecentChip.Saved) -> Unit = {}, +) { + var more by rememberSaveable { mutableStateOf(false) } + var searching by rememberSaveable { mutableStateOf(false) } + BackHandler { if (searching) searching = false else if (more) more = false else onBack() } + if (more) { + MoreSports(contentPadding, onReady) { more = false } + return + } + if (searching) { + SearchPage(contentPadding, onReady) { searching = false } + return + } + val header: @Composable () -> Unit = { + Row(verticalAlignment = Alignment.CenterVertically) { + RoundIcon(AppIcons.X, "Close", onBack) + Spacer(Modifier.width(12.dp)) + Text("Start a workout", color = Color.White, style = GlowType.pageTitle, maxLines = 1) + } + } + val choose: @Composable ColumnScope.() -> Unit = { + StrapLine(connection, liveHeartRate) + if (usual != null) UsualRow(usual, { onStartUsual(usual.type) }, { onReady(usual.type) }) + // The search opens a page of its own (board ActivitySearchTyping). + SearchEntry("Search all sports") { searching = true } + if (recent.isNotEmpty()) { + ChipLabel("Recent") + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + recent.forEach { chip -> + when (chip) { + is RecentChip.Sport -> RecentChipButton(chip.type, Workouts.shortName(chip.type)) { onReady(chip.type) } + is RecentChip.Saved -> RecentChipButton(chip.sport, chip.name) { onSaved(chip) } + } + } + } + } + SportTiles("All sports", Workouts.EVERYDAY, Workouts::shortName, { false }, onReady) + } + BoxWithConstraints(Modifier.fillMaxSize()) { + // Unfolded (spec 2.4): the rest of the sports, A to Z, in a column of their own beside the tiles, each side + // scrolling by itself, so none is a tap away behind More sports. + if (maxWidth >= GlowLayout.TwoColumnWidth) CompositionLocalProvider(LocalPageColumn provides GlowLayout.MaxContentWidth) { + WorkoutPage(Color.Transparent, contentPadding, scroll = false) { + header() + Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(GlowLayout.ColumnGap * 2)) { + Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp), content = choose) + Column(Modifier.weight(1f).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipLabel("More sports") + MoreSportsList(onReady) + } + } + } + } else WorkoutPage(Color.Transparent, contentPadding) { + header() + choose() + MoreSportsRow { more = true } + } + } +} + +/** + * Your usual: a slim row lit from its left edge in the sport's colour (not the tall hero, lit from below), its name, + * what you did last time, and Start at its end. The row itself opens Ready. + */ +@Composable +private fun UsualRow(s: ChooseSport, onStart: () -> Unit, onOpen: () -> Unit) { + val color = familyColor(Workouts.family(s.type)) + val haptics = org.openstrap.app.ui.components.rememberHaptics() + CompositionLocalProvider(LocalEdgeOverride provides EdgeOverride(edge = Edge.Left)) { + EdgeCard(color, Modifier.fillMaxWidth(), onClick = onOpen, contentPadding = PaddingValues(start = 18.dp, top = 14.dp, end = 14.dp, bottom = 14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Your usual", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + Text( + Workouts.sportName(s.type), color = Color.White, style = GlowType.headerDate, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + if (s.line.isNotEmpty()) Text( + s.line, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, + maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(12.dp)) + PageButton( + "Start", + { haptics.perform(org.openstrap.app.ui.components.HapticEvent.WorkoutStart); onStart() }, + primary = true, icon = AppIcons.PlayFill, + ) + } + } + } +} + +/** A Recent chip: the sport's icon in its colour and a name, a sport's or a saved workout's, on a quiet pill. */ +@Composable +private fun RecentChipButton(sport: Int, name: String, onClick: () -> Unit) { + Row( + Modifier.height(44.dp).clip(CircleShape).background(QuietCard).border(1.dp, RowHairline, CircleShape) + .pressClickable(onClick, CircleShape).semantics { role = Role.Button } + .padding(start = 10.dp, end = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(SportIcons.of(sport), null, tint = familyColor(Workouts.family(sport)), modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(name, color = Color.White, style = GlowType.body, maxLines = 1) + } +} + +/** Choose's quiet chips, a step off black so the usual's glow stays the one lit thing. */ +private val QuietCard = ListSurface + +/* --------------------------------------------- Sport picker -------------------------------------------- */ + +/** + * A sport's tile in the picker (spec 6.3): a 64 dp Control tile with 16 dp corners, the icon at 24 dp, the name in + * Secondary under it. The chosen sport's tile is white with a black icon; a sport done only outdoors carries the + * 12 dp location mark in its corner. + */ +@Composable +private fun SportTile(type: Int, name: String, modifier: Modifier, selected: Boolean = false, onClick: () -> Unit) { + // Tight corners and room underneath, so the press area never clips a label that fills the width. + Column(modifier.pressClickable(onClick, RoundedCornerShape(8.dp)).padding(vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier.size(SportTileSize).clip(GlowLayout.TileShape).background(if (selected) Color.White else Raised), + contentAlignment = Alignment.Center, + ) { + val ink = if (selected) Color.Black else Color.White + Icon(SportIcons.of(type), null, tint = ink, modifier = Modifier.size(24.dp)) + if (type in Workouts.OUTDOOR_ONLY) Icon( + SportIcons.Location, "Outdoors", tint = ink.copy(alpha = 0.7f), + modifier = Modifier.align(Alignment.TopEnd).padding(6.dp).size(12.dp), + ) + } + Spacer(Modifier.height(6.dp)) + // Two lines, so a long name in More sports' search isn't cut short. + Text( + name, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 2, + textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis, + ) + } +} + +private val SportTileSize = 64.dp + +/** The sport list on its own, for saving a stretch nobody started. */ +@Composable +fun GlowSportPickerScreen( + contentPadding: PaddingValues, onPick: (Int) -> Unit, onBack: () -> Unit, + recentSports: List = emptyList(), sides: Map = emptyMap(), +) = SportPicker(-1, recentSports, contentPadding, { onPick(pickedType(it, sides)) }, onBack) + +/* ------------------------------------------- Choose a sport ------------------------------------------ */ + +/** + * Choose a sport (spec 6.3), for Ready's sport and for saving a stretch nobody started: a page with the search field, + * your Recent sports, the twelve everyday ones as tiles and a More sports row for the rest, A to Z. Search looks + * through every sport, both kinds of a two-kind sport included. A tile picks its sport's key ([Workouts.tileType]); + * Ready's switch then picks the kind. [selected] is the sport chosen now, -1 for none. Choose has the same parts. + */ +@Composable +internal fun SportPicker(selected: Int, recent: List, contentPadding: PaddingValues, onPick: (Int) -> Unit, onBack: () -> Unit) { + var more by rememberSaveable { mutableStateOf(false) } + BackHandler { if (more) more = false else onBack() } + if (more) { + MoreSports(contentPadding, onPick) { more = false } + return + } + var query by rememberSaveable { mutableStateOf("") } + val chosen = Workouts.tileType(selected) + ScreenList(contentPadding, gutter = GlowLayout.Gutter) { + item { PageHeader("Choose a sport", onBack) } + item { SearchField(query, "Search") { query = it } } + if (query.isNotBlank()) { + item(key = "found") { SearchResults(query, selected, onPick) } + return@ScreenList + } + if (recent.isNotEmpty()) item(key = "recent") { SportTiles("Recent", recent, Workouts::shortName, { it == chosen }, onPick) } + item(key = "sports") { SportTiles("Sports", Workouts.EVERYDAY, Workouts::shortName, { it == chosen }, onPick) } + item(key = "more") { MoreSportsRow { more = true } } + } +} + +/** The rest of the sports, A to Z, as a list page of their own. */ +@Composable +private fun MoreSports(contentPadding: PaddingValues, onPick: (Int) -> Unit, onBack: () -> Unit) { + ScreenList(contentPadding, gutter = GlowLayout.Gutter) { + item { PageHeader("More sports", onBack) } + item { MoreSportsList(onPick) } + } +} + +/** Every sport behind the tiles, A to Z, in one panel. */ +@Composable +private fun MoreSportsList(onPick: (Int) -> Unit) = ListPanel { + Workouts.MORE.forEachIndexed { i, type -> + PageRow(Workouts.sportName(type), icon = SportIcons.of(type), divider = i > 0, onClick = { onPick(type) }) + } +} + +/** The row that opens [MoreSports]. */ +@Composable +private fun MoreSportsRow(onClick: () -> Unit) = ListPanel { + PageRow("More sports", "Tennis, padel, dance and the rest", below = true, divider = false, onClick = onClick) +} + +/** What the search finds, as tiles, or that nothing matches [query]. Picking one puts the keyboard away. */ +@Composable +private fun SearchResults(query: String, selected: Int, onPick: (Int) -> Unit) { + val found = remember(query) { Workouts.search(query) } + val keyboard = LocalSoftwareKeyboardController.current + val focus = LocalFocusManager.current + val pick: (Int) -> Unit = { keyboard?.hide(); focus.clearFocus(); onPick(it) } + if (found.isEmpty()) Text( + "No sport matches \"${query.trim()}\".", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + style = GlowType.secondary, modifier = Modifier.padding(start = 4.dp, top = 8.dp), + ) + else SportTiles(null, found, { if (it in Workouts.EVERYDAY) Workouts.shortName(it) else Workouts.sportName(it) }, { it == selected }, pick) +} + +/** Choose's search field before it's typed in: it looks like the field and opens [SearchPage]. */ +@Composable +private fun SearchEntry(hint: String, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().height(48.dp).clip(GlowLayout.TileShape).background(Raised) + .pressClickable(onClick, GlowLayout.TileShape).semantics { role = Role.Button }.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(SportIcons.Search, null, tint = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(10.dp)) + Text(hint, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.body, maxLines = 1) + } +} + +/** + * The search over every sport (board ActivitySearchTyping): back and the field, with the keyboard up, then how many + * sports match and a row for each, its icon and full name. Before anything is typed, every sport A to Z. + */ +@Composable +private fun SearchPage(contentPadding: PaddingValues, onPick: (Int) -> Unit, onBack: () -> Unit) { + var query by rememberSaveable { mutableStateOf("") } + val found = remember(query) { + if (query.isBlank()) (Workouts.EVERYDAY + Workouts.MORE).distinct().sortedBy { Workouts.sportName(it) } + // Names starting with what's typed first, then a word in them starting with it, then the rest. + else Workouts.search(query).sortedBy { type -> + val name = Workouts.sportName(type).lowercase() + val q = query.trim().lowercase() + when { + name.startsWith(q) -> 0 + name.split(' ').any { it.startsWith(q) } -> 1 + else -> 2 + } + } + } + val keyboard = LocalSoftwareKeyboardController.current + val focus = remember { androidx.compose.ui.focus.FocusRequester() } + androidx.compose.runtime.LaunchedEffect(Unit) { focus.requestFocus() } + ScreenList(contentPadding, gutter = GlowLayout.Gutter) { + item { + Row(verticalAlignment = Alignment.CenterVertically) { + BackButton(onBack) + Spacer(Modifier.width(12.dp)) + Box(Modifier.weight(1f)) { SearchField(query, "Search all sports", Modifier.focusRequester(focus)) { query = it } } + } + } + item { + Text( + when { + query.isBlank() -> "All sports" + found.isEmpty() -> "No sport matches \"${query.trim()}\"." + found.size == 1 -> "1 sport" + else -> "${found.size} sports" + }, + color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(start = 4.dp), + ) + } + if (found.isNotEmpty()) item { + ListPanel { + found.forEachIndexed { i, type -> + PageRow(Workouts.sportName(type), icon = SportIcons.of(type), divider = i > 0, onClick = { keyboard?.hide(); onPick(type) }) + } + } + } + } +} + +/** A section of sport tiles, four to a row, under its Caption [title]. */ +@Composable +private fun SportTiles(title: String?, types: List, name: (Int) -> String, isSelected: (Int) -> Boolean, onPick: (Int) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (title != null) ChipLabel(title) + types.chunked(4).forEach { row -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top) { + row.forEach { type -> SportTile(type, name(type), Modifier.weight(1f), selected = isSelected(type)) { onPick(type) } } + repeat(4 - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +/** The search on Choose and the picker: a 48 dp field on the Control surface with 16 dp corners and the search icon (spec 6.3). */ +@Composable +private fun SearchField(query: String, hint: String, modifier: Modifier = Modifier, onChange: (String) -> Unit) { + val keyboard = LocalSoftwareKeyboardController.current + BasicTextField( + query, onChange, modifier.fillMaxWidth(), singleLine = true, + textStyle = GlowType.body.copy(color = Color.White), + cursorBrush = SolidColor(Color.White), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }), + decorationBox = { field -> + Row( + Modifier.fillMaxWidth().height(48.dp).clip(GlowLayout.TileShape).background(Raised).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(SportIcons.Search, null, tint = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(10.dp)) + Box(Modifier.weight(1f)) { + if (query.isEmpty()) Text(hint, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.body, maxLines = 1) + field() + } + if (query.isNotEmpty()) Icon( + AppIcons.X, "Clear", tint = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + modifier = Modifier.size(28.dp).clip(CircleShape).clickable { onChange("") }.padding(6.dp), + ) + } + }, + ) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/DataStateParts.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/DataStateParts.kt index 9badb7c6..22db1f76 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/DataStateParts.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/DataStateParts.kt @@ -5,7 +5,8 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Brush import kotlin.math.PI @@ -15,6 +16,7 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -26,6 +28,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -54,9 +58,14 @@ import org.openstrap.app.ui.theme.MetricColors */ @Composable internal fun PairingStage(step: PairStep, modifier: Modifier = Modifier) { - val t by rememberInfiniteTransition(label = "pairing").animateFloat( - 0f, 1f, infiniteRepeatable(tween(PAIR_CYCLE_MS, easing = LinearEasing)), label = "pairing cycle", - ) + // The cycle runs only for the steps that move with it, so a stage that waits or failed costs no frames. It's read + // only in draw, so a running cycle redraws the stage without composing it again. + val moving = step == PairStep.Searching || step == PairStep.Found || step == PairStep.Connecting + val cycle: State = if (moving) { + rememberInfiniteTransition(label = "pairing").animateFloat( + 0f, 1f, infiniteRepeatable(tween(PAIR_CYCLE_MS, easing = LinearEasing)), label = "pairing cycle", + ) + } else StillCycle // Connected: the line fills from the phone to the strap, then the pod flashes once. val fill = remember { Animatable(0f) } val flash = remember { Animatable(0f) } @@ -72,7 +81,7 @@ internal fun PairingStage(step: PairStep, modifier: Modifier = Modifier) { // from the text's edges on both sides, so it sits inside the card's words rather than on them. Box(modifier.fillMaxWidth().padding(horizontal = StageInset).height(StageHeight)) { val strapWidth = StageHeight * 0.8f - Canvas(Modifier.matchParentSize()) { + Spacer(Modifier.matchParentSize().drawWithCache { val y = size.height / 2 val gap = 8.dp.toPx() val hair = 1.dp.toPx() @@ -82,13 +91,21 @@ internal fun PairingStage(step: PairStep, modifier: Modifier = Modifier) { val podX = size.width - sw + sw * POD_LEFT + sw / 2 val end = size.width - sw * (1f - 2 * POD_LEFT) - gap val reach = 46.dp.toPx() + val sparkGlow = 9.dp.toPx() + // One glow for every spark, centred on the origin and moved to each: a gradient a spark a frame is garbage. + val sparkBrush = Brush.radialGradient( + listOf(StrapLight.copy(alpha = 0.55f), StrapLight.copy(alpha = 0f)), Offset.Zero, sparkGlow, + ) + onDrawBehind { + val t = cycle.value fun ripple(cx: Float, phase: Float, color: Color, from: Float) { val r = from + (reach - from) * phase drawCircle(color.copy(alpha = color.alpha * (1f - phase) * 0.7f), r, Offset(cx, y), style = Stroke(hair)) } - fun spark(x: Float, color: Color) { - drawCircle(Brush.radialGradient(listOf(color.copy(alpha = 0.55f), color.copy(alpha = 0f)), Offset(x, y), 9.dp.toPx()), 9.dp.toPx(), Offset(x, y)) - drawCircle(color, 2.dp.toPx(), Offset(x, y)) + // The glow is the same at every spark (its colour's alpha is set, not scaled). Only the dot fades. + fun spark(x: Float, alpha: Float = 1f) { + translate(x, y) { drawCircle(sparkBrush, sparkGlow, Offset.Zero) } + drawCircle(StrapLight.copy(alpha = alpha), 2.dp.toPx(), Offset(x, y)) } when (step) { PairStep.Ready -> drawLine(Color.White.copy(alpha = 0.22f), Offset(start, y), Offset(end, y), hair, StrokeCap.Round) @@ -108,14 +125,14 @@ internal fun PairingStage(step: PairStep, modifier: Modifier = Modifier) { for (k in 0..2) { val p = (t + k / 3f) % 1f val e = p * p * (3f - 2f * p) - spark(start + (end - start) * e, StrapLight.copy(alpha = sin(p * PI.toFloat()))) + spark(start + (end - start) * e, sin(p * PI.toFloat())) } } PairStep.Connected -> { drawLine(StrapLight.copy(alpha = 0.2f), Offset(start, y), Offset(end, y), hair, StrokeCap.Round) val head = start + (end - start) * fill.value drawLine(StrapLight, Offset(start, y), Offset(head, y), hair, StrokeCap.Round) - if (fill.value < 1f) spark(head, StrapLight) + if (fill.value < 1f) spark(head) if (flash.value in 0.001f..0.999f) { ripple(podX, flash.value, StrapLight, 20.dp.toPx()) drawCircle(StrapLight.copy(alpha = 0.35f * (1f - flash.value)), 30.dp.toPx(), Offset(podX, y)) @@ -130,7 +147,8 @@ internal fun PairingStage(step: PairStep, modifier: Modifier = Modifier) { drawLine(amber, Offset(mid + broken, y), Offset(end, y), hair, StrokeCap.Round) } } - } + } + }) PhoneDrawing(Modifier.align(Alignment.CenterStart).size(width = PhoneWidth, height = 52.dp)) // Shifted out by the room right of the pod, so the pod sits as far from the card's edge as the phone does. StrapDrawing( @@ -140,6 +158,9 @@ internal fun PairingStage(step: PairStep, modifier: Modifier = Modifier) { } } +/** The cycle's resting value, for the steps that don't move with it. */ +private val StillCycle: State = mutableFloatStateOf(0f) + /** One turn of the pairing stage's moving parts: a ripple, a pulse, a spark's run along the line. */ private const val PAIR_CYCLE_MS = 1600 diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/EdgeCard.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/EdgeCard.kt index 79f1691f..e40d119b 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/EdgeCard.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/EdgeCard.kt @@ -29,6 +29,10 @@ import org.openstrap.app.ui.glow.draw.brush import org.openstrap.app.ui.glow.draw.drawEdge import org.openstrap.app.ui.glow.draw.edgeLevel import org.openstrap.app.ui.glow.draw.edgePaint +import org.openstrap.app.ui.glow.draw.EdgeStatus +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.edgeLight +import org.openstrap.app.ui.glow.draw.Edge /* * The middle tier of the glow language: a near-black card with a soft glow coming in from one edge in the metric's @@ -37,61 +41,6 @@ import org.openstrap.app.ui.glow.draw.edgePaint * status. Static: the edge only changes when the data does. */ -/** Which edge of an [EdgeCard] the light comes in from. */ -enum class Edge { Top, Bottom, Left, Right } - -/** A value against your normal. */ -enum class EdgeStatus { Usual, Above, Below } - -/** What an edge card is about; with its [EdgeStatus] it decides where the light sits. */ -enum class EdgeKind { - /** A value read against your normal: resting heart rate, HRV, skin temperature. */ - Value, - - /** Progress toward a goal or your usual by now: steps, Load so far. Gets a progress bar. */ - Progress, - - /** Live, now: heart rate today. */ - Live, - - /** About earlier: Recovery after the morning, Habit Age. */ - Earlier, - - /** Sleep: last night, regularity, breathing disturbances. Lit from the top, like a night sky. */ - Sleep, - - /** A level, like Charge: the light fills from the left across to the value, in place of a progress bar. */ - Level, -} - -/** Where an edge card's light sits and how it's drawn. */ -@Immutable -data class EdgeLight( - val edge: Edge, - /** Off your normal: a deeper, stronger glow. */ - val strong: Boolean, - /** A 4 dp progress bar inside the card. */ - val progressBar: Boolean, -) - -/** - * Where the light sits for a card of [kind] whose value is [status] (spec 2.4). A plain value lights the bottom when - * usual or below your normal and the top when above. The other kinds keep their own edge (progress bottom, live - * right, earlier left, sleep top). Off your normal, any of them glows stronger. - */ -fun edgeLight(status: EdgeStatus, kind: EdgeKind = EdgeKind.Value): EdgeLight { - val off = status != EdgeStatus.Usual - val edge = when (kind) { - EdgeKind.Value -> if (status == EdgeStatus.Above) Edge.Top else Edge.Bottom - EdgeKind.Progress -> Edge.Bottom - EdgeKind.Live -> Edge.Right - EdgeKind.Earlier -> Edge.Left - EdgeKind.Sleep -> Edge.Top - EdgeKind.Level -> Edge.Left - } - return EdgeLight(edge, strong = off, progressBar = kind == EdgeKind.Progress) -} - /** The neutral edge light, hsl 220 10% 55%: a cool soft white for cards that speak for no one metric. */ val EdgeNeutral = Color.hsl(220f, 0.10f, 0.55f) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/EditTodaySheet.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/EditTodaySheet.kt index 2707fac8..fbbc7bb6 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/EditTodaySheet.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/EditTodaySheet.kt @@ -6,7 +6,9 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -17,11 +19,12 @@ import org.openstrap.app.ui.dashboard.TodayEdits /** * Edit Today (audit 3, Sheets): which cards Today shows, and up to two pinned under the hero, as a sheet of rows. * Each row has the card's name, a Pin chip when it can be pinned, and a switch for whether it shows. Saved as a whole - * on Done; pulling the sheet down or Cancel leaves Today as it was. + * on Done. Pulling the sheet down or Cancel leaves Today as it was. The pins and hides not yet saved outlive a rotation, + * a fold and process death ([TodayEditsSaver]). */ @Composable internal fun EditTodaySheet(edits: TodayEdits, onSave: (TodayEdits) -> Unit, onDismiss: () -> Unit) { - var e by remember { mutableStateOf(edits) } + var e by rememberSaveable(stateSaver = TodayEditsSaver) { mutableStateOf(edits) } PageSheet(onDismiss, "Edit Today", note = "Pin up to two cards under the top card, or hide the ones you don't need.") { PageRows { TodayCard.entries.filter { it.editable }.forEachIndexed { i, card -> @@ -43,3 +46,13 @@ internal fun EditTodaySheet(edits: TodayEdits, onSave: (TodayEdits) -> Unit, onD } } } + +/** Keeps [TodayEdits] in a saved state bundle as the hidden and the pinned cards' names, pins in their order. */ +internal val TodayEditsSaver: Saver = listSaver( + save = { e -> listOf(ArrayList(e.hidden.map { it.name }), ArrayList(e.pinned.map { it.name })) }, + restore = { v -> + @Suppress("UNCHECKED_CAST") + fun cards(i: Int) = (v[i] as List).map(TodayCard::valueOf) + TodayEdits(cards(0).toSet(), cards(1)) + }, +) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowActivityScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowActivityScreen.kt index 5cd91c7a..a396e459 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowActivityScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowActivityScreen.kt @@ -1,5 +1,9 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.dotFormat +import org.openstrap.app.presentation.Units +import org.openstrap.app.presentation.Vo2maxCopy +import org.openstrap.app.presentation.TimeOfDay import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -23,7 +27,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -49,16 +52,14 @@ import androidx.compose.ui.unit.sp import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter import kotlin.math.roundToInt -import kotlinx.coroutines.flow.Flow import org.openstrap.app.data.Activity import org.openstrap.app.data.RecoveryResult import org.openstrap.app.data.WorkoutAnalysis import org.openstrap.app.data.Workouts -import org.openstrap.app.data.db.DailyPai import org.openstrap.app.ui.dashboard.ActivityCard import org.openstrap.app.ui.dashboard.ActivityCopy +import org.openstrap.app.ui.dashboard.ActivityHistory import org.openstrap.app.ui.dashboard.Detail import org.openstrap.app.ui.dashboard.Score import org.openstrap.app.ui.dashboard.ActivityFacts @@ -78,6 +79,14 @@ import org.openstrap.app.ui.workout.SportIcons import org.openstrap.app.ui.workout.ZONE_COLORS import org.openstrap.app.ui.glow.draw.drawHourPills import org.openstrap.app.ui.glow.draw.hourPills +import androidx.compose.foundation.layout.BoxWithConstraints +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.EdgeStatus +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.glow.draw.level +import org.openstrap.app.ui.theme.ControlSurface +import org.openstrap.app.presentation.topSports /** Heart points' weekly aim: 100 or more over the last seven days. */ private const val HEART_POINTS_TARGET = ActivityCopy.HEART_POINTS_TARGET @@ -85,12 +94,6 @@ private const val HEART_POINTS_TARGET = ActivityCopy.HEART_POINTS_TARGET /** Workouts the list shows, the day's first, before "All workouts". */ private const val LISTED_WORKOUTS = 5 -/** Each day's Heart points (the strap's PAI) between two dates, keyed by date. */ -typealias PaiHistory = (LocalDate, LocalDate) -> Flow> - -/** Each day's stored energy ([org.openstrap.app.data.DailyEnergyHistory]) between two dates, keyed by date. */ -typealias EnergyHistory = (LocalDate, LocalDate) -> Flow> - /* * The Activity tab (spec 5.3): "how much did I move and train". A card to start a sport today, steps glowing, Load * against your usual, this week's facts and the workouts ([activityCards]). Every card and row opens its detail; the @@ -102,7 +105,8 @@ fun GlowActivityScreen( state: DayUiState, recovery: RecoveryResult?, workouts: List, - paiHistory: PaiHistory, + /** Each day's Heart points, for today's total until the strap's own comes. */ + activity: ActivityHistory, header: @Composable () -> Unit, contentPadding: PaddingValues, onOpenMetric: (Metric) -> Unit, @@ -129,8 +133,16 @@ fun GlowActivityScreen( val open = { s: Score -> onOpen(Detail.OfScore(s)) } val list = rememberLazyListState() val isToday = state.date == LocalDate.now() - val sports = remember(workouts) { topSports(workouts) } - val stored = remember(state.date) { paiHistory(state.date, state.date) }.collectAsState(emptyMap()).value + // Your three most used first, as on a phone; a wider card goes on with the rest you use, then the everyday sports, + // one tile per sport whichever kind. + val sports = remember(workouts) { + val shown = topSports(workouts).map { it.sportType }.toMutableList() + (topSports(workouts, MORE_BY_USE).map { it.sportType } + Workouts.EVERYDAY).forEach { t -> + if (shown.none { Workouts.tileType(it) == Workouts.tileType(t) }) shown += t + } + shown.toList() + } + val stored = activity.heartPointsOn(state.date) val heartPoints = state.pai?.total ?: stored[state.date]?.total val r = recovery?.takeIf { it.date == state.date } val live = liveHeartRate?.takeIf { isToday } @@ -138,11 +150,13 @@ fun GlowActivityScreen( val restingRange = rememberNormal(Metric.RestingHr, state.date, history) // A partial day lowers the steps and the day's totals: those two cards carry the tag (spec 4.3). var partialInfo by rememberSaveable { mutableStateOf(false) } - val partial = wear?.takeIf { it.partial }?.let { TitleTag(PARTIAL_DAY) { partialInfo = true } } + // Built once, so the cards given it can skip (audit K11). + val partialTag = remember { TitleTag(PARTIAL_DAY) { partialInfo = true } } + val partial = partialTag.takeIf { wear?.partial == true } if (partialInfo && wear != null) PartialDaySheet(wear, isToday, onDismiss = { partialInfo = false }) CompositionLocalProvider(LocalGlowPaused provides { list.isScrollInProgress }) { GlowScreenList(contentPadding, list) { - full { header() } + add(Pane.Full, GlowBlocks.HEADER) { header() } tabCards(slots) { card, m -> when (card) { ActivityCard.Start -> StartCard(sports, onStartWorkout, onAllSports) @@ -150,13 +164,13 @@ fun GlowActivityScreen( ActivityCard.ActiveMinutes -> r?.activity.let { a -> SmallCard( MetricColors.steps, "Active minutes", a?.weekEquivalent?.toString(), null, - a?.let { TabCopy.activeMinutes(it.weekEquivalent, Activity.MVPA_WEEKLY_TARGET) } ?: "No reading yet", m, + a?.let { TabCopy.activeMinutes(it.weekEquivalent, Activity.MVPA_WEEKLY_TARGET) } ?: NO_READING, m, onClick = { open(Score.ActiveMinutes) }, ) } ActivityCard.HeartPoints -> SmallCard( MetricColors.heart, "Heart points", heartPoints?.roundToInt()?.toString(), null, - heartPoints?.let { TabCopy.heartPoints(it.roundToInt(), HEART_POINTS_TARGET) } ?: "After the strap's next sync", m, + heartPoints?.let { TabCopy.heartPoints(it.roundToInt(), HEART_POINTS_TARGET) } ?: AFTER_NEXT_SYNC, m, onClick = { open(Score.HeartPoints) }, ) ActivityCard.Load -> LoadCard(r) { open(Score.Load) } @@ -177,29 +191,41 @@ fun GlowActivityScreen( /** * Start a workout (spec 5.3): a Wide edge card in the neutral light from below, its title, then your three most used * sports as 64 dp tiles with their names under them and All sports after them. Words with the icons, since an icon - * alone doesn't say "start". A sport opens Ready with it chosen ([onStart]); All sports opens Choose a sport. The - * tiles are the buttons, so the card itself opens nothing and has no caret. Today only. + * alone doesn't say "start". On a wider card (unfolded) as many more tiles as fit at a phone's spacing, your other + * sports and then the everyday ones ([sports] in that order). A sport opens Ready with it chosen ([onStart]); All + * sports opens Choose with every sport. The tiles are the buttons, so the card itself opens nothing and has no caret. + * Today only. */ @Composable -private fun StartCard(sports: List, onStart: (Int) -> Unit, onAllSports: () -> Unit) { +private fun StartCard(sports: List, onStart: (Int) -> Unit, onAllSports: () -> Unit) { // Unlit (its look), with the heart-red light travelling round its border, as on Today. EdgeCard(EdgeNeutral, Modifier.fillMaxWidth().movingBorder(MetricColors.heart), kind = EdgeKind.Value) { EdgeTitle("Start a workout", null) Spacer(Modifier.height(GlowLayout.TitleGap)) - // Four even columns up to the phone's width, so the tiles keep together on an unfolded screen. - Row(Modifier.widthIn(max = StartTilesWidth).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - sports.take(3).forEach { s -> - SportTile(SportIcons.of(s.sportType), Workouts.sportName(s.sportType), outdoor = s.sportType in Workouts.OUTDOOR, Modifier.weight(1f)) { - onStart(s.sportType) + BoxWithConstraints(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { + // Four on a phone; wider, as many as fit at a phone's column, so the tiles never spread apart, centred. + val columns = maxOf(4, ((maxWidth + StartTileGap) / (StartTileColumn + StartTileGap)).toInt()) + Row( + Modifier.widthIn(max = StartTileColumn * columns + StartTileGap * (columns - 1)).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(StartTileGap), + ) { + sports.take(columns - 1).forEach { type -> + SportTile(SportIcons.of(type), Workouts.sportName(type), outdoor = type in Workouts.OUTDOOR, Modifier.weight(1f)) { + onStart(type) + } } + SportTile(AppIcons.MoreHorizontalCircle01, "All sports", outdoor = false, Modifier.weight(1f), onClick = onAllSports) } - SportTile(AppIcons.MoreHorizontalCircle01, "All sports", outdoor = false, Modifier.weight(1f), onClick = onAllSports) } } } -/** Four tiles and their gaps at most: a phone's width inside the card. */ -private val StartTilesWidth = 344.dp +/** A tile's column on a phone: four tiles and their gaps in 344 dp. */ +private val StartTileColumn = 80.dp +private val StartTileGap = 8.dp + +/** How many sports by use a wide Start card offers before the everyday ones pad it. */ +private const val MORE_BY_USE = 8 private val SportTileSize = 64.dp private val SportTileShape = RoundedCornerShape(GlowLayout.TileCorner) @@ -250,7 +276,7 @@ private fun StepsGlow(state: DayUiState, usual: List?, tag: TitleTag?, on var hour by remember(state.date, state.hourlySteps) { mutableStateOf(null) } GlowCard(GlowStyle.level(MetricColors.steps, share), Modifier.fillMaxWidth().gridSize(CardSize.Wide), onClick = onClick) { Column { - GlowHeader("Steps", trailing = "Goal %,d".format(goal), tag = tag) + GlowHeader("Steps", trailing = "Goal ${Units.count(goal)}", tag = tag) Spacer(Modifier.height(GlowLayout.TitleGap)) HourPills(state.hourlySteps, hour) { hour = it } Spacer(Modifier.height(16.dp)) @@ -259,7 +285,7 @@ private fun StepsGlow(state: DayUiState, usual: List?, tag: TitleTag?, on val h = hour val picked = h?.let { state.hourlySteps.getOrNull(it) } HeroFigure( - "%,d".format(if (h != null && picked != null) Math.round(picked) else state.steps), + dotFormat("%,d", if (h != null && picked != null) Math.round(picked) else state.steps), if (h != null && picked != null) ActivityCopy.hourLabel(h) else ActivityCopy.activityLine(state.steps, goal, pace, isToday), big = true, dot = DotSize.Second, labelLines = 2, ) @@ -318,7 +344,7 @@ private fun LoadCard(r: RecoveryResult?, onClick: () -> Unit) { kind = EdgeKind.Level, progress = r?.let { l -> usual?.let { l.load / it } } ?: 0f, onClick = onClick, ) { EdgeTitle("Load", TabCopy.loadBalance(r?.loadTrend)) - EdgeValue(r?.load?.roundToInt()?.toString(), "today", if (r == null) "No reading yet" else TabCopy.loadUsual(usual)) + EdgeValue(r?.load?.roundToInt()?.toString(), "today", if (r == null) NO_READING else TabCopy.loadUsual(usual)) } } @@ -334,7 +360,7 @@ private fun FactsList( /** The Partial day tag: energy and distance are the day's totals. */ tag: TitleTag? = null, ) { - val waiting = "No reading yet" + val waiting = NO_READING ListPanel(title = "Today", note = if (isToday) "So far" else null, tag = tag) { val a = r?.activity // The latest workout the strap read VO2 max from; the estimate from your profile otherwise. @@ -346,12 +372,12 @@ private fun FactsList( fromWorkouts?.toString() ?: est?.let { "${it.low}-${it.high}" }, when { fromWorkouts != null -> "From your workouts" - est != null -> est.band?.let { "${it.label} for your age" } ?: "Estimated from your profile" + est != null -> est.band?.let { "${Vo2maxCopy.band(it)} for your age" } ?: "Estimated from your profile" else -> "After a workout the strap can read it from" }, divider = false, onClick = { open(Score.Vo2Max) }, icon = Score.Vo2Max.rowIcon(), ) - val recover = workouts.filter { it.recoveryMinutes != null }.maxByOrNull { it.end }?.let { w -> + val recover = latestRecoveryEstimate(workouts)?.let { w -> val now = System.currentTimeMillis() / 1000 TodayCopy.recoverText(TodayCopy.recoverLeft(w.end, w.recoveryMinutes ?: 0, now), now, ZoneId.systemDefault()) } @@ -365,15 +391,15 @@ private fun FactsList( val goal = LocalGoals.current.calories val active = e?.activeKcal?.roundToInt() ListRow( - "Calories", MetricColors.calories, active?.let { "%,d".format(it) }, - e?.let { CaloriesCopy.rowLine(active ?: 0, goal, it.totalKcal.roundToInt()) } ?: "Add age, weight and height to see it", + "Calories", MetricColors.calories, active?.let { Units.count(it) }, + e?.let { CaloriesCopy.rowLine(active ?: 0, goal, it.totalKcal.roundToInt()) } ?: "$ADD_PROFILE to see it", onClick = if (e == null && r != null) onEditProfile else { { open(Score.Energy) } }, dash = e != null, icon = Score.Energy.rowIcon(), ring = active?.let { CaloriesCopy.share(it, goal) }, ) // Ours from step length and height when we can; the strap's own figure otherwise. Always approximate. val distance = a?.distanceMeters ?: state.distanceMeters?.toFloat() ListRow( - "Distance", MetricColors.steps, distance?.let { "%.1f".format(it / 1000f) }, + "Distance", MetricColors.steps, distance?.let { Units.km(it, Units.Precision.Day) }, if (distance == null) waiting else ActivityCopy.distanceSource(a?.distanceMeters != null), onClick = { open(Score.Distance) }, icon = Score.Distance.rowIcon(), ) @@ -452,7 +478,7 @@ internal fun WorkoutRow(a: WorkoutAnalysis, usual: Float?, divider: Boolean, onC Modifier .fillMaxWidth() .clickable(onClick = onClick) - .let { if (divider) it.drawBehind { drawLine(RowHairline, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) } else it }, + .topHairline(divider, RowHairline), ) { Row(Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { EffortLight(SportIcons.of(a.workout.sportType), Workouts.effort(a.workout.trainingLoad, usual)) @@ -469,7 +495,8 @@ internal fun WorkoutRow(a: WorkoutAnalysis, usual: Float?, divider: Boolean, onC } } Text( - TabCopy.workoutLine(fact, start.format(CLOCK)), color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, + TabCopy.workoutLine(fact, TimeOfDay.format(start.toLocalTime())), + color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } @@ -520,15 +547,13 @@ internal fun FoundTag() { ) } -private val CLOCK = DateTimeFormatter.ofPattern("HH:mm") - /** A stretch nobody started, as a row with Save: when it was and how hard. */ @Composable private fun SuggestionRow(s: org.openstrap.app.data.WorkoutSuggestions.Suggestion, onSave: () -> Unit, onDismiss: () -> Unit, divider: Boolean) { Column { ListRow( "Looks like a workout", ZONE_COLORS[s.zone], null, - "${clockAt(s.start)}–${clockAt(s.end)}, mostly zone ${s.zone + 1}, ${s.averageHeartRate} bpm on average", + "${clock(s.start)}–${clock(s.end)}, mostly zone ${s.zone + 1}, ${s.averageHeartRate} bpm on average", dash = false, divider = divider, icon = AppIcons.Heartbeat, ) Row(Modifier.fillMaxWidth().padding(start = 20.dp, bottom = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCard.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCard.kt index f39a7fe7..1f2e4793 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCard.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCard.kt @@ -64,6 +64,11 @@ import org.openstrap.app.ui.glow.draw.drawGlowCore import org.openstrap.app.ui.glow.draw.glowPaint import org.openstrap.app.ui.glow.draw.grainShader import org.openstrap.app.ui.glow.draw.randomGrainSeed +import org.openstrap.app.ui.glow.draw.GlowMotion +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.CoreBox +import org.openstrap.app.ui.glow.draw.GlowPalette +import org.openstrap.app.ui.glow.draw.light /** * A card in the glow language (see [GlowStyle]). Layers, back to front: rim-coloured fill, rim light, @@ -96,6 +101,8 @@ fun GlowCard( modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, contentPadding: PaddingValues = GlowLayout.CardPadding, + /** A passing lift of the light (a personal best), faded over the card's own layers, so it never renders anew. */ + swell: GlowSwell? = null, content: @Composable BoxScope.() -> Unit, ) { val lit = style.copy(mood = LocalGlowMood.current) @@ -141,13 +148,26 @@ fun GlowCard( // The motion doesn't change how the card looks at rest, so it isn't part of the cache key. // A live heart rate changes the motion every reading; keyed on it, each new bpm would drop the drawn layers and // show the plain body colour until they're redrawn, a flash on every beat update. - val key = GlowCache.Key(lit.copy(motion = GlowMotion.Still), size, density.density) - val bitmaps by produceState(GlowCache.peek(key), key) { - // Always the layers for this key: when the card changes size, the old ones no longer fit. - value = GlowCache.peek(key) - if (key.size == IntSize.Zero || lit.outline || value != null) return@produceState + val still = remember(lit) { lit.copy(motion = GlowMotion.Still) } + val key = GlowCache.Key(still, size, density.density) + val bitmaps by produceState(GlowCache.peek(key) ?: GlowCache.latest(still, density.density), key) { + // The layers for this key once there are some. Until then the last ones stay, or the last made for this look + // at any size, stretched to the card's (a detail opening or closing beside the tab rebuilds and resizes the + // cards): the plain body colour in between would flash. + GlowCache.peek(key)?.let { value = it; return@produceState } + if (value == null) value = GlowCache.latest(still, density.density) + if (key.size == IntSize.Zero || lit.outline) return@produceState value = withContext(Dispatchers.Default) { GlowCache.get(key) } } + // The swell's peak is one more cached look, rendered once: each frame of the swell only changes how much of it shows. + val peak = swell?.peak?.takeIf { it != still.lightLevel && !lit.outline } + val peakKey = peak?.let { GlowCache.Key(still.copy(lightLevel = it), size, density.density) } + val peakBitmaps by produceState(peakKey?.let(GlowCache::peek), peakKey) { + value = peakKey?.let(GlowCache::peek) + if (peakKey == null || value != null || peakKey.size == IntSize.Zero) return@produceState + value = withContext(Dispatchers.Default) { GlowCache.get(peakKey) } + } + val peakPaint = remember { Paint(FILTER) } // Tapped, the card grows into what it opens (spec 3.5). val opening = cardOpening(onClick, shape) @@ -171,10 +191,28 @@ fun GlowCard( // Base: fill and rim light. Until its bitmap is ready, the plain mid colour; an outline card is black. Spacer( Modifier.matchParentSize().drawBehind { + val w = this.size.width + val h = this.size.height + // A card built anew (a detail closing beside the tab) draws once before it knows its size: the layers + // already made for the size it's drawn at stand in, core and all, for that frame. + val first = if (b == null && !lit.outline) { + GlowCache.peek(GlowCache.Key(still, IntSize(w.roundToInt(), h.roundToInt()), density.density)) ?: GlowCache.latest(still, density.density) + } else null when { lit.outline -> drawRect(Color.Black) + first != null -> drawIntoCanvas { + it.nativeCanvas.drawBitmap(first.base, null, first.baseIn(w, h), FILTER) + it.nativeCanvas.drawBitmap(first.core, null, first.coreIn(w, h), Paint(FILTER).apply { alpha = (lit.coreOpacity * 255).roundToInt().coerceIn(0, 255) }) + } b == null -> drawRect(palette.mid) - else -> drawIntoCanvas { it.nativeCanvas.drawBitmap(b.base, null, b.baseDst, FILTER) } + else -> drawIntoCanvas { it.nativeCanvas.drawBitmap(b.base, null, b.baseIn(w, h), FILTER) } + } + // Read here, so the swell redraws this layer each frame and composes nothing. + val lifted = peakBitmaps + val lift = if (b != null && lifted != null) swell?.amount?.invoke() ?: 0f else 0f + if (lifted != null && lift > 0f) drawIntoCanvas { + peakPaint.alpha = (lift.coerceIn(0f, 1f) * 255).roundToInt() + it.nativeCanvas.drawBitmap(lifted.base, null, lifted.baseIn(w, h), peakPaint) } }, ) @@ -190,6 +228,13 @@ fun GlowCard( } } +/** + * A passing lift of a card's light to [peak] (its [GlowStyle.lightLevel] at the top), shown as much as [amount] says, + * 0 to 1. [amount] is read only while drawing, so an animated swell recomposes nothing. + */ +@androidx.compose.runtime.Immutable +class GlowSwell(val peak: Float, val amount: () -> Float) + /** The pre-blurred core, moved by the motion through layer properties only. */ @Composable private fun BoxScope.CoreLayer(b: GlowBitmaps, style: GlowStyle, core: CoreBox, motion: () -> Drift) { @@ -209,7 +254,7 @@ private fun BoxScope.CoreLayer(b: GlowBitmaps, style: GlowStyle, core: CoreBox, // Fade by scaling each pixel's alpha instead of rendering the layer offscreen first. compositingStrategy = CompositingStrategy.ModulateAlpha } - .drawBehind { drawIntoCanvas { it.nativeCanvas.drawBitmap(b.core, null, b.coreDst, FILTER) } }, + .drawBehind { drawIntoCanvas { it.nativeCanvas.drawBitmap(b.core, null, b.coreIn(size.width, size.height), FILTER) } }, ) } @@ -240,7 +285,20 @@ private class GlowBitmaps( val core: android.graphics.Bitmap, val baseDst: RectF, val coreDst: RectF, -) +) { + /** Where the base goes on a card [w] by [h]: its own place, or stretched over a card of another size. */ + fun baseIn(w: Float, h: Float): RectF = if (fits(w, h)) baseDst else RectF(0f, 0f, w, h) + + /** Where the core goes on a card [w] by [h], its margin stretched with the card. */ + fun coreIn(w: Float, h: Float): RectF { + if (fits(w, h)) return coreDst + val sx = w / baseDst.width() + val sy = h / baseDst.height() + return RectF(coreDst.left * sx, coreDst.top * sy, coreDst.right * sx, coreDst.bottom * sy) + } + + private fun fits(w: Float, h: Float) = w == baseDst.width() && h == baseDst.height() +} /** * Rendered glow layers, shared by every card with the same look and size. Rendering blurs is the @@ -255,14 +313,19 @@ private object GlowCache { fun peek(key: Key): GlowBitmaps? = synchronized(entries) { entries[key] } + /** The layers last used for [style] at any size, to stretch over a card until its own size's are made. */ + fun latest(style: GlowStyle, density: Float): GlowBitmaps? = synchronized(entries) { + entries.entries.lastOrNull { it.key.style == style && it.key.density == density }?.value + } + fun get(key: Key): GlowBitmaps { peek(key)?.let { return it } val built = render(key.style, key.size.width.toFloat(), key.size.height.toFloat(), Density(key.density)) return synchronized(entries) { entries.getOrPut(key) { built } } } - /** Enough for every card on a screen plus the ones just scrolled past. */ - private const val MAX_ENTRIES = 10 + /** Enough for every card on a screen and the tabs either side, plus the ones just scrolled past. */ + private const val MAX_ENTRIES = 16 } /** diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCharts.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCharts.kt index 2c3c9fb8..243edf85 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCharts.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowCharts.kt @@ -1,6 +1,7 @@ package org.openstrap.app.ui.glow import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxWithConstraints @@ -26,7 +27,9 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.lerp +import androidx.compose.ui.draw.CacheDrawScope import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.Path @@ -34,6 +37,7 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeJoin import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText @@ -44,7 +48,6 @@ import androidx.compose.ui.unit.sp import org.openstrap.app.ui.components.rememberScrubHaptics import org.openstrap.app.ui.components.scrub import org.openstrap.app.ui.theme.FunnelSans -import org.openstrap.app.ui.theme.MetricColors import org.openstrap.app.ui.glow.draw.BAR_MAX_WIDTH_DP import org.openstrap.app.ui.glow.draw.ChartInk import org.openstrap.app.ui.glow.draw.chartHairline @@ -53,12 +56,15 @@ import org.openstrap.app.ui.glow.draw.drawHypnogramBands import org.openstrap.app.ui.glow.draw.drawLineChartBody import org.openstrap.app.ui.glow.draw.drawLineChartEnd import org.openstrap.app.ui.glow.draw.drawPillBar +import org.openstrap.app.ui.glow.draw.ghostLinePaths +import org.openstrap.app.ui.glow.draw.lineChartPaths import org.openstrap.app.ui.glow.draw.ghostLineGeometry import org.openstrap.app.ui.glow.draw.hypnogramGeometry import org.openstrap.app.ui.glow.draw.lineChartGeometry import org.openstrap.app.ui.glow.draw.pillBar import kotlin.math.roundToInt import kotlin.math.sin +import org.openstrap.app.ui.glow.draw.stageColor /* * Charts for glow cards. The card's colour carries the mood, so the marks are white: lines get a soft halo, and grids, @@ -89,11 +95,25 @@ internal val BarMaxWidth = BAR_MAX_WIDTH_DP.dp internal fun DrawScope.pillBar(color: Color, x: Float, pitch: Float, bottom: Float, height: Float) = drawPillBar(pillBar(x, pitch, bottom, height, BarMaxWidth.toPx()), color) -private fun DrawScope.text(m: TextMeasurer, s: String, style: TextStyle, x: Float, y: Float, align: Float = 0f) { - val r = m.measure(s, style) +/* + * Chart text is measured in each chart's drawWithCache block, once per size and data, and only drawn on each frame: + * measuring in draw churned the measurer's small cache on every scrub frame. + */ + +/** A label measured once ([TextMeasurer.measure]) and drawn at [x], [align] of its width left of it, kept on the chart. */ +private fun DrawScope.text(r: TextLayoutResult, x: Float, y: Float, align: Float = 0f) { drawText(r, topLeft = Offset((x - r.size.width * align).coerceIn(0f, size.width - r.size.width), y)) } +/** The average's label: the axis text a touch brighter. */ +private val averageText = axisText.copy(color = Ink.copy(alpha = 0.8f)) +/** Today's day name and value on a [GlowTrend], and the other days' values. */ +private val todayAxisText = axisText.copy(color = Ink) +private val todayValueText = valueText.copy(color = Ink) +private val pastValueText = valueText.copy(color = Ink.copy(alpha = 0.75f)) +/** A [NormalTrend]'s band label and day labels. */ +private val trendLabelText = axisText.copy(fontSize = 12.sp) + /** The touch readout: a dark glass pill with white text, above [anchorY] when there's room, else below. */ private fun DrawScope.readout(m: TextMeasurer, s: String, x: Float, anchorY: Float) { val r = m.measure(s, readoutText) @@ -109,15 +129,22 @@ private fun DrawScope.readout(m: TextMeasurer, s: String, x: Float, anchorY: Flo /** A solid vertical guide under the finger. */ private fun DrawScope.guide(x: Float, bottom: Float) = drawLine(Target, Offset(x, 0f), Offset(x, bottom), strokeWidth = 1.dp.toPx()) -/** Bottom axis labels spread evenly from the left edge to the right edge. */ -private fun DrawScope.axis(m: TextMeasurer, labels: List, y: Float) { +/** [labels] measured in [style], for a chart's cache block. */ +private fun TextMeasurer.measureAll(labels: List, style: TextStyle = axisText): List = + labels.map { measure(it, style) } + +/** Bottom axis labels, measured, spread evenly from the left edge to the right edge. */ +private fun DrawScope.axis(labels: List, y: Float) { if (labels.isEmpty()) return labels.forEachIndexed { i, l -> val f = if (labels.size == 1) 0f else i / (labels.size - 1f) - text(m, l, axisText, size.width * f, y, align = f) + text(l, size.width * f, y, align = f) } } +/** A chart's cache block that draws nothing (no data yet). */ +private fun CacheDrawScope.nothing() = onDrawBehind {} + /* ------------------------------------------------------------------------------------------------ */ /** @@ -144,6 +171,13 @@ fun GlowLineChart( * on release. Used without [describe]. */ onScrub: ((Int?) -> Unit)? = null, + /** + * A chosen stretch of the x range, as fractions: shaded in [spanColor] with its two edges drawn in white (a sleep's + * times picked against its heart rate). + */ + span: ClosedFloatingPointRange? = null, + /** The shading of [span]; the line's colour by default. */ + spanColor: Color = color, /** Text for the touch readout at a reading; null turns scrubbing off. */ describe: ((index: Int, value: Float) -> String)? = null, ) { @@ -153,7 +187,7 @@ fun GlowLineChart( val inset = with(androidx.compose.ui.platform.LocalDensity.current) { 7.dp.toPx() } val scrubbing = describe != null || onScrub != null val tick = rememberScrubHaptics() - Canvas( + Spacer( modifier.fillMaxWidth().height(height) .onSizeChanged { width = it.width.toFloat() } .let { @@ -163,36 +197,53 @@ fun GlowLineChart( tick(i, values, band, true) onScrub?.invoke(i) } - }, - ) { - // The shared line geometry ([lineChartGeometry]), as a widget's chart draws it. - val g = lineChartGeometry(values, size.width, size.height, density, inset, axis = labels.isNotEmpty(), yRange = yRange, band = band) ?: return@Canvas - val h = g.plotBottom - drawLineChartBody(g, color) - - if (showAverage) { - val y = g.averageY - gridLine(y, Target) - text(m, "avg ${format(g.average)}", axisText.copy(color = Ink.copy(alpha = 0.8f)), size.width, y - 16.dp.toPx(), align = 1f) - } - - drawLineChartEnd(g, color) - - axis(m, labels, h + 4.dp.toPx()) - - // Scrubbing: the reading nearest the finger (gaps are skipped, not bridged). - val tx = touchX - if (scrubbing && tx != null) { - val i = nearestReading(values, tx, size.width, inset) - if (i != null) { - val p = Offset(g.xOf(i), g.yOf(values[i]!!)) - guide(p.x, h) - drawCircle(Color.Black.copy(alpha = 0.6f), 6.dp.toPx(), p) - drawCircle(color, 4.dp.toPx(), p) - if (describe != null) readout(m, describe(i, values[i]!!), p.x, p.y) } - } - } + .drawWithCache { + // The shared line geometry ([lineChartGeometry]), as a widget's chart draws it. + val g = lineChartGeometry( + values, size.width, size.height, density, inset, axis = labels.isNotEmpty(), yRange = yRange, band = band, + ) ?: return@drawWithCache nothing() + val paths = lineChartPaths(g, color, density) + val average = if (showAverage) m.measure("avg ${format(g.average)}", averageText) else null + val axisLabels = m.measureAll(labels) + val h = g.plotBottom + onDrawBehind { + if (span != null) { + fun x(f: Float) = inset + f.coerceIn(0f, 1f) * (size.width - inset * 2) + val a = x(span.start) + val b = x(span.endInclusive) + drawRect(spanColor.copy(alpha = 0.18f), Offset(a, 0f), Size(b - a, h)) + drawLine(Color.White, Offset(a, 0f), Offset(a, h), 1.5.dp.toPx()) + drawLine(Color.White, Offset(b, 0f), Offset(b, h), 1.5.dp.toPx()) + } + drawLineChartBody(g, paths, color) + + if (average != null) { + val y = g.averageY + gridLine(y, Target) + text(average, size.width, y - 16.dp.toPx(), align = 1f) + } + + drawLineChartEnd(g, color) + + axis(axisLabels, h + 4.dp.toPx()) + + // Scrubbing: the reading nearest the finger (gaps are skipped, not bridged). Read here, so a scrub + // only redraws. + val tx = touchX + if (scrubbing && tx != null) { + val i = nearestReading(values, tx, size.width, inset) + if (i != null) { + val p = Offset(g.xOf(i), g.yOf(values[i]!!)) + guide(p.x, h) + drawCircle(Color.Black.copy(alpha = 0.6f), 6.dp.toPx(), p) + drawCircle(color, 4.dp.toPx(), p) + if (describe != null) readout(m, describe(i, values[i]!!), p.x, p.y) + } + } + } + }, + ) } /** Your normal behind a chart: a band of the ink at 12% (spec 2.7). */ @@ -230,7 +281,7 @@ fun GlowBars( var width by remember { mutableStateOf(0f) } val scrubbing = describe != null || onScrub != null val tick = rememberScrubHaptics() - Canvas( + Spacer( modifier.fillMaxWidth().height(height).onSizeChanged { width = it.width.toFloat() }.let { if (!scrubbing) it else it.scrub(values) { x -> touchX = x @@ -239,26 +290,29 @@ fun GlowBars( tick(i, values, null, false) onScrub?.invoke(i) } + }.drawWithCache { + if (values.isEmpty()) return@drawWithCache nothing() + val axisH = if (labels.isEmpty()) 0f else 18.dp.toPx() + val top = 4.dp.toPx() + val h = size.height - axisH + val pitchX = size.width / values.size + val max = values.max().coerceAtLeast(1f) + val axisLabels = m.measureAll(labels) + onDrawBehind { + val picked = touchX?.takeIf { scrubbing }?.let { (it / pitchX).toInt().coerceIn(0, values.size - 1) } + gridLine(h - 0.5.dp.toPx()) + values.forEachIndexed { i, v -> + if (v <= 0f) return@forEachIndexed + // While scrubbing, the other columns dim so the picked one stands out. + val dim = if (picked == null || picked == i) 1f else 0.35f + val color = if (i == highlight || i == picked) accent else Ink.copy(alpha = 0.6f) + pillBar(color.copy(alpha = color.alpha * dim), pitchX * i, pitchX, h, (h - top) * v / max) + } + axis(axisLabels, h + 4.dp.toPx()) + if (picked != null && describe != null) readout(m, describe(picked, values[picked]), pitchX * picked + pitchX / 2, 0f) + } }, - ) { - if (values.isEmpty()) return@Canvas - val axisH = if (labels.isEmpty()) 0f else 18.dp.toPx() - val top = 4.dp.toPx() - val h = size.height - axisH - val pitchX = size.width / values.size - val max = values.max().coerceAtLeast(1f) - val picked = touchX?.takeIf { scrubbing }?.let { (it / pitchX).toInt().coerceIn(0, values.size - 1) } - gridLine(h - 0.5.dp.toPx()) - values.forEachIndexed { i, v -> - if (v <= 0f) return@forEachIndexed - // While scrubbing, the other columns dim so the picked one stands out. - val dim = if (picked == null || picked == i) 1f else 0.35f - val color = if (i == highlight || i == picked) accent else Ink.copy(alpha = 0.6f) - pillBar(color.copy(alpha = color.alpha * dim), pitchX * i, pitchX, h, (h - top) * v / max) - } - axis(m, labels, h + 4.dp.toPx()) - if (picked != null && describe != null) readout(m, describe(picked, values[picked]), pitchX * picked + pitchX / 2, 0f) - } + ) } /* ------------------------------------------------------------------------------------------------ */ @@ -278,37 +332,44 @@ fun GlowTrend( average: Float? = null, ) { val m = rememberTextMeasurer() - Canvas(modifier.fillMaxWidth().height(height)) { - if (days.isEmpty()) return@Canvas - val axisH = 18.dp.toPx() - val top = 22.dp.toPx() - val h = size.height - axisH - val slot = size.width / days.size - val known = days.mapNotNull { it.value } + listOfNotNull(average) - val lo = (known.minOrNull() ?: 0f) * 0.85f - val hi = (known.maxOrNull() ?: 1f).coerceAtLeast(lo + 1f) - fun yOf(v: Float) = top + (h - top - 6.dp.toPx()) * (1f - (v - lo) / (hi - lo)) - - average?.let { gridLine(yOf(it), Target) } - days.forEachIndexed { i, d -> - val cx = slot * i + slot / 2 - val today = i == days.lastIndex - text(m, d.label, if (today) axisText.copy(color = Ink) else axisText, cx, h + 4.dp.toPx(), align = 0.5f) - val v = d.value ?: return@forEachIndexed - val y = yOf(v) - drawLine( - Ink.copy(alpha = if (today) 0.9f else 0.45f), Offset(cx, h - 3.dp.toPx()), Offset(cx, y), - strokeWidth = 2.dp.toPx(), cap = StrokeCap.Round, - ) - if (today) { - drawCircle(Ink.copy(alpha = 0.18f), 11.dp.toPx(), Offset(cx, y)) - drawCircle(Ink, 6.dp.toPx(), Offset(cx, y)) - } else { - drawCircle(Ink, 4.dp.toPx(), Offset(cx, y)) + Spacer( + modifier.fillMaxWidth().height(height).drawWithCache { + if (days.isEmpty()) return@drawWithCache nothing() + val axisH = 18.dp.toPx() + val top = 22.dp.toPx() + val h = size.height - axisH + val slot = size.width / days.size + val known = days.mapNotNull { it.value } + listOfNotNull(average) + val lo = (known.minOrNull() ?: 0f) * 0.85f + val hi = (known.maxOrNull() ?: 1f).coerceAtLeast(lo + 1f) + fun yOf(v: Float) = top + (h - top - 6.dp.toPx()) * (1f - (v - lo) / (hi - lo)) + val names = days.mapIndexed { i, d -> m.measure(d.label, if (i == days.lastIndex) todayAxisText else axisText) } + val figures = days.mapIndexed { i, d -> + d.value?.let { m.measure(it.roundToInt().toString(), if (i == days.lastIndex) todayValueText else pastValueText) } } - text(m, v.roundToInt().toString(), valueText.copy(color = if (today) Ink else Ink.copy(alpha = 0.75f)), cx, y - 20.dp.toPx(), align = 0.5f) - } - } + onDrawBehind { + average?.let { gridLine(yOf(it), Target) } + days.forEachIndexed { i, d -> + val cx = slot * i + slot / 2 + val today = i == days.lastIndex + text(names[i], cx, h + 4.dp.toPx(), align = 0.5f) + val v = d.value ?: return@forEachIndexed + val y = yOf(v) + drawLine( + Ink.copy(alpha = if (today) 0.9f else 0.45f), Offset(cx, h - 3.dp.toPx()), Offset(cx, y), + strokeWidth = 2.dp.toPx(), cap = StrokeCap.Round, + ) + if (today) { + drawCircle(Ink.copy(alpha = 0.18f), 11.dp.toPx(), Offset(cx, y)) + drawCircle(Ink, 6.dp.toPx(), Offset(cx, y)) + } else { + drawCircle(Ink, 4.dp.toPx(), Offset(cx, y)) + } + figures[i]?.let { text(it, cx, y - 20.dp.toPx(), align = 0.5f) } + } + } + }, + ) } /* ------------------------------------------------------------------------------------------------ */ @@ -344,7 +405,8 @@ fun ArcGauge( val topLeft = Offset(size.width / 2 - rad, centreY.toPx() - rad) val box = Size(rad * 2, rad * 2) drawArc(Ink.copy(alpha = ARC_TRACK_ALPHA), 180f, 180f, false, topLeft, box, style = Stroke(w, cap = StrokeCap.Round)) - val sweep = 180f * shown.coerceIn(0f, 1f) + // Read here, so the fill animates by redrawing only. + val sweep = 180f * shown().coerceIn(0f, 1f) if (sweep > 0f) drawArc(tint, 180f, sweep, false, topLeft, box, style = Stroke(w, cap = StrokeCap.Round)) } Box(Modifier.fillMaxWidth().height(centreY - r * ARC_NUMBER_LIFT), contentAlignment = Alignment.BottomCenter, content = content) @@ -384,27 +446,38 @@ fun HorizonArc( val rise = HorizonRise * k val stroke = HorizonStroke * k Box(Modifier.size(span + stroke, rise + stroke)) { - Canvas(Modifier.matchParentSize()) { - val w = stroke.toPx() - val x0 = w / 2 - val base = size.height - w / 2 - val width = span.toPx() - fun hill(height: Float) = List(HORIZON_STEPS + 1) { i -> - val u = i / HORIZON_STEPS.toFloat() - Offset(x0 + u * width, base - height * sin(Math.PI * u).toFloat()) - } - fun path(points: List) = Path().apply { - moveTo(points[0].x, points[0].y) - for (i in 1 until points.size) lineTo(points[i].x, points[i].y) - } - val value = hill(rise.toPx()) - drawPath( - path(hill(rise.toPx() * HORIZON_TRACK_RISE)), Ink.copy(alpha = HORIZON_TRACK_ALPHA), - style = Stroke(w * HORIZON_TRACK_STROKE, cap = StrokeCap.Round, join = StrokeJoin.Round), - ) - val upTo = upToLength(value, shown.coerceIn(0f, 1f)) - if (upTo.size > 1) drawPath(path(upTo), tint, style = Stroke(w, cap = StrokeCap.Round, join = StrokeJoin.Round)) - } + Spacer( + Modifier.matchParentSize().drawWithCache { + // The hills are the same on every frame of a fill: only the cut of the value's line moves. + val w = stroke.toPx() + val x0 = w / 2 + val base = size.height - w / 2 + val width = span.toPx() + fun hill(height: Float) = List(HORIZON_STEPS + 1) { i -> + val u = i / HORIZON_STEPS.toFloat() + Offset(x0 + u * width, base - height * sin(Math.PI * u).toFloat()) + } + fun path(points: List) = Path().apply { + moveTo(points[0].x, points[0].y) + for (i in 1 until points.size) lineTo(points[i].x, points[i].y) + } + val value = hill(rise.toPx()) + val track = path(hill(rise.toPx() * HORIZON_TRACK_RISE)) + val trackStroke = Stroke(w * HORIZON_TRACK_STROKE, cap = StrokeCap.Round, join = StrokeJoin.Round) + val valueStroke = Stroke(w, cap = StrokeCap.Round, join = StrokeJoin.Round) + val full = path(value) + onDrawBehind { + drawPath(track, Ink.copy(alpha = HORIZON_TRACK_ALPHA), style = trackStroke) + // Read here, so the fill animates by redrawing only. At rest on a full hill it reuses the path. + val f = shown().coerceIn(0f, 1f) + if (f >= 1f) drawPath(full, tint, style = valueStroke) + else { + val upTo = upToLength(value, f) + if (upTo.size > 1) drawPath(path(upTo), tint, style = valueStroke) + } + } + }, + ) Box( Modifier.matchParentSize().padding(bottom = stroke / 2 + span * HORIZON_NUMBER_LIFT), contentAlignment = Alignment.BottomCenter, content = content, @@ -446,13 +519,14 @@ private fun upToLength(points: List, fraction: Float): List { * system's animations off it jumps. */ @Composable -private fun rememberGaugeProgress(target: Float, key: Any?): Float { +private fun rememberGaugeProgress(target: Float, key: Any?): () -> Float { val animate = org.openstrap.app.ui.components.rememberAnimationsOn() val shown = remember(key) { Animatable(target) } LaunchedEffect(shown, target, animate) { if (animate) shown.animateTo(target, tween(GAUGE_FILL_MS, easing = FastOutSlowInEasing)) else shown.snapTo(target) } - return shown.value + // Read by the draw, so the fill never composes the gauge again. + return remember(shown) { { shown.value } } } private const val GAUGE_FILL_MS = 450 @@ -480,6 +554,8 @@ fun GlowHypnogram( * hero, spec 2.7); null keeps the white pills, brighter the deeper, joined by thin connectors. */ rowColors: List? = null, + /** The part kept, as fractions of the chart: what's either side is dimmed (a sleep whose times the user moved). */ + kept: ClosedFloatingPointRange? = null, /** Text for the touch readout on a stage; null turns scrubbing off. */ describe: ((GlowStage) -> String)? = null, ) { @@ -489,7 +565,7 @@ fun GlowHypnogram( val nameWidth = with(androidx.compose.ui.platform.LocalDensity.current) { if (showNames) 46.dp.toPx() else 0f } val haptics = org.openstrap.app.ui.components.rememberHaptics() var lastStage by remember { mutableStateOf(null) } - Canvas( + Spacer( modifier.fillMaxWidth().height(height).onSizeChanged { width = it.width.toFloat() }.let { if (describe != null) it.scrub(stages) { x -> touchX = x @@ -501,35 +577,44 @@ fun GlowHypnogram( if (stage != null && lastStage != null && stage != lastStage) haptics.perform(org.openstrap.app.ui.components.HapticEvent.ScrubStep) lastStage = if (x == null) null else stage ?: lastStage } else it - }, - ) { - val nameW = if (showNames) 46.dp.toPx() else 0f - // The shared hypnogram geometry ([hypnogramGeometry]), as a widget's chart draws it. - val g = hypnogramGeometry(stages, rowNames.size, size.width, size.height, density, nameW, axis = labels.isNotEmpty()) ?: return@Canvas - val h = g.plotBottom - - rowNames.forEachIndexed { row, name -> - gridLine(g.rowY[row], x0 = nameW) - if (showNames) text(m, name, axisText, 0f, g.rowY[row] - 7.dp.toPx()) - } - drawHypnogramBands(g, rowColors) - if (labels.isNotEmpty()) { - labels.forEachIndexed { i, l -> - val f = if (labels.size == 1) 0f else i / (labels.size - 1f) - text(m, l, axisText, g.xOf(f), h + 4.dp.toPx(), align = f) + }.drawWithCache { + val nameW = if (showNames) 46.dp.toPx() else 0f + // The shared hypnogram geometry ([hypnogramGeometry]), as a widget's chart draws it. + val g = hypnogramGeometry(stages, rowNames.size, size.width, size.height, density, nameW, axis = labels.isNotEmpty()) + ?: return@drawWithCache nothing() + val h = g.plotBottom + val names = if (showNames) m.measureAll(rowNames) else emptyList() + val axisLabels = m.measureAll(labels) + onDrawBehind { + rowNames.indices.forEach { row -> + gridLine(g.rowY[row], x0 = nameW) + if (showNames) text(names[row], 0f, g.rowY[row] - 7.dp.toPx()) + } + drawHypnogramBands(g, rowColors) + if (kept != null) { + val dim = Color.Black.copy(alpha = 0.65f) + val a = g.xOf(kept.start.coerceIn(0f, 1f)) + val b = g.xOf(kept.endInclusive.coerceIn(0f, 1f)) + if (a > nameW) drawRect(dim, Offset(nameW, 0f), Size(a - nameW, h)) + if (b < size.width) drawRect(dim, Offset(b, 0f), Size(size.width - b, h)) + } + axisLabels.forEachIndexed { i, l -> + val f = if (axisLabels.size == 1) 0f else i / (axisLabels.size - 1f) + text(l, g.xOf(f), h + 4.dp.toPx(), align = f) + } + val tx = touchX + if (describe != null && tx != null) { + val x = tx.coerceIn(nameW, size.width) + val f = (x - nameW) / (size.width - nameW) + // The stage under the finger, or the nearest one when the finger is in a gap. + val band = g.bands.firstOrNull { f >= it.stage.from && f < it.stage.to } + ?: g.bands.minBy { minOf(kotlin.math.abs(it.stage.from - f), kotlin.math.abs(it.stage.to - f)) } + guide(x, h) + readout(m, describe(band.stage), x, band.topLeft.y) + } } - } - val tx = touchX - if (describe != null && tx != null) { - val x = tx.coerceIn(nameW, size.width) - val f = (x - nameW) / (size.width - nameW) - // The stage under the finger, or the nearest one when the finger is in a gap. - val band = g.bands.firstOrNull { f >= it.stage.from && f < it.stage.to } - ?: g.bands.minBy { minOf(kotlin.math.abs(it.stage.from - f), kotlin.math.abs(it.stage.to - f)) } - guide(x, h) - readout(m, describe(band.stage), x, band.topLeft.y) - } - } + }, + ) } /* ------------------------------------------------------------------------------------------------ */ @@ -541,10 +626,14 @@ fun GlowHypnogram( */ @Composable fun GhostLine(today: List, color: Color, max: Float, modifier: Modifier = Modifier, usual: List? = null, height: Dp = 56.dp) { - Canvas(modifier.fillMaxWidth().height(height)) { - // The shared ghost line geometry ([ghostLineGeometry]), as a widget's chart draws it. - ghostLineGeometry(today, usual, max, size.width, size.height, density)?.let { drawGhostLine(it, color) } - } + Spacer( + modifier.fillMaxWidth().height(height).drawWithCache { + // The shared ghost line geometry ([ghostLineGeometry]), as a widget's chart draws it. + val g = ghostLineGeometry(today, usual, max, size.width, size.height, density) ?: return@drawWithCache nothing() + val paths = ghostLinePaths(g, density) + onDrawBehind { drawGhostLine(g, paths, color) } + }, + ) } /* ------------------------------------------------------------------------------------------------ */ @@ -604,7 +693,7 @@ internal fun NormalTrend( var width by remember { mutableStateOf(0f) } val inset = with(androidx.compose.ui.platform.LocalDensity.current) { 16.dp.toPx() } val tick = rememberScrubHaptics() - Canvas( + Spacer( modifier.fillMaxWidth().height(height) .onSizeChanged { width = it.width.toFloat() } .let { @@ -614,76 +703,84 @@ internal fun NormalTrend( tick(i, values, band, true) onScrub(i) } - }, - ) { - val known = values.filterNotNull() - if (known.isEmpty()) return@Canvas - val axisH = if (labels.isEmpty()) 0f else 24.dp.toPx() - // Fewer, bigger dots for a week; a month or a year of them get smaller. - val scale = when { - values.size <= 7 -> 1f - values.size <= 31 -> 0.55f - else -> 0.4f - } - // The band's label has a row of its own above the plot, clear of the biggest halo, so it never sits on a point - // or the line wherever the data goes. A swatch of the band's colour ties it to the band. - val labelStyle = axisText.copy(fontSize = 12.sp) - val labelH = bandLabel?.takeIf { band != null }?.let { m.measure(it, labelStyle).size.height.toFloat() } - val top = (labelH?.plus(6.dp.toPx()) ?: 0f) + 14.dp.toPx() * scale + 4.dp.toPx() - val h = size.height - axisH - 16.dp.toPx() - val lo0 = minOf(known.min(), band?.start ?: Float.MAX_VALUE) - val hi0 = maxOf(known.max(), band?.endInclusive ?: -Float.MAX_VALUE) - val pad = ((hi0 - lo0) * 0.12f).coerceAtLeast(0.5f) - val lo = lo0 - pad - val hi = hi0 + pad - fun xOf(i: Int) = inset + i / (values.size - 1f).coerceAtLeast(1f) * (size.width - inset * 2) - fun yOf(v: Float) = top + (h - top) * (1f - (v - lo) / (hi - lo)) - band?.let { b -> - val y0 = yOf(b.endInclusive) - drawRoundRect(color.copy(alpha = NormalBandAlpha), Offset(0f, y0), Size(size.width, yOf(b.start) - y0), CornerRadius(12.dp.toPx())) - if (bandLabel != null && labelH != null) { - val w = m.measure(bandLabel, labelStyle).size.width - val sw = 14.dp.toPx() - val x = size.width - w - drawRoundRect(color.copy(alpha = 0.35f), Offset(x - sw - 6.dp.toPx(), labelH / 2 - 4.dp.toPx()), Size(sw, 8.dp.toPx()), CornerRadius(3.dp.toPx())) - text(m, bandLabel, labelStyle, size.width, 0f, align = 1f) } - } - - val pts = values.mapIndexedNotNull { i, v -> v?.let { i to Offset(xOf(i), yOf(it)) } } - val stroke = 2.4.dp.toPx() * (0.6f + 0.4f * scale) - pts.zipWithNext { (i, a), (j, b) -> - // A missing day between two dots: the line stops there. - if (j - i == 1) drawLine(color.copy(alpha = 0.85f), a, b, stroke, StrokeCap.Round) - } - val dim = lerp(color, Color.Black, 0.5f) - pts.forEach { (i, p) -> - when (levels.getOrNull(i)) { - org.openstrap.app.ui.dashboard.DetailCopy.Level.Above, org.openstrap.app.ui.dashboard.DetailCopy.Level.Below -> { - val lit = lerp(color, Color.White, 0.3f) - drawCircle(lit.copy(alpha = 0.35f), 14.dp.toPx() * scale, p) - drawCircle(lit, 7.dp.toPx() * scale, p) + .drawWithCache { + // Labels measured and points placed once per size and data. The scrub is read in draw only. + val known = values.filterNotNull() + if (known.isEmpty()) return@drawWithCache nothing() + val axisH = if (labels.isEmpty()) 0f else 24.dp.toPx() + // Fewer, bigger dots for a week; a month or a year of them get smaller. + val scale = when { + values.size <= 7 -> 1f + values.size <= 31 -> 0.55f + else -> 0.4f } - else -> { - drawCircle(dim, 5.dp.toPx() * scale, p) - drawCircle(color, 5.dp.toPx() * scale, p, style = Stroke(2.dp.toPx() * scale.coerceAtLeast(0.6f))) + // The band's label has a row of its own above the plot, clear of the biggest halo, so it never sits on a point + // or the line wherever the data goes. A swatch of the band's colour ties it to the band. + val bandText = bandLabel?.takeIf { band != null }?.let { m.measure(it, trendLabelText) } + val labelH = bandText?.size?.height?.toFloat() + val top = (labelH?.plus(6.dp.toPx()) ?: 0f) + 14.dp.toPx() * scale + 4.dp.toPx() + val h = size.height - axisH - 16.dp.toPx() + val lo0 = minOf(known.min(), band?.start ?: Float.MAX_VALUE) + val hi0 = maxOf(known.max(), band?.endInclusive ?: -Float.MAX_VALUE) + val pad = ((hi0 - lo0) * 0.12f).coerceAtLeast(0.5f) + val lo = lo0 - pad + val hi = hi0 + pad + fun xOf(i: Int) = inset + i / (values.size - 1f).coerceAtLeast(1f) * (size.width - inset * 2) + fun yOf(v: Float) = top + (h - top) * (1f - (v - lo) / (hi - lo)) + val pts = values.mapIndexedNotNull { i, v -> v?.let { i to Offset(xOf(i), yOf(it)) } } + val stroke = 2.4.dp.toPx() * (0.6f + 0.4f * scale) + val dim = lerp(color, Color.Black, 0.5f) + val perDay = labels.size == values.size + val dayLabels = m.measureAll(labels, if (perDay) trendLabelText else axisText) + onDrawBehind { + band?.let { b -> + val y0 = yOf(b.endInclusive) + drawRoundRect(color.copy(alpha = NormalBandAlpha), Offset(0f, y0), Size(size.width, yOf(b.start) - y0), CornerRadius(12.dp.toPx())) + if (bandText != null && labelH != null) { + val w = bandText.size.width + val sw = 14.dp.toPx() + val x = size.width - w + drawRoundRect(color.copy(alpha = 0.35f), Offset(x - sw - 6.dp.toPx(), labelH / 2 - 4.dp.toPx()), Size(sw, 8.dp.toPx()), CornerRadius(3.dp.toPx())) + text(bandText, size.width, 0f, align = 1f) + } + } + + pts.zipWithNext { (i, a), (j, b) -> + // A missing day between two dots: the line stops there. + if (j - i == 1) drawLine(color.copy(alpha = 0.85f), a, b, stroke, StrokeCap.Round) + } + pts.forEach { (i, p) -> + when (levels.getOrNull(i)) { + org.openstrap.app.ui.dashboard.DetailCopy.Level.Above, + org.openstrap.app.ui.dashboard.DetailCopy.Level.Below, + -> { + val lit = lerp(color, Color.White, 0.3f) + drawCircle(lit.copy(alpha = 0.35f), 14.dp.toPx() * scale, p) + drawCircle(lit, 7.dp.toPx() * scale, p) + } + else -> { + drawCircle(dim, 5.dp.toPx() * scale, p) + drawCircle(color, 5.dp.toPx() * scale, p, style = Stroke(2.dp.toPx() * scale.coerceAtLeast(0.6f))) + } + } + } + + if (perDay) dayLabels.forEachIndexed { i, l -> text(l, xOf(i), size.height - axisH + 6.dp.toPx(), align = 0.5f) } + else axis(dayLabels, size.height - axisH + 6.dp.toPx()) + + val tx = touchX + if (onScrub != null && tx != null) { + nearestReading(values, tx, size.width, inset)?.let { i -> + val p = Offset(xOf(i), yOf(values[i]!!)) + guide(p.x, h) + drawCircle(Color.Black.copy(alpha = 0.6f), 8.dp.toPx(), p) + drawCircle(Ink, 5.dp.toPx(), p) + } + } } - } - } - - if (labels.size == values.size) labels.forEachIndexed { i, l -> text(m, l, axisText.copy(fontSize = 12.sp), xOf(i), size.height - axisH + 6.dp.toPx(), align = 0.5f) } - else axis(m, labels, size.height - axisH + 6.dp.toPx()) - - val tx = touchX - if (onScrub != null && tx != null) { - nearestReading(values, tx, size.width, inset)?.let { i -> - val p = Offset(xOf(i), yOf(values[i]!!)) - guide(p.x, h) - drawCircle(Color.Black.copy(alpha = 0.6f), 8.dp.toPx(), p) - drawCircle(Ink, 5.dp.toPx(), p) - } - } - } + }, + ) } /* ------------------------------------------------------------------------------------------------ */ @@ -720,11 +817,3 @@ fun StageStrip(spans: List, modifier: } } } - -/** A sleep stage's colour, as on the strap's own charts. */ -internal fun stageColor(stage: Int): Color = when (stage) { - org.openstrap.app.data.db.SleepStage.DEEP -> MetricColors.sleepDeep - org.openstrap.app.data.db.SleepStage.REM -> MetricColors.sleepRem - org.openstrap.app.data.db.SleepStage.AWAKE -> MetricColors.sleepAwake - else -> MetricColors.sleepLight -} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowGrid.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowGrid.kt index 31f2f850..ae3d9ab9 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowGrid.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowGrid.kt @@ -50,9 +50,6 @@ object GlowGrid { val StripHeight = 68.dp - /** One unit's width on a screen [screenWidth] wide: (width minus both gutters minus the gap) / 2. */ - fun unitWidth(screenWidth: Dp): Dp = (screenWidth - Gutter * 2 - Gap) / 2 - /** One unit's width inside a column already [width] wide (the gutters already taken off). */ fun unitWidthIn(width: Dp): Dp = (width - Gap) / 2 diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowHealthspan.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowHealthspan.kt index c49a5920..fab2556e 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowHealthspan.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowHealthspan.kt @@ -1,16 +1,14 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.Units import androidx.compose.foundation.Canvas -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.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,30 +19,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlin.math.abs import kotlin.math.roundToInt import org.openstrap.app.data.Healthspan import org.openstrap.app.ui.theme.MetricColors -import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.glow.draw.EdgeKind /** The Healthspan card's orchid, used for the glow: warm enough to stay clear of Sleep's blue. */ private val Orchid = MetricColors.habitAge -/** The same recipe as the Heart rate and Activity cards, in orchid. */ -internal val HealthspanGlow = GlowStyle.chart(Orchid) - -/** "3 years younger", "1 year older" or "Right on your age" for an [estimate] against [age], in whole years. */ -internal fun healthspanVerdict(age: Int, estimate: Float): String { - val years = age - estimate.roundToInt() - return when { - years > 0 -> "$years ${if (years == 1) "year" else "years"} younger" - years < 0 -> "${-years} ${if (-years == 1) "year" else "years"} older" - else -> "Right on your age" - } -} - /** "Steady" at 1x, "Slower" below, "Faster" above; "Learning" until there's enough history. */ internal fun paceLabel(pace: Float?): String = when { pace == null -> "Learning" @@ -80,30 +64,27 @@ internal fun termDetail(t: Healthspan.Term, age: Int): String { (if (m < Healthspan.STRENGTH_TARGET_MINUTES) ", aim for ${Healthspan.STRENGTH_TARGET_MINUTES}" else "") } Healthspan.Habit.Vo2max -> "${v.roundToInt()} ml/kg/min, typical at $age: ${ref?.roundToInt()}" - Healthspan.Habit.Steps -> "${"%,d".format(v.roundToInt())} a day, typical: ${"%,d".format(((ref ?: 0f) / 100).roundToInt() * 100)}" + + Healthspan.Habit.Steps -> "${Units.count(v.roundToInt())} a day, typical: ${Units.count(((ref ?: 0f) / 100).roundToInt() * 100)}" + (if (v < 8000f) ", aim for 8,000–10,000" else "") Healthspan.Habit.Regularity -> "${v.roundToInt()}, typical: ${ref?.roundToInt()}" Healthspan.Habit.RestingHr -> "${v.roundToInt()} bpm asleep, typical: about ${ref?.roundToInt()}" - Healthspan.Habit.TimeAsleep -> v.roundToInt().let { m -> "${m / 60} h ${m % 60} m, counts only below 6 h 30 m" } + Healthspan.Habit.TimeAsleep -> v.roundToInt().let { m -> + "${Units.duration(m * MINUTE_S)}, counts only below ${Units.duration(Healthspan.SLEEP_COUNTS_BELOW_MINUTES * MINUTE_S)}" + } } } +/** Seconds in a minute, for the sleep habit's minutes. */ +private const val MINUTE_S = 60L + /** "Sunday 22 Nov" for local midnight [day] (epoch seconds), with no-break spaces so it never wraps inside the date. */ internal fun sundayText(day: Long): String = java.time.Instant.ofEpochSecond(day).atZone(java.time.ZoneId.systemDefault()).toLocalDate() - .format(java.time.format.DateTimeFormatter.ofPattern("EEEE d MMM")).replace(' ', '\u00A0') + .format(org.openstrap.app.presentation.Formatters.of("EEEE d MMM", java.util.Locale.getDefault())).replace(' ', '\u00A0') /** What to add to the profile: "Add your birth year and sex in your profile to see your Habit Age." */ internal fun profileReminder(missing: List): String = "Add your ${missing.joinToString(" and ")} in your profile to see your Habit Age." -/** The starting state's line under your age: when a first estimate is due, or what it's still waiting for. */ -internal fun learningText(o: Healthspan.Outcome): String = when { - // The date's line is kept whole, so "First estimate:" never ends up apart from its date. - o.firstEstimate != null -> "Starts at your age.\nFirst\u00A0estimate:\u00A0${sundayText(o.firstEstimate)}." - o.withheld == Healthspan.Withheld.Learning -> "Starts at your age. Wear the strap for 30 days for a first estimate." - else -> "Starts at your age. ${o.withheld?.reason.orEmpty()}" -} - /** The local date of [epoch] (a local midnight). */ private fun dateOf(epoch: Long) = java.time.Instant.ofEpochSecond(epoch).atZone(java.time.ZoneId.systemDefault()).toLocalDate() diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowLayout.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowLayout.kt index dd818016..d032f630 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowLayout.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowLayout.kt @@ -8,10 +8,8 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -67,8 +65,6 @@ object GlowLayout { /** Title row to the hero number. */ val TitleGap = 14.dp - /** Hero number (or the last line of text) to the chart. */ - val ChartGap = 20.dp /** Every chart in a card has the same height, axis labels included. */ val ChartHeight = 116.dp /** From this width (an unfolded foldable, a tablet, a phone on its side) the dashboards use two columns. */ @@ -160,23 +156,43 @@ val LocalPageColumn = compositionLocalOf { GlowLayout.PageMaxWidth } /** Where a dashboard block goes when there's room for two columns. On a phone every block stacks in the order given. */ enum class Pane { Full, Left, Right } -/** The blocks of a dashboard, in phone order, each with the column it takes on a wide screen. */ +/** + * The blocks of a dashboard, in phone order, each with the column it takes on a wide screen and the [key] its lazy + * item goes by. A card's key is its card (the cards of its grid row, [cardsKey]), so hiding or pinning one in Edit Today + * moves the saved state inside the others with them and keeps the scroll anchor on the same card (audit K5). + */ class GlowBlocks internal constructor( /** On two columns too narrow for Smalls ([GlowLayout.smallsAcross]): a row of Smalls is a full block. */ val smallsAcross: Boolean = false, ) { - internal val blocks = mutableListOf Unit>>() + internal val blocks = mutableListOf() /** The block [GlowScreenList] scrolls to when asked ([focus]), or -1. */ internal var focusBlock = -1 /** Marks the next block as the one to scroll to. */ fun focusNext() { focusBlock = blocks.size } - fun full(content: @Composable () -> Unit) = add(Pane.Full, content) - fun left(content: @Composable () -> Unit) = add(Pane.Left, content) - fun right(content: @Composable () -> Unit) = add(Pane.Right, content) - private fun add(pane: Pane, content: @Composable () -> Unit) { - blocks += pane to content + fun full(content: @Composable () -> Unit) = add(Pane.Full, null, content) + fun left(content: @Composable () -> Unit) = add(Pane.Left, null, content) + fun right(content: @Composable () -> Unit) = add(Pane.Right, null, content) + + /** + * Adds [content] to [pane], its lazy item keyed on [key]. Without one it goes by its place, as an item with no key + * does, so a block added before it moves its state to another block. + */ + fun add(pane: Pane, key: String?, content: @Composable () -> Unit) { + blocks += Block(pane, key ?: "#${blocks.size}", content) + } + + /** One block: its [pane], the [key] its lazy item goes by, and what it draws. */ + internal class Block(val pane: Pane, val key: String, val content: @Composable () -> Unit) + + internal companion object { + /** The key of a dashboard's header block. */ + const val HEADER = "header" + + /** The key of a grid row holding [cards]: their names, in order. */ + fun cardsKey(cards: List): String = cards.joinToString("+") } } @@ -200,19 +216,23 @@ fun GlowScreenList( val gutter = GlowLayout.dashboardGutter(maxWidth) val built = GlowBlocks(smallsAcross = GlowLayout.smallsAcross(maxWidth)).apply(content) val blocks = built.blocks + val keys = uniqueKeys(blocks.map { it.key }) // The lazy item the marked block lands in: its own on a phone; on a wide screen a two-column run is one item. val target = built.focusBlock.takeIf { it >= 0 }?.let { if (wide) itemOf(blocks, it) else it } androidx.compose.runtime.LaunchedEffect(focus, target) { if (focus && target != null) { state.animateScrollToItem(target); onFocused() } } androidx.compose.runtime.CompositionLocalProvider(LocalPageColumn provides Dp.Unspecified) { ScreenList(contentPadding, state, gutter = gutter) { - if (!wide) blocks.forEach { (_, block) -> item { block() } } - else sections(blocks).forEach { section -> - val left = section.filter { it.first == Pane.Left }.map { it.second } - val right = section.filter { it.first == Pane.Right }.map { it.second } + if (!wide) blocks.forEachIndexed { i, block -> item(keys[i]) { block.content() } } + else sections(blocks.indices.toList()) { blocks[it].pane }.forEach { section -> + val left = section.filter { blocks[it].pane == Pane.Left } + val right = section.filter { blocks[it].pane == Pane.Right } // A full block, or a run with nothing on one side, takes the whole width rather than leave a hole. - if (left.isEmpty() || right.isEmpty()) section.forEach { (_, block) -> item { block() } } - else item { BalancedColumns(left, right) } + if (left.isEmpty() || right.isEmpty()) section.forEach { i -> item(keys[i]) { blocks[i].content() } } + // Two columns are one item, keyed on every block in them. + else item(section.joinToString("|") { keys[it] }) { + BalancedColumns(left.map { blocks[it].content }, right.map { blocks[it].content }) + } } } } } @@ -243,11 +263,11 @@ private fun BalancedColumns(left: List<@Composable () -> Unit>, right: List<@Com } /** The lazy item that block [index] lands in on a wide screen, following [GlowScreenList]'s layout. */ -private fun itemOf(blocks: List Unit>>, index: Int): Int { +private fun itemOf(blocks: List, index: Int): Int { var item = 0 var seen = 0 - for (section in sections(blocks)) { - val split = section.any { it.first == Pane.Left } && section.any { it.first == Pane.Right } + for (section in sections(blocks) { it.pane }) { + val split = section.any { it.pane == Pane.Left } && section.any { it.pane == Pane.Right } if (index < seen + section.size) return if (split) item else item + (index - seen) item += if (split) 1 else section.size seen += section.size @@ -256,15 +276,28 @@ private fun itemOf(blocks: List Unit>>, index: Int) } /** Splits the blocks into runs: each full block on its own, and each unbroken stretch of left and right blocks together. */ -private fun sections(blocks: List Unit>>): List Unit>>> { - val out = mutableListOf Unit>>>() +private fun sections(blocks: List, pane: (T) -> Pane): List> { + val out = mutableListOf>() blocks.forEach { b -> val last = out.lastOrNull() - if (b.first != Pane.Full && last != null && last.first().first != Pane.Full) last += b else out += mutableListOf(b) + if (pane(b) != Pane.Full && last != null && pane(last.first()) != Pane.Full) last += b else out += mutableListOf(b) } return out } +/** + * [keys] made unique for a lazy list, which throws on a repeat: a key seen before gets its count after it. A dashboard + * never repeats a card, so this only guards against a crash. + */ +internal fun uniqueKeys(keys: List): List { + val seen = mutableMapOf() + return keys.map { k -> + val n = seen.getOrElse(k) { 0 } + seen[k] = n + 1 + if (n == 0) k else "$k#$n" + } +} + /** * Whether the card around this content opens something when tapped. [GlowCard] and [EdgeCard] provide it from their * `onClick`, so every card title ([CardTitle]) shows the tap caret on its own (spec 2.5, Tappable says so). @@ -433,27 +466,6 @@ fun GlowHeader( internal fun NoteText(text: String) = Text(text, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) -/** - * The hero number of a card in dot-matrix, its unit beside it, and up to two short facts on the right - * (text, or [side] for anything richer). Sits [GlowLayout.TitleGap] under the title. - */ -@Composable -fun GlowHeroNumber(value: String, unit: String, facts: List = emptyList(), side: (@Composable () -> Unit)? = null) { - Row(Modifier.fillMaxWidth().padding(top = GlowLayout.TitleGap), verticalAlignment = Alignment.Bottom) { - DotMatrixText(value, dot = DotSize.Hero) - // The word after a number (spec 2.3). - Text(unit, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp).weight(1f)) - if (facts.isNotEmpty()) Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) { - facts.forEach { Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) } - } - side?.invoke() - } -} - -/** The gap above a card's chart. */ -@Composable -fun GlowChartGap() = Spacer(Modifier.padding(top = GlowLayout.ChartGap)) - /** * A small sync marker next to a card's title, "Up to 09:40" (spec 4.3): amber, as the strap chip, since it's about * the sync and not the body. @@ -467,7 +479,8 @@ internal fun StateMarker(text: String, modifier: Modifier = Modifier) { * A neutral tag next to a card's title (spec 4.3, E4): "Partial day", "First score". About the data, not the sync, * so white and not amber (spec 2.1). With [onClick], tapping it explains it (spec 7.1, one caveat per card). */ -class TitleTag(val text: String, val onClick: (() -> Unit)? = null) +@androidx.compose.runtime.Immutable +data class TitleTag(val text: String, val onClick: (() -> Unit)? = null) /** What sits next to a card's title: the sync [marker] when there's one, else the [tag]. One mark per card. */ @Composable diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowMetricDetail.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowMetricDetail.kt index f3e1c194..10d3e062 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowMetricDetail.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowMetricDetail.kt @@ -1,5 +1,9 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.dotFormat +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.Units import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -9,10 +13,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -52,6 +54,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openstrap.app.data.DayStat import org.openstrap.app.data.History +import org.openstrap.app.data.Weeks import org.openstrap.app.ui.components.ChartPoint import org.openstrap.app.ui.components.LocalCardsGrow import org.openstrap.app.ui.components.detailNumber @@ -71,27 +74,14 @@ import org.openstrap.app.ui.dashboard.avg import org.openstrap.app.ui.dashboard.colorOf import org.openstrap.app.ui.dashboard.SleepCopy import org.openstrap.app.ui.dashboard.siblingsOf -import org.openstrap.app.ui.theme.AppIcons -import java.time.DayOfWeek -import java.time.Instant import java.time.LocalDate -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.TextStyle -import java.time.temporal.TemporalAdjusters -import java.util.Locale +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.theme.ControlSurface /* ------------------------------------------- Shared pieces ------------------------------------------ */ -/** The one page header ([PageHeader], spec 3.4): the round back button, the page title, and the day or note under it. */ -@Composable -internal fun GlowDetailHeader(title: String, subtitle: String?, onBack: () -> Unit) = PageHeader(title, onBack, subtitle = subtitle) - -internal fun dayName(date: LocalDate): String = when (date) { - LocalDate.now() -> "Today" - LocalDate.now().minusDays(1) -> "Yesterday" - else -> date.format(DateTimeFormatter.ofPattern("EEE d MMM")) -} +/** [date] as a heading: "Today", "Yesterday", the weekday within the week, then the date ([DayWords.relative]). */ +internal fun dayName(date: LocalDate): String = DayWords.relative(date, LocalDate.now()) /** A row of choices on a faint pill; the chosen one is a soft white pill. */ @Composable @@ -149,7 +139,8 @@ internal fun siblings(detail: Detail, onSwitch: (Detail) -> Unit): List Unit, heroValue: (@Composable () -> Unit)? = null, heroSide: (@Composable () -> Unit)? = null, + scrubbed: () -> DetailHeadline? = { null }, body: LazyListScope.() -> Unit, ) { BackHandler(onBack = onBack) val list = rememberLazyListState() - var switching by remember { mutableStateOf(false) } + var switching by rememberSaveable { mutableStateOf(false) } val gutter = GlowLayout.WideGutter // Rows inside a detail open the next one with a crossfade, not by growing (spec 3.5). CompositionLocalProvider(LocalGlowPaused provides { list.isScrollInProgress }, LocalCardsGrow provides false) { ScreenList(contentPadding, list, gutter = gutter) { item { DetailHero(color, gutter) { + // A scrub is read here, so only the hero follows the finger, not the whole detail (audit K4). + val scrub = scrubbed() + val shown = scrub ?: headline + // A scrubbed reading takes the gauge's place, as the value row. + val gauge = heroValue.takeIf { scrub == null } DetailNav(title, siblings.size > 1, onBack, onInfo, onSwitch = { switching = true }) // A score gauge, its label and status word make one block, centred in the header (spec 6.1). - if (heroValue != null) Column(Modifier.fillMaxWidth().padding(top = 22.dp), horizontalAlignment = Alignment.CenterHorizontally) { - heroValue() + if (gauge != null) Column( + Modifier.fillMaxWidth().padding(top = 22.dp), horizontalAlignment = Alignment.CenterHorizontally, + ) { + gauge() Text( - headline.label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + shown.label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 8.dp), ) - if (headline.word != null && headline.value != null) StatusWord(headline.word, color, Modifier.padding(top = 12.dp)) + if (shown.word != null && shown.value != null) StatusWord(shown.word, color, Modifier.padding(top = 12.dp)) } else Row(Modifier.fillMaxWidth().padding(top = 22.dp), verticalAlignment = Alignment.Bottom) { Box(Modifier.weight(1f)) { when { // No number: the state in words, large in the value's place, as a Small does. - headline.value == null -> Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(headline.word ?: "No reading", color = Color.White, style = GlowType.pageTitle) - Text(headline.label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + shown.value == null -> Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(shown.word ?: "No reading", color = Color.White, style = GlowType.pageTitle) + Text( + shown.label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, + ) } else -> Row(verticalAlignment = Alignment.Bottom) { - DotMatrixText(headline.value, Modifier.detailNumber(headline.value), dot = DotSize.Detail) + DotMatrixText(shown.value, Modifier.detailNumber(shown.value), dot = DotSize.Detail) Text( - headline.label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + shown.label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(start = 10.dp, bottom = 2.dp), ) } @@ -206,7 +207,9 @@ internal fun DetailTemplate( } heroSide?.invoke() } - if (heroValue == null && headline.word != null && headline.value != null) StatusWord(headline.word, color, Modifier.padding(top = 12.dp)) + if (gauge == null && shown.word != null && shown.value != null) { + StatusWord(shown.word, color, Modifier.padding(top = 12.dp)) + } if (strip.isNotEmpty()) WeekStrip(strip, color, onDay, Modifier.padding(top = 20.dp)) } } @@ -432,13 +435,14 @@ fun GlowMetricDetailScreen( val to = maxOf(end, strip0.last().date) val from = minOf(end.minusDays(maxOf(range.days, History.NORMAL_DAYS) - 1L), strip0.first().date) val daily = if (night) SleepCopy.nightDaily(metric) else metric.daily - val stats by remember(daily, from, to) { history(daily, from, to) }.collectAsState(null) - val normal = stats?.let { History.normal(it, end) } - val normalDays = stats?.count { it.date in end.minusDays(History.NORMAL_DAYS - 1L)..end } ?: 0 + val stats by remember(daily, from, to, history) { history(daily, from, to) }.collectAsState(null) + // Worked out once per load, not on every pass of the screen (audit K4). + val normal = remember(stats, end) { stats?.let { History.normal(it, end) } } + val normalDays = remember(stats, end) { stats?.count { it.date in end.minusDays(History.NORMAL_DAYS - 1L)..end } ?: 0 } // The night's own: its normal from the nights before, and the last 7 nights. Day and Week read against these. - val nightNormal = if (night) SleepCopy.nightNormal(state, metric) else null - val nightDays = if (night) SleepCopy.nightNormalNights(state, metric) else 0 - val nightWeek = if (night) SleepCopy.nightWeek(state, metric) else emptyList() + val nightNormal = remember(night, state, metric) { if (night) SleepCopy.nightNormal(state, metric) else null } + val nightDays = remember(night, state, metric) { if (night) SleepCopy.nightNormalNights(state, metric) else 0 } + val nightWeek = remember(night, state, metric) { if (night) SleepCopy.nightWeek(state, metric) else emptyList() } val fromNight = night && (range == Range.Day || range == Range.Week) val color = colorOf(metric) val nightly = if (night) range == Range.Day || range == Range.Week || SleepCopy.dailyIsNightly(metric) else metric in NIGHTLY @@ -449,7 +453,7 @@ fun GlowMetricDetailScreen( // The range's number and the daily values its chart draws (weekly over a year). val start = end.minusDays(range.days - 1L) - val summary = stats?.takeIf { range != Range.Day }?.let { History.summary(it, start, end) } + val summary = remember(stats, range, start, end) { stats?.takeIf { range != Range.Day }?.let { History.summary(it, start, end) } } val value = when { night && range == Range.Day -> SleepCopy.nightValue(state, metric) night && range == Range.Week -> nightWeek.mapNotNull { it.second }.takeIf { it.isNotEmpty() }?.average()?.toFloat() @@ -463,20 +467,22 @@ fun GlowMetricDetailScreen( else -> History.status(value, normal) } val label = if (night) SleepCopy.nightLabel(metric, range) else DetailCopy.valueLabel(metric, range, live = live != null, today = isToday) - val headline = scrub ?: when { - value != null -> DetailHeadline(metric.format(value), label, DetailCopy.statusWord(status)) + val headline = when { + value != null -> DetailHeadline(metric.format(value, Units.DOT_LOCALE), label, DetailCopy.statusWord(status)) night && range == Range.Day -> SleepCopy.nightNoValue(state.sleep != null, isToday).let { (word, why) -> DetailHeadline.none(word, why) } night && range == Range.Week -> DetailHeadline.none("No reading", "In the last 7 nights") range != Range.Day && stats == null -> DetailHeadline.none("Loading", DetailCopy.noValue(metric, range, isToday)) else -> DetailHeadline.none("No reading", DetailCopy.noValue(metric, range, isToday)) } - val withData = stats.orEmpty().map { it.date }.toSet() + nightWeek.filter { it.second != null }.map { it.first } + val withData = remember(stats, nightWeek) { + stats.orEmpty().map { it.date }.toSet() + nightWeek.filter { it.second != null }.map { it.first } + } DetailTemplate( metric.title, color, siblings(Detail.OfMetric(metric, night), onSwitch), onInfo = { info = true }, onBack = onBack, contentPadding = contentPadding, headline = headline, strip = DetailCopy.strip(end, today, withData), onDay = onSelectDay, - ranges = Range.entries, range = range, onRange = { rangeIndex = it.ordinal }, + ranges = Range.entries, range = range, onRange = { rangeIndex = it.ordinal }, scrubbed = { scrub }, heroSide = if (!night && metric == Metric.HeartRate && isToday && canUseLive && range == Range.Day) { { LiveChip(liveEnabled, onLiveToggle) @@ -499,11 +505,14 @@ fun GlowMetricDetailScreen( if (values.all { it == null }) DetailEmpty("No ${inSentence(metric.title)} in the last 7 nights yet.") else Column { NormalTrend( - values, DetailCopy.levels(values, nightNormal), color, labels = nightWeek.map { shortDay(it.first) }, + values, DetailCopy.levels(values, nightNormal), color, labels = nightWeek.map { DayWords.weekdayShort(it.first) }, band = nightNormal?.let { it.low..it.high }, bandLabel = nightNormal?.let { DetailCopy.bandLabel(it, metric::format) }, onScrub = { i -> scrub = i?.let { nightWeek[it] }?.let { (d, v) -> - v?.let { DetailHeadline(metric.format(it), "${metric.unit}, ${d.format(DateTimeFormatter.ofPattern("EEE d MMM"))}", DetailCopy.statusWord(History.status(it, nightNormal))) } + v?.let { + val word = DetailCopy.statusWord(History.status(it, nightNormal)) + DetailHeadline(metric.format(it, Units.DOT_LOCALE), "${metric.unit}, ${DayWords.short(d)}", word) + } } }, ) @@ -567,7 +576,6 @@ fun GlowMetricDetailScreen( } } -private fun shortDay(d: LocalDate) = d.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) /** * The night's readings of [metric] while asleep as a line to scrub, from falling asleep to waking ([SleepCopy.nightSeries]); @@ -583,34 +591,32 @@ private fun NightChart(metric: Metric, state: DayUiState, color: Color, onScrub: } val window = session.start..session.end // Heart rate and breathing come every few minutes in sleep, the rest more sparsely. - val bucket = if (metric == Metric.HeartRate || metric == Metric.Breathing) 5 * 60L else 15 * 60L - val values = remember(series, window) { evenly(series, window, bucket) } + val slot = if (metric == Metric.HeartRate || metric == Metric.Breathing) 5 * 60L else 15 * 60L + val values = remember(series, window) { bucket(series, window, slot) } Column(Modifier.padding(top = 8.dp)) { GlowLineChart( values, height = 220.dp, labels = listOf(clock(window.first), clock(window.last)), color = color, showAverage = false, format = metric::format, onScrub = { i -> - onScrub(i?.let { values[it] }?.let { v -> DetailHeadline(metric.format(v), "${metric.unit} at ${clock(window.first + i * bucket)}".trim(), null) }) + onScrub(i?.let { values[it] }?.let { v -> scrubAt(metric, v, window.first + i * slot) }) }, ) } } -/** Averages timed points into evenly spaced buckets across [range]; empty buckets are gaps. */ -private fun evenly(points: List, range: LongRange, seconds: Long): List { - val n = ((range.last - range.first + 1) / seconds).toInt().coerceAtLeast(2) - val sums = FloatArray(n) - val counts = IntArray(n) - points.forEach { - val i = ((it.x - range.first) / seconds).toInt() - if (i in 0 until n) { sums[i] += it.y; counts[i]++ } - } - return List(n) { if (counts[it] == 0) null else sums[it] / counts[it] } +/** A scrubbed reading of [metric] in the headline: its value and the time it was taken, "bpm at 22:30". */ +private fun scrubAt(metric: Metric, v: Float, at: Long) = DetailHeadline(metric.format(v, Units.DOT_LOCALE), "${metric.unit} at ${clock(at)}".trim(), null) + +/** A held hour's steps in the headline: "steps, 09:00 to 10:00". */ +private fun hourSteps(hour: Int, steps: Float): DetailHeadline { + val (from, to) = TimeOfDay.minutes(hour * MINUTES_PER_HOUR) to TimeOfDay.minutes((hour + 1) * MINUTES_PER_HOUR) + return DetailHeadline(dotFormat("%,d", Math.round(steps)), "steps, $from to $to", null) } +private const val MINUTES_PER_HOUR = 60 + /** A name inside a sentence: lower case, unless it's an acronym like HRV. */ internal fun inSentence(name: String): String = if (name.all { it.isUpperCase() || it.isDigit() }) name else name.lowercase() -private fun clock(epoch: Long) = Instant.ofEpochSecond(epoch).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm")) /** * The selected day's own chart: steps by the hour, the week of resting heart rates, or the day's readings as a line @@ -637,21 +643,21 @@ private fun DayChart(metric: Metric, state: DayUiState, color: Color, hasValue: // Holding an hour puts its steps in the headline. Metric.Steps -> GlowBars( state.hourlySteps, height = 200.dp, labels = HOURS, accent = color, - onScrub = { h -> onScrub(h?.let { DetailHeadline("%,d".format(Math.round(state.hourlySteps[it])), "steps, %02d:00 to %02d:00".format(it, (it + 1) % 24), null) }) }, + onScrub = { h -> onScrub(h?.let { hourSteps(it, state.hourlySteps[it]) }) }, ) Metric.RestingHr -> if (state.restingTrend.any { it.value != null }) { GlowTrend(state.restingTrend.map { GlowDay(it.label, it.value) }, height = 200.dp) } else -> if (series.isNotEmpty()) { // Readings come every minute for heart rate, sparser for the rest. - val bucket = if (metric == Metric.HeartRate) 5 * 60L else 15 * 60L - val values = remember(series, window) { evenly(series, window, bucket) } + val slot = if (metric == Metric.HeartRate) 5 * 60L else 15 * 60L + val values = remember(series, window) { bucket(series, window, slot) } val labels = if (window == day) HOURS else listOf(clock(window.first), clock(window.last)) val band = if (metric == Metric.HeartRate) normalOf(Metric.RestingHr)?.let { it.low..it.high } else null GlowLineChart( values, height = 220.dp, labels = labels, color = color, showAverage = false, band = band, format = metric::format, onScrub = { i -> - onScrub(i?.let { values[it] }?.let { v -> DetailHeadline(metric.format(v), "${metric.unit} at ${clock(window.first + i * bucket)}".trim(), null) }) + onScrub(i?.let { values[it] }?.let { v -> scrubAt(metric, v, window.first + i * slot) }) }, ) } @@ -670,15 +676,15 @@ private fun HistoryChart( ) { val (values, labels, dates) = remember(stats, range, start, end) { series(range, stats, start, end) } val levels = remember(values, normal) { DetailCopy.levels(values, normal) } - val day = DateTimeFormatter.ofPattern("EEE d MMM") NormalTrend( values, levels, color, labels = labels, band = normal?.let { it.low..it.high }, bandLabel = normal?.let { DetailCopy.bandLabel(it, metric::format) }, onScrub = { i -> onScrub(i?.let { values[it] }?.let { v -> val d = dates[i] - val label = if (range == Range.Year) "week of ${d.format(DateTimeFormatter.ofPattern("d MMM"))}" else d.format(day) - DetailHeadline(metric.format(v), listOf(metric.unit, label).filter { it.isNotEmpty() }.joinToString(", "), DetailCopy.statusWord(History.status(v, normal))) + val label = if (range == Range.Year) "week of ${DayWords.dayMonth(d)}" else DayWords.short(d) + val unitAndDay = listOf(metric.unit, label).filter { it.isNotEmpty() }.joinToString(", ") + DetailHeadline(metric.format(v, Units.DOT_LOCALE), unitAndDay, DetailCopy.statusWord(History.status(v, normal))) }) }, ) @@ -689,22 +695,25 @@ private fun HistoryChart( } /** Evenly spaced values for the chart: one a day, or one a week over a year; gaps are null. Also axis labels and each point's date. */ -private fun series(range: Range, stats: List, start: LocalDate, end: LocalDate): Triple, List, List> { +internal fun series( + range: Range, stats: List, start: LocalDate, end: LocalDate, +): Triple, List, List> { val inRange = stats.filter { it.date in start..end } val dates: List val byDate: Map if (range == Range.Year) { - val first = start.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) - dates = generateSequence(first) { it.plusWeeks(1) }.takeWhile { !it.isAfter(end) }.toList() - byDate = History.weekly(inRange).associate { it.date to it.value } + // The chart's weeks and History's must start on the same day, or no week finds its average. + val firstDay = Weeks.firstDay() + dates = generateSequence(Weeks.start(start, firstDay)) { it.plusWeeks(1) }.takeWhile { !it.isAfter(end) }.toList() + byDate = History.weekly(inRange, firstDay).associate { it.date to it.value } } else { dates = generateSequence(start) { it.plusDays(1) }.takeWhile { !it.isAfter(end) }.toList() byDate = inRange.associate { it.date to it.value } } val labels = when (range) { - Range.Week -> dates.map { it.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) } - Range.Month -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { dates[((dates.size - 1) * it).toInt()].format(DateTimeFormatter.ofPattern("d MMM")) } - else -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { dates[((dates.size - 1) * it).toInt()].format(DateTimeFormatter.ofPattern("MMM")) } + Range.Week -> dates.map { DayWords.weekdayShort(it) } + Range.Month -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { DayWords.dayMonth(dates[((dates.size - 1) * it).toInt()]) } + else -> listOf(0f, 1 / 3f, 2 / 3f, 1f).map { DayWords.monthShort(dates[((dates.size - 1) * it).toInt()]) } } return Triple(dates.map { byDate[it] }, labels, dates) } @@ -720,7 +729,7 @@ private fun RelatedList(related: List, state: DayUiState, history: Daily val score = state.sleep?.score ListRow( org.openstrap.app.ui.dashboard.Score.Sleep.title, org.openstrap.app.ui.theme.MetricColors.sleepDeep, score?.toString(), - score?.let(org.openstrap.app.ui.dashboard.TabCopy::sleepScoreWord) ?: "Nothing for this night", + score?.let(org.openstrap.app.ui.dashboard.TabCopy::sleepScoreWord) ?: NO_NIGHT_VALUE, onClick = { onOpen(Detail.OfScore(org.openstrap.app.ui.dashboard.Score.Sleep)) }, divider = false, icon = org.openstrap.app.ui.dashboard.Score.Sleep.rowIcon(), ) @@ -731,7 +740,7 @@ private fun RelatedList(related: List, state: DayUiState, history: Daily val status = if (m == Metric.HeartRate) null else History.status(v, normal) ListRow( m.title, colorOf(m), v?.let { m.format(it) }, - if (v == null) "Nothing for this day" else DetailCopy.statusWord(status) ?: m.unit.ifEmpty { null }, + if (v == null) NO_VALUE else DetailCopy.statusWord(status) ?: m.unit.ifEmpty { null }, offNormal = status != null && status != 0, onClick = { onOpen(Detail.OfMetric(m)) }, divider = i > 0 || sleepRow, icon = m.rowIcon(), ) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowParts.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowParts.kt index 1de0691f..3795bb4d 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowParts.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowParts.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.unit.dp import org.openstrap.app.ui.glow.draw.DotFont import org.openstrap.app.ui.glow.draw.dotLayout import org.openstrap.app.ui.glow.draw.drawDots +import org.openstrap.app.ui.glow.draw.DotSize /* ------------------------------------------------------------------------------------------------ */ /* Dot-matrix numerals */ diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowQuiet.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowQuiet.kt index c128248c..d5280d09 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowQuiet.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowQuiet.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row 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.size import androidx.compose.foundation.layout.width @@ -97,11 +96,8 @@ fun Modifier.glass(shape: Shape, onClick: (() -> Unit)? = null, scale: Float = 0 /** The shape of a glass row inside a quiet panel. */ val GlassRowShape = GlowLayout.TileShape -/** A quiet rule between blocks in a panel: a solid 1 dp hairline, as between list rows. */ -@Composable -fun PanelDivider(modifier: Modifier = Modifier) { - Box(modifier.fillMaxWidth().height(1.dp).background(RowHairline)) -} +/** A change for the better, or a GPS fix good enough: the app's good green. */ +internal val TrendGood = MetricColors.good -internal val TrendGood = Color(0xFF3DDC97) -internal val TrendBad = Color(0xFFFFB547) +/** A change for the worse, or a GPS fix too poor: the app's warning amber. */ +internal val TrendBad = MetricColors.warn diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowRecoveryScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowRecoveryScreen.kt index c1f8b4b3..f6954599 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowRecoveryScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowRecoveryScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -25,6 +24,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.unit.dp +import org.openstrap.app.presentation.chargeShown import org.openstrap.app.data.Healthspan import org.openstrap.app.data.History import org.openstrap.app.data.Recovery @@ -49,6 +49,9 @@ import org.openstrap.app.ui.dashboard.TodayCopy import org.openstrap.app.ui.dashboard.avg import org.openstrap.app.ui.dashboard.recoveryCards import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.glow.draw.CoreShape +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.DotSize /* * The Recovery tab (spec 5.1): "can I push today, and why". The score glows with the last 7 days beside it, what @@ -58,9 +61,6 @@ import org.openstrap.app.ui.theme.MetricColors * The hero, the contributors and the Charge battery open their details on this tab's stack. */ -/** Charge as a whole number the way every screen shows it, cut rather than rounded, so they all agree. */ -fun chargeShown(value: Float): Int = value.toInt() - @Composable fun GlowRecoveryScreen( state: DayUiState, @@ -97,22 +97,24 @@ fun GlowRecoveryScreen( val failure = recoveryFailure?.takeIf { it.date == state.date && r == null } val learning = r?.readiness?.calibrating == true val firstNights = r != null && !r.charge.anchored - val waiting = if (firstNights) AFTER_FIRST_NIGHT else "No reading yet" + val waiting = if (firstNights) AFTER_FIRST_NIGHT else NO_READING val slots = recoveryCards(RecoveryFacts(habitAge = healthspan != null, learning = learning, hasRecovery = r != null)) var info by rememberSaveable { mutableStateOf(false) } var partialInfo by rememberSaveable { mutableStateOf(false) } - val partial = wear?.takeIf { it.partial }?.let { TitleTag(PARTIAL_DAY) { partialInfo = true } } + // Built once, so the cards given it can skip (audit K11). + val partialTag = remember { TitleTag(PARTIAL_DAY) { partialInfo = true } } + val partial = partialTag.takeIf { wear?.partial == true } val isToday = state.date == java.time.LocalDate.now() CompositionLocalProvider(LocalGlowPaused provides { list.isScrollInProgress }) { GlowScreenList(contentPadding, list, focus = showCharge, onFocused = onChargeShown) { - full { header() } + add(Pane.Full, GlowBlocks.HEADER) { header() } tabCards(slots, focus = RecoveryCard.Charge) { card, m -> when (card) { RecoveryCard.Hero -> if (failure != null) RecoveryFailedHero(failure, onOpen = { onOpenScore(Score.Recovery) }, onRetry = onRetryRecovery) else RecoveryHero( r, onOpen = { onOpenScore(Score.Recovery) }, onInfo = { info = true }, firstNights = firstNights, - noSleep = isToday && state.loaded && state.asleepSeconds == null, firstScore = firstScore, + noSleep = isToday && state.loaded && state.asleepSeconds == null, deleted = state.deletedSleep != null, firstScore = firstScore, shown = TodayCopy.recoveryShown(r?.readiness, strapScore, waiting), ) RecoveryCard.Drivers -> DriversList(r?.readiness, onAll = { onOpenScore(Score.Recovery) }) @@ -155,7 +157,7 @@ private fun recoveryHeroStyle(color: Color) = GlowStyle.Bloom.copy( */ @Composable private fun RecoveryHero( - r: RecoveryResult?, onOpen: () -> Unit, onInfo: () -> Unit, firstNights: Boolean = false, noSleep: Boolean = false, firstScore: Boolean = false, + r: RecoveryResult?, onOpen: () -> Unit, onInfo: () -> Unit, firstNights: Boolean = false, noSleep: Boolean = false, deleted: Boolean = false, firstScore: Boolean = false, shown: TodayCopy.RecoveryShown? = null, ) { val ready = r?.readiness @@ -177,7 +179,7 @@ private fun RecoveryHero( firstNights -> TodayCopy.RECOVERY_FIRST_NIGHT ready == null -> TabCopy.RECOVERY_WAITING score != null -> TabCopy.recovery(score, alert, ready.contributors) - noSleep -> TabCopy.NO_SLEEP_SCORE + noSleep -> if (deleted) TabCopy.DELETED_SLEEP_SCORE else TabCopy.NO_SLEEP_SCORE ready.phase == Recovery.Phase.Withheld -> TodayCopy.noScoreLine(TodayCopy.NoScore.Learning(ready.baselineNights, Recovery.SCORED_NIGHTS)) ready.withheld -> TodayCopy.noScoreLine(TodayCopy.NoScore.NoHeartData) else -> "Recovery is scored each morning from the night's sleep." @@ -260,7 +262,7 @@ private fun ChargeTall( val step = 15 * 60L val values = remember(c?.points, day) { c?.points?.let { bucket(it, day, step) }.orEmpty() } var picked by remember(values) { androidx.compose.runtime.mutableStateOf(null) } - val scrubbed = picked?.let { i -> values.getOrNull(i)?.let { it.toInt() to clockAt(day.first + i * step) } } + val scrubbed = picked?.let { i -> values.getOrNull(i)?.let { it.toInt() to clock(day.first + i * step) } } GlowCard(GlowStyle.Ember.copy(lightLevel = (now ?: 0) / 100f, second = true), modifier, onClick = onOpen) { Column(Modifier.fillMaxHeight()) { GlowHeader("Energy", tag = tag) @@ -303,18 +305,6 @@ private fun ChargeSmall(c: Recovery.ChargeDay?, recharged: Boolean, modifier: Mo SmallCard(MetricColors.charge, if (recharged) "Recharged" else "Drained", value, null, status, modifier, plain = true) } -/** Averages charge samples into evenly spaced buckets across [range]; empty buckets are gaps. */ -private fun bucket(points: List, range: LongRange, seconds: Long): List { - val n = ((range.last - range.first + 1) / seconds).toInt().coerceAtLeast(2) - val sums = FloatArray(n) - val counts = IntArray(n) - points.forEach { - val i = ((it.t - range.first) / seconds).toInt() - if (i in 0 until n) { sums[i] += it.value; counts[i]++ } - } - return List(n) { if (counts[it] == 0) null else sums[it] / counts[it] } -} - /* ------------------------------------------- Body signals ------------------------------------------- */ /** A body signal for the list: today's value and how it compares with your normal. */ @@ -329,30 +319,31 @@ private class BodySignal( val unit: String = "", ) -/** Loads the last 30 days of each body signal, enough for its normal. */ +/** + * Loads the last 30 days of each body signal, enough for its normal ([rememberNormal], keyed on the day and the + * history, each normal worked out once per load), and the signals once per day state (audit K8). + */ @Composable private fun rememberSignals(state: DayUiState, history: DailyHistory): List { - val end = state.date - val from = end.minusDays(History.NORMAL_DAYS - 1L) - @Composable fun daily(m: Metric) = remember(m, end) { history(m.daily, from, end) } - val rhr by daily(Metric.RestingHr).collectAsState(emptyList()) - val hrv by daily(Metric.Hrv).collectAsState(emptyList()) - val skin by daily(Metric.SkinTemperature).collectAsState(emptyList()) - val breath by daily(Metric.Breathing).collectAsState(emptyList()) - val spo2 by daily(Metric.Spo2).collectAsState(emptyList()) - val stress by daily(Metric.Stress).collectAsState(emptyList()) - fun normalOf(stats: List) = History.normal(stats, end) - return listOf( - BodySignal(Metric.RestingHr, "Resting heart rate", MetricColors.heart, state.restingHeartRate?.toFloat(), normalOf(rhr)), - BodySignal(Metric.Hrv, "HRV", MetricColors.hrv, state.hrv.avg()?.toFloat(), normalOf(hrv)), - BodySignal( - Metric.SkinTemperature, "Skin temperature", MetricColors.temperature, - state.skinTemperature.takeIf { it.isNotEmpty() }?.map { it.y }?.average()?.toFloat(), normalOf(skin), asDeviation = true, - ), - BodySignal(Metric.Breathing, "Breathing rate", MetricColors.sleepLight, state.breathRate.avg()?.toFloat(), normalOf(breath)), - BodySignal(Metric.Spo2, "Blood oxygen", MetricColors.spo2, state.spo2.avg()?.toFloat(), normalOf(spo2), unit = "%"), - BodySignal(Metric.Stress, "Stress", MetricColors.stress, state.stress.avg()?.toFloat(), normalOf(stress)), - ) + val rhr = rememberNormal(Metric.RestingHr, state.date, history) + val hrv = rememberNormal(Metric.Hrv, state.date, history) + val skin = rememberNormal(Metric.SkinTemperature, state.date, history) + val breath = rememberNormal(Metric.Breathing, state.date, history) + val spo2 = rememberNormal(Metric.Spo2, state.date, history) + val stress = rememberNormal(Metric.Stress, state.date, history) + return remember(state, rhr, hrv, skin, breath, spo2, stress) { + listOf( + BodySignal(Metric.RestingHr, "Resting heart rate", MetricColors.heart, state.restingHeartRate?.toFloat(), rhr), + BodySignal(Metric.Hrv, "HRV", MetricColors.hrv, state.hrv.avg()?.toFloat(), hrv), + BodySignal( + Metric.SkinTemperature, "Skin temperature", MetricColors.temperature, + state.skinTemperature.takeIf { it.isNotEmpty() }?.map { it.y }?.average()?.toFloat(), skin, asDeviation = true, + ), + BodySignal(Metric.Breathing, "Breathing rate", MetricColors.sleepLight, state.breathRate.avg()?.toFloat(), breath), + BodySignal(Metric.Spo2, "Blood oxygen", MetricColors.spo2, state.spo2.avg()?.toFloat(), spo2, unit = "%"), + BodySignal(Metric.Stress, "Stress", MetricColors.stress, state.stress.avg()?.toFloat(), stress), + ) + } } /** diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowShared.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowShared.kt new file mode 100644 index 00000000..d8183844 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowShared.kt @@ -0,0 +1,44 @@ +package org.openstrap.app.ui.glow + +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.Summaries +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.ui.components.ChartPoint + +/* + * Words and small helpers the glow screens share, each written once. Date labels and numbers are in presentation's + * DayWords and Units. + */ + +/** Shown under a row's name when the day has no value for it. */ +internal const val NO_VALUE = "Nothing for this day" + +/** [NO_VALUE] for a night's row. */ +internal const val NO_NIGHT_VALUE = "Nothing for this night" + +/** A card or row whose number hasn't come in yet today. */ +internal const val NO_READING = "No reading yet" + +/** Heart points and the like, which come with the strap's daily records. */ +internal const val AFTER_NEXT_SYNC = "After the strap's next sync" + +/** What Calories needs from the profile before it can be worked out. */ +internal const val ADD_PROFILE = "Add age, weight and height" + +/** + * The latest workout the strap gave a time to recover, the one Time to recover counts down from on Today, Activity and + * its detail. Null when none has one. + */ +internal fun latestRecoveryEstimate(workouts: List): WorkoutAnalysis? = + workouts.filter { it.recoveryMinutes != null }.maxByOrNull { it.end } + +/** Averages timed points into evenly spaced buckets across [range]; empty buckets are null (gaps). */ +internal fun bucket(points: List, range: LongRange, seconds: Long): List = + Summaries.evenly(points, range, seconds, { it.x }, { it.y }) + +/** [bucket] for Charge's samples. */ +@JvmName("bucketCharge") +internal fun bucket(points: List, range: LongRange, seconds: Long): List = + Summaries.evenly(points, range, seconds, { it.t }, { it.value }) + + diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowSleepScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowSleepScreen.kt index 70172f3a..ed67f89e 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowSleepScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowSleepScreen.kt @@ -1,5 +1,6 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.dotFormat import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -63,6 +64,10 @@ import org.openstrap.app.ui.theme.AppIcons import org.openstrap.app.ui.theme.MetricColors import org.openstrap.app.ui.glow.draw.StageBarLook import org.openstrap.app.ui.glow.draw.stageTotals +import org.openstrap.app.ui.glow.draw.GlowMood +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.stageColor /* * The Sleep tab (spec 5.2): "how did I sleep, and is it trending right". The night glows with its stages, the @@ -81,6 +86,8 @@ fun GlowSleepScreen( onOpen: (Detail) -> Unit, /** The nightly model, for regularity and breathing disturbances. */ recovery: org.openstrap.app.data.RecoveryResult? = null, + /** Brings back a night the user deleted, the strap's own session given: the hero's Restore. */ + onRestore: (org.openstrap.app.data.db.SleepSession) -> Unit = {}, ) { val list = rememberLazyListState() val rec = recovery?.takeIf { it.date == state.date } @@ -97,7 +104,7 @@ fun GlowSleepScreen( val pair = (breathing != null || firstNights) && state.naps.isNotEmpty() tabCards(slots) { card, m -> when (card) { - SleepCard.Hero -> NightHero(state, firstNights) { onOpen(Detail.OfScore(Score.Sleep)) } + SleepCard.Hero -> NightHero(state, firstNights, onRestore) { onOpen(Detail.OfScore(Score.Sleep)) } SleepCard.Vitals -> VitalsList(state, firstNights) { onOpen(Detail.OfMetric(it, night = true)) } SleepCard.Regularity -> RegularityCard(state, rec?.regularity, firstNights) { onOpen(Detail.OfScore(Score.Regularity)) } SleepCard.Breathing -> when { @@ -128,16 +135,15 @@ internal val SleepGlow = GlowStyle.Midnight.copy( coreX = 0.5f, coreY = 0.6f, coreWidth = 1.1f, coreHeight = 0.9f, ) -private fun clock(epoch: Long) = Instant.ofEpochSecond(epoch).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm")) /** * The night (spec 5.2, the sleep hero): how long, against the rest of the week, then the score in dots with its word * and how often you woke beside it, the one dotted number on the card. The stages to scrub in their colours, then * their totals as one bar with a plain legend. No arc. As high as its content. Tapping opens the Sleep score's detail - * ([onOpen]). + * ([onOpen]). A night the user deleted says so, with Restore ([onRestore]). */ @Composable -private fun NightHero(state: DayUiState, firstNights: Boolean, onOpen: () -> Unit) { +private fun NightHero(state: DayUiState, firstNights: Boolean, onRestore: (org.openstrap.app.data.db.SleepSession) -> Unit, onOpen: () -> Unit) { val session = state.sleep val isToday = state.date == LocalDate.now() GlowCard(SleepGlow, Modifier.fillMaxWidth().gridSize(CardSize.Wide), onClick = onOpen) { @@ -145,7 +151,14 @@ private fun NightHero(state: DayUiState, firstNights: Boolean, onOpen: () -> Uni GlowHeader("Last night", trailing = session?.let { TabCopy.nightTimes(it.start, it.end) }) if (session == null) { // Nothing said until the night has loaded, so a cold start doesn't open on "No sleep recorded". - if (state.loaded) HeroSentence(if (firstNights) TabCopy.FIRST_NIGHT_SLEEP else TabCopy.noNight(state.naps.isNotEmpty(), isToday)) + val deleted = state.deletedSleep + if (state.loaded) HeroSentence( + if (firstNights) TabCopy.FIRST_NIGHT_SLEEP else TabCopy.noNight(state.naps.isNotEmpty(), isToday, deleted = deleted != null), + ) + if (state.loaded && deleted != null) { + Spacer(Modifier.height(20.dp)) + PageButton("Restore", { onRestore(deleted.strap) }, icon = org.openstrap.app.ui.theme.AppIcons.ArrowCounterClockwise) + } return@Column } val asleep = state.asleepSeconds ?: (session.end - session.start) @@ -212,9 +225,10 @@ private fun StageLegend(totals: List>) { /** * Stages as the glow hypnogram, rows Awake to Deep, each stage a band in its own colour (spec 2.7, every hypnogram), * with start and end times underneath. Shared with Today and the Sleep detail; [compact] leaves the names and times out. + * [kept], the user's own times for a sleep they moved, dims the strap's stages outside it. */ @Composable -internal fun GlowSleepStages(spans: List, height: Dp, compact: Boolean = false) { +internal fun GlowSleepStages(spans: List, height: Dp, compact: Boolean = false, kept: LongRange? = null) { val start = spans.minOf { it.start } val end = spans.maxOf { it.end }.coerceAtLeast(start + 1) val rows = DayViewModel.SLEEP_STAGE_ROWS @@ -227,6 +241,7 @@ internal fun GlowSleepStages(spans: List, height: Dp, compact: Boolea GlowHypnogram( stages, names, height = height, labels = if (compact) emptyList() else listOf(clock(start), clock(end)), showNames = !compact, rowColors = colors, + kept = kept?.let { (it.first - start).toFloat() / (end - start)..(it.last - start).toFloat() / (end - start) }, ) { st -> val mins = (((end - start) * (st.to - st.from)) / 60).toInt().coerceAtLeast(1) "${names[st.row]}, ${at(st.from)}–${at(st.to)}, $mins min" @@ -309,14 +324,17 @@ private fun BreathingCard(b: org.openstrap.app.data.Recovery.BreathingDisturbanc // As high as its content: a number and a line, so a unit high would leave an empty band under them (spec 2.4). EdgeCard(MetricColors.sleepLight, Modifier.fillMaxWidth(), kind = EdgeKind.Value, onClick = onInfo) { EdgeTitle("Breathing disturbances", "Early estimate") - EdgeValue("%.1f".format(b.perHour), "an hour", TabCopy.breathing(b)) + EdgeValue(dotFormat("%.1f", b.perHour), "an hour", TabCopy.breathing(b)) } } /** Breathing disturbances as a Small beside the naps: the count an hour and your usual. Lit from below, a value. */ @Composable private fun BreathingSmall(b: org.openstrap.app.data.Recovery.BreathingDisturbances, modifier: Modifier, onInfo: () -> Unit) = - SmallCard(MetricColors.sleepLight, "Breathing disturbances", "%.1f".format(b.perHour), "an hour", TabCopy.breathingShort(b), modifier, onClick = onInfo) + SmallCard( + MetricColors.sleepLight, "Breathing disturbances", dotFormat("%.1f", b.perHour), "an hour", TabCopy.breathingShort(b), modifier, + onClick = onInfo, + ) /** * The day's naps as a Small (a Wide when alone): the time napped, a strip of the latest nap's stages and when it was. diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTabletop.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTabletop.kt index 253eef8e..581fe7df 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTabletop.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTabletop.kt @@ -18,6 +18,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import org.openstrap.app.ui.dashboard.DayUiState import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.glow.draw.GlowMotion +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.glow.draw.chart /** * The top half of a foldable standing half-open on a table: your heart rate, big enough to read from across @@ -60,7 +64,9 @@ fun GlowTabletopHeart( } } if (state.heartRate.isNotEmpty()) { - val values = remember(state.heartRate, state.dayStart) { bucket(state.heartRate, state.dayStart..state.dayEnd, 5 * 60) } + val values = remember(state.heartRate, state.dayStart, state.dayEnd) { + bucket(state.heartRate, state.dayStart..state.dayEnd, 5 * 60) + } GlowLineChart(values, height = GlowLayout.ChartHeight, labels = listOf("00", "06", "12", "18", "24")) { _, v -> "${v.toInt()} bpm" } } } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTodayScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTodayScreen.kt index 538ec096..6fe2a270 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTodayScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowTodayScreen.kt @@ -1,5 +1,7 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.dotFormat +import org.openstrap.app.data.Stats import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Arrangement @@ -30,6 +32,7 @@ import androidx.compose.ui.unit.dp import java.time.Instant import java.time.LocalTime import java.time.ZoneId +import org.openstrap.app.presentation.chargeShown import org.openstrap.app.data.Moment import org.openstrap.app.data.Recovery import org.openstrap.app.data.RecoveryResult @@ -37,7 +40,6 @@ import org.openstrap.app.data.WorkoutAnalysis import org.openstrap.app.data.Workouts import org.openstrap.app.data.db.StrapReadinessRow import org.openstrap.app.device.SyncRecord -import org.openstrap.app.ui.components.ChartPoint import org.openstrap.app.ui.components.HapticEvent import org.openstrap.app.ui.components.rememberHaptics import org.openstrap.app.ui.dashboard.DailyHistory @@ -82,6 +84,25 @@ import org.openstrap.app.ui.dashboard.pairSentence import org.openstrap.app.ui.dashboard.wakingLead import org.openstrap.app.ui.dashboard.todayCards import org.openstrap.app.ui.theme.MetricColors +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.draw.clip +import androidx.compose.ui.Alignment +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.background +import org.openstrap.app.ui.glow.draw.LightAngle +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.heroStyle +import org.openstrap.app.ui.glow.draw.WINDOW_RIM +import org.openstrap.app.ui.glow.draw.AFTER_RIM +import org.openstrap.app.ui.glow.draw.EVENING_BODY +import org.openstrap.app.ui.glow.draw.EdgeStatus +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.presentation.topSports /* * Today (spec 4): one hero whose content follows the moment, then a stable set of cards for it ([todayCards]), with @@ -149,7 +170,8 @@ fun GlowTodayScreen( /** A workout recording now: the hero during a workout leads back to it ([WorkoutShortcut]). */ running: org.openstrap.app.device.WorkoutState.Recording? = null, onOpenRunning: () -> Unit = {}, - onResumeRunning: () -> Unit = {}, + /** The live workout's glance and what its hero does (workouts spec 2.10). */ + liveHero: LiveHeroHooks = LiveHeroHooks(), /** Where the hero's light comes from for the moment ([org.openstrap.app.ui.dashboard.heroLightAngle]); null keeps its own. */ heroLightAngle: Float? = null, strap: TodayStrap = TodayStrap(), @@ -175,6 +197,8 @@ fun GlowTodayScreen( var editing by rememberSaveable { mutableStateOf(false) } if (editing) EditTodaySheet(edits, onSave = { onEditsChange(it); editing = false }, onDismiss = { editing = false }) var partialInfo by rememberSaveable { mutableStateOf(false) } + // Built once, so the cards given it can skip (audit K11). + val partialTag = remember { TitleTag(PARTIAL_DAY) { partialInfo = true } } val zone = ZoneId.systemDefault() val now = Instant.now() @@ -189,7 +213,7 @@ fun GlowTodayScreen( strapScore = strapReadiness?.takeIf { it.day == state.dayStart }?.score, stopped = strap.stopped, staleAfter = strap.staleAfter, recoveryFailure = recoveryFailure?.takeIf { it.date == state.date && rec == null }, - partial = wear?.takeIf { it.partial }?.let { TitleTag(PARTIAL_DAY) { partialInfo = true } }, + partial = partialTag.takeIf { wear?.partial == true }, firstScore = firstScore, ) if (partialInfo && wear != null) PartialDaySheet(wear, isToday, onDismiss = { partialInfo = false }) @@ -219,8 +243,9 @@ fun GlowTodayScreen( val slots = varyLooks(applyEdits(todayCards(moment, facts, morning), edits, moment, unavailable), looksFor, live, wide, across) val panes = todayPanes(slots, across) GlowScreenList(contentPadding, list) { - full { header() } - // One block per grid row: a Small pair stays side by side, and on a wide screen in one column. + add(Pane.Full, GlowBlocks.HEADER) { header() } + // One block per grid row: a Small pair stays side by side, and on a wide screen in one column. Each is + // keyed on its cards, so its state stays with them when Edit Today hides or pins one above (audit K5). packGrid(slots.indices.map { slots[it].size to it }).forEach { row -> val block: @Composable () -> Unit = { GridRowBlock(row) { i, m -> @@ -241,7 +266,7 @@ fun GlowTodayScreen( when (slot.card) { TodayCard.Hero -> Hero( ctx, strap, pairing, heroLightAngle, usualBedtime, - running, onOpenRunning, onResumeRunning, onStartSport, onOtherSport, onPair, onSyncNow, + running, onOpenRunning, liveHero, onStartSport, onOtherSport, onPair, onSyncNow, onOpenRecovery, onOpenWorkout, onRetryRecovery, onOpenScore = { onOpen(org.openstrap.app.ui.dashboard.Detail.OfScore(it)) }, energyHidden = slots.none { it.card == TodayCard.Charge }, @@ -257,19 +282,19 @@ fun GlowTodayScreen( } } // The hero and strips span the width on a wide screen; the rows between take turns. - when (panes[row.items().first()]) { - Pane.Full -> full(block) - Pane.Left -> left(block) - Pane.Right -> right(block) - } + add(panes[row.items().first()], GlowBlocks.cardsKey(row.items().map { slots[it].card }), block) } } } } } -/** What Today's cards read, worked out once per screen. */ -private class TodayContext( +/** + * What Today's cards read, worked out once per screen. Equal by value and immutable, so a card given an equal one + * skips rather than recompose with every pass of Today (audit K11). + */ +@androidx.compose.runtime.Immutable +private data class TodayContext( val state: DayUiState, val rec: RecoveryResult?, val live: Int?, @@ -327,7 +352,7 @@ private class TodayContext( get() = when { firstNights -> AFTER_FIRST_NIGHT stuck -> WAITING_LAST_NIGHT - else -> "No reading yet" + else -> NO_READING } /** The day's workouts you'd call workouts, oldest first: the strap's own "Activity" records aren't. */ @@ -350,7 +375,7 @@ private class TodayContext( /** Time to recover from the latest workout that says how long it needs, or null when none does. */ val recover: Pair? get() { - val w = workouts.filter { it.recoveryMinutes != null }.maxByOrNull { it.end } ?: return null + val w = latestRecoveryEstimate(workouts) ?: return null return TodayCopy.recoverText(TodayCopy.recoverLeft(w.end, w.recoveryMinutes ?: 0, now), now, zone) } @@ -368,7 +393,7 @@ private class TodayContext( @Composable private fun Hero( ctx: TodayContext, strap: TodayStrap, pairing: PairStep?, lightAngle: Float?, bedtime: LocalTime?, - running: org.openstrap.app.device.WorkoutState.Recording?, onOpenRunning: () -> Unit, onResumeRunning: () -> Unit, + running: org.openstrap.app.device.WorkoutState.Recording?, onOpenRunning: () -> Unit, liveHero: LiveHeroHooks, onStartSport: (Int) -> Unit, onOtherSport: () -> Unit, onPair: () -> Unit, onSyncNow: () -> Unit, onOpenRecovery: () -> Unit, onOpenWorkout: (Long) -> Unit, onRetryRecovery: () -> Unit, /** @@ -402,9 +427,10 @@ private fun Hero( if (it == HeroKind.Waking && ctx.state.loaded && ctx.recoveryFailure == null && wakingLead(ctx.firstNights, ctx.state.asleepSeconds, ctx.isToday) == WakingLead.Charge) HeroKind.NoSleep else it } - // The During hero keeps drawing while it fades out after the workout ends, when [running] is already gone. + // The During hero keeps drawing while it fades out after the workout ends, when [running] is already gone. Kept + // once each composition is applied, not written during it (audit K6). Only the fading hero reads it. val lastRunning = remember { androidx.compose.runtime.mutableStateOf(running) } - if (running != null) lastRunning.value = running + androidx.compose.runtime.SideEffect { if (running != null) lastRunning.value = running } Crossfade(kind, animationSpec = tween(300), label = "hero") { k -> when (k) { HeroKind.Pair -> PairHero(pairing ?: PairStep.Ready, onPair, m) @@ -415,29 +441,13 @@ private fun Hero( HeroKind.Window -> WindowHero(ctx, lightAngle, onStartSport, onOtherSport, m) HeroKind.After -> AfterHero(ctx, lightAngle, onOpenWorkout, m) HeroKind.Evening -> EveningHero(ctx, lightAngle, bedtime, energyHidden, onOpenRecovery, m) - HeroKind.During -> (running ?: lastRunning.value)?.let { DuringHero(ctx, it, onOpenRunning, onResumeRunning, m) } + HeroKind.During -> (running ?: lastRunning.value)?.let { DuringHero(it, onOpenRunning, liveHero, m) } } } } private enum class HeroKind { Pair, Stuck, Waking, NoSleep, Midday, Window, After, Evening, During } -/** - * The hero's recipe, L4 (spec 2.1): the rim at full from the one side the moment places, no bevel, and the dark core - * opposite the light. [rim] raises or calms it for the moment (spec 4.2: 120% before a workout, 80% after one). The - * Moment widget lights its background with the same recipe (widgets spec 3.1). - */ -internal fun heroStyle(color: Color, lightAngle: Float?, rim: Float = 1f) = GlowStyle.Bloom.copy( - lightAngle = lightAngle, color = color, - coreShape = CoreShape.Radial, coreX = 0.5f, coreY = 0.6f, coreWidth = 0.95f, coreHeight = 0.75f, - coreBlur = 30.dp, toneWidth = 16.dp, toneShift = -10f, glowStrength = rim, bevel = 0f, -) - -/** The workout window's rim, raised for effort, and the calmer one after a workout (spec 4.2). */ -internal const val WINDOW_RIM = 1.2f -internal const val AFTER_RIM = 0.8f -internal const val EVENING_BODY = 0.46f - /** * Waking up: how you slept and what Recovery says, the score in dots with its word, and Charge at waking as text * beside it (one dotted number a card, spec 2.3). Recovery's one teal, lit from the lower left (spec 4.2). Without @@ -456,7 +466,7 @@ private fun WakingHero(ctx: TodayContext, lightAngle: Float?, onOpen: () -> Unit "Recovery", trailing = score?.let { org.openstrap.app.ui.dashboard.TabCopy.vsYesterday(it, before) }, tag = if (ctx.firstScore && score != null) TitleTag(FIRST_SCORE) else null, ) - if (ctx.state.loaded) HeroSentence(TodayCopy.waking(ctx.state.asleepSeconds, score, ctx.alert, ctx.noScore, ctx.firstNights, ctx.isToday)) + if (ctx.state.loaded) HeroSentence(TodayCopy.waking(ctx.state.asleepSeconds, score, ctx.alert, ctx.noScore, ctx.firstNights, ctx.isToday, deleted = ctx.state.deletedSleep != null)) // A hero leaves out a figure it doesn't have rather than show a lone dash; the sentence says why. if (shown.value == null && atWake == null) return@Column Spacer(Modifier.height(24.dp)) @@ -507,7 +517,7 @@ private fun ChargeHero(ctx: TodayContext, onOpen: () -> Unit, onRetry: () -> Uni HeroSentence( when { failed -> CHARGE_FAILED - noSleep -> TodayCopy.NO_SLEEP + noSleep -> if (ctx.state.deletedSleep != null) TodayCopy.deletedNight(ctx.isToday) else TodayCopy.NO_SLEEP else -> TodayCopy.charge(now, c?.let { org.openstrap.app.ui.dashboard.TabCopy.drainedAmount(it.drainedExercise + it.drainedStress + it.drainedLiving).toInt() }, c?.anchored == true, LocalTime.now().hour) }, ) @@ -584,8 +594,8 @@ private fun AfterHero(ctx: TodayContext, lightAngle: Float?, onOpenWorkout: (Lon // About 40 points over whatever time has passed, and never narrower than the strap's usual gap between // readings (it keeps one every few minutes outside a workout): a slot with no reading between two that // have one breaks the line into lone dots. - val gaps = since.zipWithNext { a, b -> b.x - a.x }.filter { it > 0 }.sorted() - val typical = gaps.getOrNull(gaps.size / 2) ?: 0L + val gaps = since.zipWithNext { a, b -> (b.x - a.x).toInt() }.filter { it > 0 } + val typical = Stats.upperMiddle(gaps)?.toLong() ?: 0L // Half as wide again, so a reading a little late still lands in its own slot. val step = maxOf(60L, (ctx.now - w.end) / 40, typical * 3 / 2) val values = remember(since, w.end, step) { bucket(since, w.end..ctx.now, step) } @@ -621,7 +631,7 @@ private fun EveningHero(ctx: TodayContext, lightAngle: Float?, bedtime: LocalTim // The count is in dots below, so the sentence says the steps against the goal instead. HeroSentence(TodayCopy.evening(nouns, ctx.state.steps, goals.steps, ctx.loadStatus, stepCount = false)) Spacer(Modifier.height(24.dp)) - HeroNumber("%,d".format(ctx.state.steps), listOf("steps")) + HeroNumber(dotFormat("%,d", ctx.state.steps), listOf("steps")) // Load is said in the sentence, and Energy has its own card right under the hero. Only with that card // hidden in Edit Today does Energy come back here, last and full width, never a lone dash. val energy = ctx.charge?.takeIf { energyHidden } @@ -634,23 +644,46 @@ private fun EveningHero(ctx: TodayContext, lightAngle: Float?, bedtime: LocalTim } /** - * During a workout: the live heart rate, number first, and the way back to the live screen. Lit from below in the - * current zone's colour, pulsing at the real bpm (spec 4.2). + * What the During hero needs from the workout (workouts spec 2.10): its [glance], read only here so Today doesn't + * recompose every second, what its buttons do ([onAction]), and whether it's on screen ([onShown]), so the floating + * bar can stay away while it is. + */ +class LiveHeroHooks( + val glance: () -> org.openstrap.app.ui.workout.LiveGlance? = { null }, + val onAction: (org.openstrap.app.ui.workout.GlanceAction) -> Unit = {}, + val onShown: (Boolean) -> Unit = {}, +) + +/** + * During a workout: the workout at a glance ([LiveHero]), and under it, for a GPS workout, the last kilometre on a + * quiet strip, so the two never look alike. Says when it's on screen, for the floating bar. */ @Composable -private fun DuringHero( - ctx: TodayContext, r: org.openstrap.app.device.WorkoutState.Recording, onOpen: () -> Unit, onResume: () -> Unit, modifier: Modifier, -) { - val live = ctx.live - val zone = live?.let { zoneColor(it, r.maxHeartRate) } ?: MetricColors.heart - val style = heroStyle(zone, LightAngle.Below).let { if (live != null) it.copy(motion = GlowMotion.Heartbeat(live)) else it } - GlowCard(style, modifier, onClick = onOpen) { - Column { - GlowHeader(Workouts.sportName(r.workout.sportType)) - Spacer(Modifier.height(18.dp)) - HeroFigure(live?.toString(), if (live != null) "bpm, live" else "Waiting for heart rate", big = true) - Spacer(Modifier.height(28.dp)) - WorkoutShortcut(r, live, onOpen, onResume) +private fun DuringHero(r: org.openstrap.app.device.WorkoutState.Recording, onOpen: () -> Unit, hooks: LiveHeroHooks, modifier: Modifier) { + val glance = hooks.glance() ?: return + val onShown by rememberUpdatedState(hooks.onShown) + DisposableEffect(Unit) { onDispose { onShown(false) } } + Column( + Modifier.onGloballyPositioned { c -> + // On screen while any of it is inside the window's height. + val b = c.boundsInWindow() + onShown(b.height > 0f && b.bottom > 0f) + }, + verticalArrangement = Arrangement.spacedBy(GlowLayout.ColumnGap), + ) { + LiveHero(glance, r.workout.sportType, glance.clock, onOpen, hooks.onAction, modifier) + glance.split?.let { (title, line) -> + Row( + Modifier.fillMaxWidth().clip(GlowLayout.CardShape).background(ListSurface).padding(horizontal = 18.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Canvas(Modifier.size(8.dp)) { drawCircle(TrendGood) } + Spacer(Modifier.width(12.dp)) + Column { + Text(title, color = Color.White, style = GlowType.body) + line?.let { Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) } + } + } } } } @@ -788,7 +821,7 @@ private fun TodayRow( onLoad = { openScore(org.openstrap.app.ui.dashboard.Score.Load) }, onRecover = { openScore(org.openstrap.app.ui.dashboard.Score.Recover) }, ) TodayCard.Recover -> { - val left = ctx.workouts.filter { it.recoveryMinutes != null }.maxByOrNull { it.end } + val left = latestRecoveryEstimate(ctx.workouts) ?.let { w -> TodayCopy.recoverShort(TodayCopy.recoverLeft(w.end, w.recoveryMinutes ?: 0, ctx.now), ctx.now, ctx.zone) } SmallCard( MetricColors.load, "To recover", left?.first, "h", left?.second ?: "No estimate from the strap", modifier, @@ -809,21 +842,5 @@ private fun TodayRow( /** The day's Charge in quarter hours across [day], up to [now]; the quarters still to come are null. */ internal fun chargeSoFar(points: List, day: LongRange, now: Long): List { val step = 15 * 60L - val n = ((day.last - day.first + 1) / step).toInt().coerceAtLeast(2) - val sums = FloatArray(n) - val counts = IntArray(n) - points.forEach { val i = ((it.t - day.first) / step).toInt(); if (i in 0 until n && it.t <= now) { sums[i] += it.value; counts[i]++ } } - return List(n) { if (counts[it] == 0) null else sums[it] / counts[it] } -} - -/** Averages timed points into evenly spaced buckets across [range]; empty buckets are null (gaps). */ -internal fun bucket(points: List, range: LongRange, seconds: Long): List { - val n = ((range.last - range.first + 1) / seconds).toInt().coerceAtLeast(2) - val sums = FloatArray(n) - val counts = IntArray(n) - points.forEach { - val i = ((it.x - range.first) / seconds).toInt() - if (i in 0 until n) { sums[i] += it.y; counts[i]++ } - } - return List(n) { if (counts[it] == 0) null else sums[it] / counts[it] } + return org.openstrap.app.data.Summaries.evenly(points.filter { it.t <= now }, day, step, { it.t }, { it.value }) } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowType.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowType.kt index 2be5446e..4a96a2cc 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowType.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowType.kt @@ -2,8 +2,6 @@ package org.openstrap.app.ui.glow import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import org.openstrap.app.ui.theme.FunnelDisplay @@ -18,6 +16,24 @@ object GlowType { /** You, Strap, every detail and settings page: Display 400, 24 on 28. */ val pageTitle = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Normal, fontSize = 24.sp, lineHeight = 28.sp) + /** HIIT's phase word over the seconds, WORK or REST (boards HiitWork, HiitRest): Display 500, 30 on 34, spaced out. */ + val phaseWord = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Medium, fontSize = 30.sp, lineHeight = 34.sp, letterSpacing = 0.14.em) + + /** Mind and body's time inside its ring (board LiveMind): Display 300, 72 on 76, tabular. */ + val mindClock = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Light, fontSize = 72.sp, letterSpacing = (-0.02).em, lineHeight = 76.sp, fontFeatureSettings = "tnum") + + /** The moving time on the map's panel, beside its two figures (board RunMap): Display 300, 38 on 42, tabular. */ + val mapClock = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Light, fontSize = 38.sp, letterSpacing = (-0.02).em, lineHeight = 42.sp, fontFeatureSettings = "tnum") + + /** A run's distance, time and pace on its share card (board ShareRun): Display 300, 30 on 33, tabular. */ + val storyStat = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Light, fontSize = 30.sp, letterSpacing = (-0.01).em, lineHeight = 33.sp, fontFeatureSettings = "tnum") + + /** The sport at the top of a share card ("Push day", boards ShareStrength, ShareHiit): Display 500, 30 on 34. */ + val storyTitle = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Medium, fontSize = 30.sp, lineHeight = 34.sp) + + /** "OpenStrap" on a share card: Display 500, 14 on 18, spaced out a little. */ + val storyBrand = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.04.em) + /** The date in the moment header: Display 500, 20 on 24. */ val headerDate = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Medium, fontSize = 20.sp, lineHeight = 24.sp) @@ -62,37 +78,13 @@ object GlowType { * the dotted answer is the other card's. */ val smallValue = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Light, fontSize = 34.sp, letterSpacing = (-0.02).em, lineHeight = 36.sp, fontFeatureSettings = "tnum") -} -/** - * The six dot sizes of dot-matrix numbers (spec 2.3), by where the number sits. [DotMatrixText] takes only these, so - * the same kind of number is the same size on every screen. - */ -enum class DotSize(val dot: Dp) { - /** Four or more figures side by side ([FigureRow]). List rows are plain text ([GlowType.rowValue]). */ - Row(2.4.dp), - - /** Edge cards and Smalls. */ - Edge(3.dp), - - /** Talls, the second glow, glow cards that aren't the hero. */ - Second(3.6.dp), - - /** The hero's number. */ - Hero(4.5.dp), - - /** A detail's header. */ - Detail(6.dp), - - /** The live workout. */ - Live(9.dp), - ; - - /** The space between dots: a fixed share of the dot, so every size has the same texture. */ - val gap: Dp get() = dot * DOT_GAP + /** + * A goal's value on Ready, typed in place ("7.5 km", "1 h 15 min", "6 × 400 m"): Display 300, 44 on 44, tabular. + * Plain, since the card's dotted number is the heart rate's. + */ + val goalValue = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Light, fontSize = 44.sp, letterSpacing = (-0.02).em, lineHeight = 44.sp, fontFeatureSettings = "tnum") - companion object { - /** The gap between dots as a share of the dot. */ - const val DOT_GAP = 0.38f - } + /** The smaller numbers beside a [goalValue] (the rest in "with 90 s rest between", the ×): Display 300, 26 on 28. */ + val goalValueMinor = TextStyle(fontFamily = FunnelDisplay, fontWeight = FontWeight.Light, fontSize = 26.sp, lineHeight = 28.sp, fontFeatureSettings = "tnum") } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutMode.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutMode.kt index 736893f7..a0066897 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutMode.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutMode.kt @@ -1,25 +1,14 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.Units +import org.openstrap.app.presentation.WorkoutColors import androidx.compose.runtime.LaunchedEffect -import androidx.compose.foundation.layout.widthIn import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.keyframes -import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectHorizontalDragGestures -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -31,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding @@ -41,48 +31,26 @@ import org.openstrap.app.ui.components.PinnedFade import org.openstrap.app.ui.components.pinnedFade import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import android.content.Context -import android.content.Intent -import android.location.LocationManager -import android.provider.Settings -import androidx.compose.foundation.clickable -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.Icon import androidx.compose.material3.Text -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.selected -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.em -import androidx.lifecycle.compose.LifecycleResumeEffect -import org.openstrap.app.device.WorkoutLocation import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.setValue import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset @@ -90,80 +58,46 @@ import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.flow.drop -import kotlin.math.cos import kotlin.math.min -import kotlin.math.sin -import org.openstrap.app.data.WorkoutAnalysis import org.openstrap.app.data.WorkoutRecorder import org.openstrap.app.data.WorkoutSuggestions -import org.openstrap.app.data.WorkoutTarget -import org.openstrap.app.data.Route -import org.openstrap.app.data.Strength -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState -import androidx.compose.foundation.layout.wrapContentHeight import org.openstrap.app.data.Workouts -import org.openstrap.app.device.ConnectionState import org.openstrap.app.device.WorkoutState import org.openstrap.app.data.db.AppWorkout import org.openstrap.app.ui.workout.PhoneSummary import org.openstrap.app.ui.components.GlassAlertDialog import org.openstrap.app.ui.components.pressClickable -import org.openstrap.app.ui.components.HapticEvent -import org.openstrap.app.ui.components.rememberHaptics -import org.openstrap.app.ui.dashboard.Info -import org.openstrap.app.ui.dashboard.ScreenList import org.openstrap.app.ui.theme.MetricColors import org.openstrap.app.ui.theme.AppIcons import org.openstrap.app.ui.workout.SportIcons import org.openstrap.app.ui.workout.ZONE_COLORS -import org.openstrap.app.ui.workout.LiveView -import org.openstrap.app.ui.workout.SideFigure -import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onClick -import org.openstrap.app.ui.workout.WorkoutCopy import org.openstrap.app.ui.workout.SummaryCopy import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.theme.ControlSurface /* ---------------------------------------------- Shared ----------------------------------------------- */ -/** A paused workout, or one with no reading yet, is a cool grey rather than a zone. */ -internal val PausedColor = Color(0xFF9AA0B4) +/** A paused workout, or one with no reading yet, is a cool grey rather than a zone, the notification's grey. */ +internal val PausedColor = Color(WorkoutColors.PAUSED_GREY) /** The Control surface (spec 2.2): round buttons, chips, pills, tiles and fields on the workout pages. */ -internal val Raised = Color(0xFF1F1F22) +internal val Raised = ControlSurface /** A button's label: Body (spec 2.3), a step heavier so it reads as the thing to press. */ private val ButtonText = GlowType.body.copy(fontWeight = FontWeight.SemiBold) /** Body in Medium, for pills, chips and short live lines. */ -private val MediumBody = GlowType.body.copy(fontWeight = FontWeight.Medium) - -/** - * The live stats row's figures, unchanged from before spec 6.3 ("Stats row unchanged"): the moving time large in the - * middle and the two figures either side, in Display Light. The only two sizes on the workout pages no role has; a - * live clock read at arm's length isn't one of spec 2.3's. - */ -private val LiveClock = GlowType.sentence.copy(fontSize = 46.sp, lineHeight = 50.sp, letterSpacing = (-0.02).em, fontFeatureSettings = "tnum") -private val LiveFigure = GlowType.sentence.copy(fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.02).em, fontFeatureSettings = "tnum") +internal val MediumBody = GlowType.body.copy(fontWeight = FontWeight.Medium) /** The colour a workout takes: its live zone's, or [PausedColor] with no reading. */ internal fun zoneColor(bpm: Int?, maxHr: Int): Color = WorkoutRecorder.liveZone(bpm, maxHr)?.let { ZONE_COLORS[it] } ?: PausedColor @@ -172,37 +106,35 @@ internal fun zoneColor(bpm: Int?, maxHr: Int): Color = WorkoutRecorder.liveZone( fun workoutColor(r: WorkoutState.Recording, liveHeartRate: Int?, connected: Boolean): Color = if (r.paused) PausedColor else zoneColor(liveHeartRate.takeIf { connected }, r.maxHeartRate) -/** "12:34", or "1:02:03" past the hour. */ -internal fun workoutClock(seconds: Long): String = - if (seconds >= 3600) "%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) else "%d:%02d".format(seconds / 60, seconds % 60) +/** "12:34", or "1:02:03" past the hour, in the phone's digits. Text drawn in dots passes [Units.DOT_LOCALE]. */ +internal fun workoutClock(seconds: Long, locale: java.util.Locale = java.util.Locale.getDefault()): String = Units.workoutClock(seconds, locale) + +/** [workoutClock] for the dot font, whose digits are ASCII only. */ +internal fun dotClock(seconds: Long): String = Units.workoutClock(seconds, Units.DOT_LOCALE) -internal fun zoneRange(zone: Int, maxHr: Int) = - if (zone == 0) "under ${60 * maxHr / 100} bpm" - else "${(50 + zone * 10) * maxHr / 100}–${if (zone == Workouts.ZONES - 1) maxHr else (60 + zone * 10) * maxHr / 100 - 1} bpm" +/** A live zone's heart rates in your zones: "under 115 bpm" for zone 1 (anything slower counts), else "134 to 152 bpm". */ +// Collected, so it reads again when the zones are moved on the Heart rate zones page. +@Composable +internal fun zoneRange(zone: Int, maxHr: Int): String { + val zones = org.openstrap.app.data.HeartZones.current.collectAsState().value + return if (zone == 0) "under ${zones.startBpm(1, maxHr)} bpm" else "${zones.startBpm(zone, maxHr)} to ${zones.endBpm(zone, maxHr) ?: maxHr} bpm" +} /** Active time of the workout, ticking once a second while it records. */ @Composable -internal fun activeSeconds(r: WorkoutState.Recording): Long = produceState(WorkoutRecorder.activeSeconds(r.workout, System.currentTimeMillis() / 1000), r.workout) { +internal fun activeSeconds(r: WorkoutState.Recording): Long = activeSecondsState(r).value + +/** + * [activeSeconds] as state, for a live screen to hand on as `{ active.value }`: only what shows the time reads it, so + * the screen itself isn't composed again each second. + */ +@Composable +internal fun activeSecondsState(r: WorkoutState.Recording): androidx.compose.runtime.State = produceState(WorkoutRecorder.activeSeconds(r.workout, System.currentTimeMillis() / 1000), r.workout) { while (true) { value = WorkoutRecorder.activeSeconds(r.workout, System.currentTimeMillis() / 1000) if (r.paused) break delay(1000 - System.currentTimeMillis() % 1000) } -}.value - -/** A target's colour: the top zone it aims for, a calm blue for a time, Calories' gold for kcal, white for none. */ -internal fun targetColor(t: WorkoutTarget?): Color = when (t) { - is WorkoutTarget.Zones -> ZONE_COLORS[t.high] - is WorkoutTarget.Time -> ZONE_COLORS[0] - is WorkoutTarget.Kcal -> MetricColors.calories - null -> Color.White -} - -internal fun targetLabel(t: WorkoutTarget?): String = when (t) { - is WorkoutTarget.Zones -> t.label - is WorkoutTarget.Time -> t.label - is WorkoutTarget.Kcal -> t.label - null -> "None" } /** A full-width pill button, 56 dp like Start and Done (spec 6.3). */ @@ -233,15 +165,20 @@ internal fun WorkoutButton( internal fun WorkoutPage( glow: Color, contentPadding: PaddingValues, scroll: Boolean = true, pinned: (@Composable ColumnScope.() -> Unit)? = null, + /** Painted over the whole screen in place of the top glow (HIIT's phase light). */ + backdrop: Brush? = null, content: @Composable ColumnScope.() -> Unit, ) { BoxWithConstraints(Modifier.fillMaxSize().background(Color.Black)) { val width = constraints.maxWidth.toFloat() val density = LocalDensity.current var pinnedHeight by remember { mutableStateOf(0.dp) } - Box( - Modifier.fillMaxWidth().height(420.dp).background( - Brush.radialGradient(listOf(glow.copy(alpha = 0.22f), Color.Transparent), center = Offset(width / 2f, 0f), radius = width * 0.9f), + // No wider than the box is tall: on a wide screen (unfolded) a wider glow would be cut off in a straight line. + val glowHeight = 420.dp + val radius = minOf(width * 0.9f, with(density) { glowHeight.toPx() }) + if (backdrop != null) Box(Modifier.fillMaxSize().background(backdrop)) else Box( + Modifier.fillMaxWidth().height(glowHeight).background( + Brush.radialGradient(listOf(glow.copy(alpha = 0.22f), Color.Transparent), center = Offset(width / 2f, 0f), radius = radius), ), ) // On a wide screen the page keeps to one centred column (spec 2.4 Unfolded); the glow stays the screen's. @@ -251,9 +188,10 @@ internal fun WorkoutPage( val padTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + contentPadding.calculateTopPadding() + 8.dp val padBottom = contentPadding.calculateBottomPadding() val end = if (pinned != null) (PinnedFade.listClearance(pinnedHeight) - padBottom).coerceAtLeast(16.dp) else 16.dp - // The live screen fits the screen instead (scroll = false): its controls never move. + // The live screen fits the screen instead (scroll = false): its controls never move. A scrolling page ends at + // the keyboard, so what's typed can scroll above it. Column( - Modifier.fillMaxSize().then(if (scroll) Modifier.verticalScroll(rememberScrollState()) else Modifier).statusBarsPadding() + Modifier.fillMaxSize().then(if (scroll) Modifier.imePadding().verticalScroll(rememberScrollState()) else Modifier).statusBarsPadding() .padding(contentPadding).padding(start = side, end = side, top = 8.dp, bottom = end) // At least the screen's height, so the controls sit at the bottom without the page scrolling. .then(if (scroll) Modifier.heightIn(min = (maxHeight - padTop - padBottom - end).coerceAtLeast(0.dp)) else Modifier), @@ -273,7 +211,7 @@ internal fun WorkoutPage( /** A dark pill: a dot and a label, optionally tappable with a caret. */ @Composable -private fun Pill(label: String, dot: Color?, onClick: (() -> Unit)? = null, caret: Boolean = false) { +internal fun Pill(label: String, dot: Color?, onClick: (() -> Unit)? = null, caret: Boolean = false) { Row( Modifier.height(44.dp).clip(RoundedCornerShape(50)).background(Raised) .let { if (onClick != null) it.pressClickable(onClick, RoundedCornerShape(50)) else it } @@ -294,50 +232,6 @@ private fun Pill(label: String, dot: Color?, onClick: (() -> Unit)? = null, care /* ----------------------------------------- Activity tab entry ---------------------------------------- */ -/** A sport someone has done, for the start card: how often and when last. */ -data class SportUse(val sportType: Int, val count: Int, val lastStart: Long?) - -/** The 3 most-used sports across both kinds of workout, else three sensible defaults. */ -fun topSports(workouts: List, n: Int = 3): List { - // The strap's own "Activity" records aren't a sport to offer. - val used = workouts.filter { it.workout.sportType != Workouts.GENERIC_ACTIVITY }.groupBy { it.workout.sportType } - .map { (type, ws) -> SportUse(type, ws.size, ws.maxOf { it.workout.start }) } - .sortedWith(compareByDescending { it.count }.thenByDescending { it.lastStart }) - .take(n) - return used + Workouts.DEFAULT_SPORTS.filter { d -> used.none { it.sportType == d } }.take(n - used.size).map { SportUse(it, 0, null) } -} - -/** - * A sport's tile in the picker (spec 6.3): a 64 dp Control tile with 16 dp corners, the icon at 24 dp, the name in - * Secondary under it. The chosen sport's tile is white with a black icon; a sport done only outdoors carries the - * 12 dp location mark in its corner. - */ -@Composable -private fun SportTile(type: Int, name: String, modifier: Modifier, selected: Boolean = false, onClick: () -> Unit) { - // Tight corners and room underneath, so the press area never clips a label that fills the width. - Column(modifier.pressClickable(onClick, RoundedCornerShape(8.dp)).padding(vertical = 4.dp), horizontalAlignment = Alignment.CenterHorizontally) { - Box( - Modifier.size(SportTileSize).clip(GlowLayout.TileShape).background(if (selected) Color.White else Raised), - contentAlignment = Alignment.Center, - ) { - val ink = if (selected) Color.Black else Color.White - Icon(SportIcons.of(type), null, tint = ink, modifier = Modifier.size(24.dp)) - if (type in Workouts.OUTDOOR_ONLY) Icon( - SportIcons.Location, "Outdoors", tint = ink.copy(alpha = 0.7f), - modifier = Modifier.align(Alignment.TopEnd).padding(6.dp).size(12.dp), - ) - } - Spacer(Modifier.height(6.dp)) - // Two lines, so a long name in More sports' search isn't cut short. - Text( - name, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 2, - textAlign = TextAlign.Center, overflow = TextOverflow.Ellipsis, - ) - } -} - -private val SportTileSize = 64.dp - /** * Where an offer or a suggestion sits: a quiet panel, or on Today a strip lit in heart red from [edge]'s side, so the * two never look alike when both show. @@ -461,27 +355,33 @@ private val TrimSide = 12.dp private fun RangeChart(series: List>, first: Long, last: Long, from: Long, to: Long, maxHr: Int, onChange: (Long, Long) -> Unit) { val lo = (series.minOfOrNull { it.second } ?: 60) - 5f val hi = (series.maxOfOrNull { it.second } ?: 180) + 5f - var f by remember { mutableStateOf(from) } - var t by remember { mutableStateOf(to) } - f = from - t = to + // The drag reads the range as it stands when it starts and keeps its own copy while it moves, so nothing is + // written back during composition (audit K6). + val range by rememberUpdatedState(from to to) + val change by rememberUpdatedState(onChange) Canvas( - Modifier.fillMaxWidth().height(120.dp).pointerInput(series) { + Modifier.fillMaxWidth().height(120.dp).pointerInput(series, first, last) { var dragging = 0 + var f = 0L + var t = 0L val inset = HandleInset.toPx() fun timeAt(x: Float) = (first + ((x - inset) / (size.width - 2 * inset) * (last - first)).toLong()).coerceIn(first, last) detectHorizontalDragGestures( onDragStart = { o -> + f = range.first + t = range.second val at = timeAt(o.x) dragging = if (kotlin.math.abs(at - f) <= kotlin.math.abs(at - t)) 0 else 1 }, - ) { change, _ -> - val at = timeAt(change.position.x) / 60 * 60 + ) { c, _ -> + val at = timeAt(c.position.x) / 60 * 60 if (dragging == 0) f = at.coerceAtMost(t - 60) else t = at.coerceAtLeast(f + 60) - onChange(f, t) + change(f, t) } }, ) { + val f = from + val t = to val inset = HandleInset.toPx() fun x(time: Long) = inset + (time - first).toFloat() / (last - first) * (size.width - 2 * inset) fun y(v: Float) = size.height - (v - lo) / (hi - lo) * size.height @@ -499,1030 +399,8 @@ private fun RangeChart(series: List>, first: Long, last: Long, f } } -/** The sport list on its own, for saving a stretch nobody started. */ -@Composable -fun GlowSportPickerScreen( - contentPadding: PaddingValues, onPick: (Int) -> Unit, onBack: () -> Unit, - recentSports: List = emptyList(), sides: Map = emptyMap(), -) = SportPicker(-1, recentSports, contentPadding, { onPick(pickedType(it, sides)) }, onBack) - -/* -------------------------------------------- Ready screen ------------------------------------------- */ - -/** - * One screen, not a wizard (spec 6.3): close, the sport (tap to change), the Outdoor or Indoor switch for a sport with - * both, the strap as the hero with its live heart rate, the settings as list rows, and Start pinned to the bottom. - * Recording starts even when the strap is away; the strap's history fills in after it syncs. - * - * The switch picks the sport type saved ([Workouts.Sides]) and whether GPS is offered; it opens on the side you last - * chose for the sport ([sides], saved through [onSide]). GPS is on for the outdoor side and sports done outdoors, with - * its row to turn it off for this workout. While the phone's location is off or not allowed, the row says so with - * Turn on ([onTurnOnLocation] asks for the permission), and Start works without GPS. - */ -@Composable -fun GlowWorkoutReadyScreen( - sportType: Int, - pickSport: Boolean, - connection: ConnectionState, - liveHeartRate: Int?, - contentPadding: PaddingValues, - onSport: (Int) -> Unit, - onStart: (sportType: Int, askToEnd: Boolean, target: WorkoutTarget?, useGps: Boolean, keepRoute: Boolean, rest: Strength.Rest?, setsTarget: Int?) -> Unit, - onBack: () -> Unit, - recentSports: List = emptyList(), - sides: Map = emptyMap(), - onSide: (sport: Int, indoor: Boolean) -> Unit = { _, _ -> }, - onTurnOnLocation: (then: () -> Unit) -> Unit = { it() }, - /** kcal targets are offered: there's a profile to work kcal out from. */ - kcalTargets: Boolean = false, -) { - var picking by rememberSaveable { mutableStateOf(pickSport) } - var askToEnd by rememberSaveable { mutableStateOf(true) } - var targetCode by rememberSaveable { mutableStateOf(null) } - val pair = Workouts.sidesOf(sportType) - // The side picked on this screen; until then the one you last chose for the sport, or its default. - var chosenIndoor by rememberSaveable(sportType) { mutableStateOf(null) } - val indoor = chosenIndoor ?: Workouts.indoorSide(sportType, sides) - val type = pair?.type(indoor == true) ?: sportType - val outdoor = Workouts.gpsOffered(sportType, indoor) - // GPS is on wherever it's offered; turning it off holds for this workout only. - var gpsOff by rememberSaveable(type, indoor) { mutableStateOf(false) } - var keepRoute by rememberSaveable { mutableStateOf(false) } - val context = LocalContext.current - var locationOn by remember { mutableStateOf(locationAvailable(context)) } - LifecycleResumeEffect(Unit) { - locationOn = locationAvailable(context) - onPauseOrDispose { } - } - val useGps = outdoor && !gpsOff && locationOn - val strength = type in Strength.SPORTS - var setsTarget by rememberSaveable { mutableStateOf(null) } - var restSeconds by rememberSaveable { mutableStateOf(Strength.Rest.DEFAULT.seconds) } - var restBelow by rememberSaveable { mutableStateOf(null) } - // A kcal target picked before the profile went is dropped with its chip. - val target = WorkoutTarget.parse(targetCode)?.takeIf { it in WorkoutTarget.offered(kcalTargets) } - var info by rememberSaveable { mutableStateOf(false) } - val haptics = rememberHaptics() - if (picking) { - SportPicker(type, recentSports, contentPadding, onPick = { onSport(it); picking = false }, onBack = { if (pickSport) onBack() else picking = false }) - return - } - BackHandler(onBack = onBack) - // Start takes the target's colour, previewing the workout mode; plain white with none. - val accent = targetColor(target) - WorkoutPage( - accent, contentPadding, - pinned = { - WorkoutButton(WorkoutCopy.startLabel(type), accent, Color.Black, { - haptics.perform(HapticEvent.WorkoutStart) - onStart( - type, askToEnd, target, useGps, useGps && keepRoute, - Strength.Rest(restSeconds, restBelow).takeIf { strength }, setsTarget.takeIf { strength }, - ) - }, icon = AppIcons.PlayFill) - }, - ) { - Box(Modifier.fillMaxWidth()) { - Box( - Modifier.size(44.dp).clip(CircleShape).background(Raised).pressClickable(onBack, CircleShape), - contentAlignment = Alignment.Center, - ) { Icon(AppIcons.X, "Close", tint = Color.White, modifier = Modifier.size(18.dp)) } - Box(Modifier.align(Alignment.Center)) { Pill(Workouts.shortName(type), null, onClick = { picking = true }, caret = true) } - } - Column(Modifier.padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text("Ready when you are", color = Color.White, style = GlowType.pageTitle, maxLines = 1) - Text("Recording starts when you tap Start.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) - } - // Outdoor or indoor is a switch, not a second sport. - if (pair != null) SideSwitch(listOf(pair.outdoorName, pair.indoorName), if (indoor == true) 1 else 0) { i -> - chosenIndoor = i == 1 - onSide(pair.outdoor, i == 1) - } - StrapHero(connection, liveHeartRate) { info = true } - // GPS for the outdoor side and sports done outdoors only; indoors there's no row at all. - if (outdoor) ListPanel { - if (locationOn) { - SettingRow(SportIcons.Location, "GPS", WorkoutCopy.gpsNote(useGps, type), divider = false, onClick = { gpsOff = !gpsOff }) { - GlowSwitch(!gpsOff, { gpsOff = !it }) - } - if (useGps) SettingRow(AppIcons.Route01, "Keep the route", WorkoutCopy.routeNote(keepRoute), onClick = { keepRoute = !keepRoute }) { - GlowSwitch(keepRoute, { keepRoute = it }) - } - } else { - val turnOn = { - if (!WorkoutLocation.granted(context)) onTurnOnLocation { locationOn = locationAvailable(context) } - else context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) - } - SettingRow(SportIcons.Location, WorkoutCopy.LOCATION_OFF, WorkoutCopy.LOCATION_OFF_NOTE, divider = false, onClick = turnOn) { - SmallPill("Turn on", turnOn) - } - } - } - // Strength counts sets and rest; the strap buzzes when rest is over, so the phone can stay in a pocket. - if (strength) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ChipLabel("Sets") - Chips(listOf("Open") + listOf(3, 4, 5).map { "$it sets" }, listOf(null, 3, 4, 5).indexOf(setsTarget), Color.White) { setsTarget = listOf(null, 3, 4, 5)[it] } - ChipLabel("Rest. The strap buzzes when it's over.") - Chips(Strength.Rest.TIMES.map { "${it} s" }, Strength.Rest.TIMES.indexOf(restSeconds), Color.White) { restSeconds = Strength.Rest.TIMES[it] } - ChipLabel("Or sooner, once heart rate drops below") - Chips(Strength.Rest.LEVELS.map { it?.let { b -> "$b bpm" } ?: "Off" }, Strength.Rest.LEVELS.indexOf(restBelow), Color.White) { restBelow = Strength.Rest.LEVELS[it] } - } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ChipLabel(WorkoutCopy.targetHint(target)) - Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - (listOf(null) + WorkoutTarget.offered(kcalTargets)).forEach { t -> - Chip(targetLabel(t), t == target, targetColor(t)) { targetCode = t?.code } - } - } - } - ListPanel { - SettingRow(null, "Ask to end when I stop", "After 8 min near resting heart rate and no steps, the strap buzzes and asks.", divider = false, onClick = { askToEnd = !askToEnd }) { - GlowSwitch(askToEnd, { askToEnd = it }) - } - } - } - if (info) InfoSheet( - Info("Recording", WorkoutCopy.READY_MEANING, emptyList(), "", "", ""), accent, null, null, - onDismiss = { info = false }, rows = WorkoutCopy.readyRows(outdoor), - ) -} - -/** Whether the phone's location can be used now: allowed, and turned on. */ -internal fun locationAvailable(context: Context): Boolean = - WorkoutLocation.granted(context) && context.getSystemService(LocationManager::class.java)?.isLocationEnabled == true - -/** - * Ready's hero, the strap (spec 6.3): a glow in heart red lit from below with the live heart rate in the hero's dots, - * pulsing at the real bpm, "Live from your strap." and the battery when low. While the strap is away it drops to an - * outline card that says you can start anyway. The (i) explains recording, GPS and the route. - */ -@Composable -private fun StrapHero(connection: ConnectionState, liveHeartRate: Int?, onInfo: () -> Unit) { - val hero = WorkoutCopy.readyHero(connection, liveHeartRate) - val bpm = liveHeartRate.takeIf { hero.live } - val style = GlowStyle.chart(MetricColors.heart).copy( - lightAngle = LightAngle.Below, - motion = if (bpm != null) GlowMotion.Heartbeat(bpm) else GlowMotion.Still, - outline = !hero.live, - ) - GlowCard(style, Modifier.fillMaxWidth()) { - Column { - GlowHeader(hero.title, trailingContent = { RoundButton(AppIcons.Info, "About recording", onInfo, size = 32.dp) }) - if (bpm != null) Row(Modifier.padding(top = 20.dp), verticalAlignment = Alignment.Bottom) { - DotMatrixText(bpm.toString(), dot = DotSize.Hero) - // The word after a number (spec 2.3). - Text("bpm", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) - } - Text( - hero.line, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, - modifier = Modifier.padding(top = if (bpm != null) 4.dp else 12.dp), - ) - } - } -} - -/** The Outdoor or Indoor switch: two halves on a Control pill, the chosen one white. */ -@Composable -private fun SideSwitch(options: List, selected: Int, onSelect: (Int) -> Unit) { - Row(Modifier.fillMaxWidth().clip(CircleShape).background(Raised).padding(4.dp)) { - options.forEachIndexed { i, label -> - val on = i == selected - Box( - Modifier.weight(1f).clip(CircleShape).background(if (on) Color.White else Color.Transparent) - .pressClickable({ if (!on) onSelect(i) }, CircleShape, scale = 0.97f) - .semantics { this.selected = on; role = Role.Tab } - .padding(vertical = 9.dp), - contentAlignment = Alignment.Center, - ) { - Text( - label, color = if (on) Color.Black else Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), - style = GlowType.body.copy(fontWeight = FontWeight.Medium), maxLines = 1, - ) - } - } - } -} - -/** - * A settings row in a list (spec 2.5, Grouped list): an optional white [icon], the [title] in Body with its [note] - * under it in Secondary, and [trailing] (a switch) on the right. The whole row toggles through [onClick]. - */ -@Composable -private fun SettingRow( - icon: androidx.compose.ui.graphics.vector.ImageVector?, title: String, note: String, divider: Boolean = true, - onClick: () -> Unit, trailing: @Composable () -> Unit, -) { - Row( - Modifier.fillMaxWidth().clickable(onClick = onClick) - .let { if (divider) it.drawBehind { drawLine(RowHairline, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) } else it } - .padding(vertical = GlowLayout.RowPadding), - verticalAlignment = Alignment.CenterVertically, - ) { - if (icon != null) { - Icon(icon, null, tint = SportIconTint, modifier = Modifier.size(RowIcon)) - Spacer(Modifier.width(RowIconGap)) - } - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { - RowName(title) - Text(note, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) - } - Spacer(Modifier.width(12.dp)) - trailing() - } -} - -/** A small white pill button at the end of a row ("Turn on"). */ -@Composable -private fun SmallPill(label: String, onClick: () -> Unit) { - Text( - label, color = Color.Black, style = GlowType.body.copy(fontWeight = FontWeight.Medium), maxLines = 1, - modifier = Modifier.clip(CircleShape).background(Color.White).pressClickable(onClick, CircleShape) - .padding(horizontal = 14.dp, vertical = 8.dp), - ) -} - -/** The label over a row of chips, in Caption. */ -@Composable -private fun ChipLabel(text: String) = - Text(text, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.caption, modifier = Modifier.padding(start = 4.dp)) - -/** A choice on a Control tile, the chosen one lit in [color]. */ -@Composable -private fun Chip(label: String, on: Boolean, color: Color, onClick: () -> Unit) { - Text( - label, color = if (on) Color.Black else Color.White, style = GlowType.body.copy(fontWeight = FontWeight.Medium), maxLines = 1, - modifier = Modifier.height(40.dp).clip(GlowLayout.TileShape).background(if (on) color else Raised) - .pressClickable(onClick, GlowLayout.TileShape).padding(horizontal = 14.dp).wrapContentHeight(Alignment.CenterVertically), - ) -} - -/** A scrolling row of choices; the chosen one lit in [color]. */ -@Composable -private fun Chips(labels: List, selected: Int, color: Color, onSelect: (Int) -> Unit) { - Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - labels.forEachIndexed { i, label -> Chip(label, i == selected, color) { onSelect(i) } } - } -} - -/* ------------------------------------------- Choose a sport ------------------------------------------ */ - -/** - * Choose a sport (spec 6.3): a page with the search field, your Recent sports, the twelve everyday ones as tiles and - * a More sports row for the rest, A to Z. Search looks through every sport, both kinds of a two-kind sport included. - * A tile picks its sport's key ([Workouts.tileType]); Ready's switch then picks the kind. [selected] is the sport - * chosen now, -1 for none. - */ -@Composable -private fun SportPicker(selected: Int, recent: List, contentPadding: PaddingValues, onPick: (Int) -> Unit, onBack: () -> Unit) { - var more by rememberSaveable { mutableStateOf(false) } - BackHandler { if (more) more = false else onBack() } - if (more) { - ScreenList(contentPadding, gutter = GlowLayout.Gutter) { - item { PageHeader("More sports", { more = false }) } - item { - ListPanel { - Workouts.MORE.forEachIndexed { i, type -> - PageRow(Workouts.sportName(type), icon = SportIcons.of(type), divider = i > 0, onClick = { onPick(type) }) - } - } - } - } - return - } - var query by rememberSaveable { mutableStateOf("") } - val found = remember(query) { Workouts.search(query) } - val chosen = Workouts.tileType(selected) - ScreenList(contentPadding, gutter = GlowLayout.Gutter) { - item { PageHeader("Choose a sport", onBack) } - item { SearchField(query) { query = it } } - if (query.isNotBlank()) { - item(key = "found") { - if (found.isEmpty()) Text( - "No sport matches \"${query.trim()}\".", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), - style = GlowType.secondary, modifier = Modifier.padding(start = 4.dp, top = 8.dp), - ) - else SportTiles(null, found, { if (it in Workouts.EVERYDAY) Workouts.shortName(it) else Workouts.sportName(it) }, { it == selected }, onPick) - } - return@ScreenList - } - if (recent.isNotEmpty()) item(key = "recent") { SportTiles("Recent", recent, Workouts::shortName, { it == chosen }, onPick) } - item(key = "sports") { SportTiles("Sports", Workouts.EVERYDAY, Workouts::shortName, { it == chosen }, onPick) } - item(key = "more") { - ListPanel { - PageRow("More sports", "Tennis, padel, dance and the rest", below = true, divider = false, onClick = { more = true }) - } - } - } -} - -/** A section of sport tiles, four to a row, under its Caption [title]. */ -@Composable -private fun SportTiles(title: String?, types: List, name: (Int) -> String, isSelected: (Int) -> Boolean, onPick: (Int) -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (title != null) ChipLabel(title) - types.chunked(4).forEach { row -> - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top) { - row.forEach { type -> SportTile(type, name(type), Modifier.weight(1f), selected = isSelected(type)) { onPick(type) } } - repeat(4 - row.size) { Spacer(Modifier.weight(1f)) } - } - } - } -} - -/** The picker's search: a 48 dp field on the Control surface with 16 dp corners and the search icon (spec 6.3). */ -@Composable -private fun SearchField(query: String, onChange: (String) -> Unit) { - val keyboard = LocalSoftwareKeyboardController.current - BasicTextField( - query, onChange, Modifier.fillMaxWidth(), singleLine = true, - textStyle = GlowType.body.copy(color = Color.White), - cursorBrush = SolidColor(Color.White), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { keyboard?.hide() }), - decorationBox = { field -> - Row( - Modifier.fillMaxWidth().height(48.dp).clip(GlowLayout.TileShape).background(Raised).padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(SportIcons.Search, null, tint = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(10.dp)) - Box(Modifier.weight(1f)) { - if (query.isEmpty()) Text("Search", color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.body, maxLines = 1) - field() - } - if (query.isNotEmpty()) Icon( - AppIcons.X, "Clear", tint = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), - modifier = Modifier.size(28.dp).clip(CircleShape).clickable { onChange("") }.padding(6.dp), - ) - } - }, - ) -} - -/* -------------------------------------------- Live screen -------------------------------------------- */ - -/** - * The workout as it records, in the current zone's colour. The card beats at the live heart rate on its zone arc, the - * last ten minutes behind the number; the zone, its range and the time spent in it sit under the arc. A GPS workout - * swipes between that card and the map, which keeps the heart rate on a chip, and opens on the view used last - * ([liveView], saved through [onLiveView]). Back tucks it away; ending takes a one-second hold on the square so a - * pocket can't end it. With [split] the heart card is on the other half of a folded phone ([GlowWorkoutGlance]), so - * this half keeps the figures and the controls. - */ -@Composable -fun GlowWorkoutLiveScreen( - r: WorkoutState.Recording, - liveHeartRate: Int?, - recent: List>, - stats: WorkoutRecorder.Stats?, - zoneSeconds: IntArray, - connection: ConnectionState, - contentPadding: PaddingValues, - onPause: () -> Unit, - onResume: () -> Unit, - onEnd: () -> Unit, - onMinimize: () -> Unit, - onEndAtPrompt: () -> Unit, - onKeepGoing: () -> Unit, - route: List = emptyList(), - history: List> = emptyList(), - onStartSet: () -> Unit = {}, - onSetDone: () -> Unit = {}, - onExtendRest: () -> Unit = {}, - onLap: () -> Unit = {}, - split: Boolean = false, - liveView: Int? = null, - onLiveView: (Int) -> Unit = {}, - kcal: WorkoutRecorder.Kcal? = null, -) { - BackHandler(onBack = onMinimize) - // Saved with the screen; [liveView] is the view from the last workout, loaded a moment after the screen opens. - val pager = rememberPagerState(initialPage = LiveView.of(liveView).ordinal) { LiveView.entries.size } - var viewApplied by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(liveView) { - if (!viewApplied && liveView != null) { - pager.scrollToPage(LiveView.of(liveView).ordinal) - viewApplied = true - } - } - // Only a swipe or a dot saves the view, never the page the screen opened on. - val saveView by rememberUpdatedState(onLiveView) - LaunchedEffect(pager) { snapshotFlow { pager.settledPage }.drop(1).collect { saveView(it) } } - // The map takes the heart card's height, so swiping never moves what's below. - var cardHeight by remember { mutableStateOf(360.dp) } - val density = LocalDensity.current - val scope = rememberCoroutineScope() - var routeOnly by rememberSaveable { mutableStateOf(false) } - val connected = connection is ConnectionState.Connected - val color = workoutColor(r, liveHeartRate, connected) - val active = activeSeconds(r) - val haptics = rememberHaptics() - val bpm = liveHeartRate.takeIf { connected && !r.paused } - // A tick when the zone changes (spec 3.6); a pause forgets the zone, so resuming in another doesn't tick. - val zone = WorkoutRecorder.liveZone(bpm, r.maxHeartRate) - var lastZone by remember { mutableStateOf(null) } - LaunchedEffect(zone, r.paused) { - if (WorkoutCopy.zoneTick(lastZone, zone)) haptics.perform(HapticEvent.ZoneChange) - lastZone = if (r.paused) null else zone ?: lastZone - } - WorkoutPage(color, contentPadding, scroll = false) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.size(44.dp).clip(CircleShape).background(Raised).pressClickable(onMinimize, CircleShape), - contentAlignment = Alignment.Center, - ) { Icon(AppIcons.CaretDown, "Minimize", tint = Color.White, modifier = Modifier.size(18.dp)) } - Spacer(Modifier.width(10.dp)) - Pill(Workouts.sportName(r.workout.sportType), color) - Spacer(Modifier.weight(1f)) - Text( - when { r.paused -> "Paused"; connected -> "Strap"; else -> "Reconnecting" }, - color = Color.White.copy(alpha = 0.65f), style = GlowType.secondary, - ) - Spacer(Modifier.width(8.dp)) - Canvas(Modifier.size(6.dp)) { drawCircle(if (connected) TrendGood else TrendBad) } - } - val heartCard: @Composable () -> Unit = { - WorkoutHeartCard( - r, liveHeartRate, recent, zoneSeconds, connected, active, - Modifier.fillMaxWidth().onSizeChanged { cardHeight = with(density) { it.height.toDp() } }, kcal = kcal?.active, - ) - } - // GPS workouts have two views, heart rate and the map, with two dots under them (pattern D5). They swipe as - // well as tap; a drag that starts on the map pans the map. Split across a fold, the card has the other half. - if (split) Unit - else if (r.workout.useGps) { - HorizontalPager(pager, pageSpacing = 16.dp, verticalAlignment = Alignment.Top, beyondViewportPageCount = 1) { page -> - Column(Modifier.fillMaxWidth()) { - when (LiveView.entries[page]) { - LiveView.Map -> LiveMapPage(route, history, r.maxHeartRate, routeOnly, bpm, color, r.paused, Modifier.height(cardHeight)) { routeOnly = it } - LiveView.Heart -> heartCard() - } - } - } - PageDots(pager.currentPage, LiveView.entries.size) { scope.launch { pager.animateScrollToPage(it) } } - } else heartCard() - if (r.strength) SetsPanel(r, color, onStartSet, { haptics.perform(HapticEvent.SetDone); onSetDone() }, onExtendRest) - // Centred between the card and the buttons: the moving time large in the middle, the average and peak either - // side (Oura's avg and max around the live number), labels under the numbers and thin rules between (WHOOP). - // kcal, the distance and the pace sit in a row under it, so every figure shows without a tap. - Spacer(Modifier.weight(1f)) - val figures = WorkoutCopy.liveFigures(WorkoutCopy.sideFigures(kcal != null, r.workout.distanceMeters != null, !r.workout.distanceFromSteps)) - Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min), verticalAlignment = Alignment.Bottom) { - SideStat(figures.sides[0], r, stats, kcal, route, Modifier.weight(1f)) - StatRule() - LiveStat("Moving time", Modifier.weight(1.5f)) { - // Plain digits: the heart rate above is the one dot-matrix number on the screen. - Text(workoutClock(active), color = Color.White, style = LiveClock) - } - StatRule() - SideStat(figures.sides[1], r, stats, kcal, route, Modifier.weight(1f)) - } - if (figures.below.isNotEmpty()) Row(Modifier.fillMaxWidth().padding(top = 16.dp).height(IntrinsicSize.Min), verticalAlignment = Alignment.Bottom) { - // A figure alone sits in the middle third, under the moving time, rather than across the screen. - if (figures.below.size == 1) Spacer(Modifier.weight(1f)) - figures.below.forEachIndexed { i, f -> - if (i > 0) StatRule() - SideStat(f, r, stats, kcal, route, Modifier.weight(1f)) - } - if (figures.below.size == 1) Spacer(Modifier.weight(1f)) - } - // The splits live in the summary; here each kilometre gets a line for a few seconds. - if (r.workout.useGps) KmSplitLine(route, WorkoutCopy.readings(history, recent), r.workout.pauseRanges) - // Once Lap has been pressed: the lap under way and its own clock, so a press shows on screen too. - r.workout.lapMarks.lastOrNull()?.let { mark -> - val lap = (active - WorkoutRecorder.activeSeconds(r.workout, mark)).coerceAtLeast(0) - Text( - "Lap ${r.workout.lapMarks.size + 1}, ${workoutClock(lap)}", - color = Color.White.copy(alpha = 0.75f), style = MediumBody.copy(fontFeatureSettings = "tnum"), - modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, - ) - } - if (!connected && !r.paused) { - Note("The strap isn't connected. Recording carries on, and the minutes missed are filled from its own history after it syncs.") - } - r.endPrompt?.let { at -> StoppedPrompt(r, at, onEndAtPrompt, onKeepGoing) } - Spacer(Modifier.weight(1f)) - // At most a thumb's reach wide, so on a wide screen (an unfolded or half-folded phone) Lap and End stay by Pause. - Row( - Modifier.align(Alignment.CenterHorizontally).widthIn(max = 360.dp).fillMaxWidth().padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Lap (not for strength, which has sets), keeping Pause centred either way. An ordinary tap: no tick. - if (!r.strength && !r.paused) FlatButton(Raised, 64.dp, onClick = onLap) { - Icon(SportIcons.Lap, "Lap", tint = Color.White, modifier = Modifier.size(22.dp)) - } else Spacer(Modifier.size(64.dp)) - Spacer(Modifier.weight(1f)) - FlatButton(Color.White, 88.dp, onClick = if (r.paused) onResume else onPause) { - Icon(if (r.paused) AppIcons.PlayFill else AppIcons.PauseFill, if (r.paused) "Resume" else "Pause", tint = Color.Black, modifier = Modifier.size(30.dp)) - } - Spacer(Modifier.weight(1f)) - HoldToEnd(onEnd) - } - Text( - "Hold the square to end", color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, - modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, - ) - // A little room under the controls, so they sit up off the screen's edge. - Spacer(Modifier.height(20.dp)) - } -} - -/** Two small dots for the live views, the current one white; tapping one goes there (the map keeps its own drags). */ -@Composable -private fun PageDots(current: Int, count: Int, onSelect: (Int) -> Unit) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { - repeat(count) { i -> - Box( - Modifier.size(28.dp).clip(CircleShape).pressClickable({ onSelect(i) }, CircleShape), - contentAlignment = Alignment.Center, - ) { - Box(Modifier.size(7.dp).clip(CircleShape).background(Color.White.copy(alpha = if (i == current) 1f else 0.3f))) - } - } - } -} - -/** - * "Km 3 in 5:42, avg 152" under the stats for ten seconds after each kilometre, with one tick. Kilometres done before - * the screen opened have had theirs. - */ -@Composable -private fun KmSplitLine(route: List, readings: List>, pauses: List) { - val splits = remember(route.size, pauses) { Route.splits(route, pauses) } - val now by produceState(System.currentTimeMillis() / 1000) { - while (true) { - delay(1000 - System.currentTimeMillis() % 1000) - value = System.currentTimeMillis() / 1000 - } - } - val line = WorkoutCopy.splitLine(splits, readings, route.firstOrNull()?.t, now) - val haptics = rememberHaptics() - var ticked by remember { mutableIntStateOf(WorkoutCopy.wholeKms(splits)) } - LaunchedEffect(line?.index) { - if (line != null && WorkoutCopy.splitTick(line, ticked)) { - haptics.perform(HapticEvent.KmSplit) - ticked = line.index - } - } - if (line != null) Text( - line.text, color = Color.White, style = MediumBody.copy(fontFeatureSettings = "tnum"), - modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, - ) -} - -/** Where the zone arc starts (lower left, clockwise from three o'clock) and how far it goes, in degrees. */ -private const val ARC_START = 150f -private const val ARC_SWEEP = 240f -private const val ARC_GAP = 8f - -/** - * The five zones as segments of an arc open at the bottom, only the current one lit, with a marker where [bpm] sits - * (pattern D2). The number goes in its middle. - */ -@Composable -private fun ZoneArc(bpm: Int?, maxHr: Int, modifier: Modifier) { - val zone = WorkoutRecorder.liveZone(bpm, maxHr) - Canvas(modifier) { - val stroke = 6.dp.toPx() - val halo = 9.dp.toPx() - val r = min(size.width, size.height) / 2 - halo - val topLeft = Offset(center.x - r, center.y - r) - val box = Size(r * 2, r * 2) - val seg = (ARC_SWEEP - ARC_GAP * (Workouts.ZONES - 1)) / Workouts.ZONES - for (z in 0 until Workouts.ZONES) { - val start = ARC_START + z * (seg + ARC_GAP) - val on = z == zone - // The lit segment glows: a wide soft stroke of itself under it. - if (on) drawArc(ZONE_COLORS[z].copy(alpha = 0.3f), start, seg, false, topLeft, box, style = Stroke(stroke * 2.6f, cap = StrokeCap.Round)) - drawArc(ZONE_COLORS[z].copy(alpha = if (on) 1f else 0.22f), start, seg, false, topLeft, box, style = Stroke(stroke, cap = StrokeCap.Round)) - } - if (bpm != null) { - val a = Math.toRadians((ARC_START + WorkoutRecorder.zonePosition(bpm, maxHr) * ARC_SWEEP).toDouble()) - val at = Offset(center.x + r * cos(a).toFloat(), center.y + r * sin(a).toFloat()) - val dot = zone?.let { ZONE_COLORS[it] } ?: Color.White - drawCircle(dot.copy(alpha = 0.3f), radius = halo, center = at) - drawCircle(Color.White, radius = 5.dp.toPx(), center = at) - } - } -} - -/** - * The live heart card: the beating number on its zone arc over the last ten minutes, then the zone, its range and the - * time in it. Its light reaches as high as the zone (spec 2.5); paused, it drops to an outline of the same layout - * (pattern D1). With [fill] it takes all the height it's given and the number grows, for the standing half of a fold. - */ -@Composable -private fun WorkoutHeartCard( - r: WorkoutState.Recording, - liveHeartRate: Int?, - recent: List>, - zoneSeconds: IntArray, - connected: Boolean, - active: Long, - modifier: Modifier, - fill: Boolean = false, - /** The live active kcal, for a kcal target's progress. */ - kcal: Float? = null, -) { - val bpm = liveHeartRate.takeIf { connected && !r.paused } - val zone = WorkoutRecorder.liveZone(bpm, r.maxHeartRate) - val color = workoutColor(r, liveHeartRate, connected) - val style = GlowStyle.chart(color).copy( - motion = if (bpm != null) GlowMotion.Heartbeat(bpm) else GlowMotion.Still, - lightLevel = WorkoutCopy.zoneLightLevel(zone), - outline = r.paused, - ) - GlowCard(style, modifier) { - Column(if (fill) Modifier.fillMaxSize() else Modifier) { - GlowHeader("Heart rate", trailing = bpm?.let { "${it * 100 / r.maxHeartRate}% of max" }) - BoxWithConstraints(Modifier.fillMaxWidth().then(if (fill) Modifier.weight(1f) else Modifier.height(220.dp))) { - val now = recent.lastOrNull()?.first ?: (System.currentTimeMillis() / 1000) - val values = remember(recent) { bucketRecent(recent, now) } - if (values.any { it != null }) { - GlowLineChart(values, Modifier.align(Alignment.BottomCenter).alpha(0.45f), height = 120.dp, showAverage = false) - } - ZoneArc(bpm, r.maxHeartRate, Modifier.matchParentSize()) - // Sized for three digits inside the arc, whatever the reading, so the number never spills past it - // and doesn't jump in size as the heart rate crosses 100. - val inside = (minOf(maxWidth, maxHeight) - 18.dp) * 0.78f - val scale = (inside / dotNumberWidth(3, DotSize.Live)).coerceAtMost(1f) - Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { - if (bpm != null) { - DotMatrixText(bpm.toString(), dot = DotSize.Live, scale = scale) - Spacer(Modifier.height(8.dp)) - Text("bpm, last 10 min behind", color = Color.White.copy(alpha = 0.75f), style = GlowType.secondary) - } else Text( - when { r.paused -> "Paused"; !connected -> "Waiting for the strap"; else -> "Waiting for heart rate" }, - color = Color.White, style = GlowType.sentence, - ) - } - } - Row(verticalAlignment = Alignment.Bottom) { - if (zone != null) { - // "Zone 3, aerobic": two text elements, the name a step quieter. - val (name, word) = WorkoutCopy.zoneTitle(zone) - Text(name, color = Color.White, style = GlowType.sentence) - Spacer(Modifier.width(6.dp)) - Text(word, color = Color.White.copy(alpha = 0.75f), style = GlowType.sentence, modifier = Modifier.weight(1f)) - Text(zoneRange(zone, r.maxHeartRate), color = Color.White.copy(alpha = 0.85f), style = GlowType.secondary) - } else Text(if (r.paused) "No zone while paused" else "No zone yet", color = Color.White, style = GlowType.sentence) - } - Spacer(Modifier.height(8.dp)) - Row { - zone?.let { Note(WorkoutCopy.zoneTime(zoneSeconds[it])) } - Spacer(Modifier.weight(1f)) - when (val t = WorkoutTarget.parse(r.workout.target)) { - is WorkoutTarget.Zones -> Note("Target ${t.label.removePrefix("Zone ")}, the strap buzzes if you drift") - is WorkoutTarget.Time -> Note("${workoutClock(active)} of ${t.label}") - is WorkoutTarget.Kcal -> Note(WorkoutCopy.kcalProgress(kcal, t)) - null -> Unit - } - } - } - } -} - -/** - * The standing half of a foldable half-open during a workout (on a table, or stood up like a book): the live heart - * card as big as the half allows, readable from a step away. The controls stay on the other half, under your hand. - */ -@Composable -fun GlowWorkoutGlance( - r: WorkoutState.Recording, - liveHeartRate: Int?, - recent: List>, - zoneSeconds: IntArray, - connection: ConnectionState, - modifier: Modifier = Modifier, - kcal: WorkoutRecorder.Kcal? = null, -) { - val connected = connection is ConnectionState.Connected - Box(modifier.background(Color.Black).statusBarsPadding().padding(horizontal = GlowLayout.Gutter, vertical = 12.dp)) { - WorkoutHeartCard(r, liveHeartRate, recent, zoneSeconds, connected, activeSeconds(r), Modifier.fillMaxSize(), fill = true, kcal = kcal?.active) - } -} - -/** - * Strength: the sets as dots against the target, and the one control that matters now: "Set done" during a set; - * during rest a countdown with "Start set N" and "+30 s"; otherwise "Start set N". - */ -@Composable -private fun SetsPanel(r: WorkoutState.Recording, color: Color, onStartSet: () -> Unit, onSetDone: () -> Unit, onExtend: () -> Unit) { - val now by produceState(System.currentTimeMillis() / 1000) { while (true) { value = System.currentTimeMillis() / 1000; delay(1000 - System.currentTimeMillis() % 1000) } } - val done = r.sets.count { it.end != null } - val target = r.workout.setsTarget - QuietPanel { - Row(verticalAlignment = Alignment.CenterVertically) { - Box(Modifier.weight(1f)) { - PanelTitle( - when { - r.inSet -> "Set ${r.sets.size}, ${workoutClock(now - r.sets.last().start)}" - r.resting -> "Resting after set $done, ${workoutClock((r.restUntil!! - now).coerceAtLeast(0))}" - done == 0 -> "Ready for set 1" - else -> "Rest over. Set ${done + 1} next." - }, - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) { - repeat(maxOf(target ?: 0, done + if (r.inSet) 1 else 0)) { i -> - Box(Modifier.size(10.dp).clip(CircleShape).background(if (i < done) color else if (i == done && r.inSet) color.copy(alpha = 0.5f) else Raised)) - } - } - } - r.restUntil?.let { until -> - val start = r.sets.lastOrNull()?.end ?: until - GlowBar(((now - start).toFloat() / (until - start).coerceAtLeast(1)).coerceIn(0f, 1f), color) - } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - if (r.inSet) Box(Modifier.weight(1f)) { WorkoutButton("Set done", Color.White, Color.Black, onSetDone, height = 52.dp) } - else Box(Modifier.weight(1f)) { WorkoutButton("Start set ${r.sets.size + 1}", Color.White, Color.Black, onStartSet, height = 52.dp) } - if (r.resting) Box(Modifier.width(96.dp)) { WorkoutButton("+30 s", Raised, Color.White, onExtend, height = 52.dp) } - } - if (target != null && done >= target && !r.inSet) Note("All $target sets done. Hold the square to end, or keep going.") - } -} - -/** "5.21" kilometres, or "820 m" under one. */ -internal fun km(meters: Float): String = if (meters < 1000) "${meters.toInt()} m" else String.format(java.util.Locale.ROOT, "%.2f", meters / 1000) - -/** - * The live route in zone colours with the heart rate on a chip in the zone's colour, so it's never hidden (pattern - * D5), then the GPS accuracy and "route only" (no tiles). - */ -@Composable -private fun LiveMapPage( - route: List, - history: List>, - maxHr: Int, - routeOnly: Boolean, - bpm: Int?, - color: Color, - paused: Boolean, - modifier: Modifier, - onRouteOnly: (Boolean) -> Unit, -) { - val stretches = remember(route.size, history.size) { Route.byZone(route, history, maxHr) } - // The whole page is the map; what its tiles reveal is said in the ready screen's (i). - Box(modifier.fillMaxWidth().clip(GlowLayout.CardShape).background(Raised)) { - // Where you are now, from the latest fix good enough to count, even while standing still. - val here = route.lastOrNull { it.accuracy <= Route.MAX_ACCURACY_M } - WorkoutMap(stretches, routeOnly, Modifier.fillMaxSize(), here) - BpmChip(bpm, color, paused, Modifier.align(Alignment.TopStart).padding(16.dp)) - Row(Modifier.align(Alignment.BottomStart).padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - val acc = route.lastOrNull()?.accuracy - Pill(if (acc == null || acc > 1000) "Finding GPS…" else "GPS ±${acc.toInt()} m", if (acc != null && acc <= Route.MAX_ACCURACY_M) TrendGood else TrendBad) - Pill(if (routeOnly) "Route only" else "Map", null, onClick = { onRouteOnly(!routeOnly) }) - } - } -} - -/** The live heart rate on the map: a pill in the zone's [color], a dot beating at it, the number in dots. */ -@Composable -private fun BpmChip(bpm: Int?, color: Color, paused: Boolean, modifier: Modifier) { - Row( - modifier.height(44.dp).clip(RoundedCornerShape(50)).background(color).padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (bpm != null) { - PulseDot(Color.Black, bpm, 8.dp) - Spacer(Modifier.width(10.dp)) - DotMatrixText("$bpm", dot = DotSize.Edge, color = Color.Black) - Spacer(Modifier.width(6.dp)) - Text("bpm", color = Color.Black, style = MediumBody) - } else Text(if (paused) "Paused" else "No heart rate yet", color = Color.Black, style = MediumBody) - } -} - -@Composable -private fun LiveStat(label: String, modifier: Modifier, value: @Composable () -> Unit) { - Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { - value() - Text(label, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary) - } -} - -/** - * One figure around the moving time. "1.25" alone could be anything: the distance's label carries the unit from a - * kilometre on. kcal is active kcal, in plain digits like the rest. - */ -@Composable -private fun SideStat( - figure: SideFigure, r: WorkoutState.Recording, stats: WorkoutRecorder.Stats?, kcal: WorkoutRecorder.Kcal?, - route: List, modifier: Modifier, -) { - val distance = r.workout.distanceMeters - val (label, value) = when (figure) { - SideFigure.Avg -> "Avg" to stats?.average?.toString() - SideFigure.Max -> "Max" to stats?.max?.toString() - SideFigure.Kcal -> "kcal" to kcal?.active?.toInt()?.toString() - SideFigure.Distance -> (if (r.workout.distanceFromSteps) "Distance ≈" else "Distance") + - (if (distance != null && distance >= 1000) " km" else "") to distance?.let(::km) - SideFigure.Pace -> "Pace /km" to Route.formatPace(Route.currentPace(route)) - } - val spoken = when (figure) { - SideFigure.Avg -> "Average heart rate" - SideFigure.Max -> "Peak heart rate" - SideFigure.Kcal -> "Active kcal" - SideFigure.Distance -> if (r.workout.distanceFromSteps) "Distance from steps" else "Distance" - SideFigure.Pace -> "Pace per kilometre" - } - val unit = if (figure == SideFigure.Distance && distance != null && distance >= 1000) " kilometres" else "" - LiveStat( - label, - modifier.semantics(mergeDescendants = true) { contentDescription = "$spoken ${value?.let { it + unit } ?: "waiting"}" }, - ) { LiveValue(value) } -} - -/** The thin rule between the live figures. */ -@Composable -private fun StatRule() = Box(Modifier.padding(vertical = 6.dp).width(1.dp).fillMaxHeight().background(Color.White.copy(alpha = 0.12f))) - -/** Avg and Max in the same face and size as the moving time beside them, or the dim dash at the same height. */ -@Composable -private fun LiveValue(text: String?) = - if (text == null) WaitingDash(Modifier.padding(vertical = 13.5.dp)) - else Text(text, color = Color.White, style = LiveFigure) - -private const val BUCKET_S = 5L -private const val WINDOW_S = 600L - -/** The last ten minutes in five-second averages, oldest first; gaps stay null and break the line. */ -private fun bucketRecent(recent: List>, now: Long): List { - val n = (WINDOW_S / BUCKET_S).toInt() - val sums = FloatArray(n) - val counts = IntArray(n) - val from = now - WINDOW_S + 1 - recent.forEach { (t, bpm) -> - val i = ((t - from) / BUCKET_S).toInt() - if (i in 0 until n) { sums[i] += bpm.toFloat(); counts[i]++ } - } - return List(n) { if (counts[it] == 0) null else sums[it] / counts[it] } -} - -/** - * A round button in the glow language: a small glow card lit from its rim in [color], its icon on the dark core. - * [onClick] null leaves the touch handling to [modifier] (the end square's hold). - */ -@Composable -private fun FlatButton(container: Color, size: Dp, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) { - // Flat, like the app's Start and Done: the live screen's card already carries the glow. - Box( - modifier.size(size).clip(CircleShape).background(container) - .then(if (onClick != null) Modifier.pressClickable(onClick, CircleShape) else Modifier), - contentAlignment = Alignment.Center, - ) { content() } -} - -/** "Looks like you've stopped": end where the wearer stopped, or keep going. The strap has buzzed already. */ -@Composable -private fun StoppedPrompt(r: WorkoutState.Recording, at: Long, onEnd: () -> Unit, onKeep: () -> Unit) { - QuietPanel { - PanelTitle(if (r.inCar) "Looks like you're in a car" else "Looks like you've stopped") - Note( - if (r.inCar) "You last moved at ${clock(at)}. End the ${Workouts.sportName(r.workout.sportType).lowercase()} there, so the drive isn't counted?" - else "Your heart rate has been back near resting since ${clock(at)}. End the ${Workouts.sportName(r.workout.sportType).lowercase()} at ${clock(at)}?", - ) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Box(Modifier.weight(1f)) { WorkoutButton("End at ${clock(at)}", Color.White, Color.Black, onEnd) } - Box(Modifier.weight(1f)) { WorkoutButton("Keep going", Raised, Color.White, onKeep) } - } - } -} - -/** The end square needs a one-second hold: a ring fills around it, and letting go early cancels it. */ -@Composable -private fun HoldToEnd(onEnd: () -> Unit) { - val progress = remember { Animatable(0f) } - val haptics = rememberHaptics() - Box(contentAlignment = Alignment.Center) { - FlatButton( - Raised, 64.dp, - Modifier.pointerInput(Unit) { - detectTapGestures(onPress = { - coroutineScope { - val hold = launch { - progress.animateTo(1f, tween(HOLD_MS, easing = LinearEasing)) - haptics.perform(HapticEvent.WorkoutEnd) - onEnd() - } - tryAwaitRelease() - if (progress.value < 1f) { - hold.cancel() - progress.animateTo(0f, tween(200)) - } - } - }) - }, - ) { - Icon(AppIcons.StopFill, "Hold to end the workout", tint = EndRed, modifier = Modifier.size(20.dp)) - } - Canvas(Modifier.size(64.dp).padding(2.dp)) { - if (progress.value > 0f) drawArc(EndRed, -90f, 360f * progress.value, false, style = Stroke(3.dp.toPx(), cap = StrokeCap.Round)) - } - } -} - -private val EndRed = MetricColors.heart -private const val HOLD_MS = 2000 - -/* ------------------------------------- Rest of the app meanwhile ------------------------------------- */ - -/** - * The workout tucked away: a bar above the tab bar ringed in the zone's colour, with a dot pulsing at the heart - * rate, the sport and time (and the active kcal after it when there's room), the zone, the bpm and Pause. Tap it to - * bring the live screen back. - */ -@Composable -fun WorkoutCapsule( - r: WorkoutState.Recording, - liveHeartRate: Int?, - connected: Boolean, - onClick: () -> Unit, - onPause: () -> Unit, - onResume: () -> Unit, - modifier: Modifier = Modifier, - kcal: WorkoutRecorder.Kcal? = null, -) { - val bpm = liveHeartRate.takeIf { connected && !r.paused } - val zone = WorkoutRecorder.liveZone(bpm, r.maxHeartRate) - val color = workoutColor(r, liveHeartRate, connected) - val active = activeSeconds(r) - val shape = RoundedCornerShape(50) - Row( - modifier.fillMaxWidth().height(64.dp).shadow(24.dp, shape, ambientColor = color, spotColor = color) - .clip(shape).background(Raised).border(BorderStroke(1.5.dp, color), shape) - .pressClickable(onClick, shape).padding(start = 18.dp, end = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - PulseDot(color, bpm, 12.dp) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - AfterIfRoom( - { Text("${Workouts.sportName(r.workout.sportType)}, ${workoutClock(active)}", color = Color.White, style = MediumBody, maxLines = 1) }, - WorkoutCopy.kcalText(kcal?.active)?.let { k -> - { Text(k, color = Color.White.copy(alpha = 0.6f), style = MediumBody.copy(fontFeatureSettings = "tnum"), maxLines = 1, modifier = Modifier.padding(start = 8.dp)) } - }, - ) - Text( - when { - r.endPrompt != null -> "Stopped? Tap to answer" - r.paused -> "Paused" - zone != null -> WorkoutCopy.zoneTitle(zone).let { (name, word) -> "$name $word" } - connected -> "Waiting for heart rate" - else -> "Strap reconnecting" - }, - color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1, - ) - } - if (bpm != null) { - DotMatrixText("$bpm", dot = DotSize.Edge, color = color) - Spacer(Modifier.width(12.dp)) - } - Box( - Modifier.size(44.dp).clip(CircleShape).background(color).pressClickable(if (r.paused) onResume else onPause, CircleShape), - contentAlignment = Alignment.Center, - ) { - Icon(if (r.paused) AppIcons.PlayFill else AppIcons.PauseFill, if (r.paused) "Resume" else "Pause", tint = Color.Black, modifier = Modifier.size(18.dp)) - } - } -} - -/** - * [first], then [after] beside it on the same line only if all of it fits in the width left; otherwise - * [after] is left out rather than cut short. - */ -@Composable -private fun AfterIfRoom(first: @Composable () -> Unit, after: (@Composable () -> Unit)?) { - androidx.compose.ui.layout.Layout(content = { first(); after?.invoke() }) { measurables, constraints -> - val loose = constraints.copy(minWidth = 0) - val a = measurables[0].measure(loose) - val b = measurables.getOrNull(1)?.measure(loose.copy(maxWidth = androidx.compose.ui.unit.Constraints.Infinity)) - ?.takeIf { a.width + it.width <= constraints.maxWidth } - val height = maxOf(a.height, b?.height ?: 0) - layout((a.width + (b?.width ?: 0)).coerceIn(constraints.minWidth, constraints.maxWidth), height) { - a.placeRelative(0, 0) - b?.placeRelative(a.width, 0) - } - } -} - -/** A dot in [color] that beats at [bpm]; still without one. */ -@Composable -fun PulseDot(color: Color, bpm: Int?, size: Dp) { - val beat = if (bpm != null) { - val period = (60_000 / bpm).coerceIn(250, 2000) - rememberInfiniteTransition(label = "beat").animateFloat( - 1f, 1f, - infiniteRepeatable(keyframes { durationMillis = period; 1.5f at period * 15 / 100; 1f at period * 40 / 100 }, RepeatMode.Restart), - label = "dot", - ).value - } else 1f - Canvas(Modifier.size(size).scale(beat)) { drawCircle(color) } -} - /* ---------------------------------------------- Summary ---------------------------------------------- */ -private fun clock(epoch: Long) = Instant.ofEpochSecond(epoch).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm")) - /** Done right after the end, and Delete workout behind a question. */ @Composable internal fun ColumnScope.SummaryActions(justEnded: Boolean, onDone: () -> Unit, onDelete: () -> Unit) { @@ -1533,15 +411,11 @@ internal fun ColumnScope.SummaryActions(justEnded: Boolean, onDone: () -> Unit, /** What deleting a phone workout does. */ internal const val PHONE_DELETE = "Its heart rate and times are removed from OpenStrap. The strap's own history is kept." -/** "Delete workout" in [DestructiveInk], asking first; [what] says what deleting it does. */ +/** "Delete this workout" as a warn coloured button with a trash icon, asking first; [what] says what deleting it does. */ @Composable internal fun DeleteWorkout(what: String, onDelete: () -> Unit, modifier: Modifier = Modifier) { - var confirmDelete by remember { mutableStateOf(false) } - Text( - "Delete this workout", color = DestructiveInk, style = GlowType.body, - modifier = modifier.clip(RoundedCornerShape(50)) - .pressClickable({ confirmDelete = true }, RoundedCornerShape(50)).padding(horizontal = 16.dp, vertical = 10.dp), - ) + var confirmDelete by rememberSaveable { mutableStateOf(false) } + PageButton("Delete this workout", { confirmDelete = true }, modifier.fillMaxWidth(), color = MetricColors.warn, icon = AppIcons.Trash) if (confirmDelete) { GlassAlertDialog( onDismissRequest = { confirmDelete = false }, @@ -1609,18 +483,14 @@ internal fun TrimPanel(summary: PhoneSummary, onSave: (Long, Long) -> Unit, onCa val a = summary.analysis val first = summary.recordedStart val last = summary.recordedEnd.coerceAtLeast(first + 60) - var from by remember(summary) { mutableStateOf(a.shownStart) } - var to by remember(summary) { mutableStateOf(a.end) } + // The handles outlive a rotation, a fold and process death while the panel stays open (audit K7). They start again + // only when the workout or its shown range changes. + var from by rememberSaveable(a.workout.start, a.shownStart, a.end) { mutableLongStateOf(a.shownStart) } + var to by rememberSaveable(a.workout.start, a.shownStart, a.end) { mutableLongStateOf(a.end) } val step = 10L val points = remember(summary) { val n = ((last - first) / step + 1).toInt().coerceAtLeast(2) - val sums = FloatArray(n) - val counts = IntArray(n) - summary.recorded.forEach { s -> - val i = ((s.timestamp - first) / step).toInt() - if (i in 0 until n) { sums[i] += s.bpm.toFloat(); counts[i]++ } - } - List(n) { if (counts[it] == 0) null else sums[it] / counts[it] } + org.openstrap.app.data.Summaries.bucketMeans(summary.recorded, n, { ((it.timestamp - first) / step).toInt() }, { it.bpm.toFloat() }) } QuietPanel { PanelTitle("Trim", trailing = SummaryCopy.trimHeader(clock(from), clock(to), to - from)) @@ -1696,58 +566,6 @@ internal fun TrimPanel(summary: PhoneSummary, onSave: (Long, Long) -> Unit, onCa } } -/** - * The live screen tucking away into its capsule above the tab bar, and back, in the app's screen motion: the screen - * drops a quarter of its height and fades while the capsule rises a little and fades in, and the reverse. [capsule] - * gets the modifier that places it; [capsuleBottom] is its distance from the bottom edge. - */ -@Composable -fun WorkoutMorph( - minimized: Boolean, - capsuleBottom: androidx.compose.ui.unit.Dp, - full: @Composable () -> Unit, - capsule: @Composable (Modifier) -> Unit, -) { - val swift = org.openstrap.app.ui.components.Motion.SWIFT - val move = androidx.compose.animation.core.tween(swift, easing = org.openstrap.app.ui.components.Motion.easing) - val fadeIn = androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(swift)) - val fadeOut = androidx.compose.animation.fadeOut(androidx.compose.animation.core.tween(org.openstrap.app.ui.components.Motion.QUICK)) - Box(Modifier.fillMaxSize()) { - androidx.compose.animation.AnimatedVisibility( - !minimized, Modifier.fillMaxSize(), - enter = androidx.compose.animation.slideInVertically(move) { it / 4 } + fadeIn, - exit = androidx.compose.animation.slideOutVertically(move) { it / 4 } + fadeOut, - ) { Box(Modifier.fillMaxSize().background(Color.Black)) { full() } } - androidx.compose.animation.AnimatedVisibility( - minimized, Modifier.align(Alignment.BottomCenter), - enter = androidx.compose.animation.slideInVertically(move) { it / 2 } + fadeIn, - exit = androidx.compose.animation.slideOutVertically(move) { it / 2 } + fadeOut, - ) { capsule(Modifier.widthIn(max = 440.dp).padding(start = 16.dp, end = 16.dp, bottom = capsuleBottom)) } - } -} - -/** - * Where Today's Start workout pill stands while a workout records, in the same white pill: running, the sport and its - * clock with a live dot, opening the live screen; paused, "Resume" with a play icon, resuming at once. - */ -@Composable -fun WorkoutShortcut(r: WorkoutState.Recording, liveHeartRate: Int?, onOpen: () -> Unit, onResume: () -> Unit) { - val shape = RoundedCornerShape(50) - val sport = Workouts.sportName(r.workout.sportType) - val clock = workoutClock(activeSeconds(r)) - Row( - Modifier.fillMaxWidth().height(52.dp).clip(shape).background(Color.White).pressClickable(if (r.paused) onResume else onOpen, shape), - verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, - ) { - if (r.paused) Icon(AppIcons.PlayFill, null, tint = Color.Black, modifier = Modifier.size(16.dp)) - else PulseDot(Color.Black, liveHeartRate, 10.dp) - Spacer(Modifier.width(10.dp)) - Text(if (r.paused) "Resume ${sport.lowercase()}" else sport, color = Color.Black, style = MediumBody) - Spacer(Modifier.width(8.dp)) - Text(clock, color = Color.Black, style = MediumBody.copy(fontFeatureSettings = "tnum")) - } -} - /** * A screen over the app that comes and goes like every other screen ([org.openstrap.app.ui.components.PushContent]): * in from the right a quarter of the width with a fade, out the same way, kept on screen until it has gone. [cut] diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutScreen.kt index c5997f30..9e038765 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutScreen.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutScreen.kt @@ -1,5 +1,6 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.TimeOfDay import androidx.activity.compose.BackHandler import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement @@ -24,14 +25,15 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.dp +import org.openstrap.app.data.Route import org.openstrap.app.data.WorkoutAnalysis import org.openstrap.app.data.WorkoutRecorder import org.openstrap.app.data.Workouts @@ -45,12 +47,19 @@ import org.openstrap.app.ui.theme.AppIcons import org.openstrap.app.ui.theme.MetricColors import org.openstrap.app.ui.workout.EffectFigures import org.openstrap.app.ui.workout.PhoneSummary +import org.openstrap.app.ui.workout.familyColor import org.openstrap.app.ui.workout.WorkoutDetailCopy import org.openstrap.app.ui.workout.WorkoutFact import java.time.Instant import java.time.LocalDate import java.time.ZoneId import kotlin.math.roundToInt +import org.openstrap.app.ui.glow.draw.LightAngle +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.glow.draw.chart +import org.openstrap.app.ui.glow.draw.Edge /* * The page of a past workout (spec 6.3, "Workout detail"), one for the strap's and the phone's: the sentence first on @@ -89,18 +98,20 @@ fun GlowWorkoutDetailScreen( onOpenDay: (() -> Unit)? = null, ) { BackHandler(onBack = onBack) - val shown = if (a?.fromPhone == true) summary?.analysis else a - val phone = summary?.takeIf { a?.fromPhone == true } + // A workout recorded on the phone opens as its summary did, per kind (workouts spec 2.8), with a back arrow. + if (a?.fromPhone == true && summary != null) { + PastSummary( + summary, workouts, contentPadding, onBack, onDelete, onTrim, onUndoTrim, dayActiveKcal, onOpenDay, + foundTag = !a.startedByYou, + ) + return + } + // A phone workout whose summary hasn't loaded yet shows only its header. + val shown = a?.takeIf { !it.fromPhone } val list = rememberLazyListState() - var info by remember { mutableStateOf(false) } + var info by rememberSaveable { mutableStateOf(false) } val usual = remember(workouts) { Workouts.usualLoad(workouts.map { it.workout.trainingLoad }) } - // Each kind keeps the zone figures its page always had: a phone workout's live zones, the strap's from its stream. - val zones = remember(shown) { - shown?.let { s -> - if (s.fromPhone) WorkoutRecorder.liveZoneSeconds(s.heartRate.filter { it.timestamp <= s.end }.map { it.timestamp to it.bpm }, s.maxHeartRate).toList() - else s.zoneSeconds - }.orEmpty() - } + val zones = shown?.zoneSeconds.orEmpty() CompositionLocalProvider(LocalGlowPaused provides { list.isScrollInProgress }) { ScreenList(contentPadding, list, gutter = GlowLayout.Gutter) { item { @@ -116,16 +127,16 @@ fun GlowWorkoutDetailScreen( val activeKcal = Workouts.activeKcal(shown, bmrPerMinute) val facts = WorkoutDetailCopy.facts( shown.distanceMeters, shown.distanceFromSteps, - if (shown.fromPhone) (shown.durationSeconds - shown.activeSeconds).coerceAtLeast(0) else 0L, w.steps, w.calories, - activeKcal = activeKcal.takeIf { shown.fromPhone || bmrPerMinute != null }, + 0L, w.steps, w.calories, + activeKcal = activeKcal.takeIf { bmrPerMinute != null }, ) + listOfNotNull(WorkoutDetailCopy.targetFact(shown.target, shown.targetReachedSeconds, shown.activeKcal?.roundToInt())) - val factsShown = facts.isNotEmpty() || (phone?.route?.size ?: 0) >= 2 - if (factsShown) item { FactsList(facts, phone) } + val factsShown = facts.isNotEmpty() + if (factsShown) item { FactsList(facts, null) } // The workout's share of its day, right under its kcal (calories spec 4.2). val today = LocalDate.now() val inDay = activeKcal?.let { k -> val date = Instant.ofEpochSecond(shown.shownStart).atZone(ZoneId.systemDefault()).toLocalDate() - CaloriesCopy.inYourDay(WorkoutDetailCopy.sport(w.sportType), k, dayActiveKcal?.roundToInt(), CaloriesCopy.dayWord(date, today), shown.fromPhone) + CaloriesCopy.inYourDay(WorkoutDetailCopy.sport(w.sportType), k, dayActiveKcal?.roundToInt(), CaloriesCopy.dayWord(date, today), false) } if (inDay != null) item { InYourDayCard(inDay, onOpenDay) } val drop = WorkoutDetailCopy.dropLine(shown.recovery60, shown.recoveryIsMeaningful, usualDrop) @@ -136,27 +147,97 @@ fun GlowWorkoutDetailScreen( val zonesShown = zones.sum() > 0 val factsRightAbove = factsShown && inDay == null && !loadShown val zonesOnEdge = zonesShown && factsRightAbove - val listAbove = if (zonesShown) !zonesOnEdge else factsRightAbove if (zonesShown) item { CompositionLocalProvider(LocalEdgeOverride provides if (zonesOnEdge) EdgeOverride(edge = Edge.Bottom) else EdgeOverride()) { ZonesList(zones.toIntArray(), shown.maxHeartRate) } } - if (phone != null) item { - CompositionLocalProvider(LocalEdgeOverride provides if (listAbove) EdgeOverride(edge = Edge.Bottom) else EdgeOverride()) { - SplitsList(phone, liveRoute = emptyList()) - } + item { DeleteWorkout(STRAP_DELETE, onDelete) } + } + } + if (info && shown != null) EffectInfo(shown, usual, emptyList(), false, onDismiss = { info = false }) +} + +/** + * A past phone workout (workouts spec 2.8, past): the summary's hero and the kind's own section, then everything this + * page always had: the heart rate line with the drop after the end, the facts and the map, In your day, Load with its + * (i), the zones and splits where the kind's section doesn't show them, Trim and Delete. No Done and nothing earned: + * that was said when it ended. + */ +@Composable +private fun PastSummary( + summary: PhoneSummary, + workouts: List, + contentPadding: PaddingValues, + onBack: () -> Unit, + onDelete: () -> Unit, + onTrim: (from: Long, to: Long) -> Unit, + onUndoTrim: () -> Unit, + dayActiveKcal: Float?, + onOpenDay: (() -> Unit)?, + foundTag: Boolean, +) { + val a = summary.analysis + val w = a.workout + val list = rememberLazyListState() + var info by rememberSaveable { mutableStateOf(false) } + val others = remember(workouts, w.start) { workouts.filter { it.workout.start != w.start }.sortedByDescending { it.workout.start } } + val usual = remember(others) { Workouts.usualLoad(others.map { it.workout.trainingLoad }) } + val family = org.openstrap.app.ui.workout.FamilySummary.familyOf(w.sportType, summary.structure) + val weekStarts = remember(workouts) { workouts.filter { it.startedByYou }.map { it.shownStart } } + CompositionLocalProvider(LocalGlowPaused provides { list.isScrollInProgress }) { + ScreenList(contentPadding, list, gutter = GlowLayout.Gutter) { + item { + PageHeader( + a.sportName, onBack, subtitle = WorkoutDetailCopy.day(a.shownStart, LocalDate.now()) + ", ${clock(a.shownStart)} to ${clock(a.end)}", + trailing = if (foundTag) ({ FoundTag() }) else null, + ) } - if (phone != null) item { TrimSection(phone, onTrim, onUndoTrim) } item { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { - if (phone != null) SummaryActions(justEnded = false, onDone = onBack, onDelete = onDelete) - else DeleteWorkout(STRAP_DELETE, onDelete, Modifier.align(Alignment.CenterHorizontally)) + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + val zones = remember(a) { WorkoutRecorder.liveZoneSeconds(a.heartRate.filter { it.timestamp <= a.end }.map { it.timestamp to it.bpm }, a.maxHeartRate) } + // The page's sentence ("An easy 40 min walk") where the kind has none of its own. + val hero = summaryHero(family, summary, usual).let { h -> + if (h.sentence != null) h + else SummaryHero(h.value, h.unit, h.figures, WorkoutDetailCopy.sentence(w.sportType, a.activeSeconds, w.trainingLoad, usual, zones.toList()), h.line) + } + SummaryHeroCard(hero, familyColor(family), Workouts.effort(w.trainingLoad, usual)) + val sectionOnEdge = FamilySection(family, summary, emptyList(), others, weekStarts, heartLine = false) + // The heart rate card is lit from the left; under the section's own edge card it takes the bottom. + if (a.heartRate.isNotEmpty() || w.averageHeartRate != null) { + CompositionLocalProvider(LocalEdgeOverride provides if (sectionOnEdge) EdgeOverride(edge = Edge.Bottom) else EdgeOverride()) { + HeartRateCard(a) + } + } + val facts = WorkoutDetailCopy.facts( + a.distanceMeters, a.distanceFromSteps, (a.durationSeconds - a.activeSeconds).coerceAtLeast(0), w.steps, w.calories, + activeKcal = Workouts.activeKcal(a, null), + ) + listOfNotNull(WorkoutDetailCopy.targetFact(a.target, a.targetReachedSeconds, a.activeKcal?.roundToInt())) + // The map is in the route card for a run; any other kind with a kept route has it here. + val map = family != Workouts.Family.Distance && Route.mappable(summary.route) + if (facts.isNotEmpty() || map) FactsList(facts, summary.takeIf { map }) + val inDay = Workouts.activeKcal(a, null)?.let { k -> + val date = Instant.ofEpochSecond(a.shownStart).atZone(ZoneId.systemDefault()).toLocalDate() + CaloriesCopy.inYourDay(WorkoutDetailCopy.sport(w.sportType), k, dayActiveKcal?.roundToInt(), CaloriesCopy.dayWord(date, LocalDate.now()), true) + } + if (inDay != null) InYourDayCard(inDay, onOpenDay) + val drop = WorkoutDetailCopy.dropLine(a.recovery60, a.recoveryIsMeaningful, null) + if (w.trainingLoad != null || (a.recoveryMinutes ?: 0) > 0 || drop != null) LoadCard(a, usual, best = null, drop = drop, onInfo = { info = true }) + // Zones and splits for the kinds whose section doesn't already show them. + val zonesShown = zones.sum() > 0 && family !in setOf(Workouts.Family.IndoorCardio, Workouts.Family.Games) + if (zonesShown) ZoneStripPanel(zones) + if (family != Workouts.Family.Distance && family != Workouts.Family.Strength) { + CompositionLocalProvider(LocalEdgeOverride provides if (zonesShown) EdgeOverride(edge = Edge.Bottom) else EdgeOverride()) { + SplitsList(summary, liveRoute = emptyList()) + } + } + TrimSection(summary, onTrim, onUndoTrim) + SummaryActions(justEnded = false, onDone = onBack, onDelete = onDelete) } } } } - if (info && shown != null) EffectInfo(shown, usual, phone?.strapWorkouts.orEmpty(), phone?.effectFromStrap == true, onDismiss = { info = false }) + if (info) EffectInfo(a, usual, summary.strapWorkouts, summary.effectFromStrap, onDismiss = { info = false }) } /** @@ -169,9 +250,9 @@ private fun WorkoutHero(a: WorkoutAnalysis, usual: Float?, zones: List) { val style = GlowStyle.chart(MetricColors.heart).copy(lightAngle = LightAngle.Below, lightLevel = Workouts.effort(w.trainingLoad, usual)) GlowCard(style, Modifier.fillMaxWidth()) { Column { - GlowHeader(WorkoutDetailCopy.day(a.shownStart, LocalDate.now()), trailing = "${clockAt(a.shownStart)}–${clockAt(a.end)}") + GlowHeader(WorkoutDetailCopy.day(a.shownStart, LocalDate.now()), trailing = "${clock(a.shownStart)}–${clock(a.end)}") HeroSentence(WorkoutDetailCopy.sentence(w.sportType, a.activeSeconds, w.trainingLoad, usual, zones)) - DotMatrixText(workoutClock(a.activeSeconds), Modifier.padding(top = 16.dp), dot = DotSize.Hero) + DotMatrixText(dotClock(a.activeSeconds), Modifier.padding(top = 16.dp), dot = DotSize.Hero) } } } @@ -188,20 +269,16 @@ private fun HeartRateCard(a: WorkoutAnalysis) { val last = (a.heartRate.maxOfOrNull { it.timestamp } ?: a.end).coerceIn(a.end, a.end + Workouts.RECOVERY_TAIL_S) val values = remember(a) { val n = ((last - first) / HeartStep + 1).toInt().coerceAtLeast(2) - val sums = FloatArray(n) - val counts = IntArray(n) - a.heartRate.forEach { s -> - val i = ((s.timestamp - first) / HeartStep).toInt() - if (i in 0 until n) { sums[i] += s.bpm.toFloat(); counts[i]++ } - } - List(n) { if (counts[it] == 0) null else sums[it] / counts[it] } + org.openstrap.app.data.Summaries.bucketMeans(a.heartRate, n, { ((it.timestamp - first) / HeartStep).toInt() }, { it.bpm.toFloat() }) } var scrubbed by remember { mutableStateOf(null) } val at = scrubbed?.let { i -> values.getOrNull(i)?.let { v -> first + i * HeartStep to v.roundToInt() } } EdgeCard(MetricColors.heart, Modifier.fillMaxWidth(), kind = EdgeKind.Earlier) { EdgeTitle("Heart rate", if (a.heartRate.isNotEmpty()) "Coloured by zone" else null) if (at != null) { - EdgeValue(at.second.toString(), "bpm", if (at.first > a.end) "At ${clockAt(at.first)}, after the end" else "At ${clockAt(at.first)}") + EdgeValue( + at.second.toString(), "bpm", if (at.first > a.end) "At ${clock(at.first)}, after the end" else "At ${clock(at.first)}", + ) } else { val range = listOfNotNull(w.maxHeartRate?.let { "Max $it" }, w.minHeartRate?.let { "lowest $it" }).joinToString(", ") EdgeValue(w.averageHeartRate?.toString(), "bpm average", range.ifEmpty { null }?.let { "$it bpm" }, waiting = false) @@ -215,12 +292,13 @@ private fun HeartRateCard(a: WorkoutAnalysis) { } ZoneHeartLine(values, first, a.end, a.maxHeartRate, a.historyRanges, scrubbed, { scrubbed = it }, Modifier.padding(top = 16.dp)) Row(Modifier.fillMaxWidth().padding(top = 6.dp)) { - Note(clockAt(first)) + Note(clock(first)) Spacer(Modifier.weight(1f)) - Note(if (last > a.end + HeartStep) "Drop after ${clockAt(a.end)}" else clockAt(a.end)) + Note(if (last > a.end + HeartStep) "Drop after ${clock(a.end)}" else clock(a.end)) } if (a.historyRanges.isNotEmpty()) Text( - "Dimmed: " + a.historyRanges.joinToString(", ") { "${clockAt(it.first)}–${clockAt(it.last + 1)}" } + " came from the strap's history.", + "Dimmed: " + a.historyRanges.joinToString(", ") { "${clock(it.first)}–${clock(it.last + 1)}" } + + " came from the strap's history.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 8.dp), ) } @@ -291,18 +369,17 @@ private fun InYourDayCard(text: String, onOpen: (() -> Unit)?) { private fun FactsList(facts: List, summary: PhoneSummary?) { ListPanel(title = "The workout") { facts.forEachIndexed { i, f -> - val color = when (f.name) { - "Paused" -> PausedColor - "Calories", "Target" -> MetricColors.calories + val color = when (f.kind) { + WorkoutFact.Kind.Paused -> PausedColor + WorkoutFact.Kind.Calories, WorkoutFact.Kind.Target -> MetricColors.calories else -> MetricColors.steps } - val icon = when (f.name) { - "Distance" -> Score.Distance.rowIcon() - "Paused" -> AppIcons.Hourglass - "Steps" -> Metric.Steps.rowIcon() - "Calories" -> Score.Energy.rowIcon() - "Target" -> AppIcons.Target - else -> null + val icon = when (f.kind) { + WorkoutFact.Kind.Distance -> Score.Distance.rowIcon() + WorkoutFact.Kind.Paused -> AppIcons.Hourglass + WorkoutFact.Kind.Steps -> Metric.Steps.rowIcon() + WorkoutFact.Kind.Calories -> Score.Energy.rowIcon() + WorkoutFact.Kind.Target -> AppIcons.Target } ListRow(f.name, color, f.value, f.status, divider = i > 0, icon = icon) } @@ -321,7 +398,7 @@ private fun EffectInfo(a: WorkoutAnalysis, usual: Float?, strap: List EffectFigures( s.workout.aerobicTrainingEffect, s.workout.anaerobicTrainingEffect, s.workout.trainingLoad, s.recoveryMinutes, - from = Instant.ofEpochSecond(s.workout.start).atZone(ZoneId.systemDefault()).toLocalTime().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")), + from = TimeOfDay.format(s.workout.start, ZoneId.systemDefault()), ) }, effectFromStrap = fromStrap, ownRecoveryMinutes = a.ownRecoveryMinutes, sportType = w.sportType, diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutSummary.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutSummary.kt index 489a9e98..5826b8b6 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutSummary.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowWorkoutSummary.kt @@ -1,5 +1,6 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.Units import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween @@ -15,6 +16,7 @@ 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.layout.widthIn import org.openstrap.app.ui.dashboard.Info import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,10 +37,16 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.text.font.FontWeight +import org.openstrap.app.ui.workout.SplitRow +import org.openstrap.app.ui.workout.familyColor +import org.openstrap.app.ui.components.pressClickable import kotlinx.coroutines.delay import org.openstrap.app.data.Route import org.openstrap.app.data.WorkoutAnalysis -import org.openstrap.app.data.WorkoutRecorder import org.openstrap.app.data.Workouts import org.openstrap.app.device.CalmDown import org.openstrap.app.device.WorkoutSession @@ -49,20 +57,32 @@ import org.openstrap.app.ui.dashboard.Score import org.openstrap.app.ui.dashboard.TodayCopy import org.openstrap.app.ui.theme.AppIcons import org.openstrap.app.ui.theme.MetricColors -import org.openstrap.app.ui.workout.BestCandidate import org.openstrap.app.ui.workout.CalmDownLine +import org.openstrap.app.presentation.EarnedLine +import org.openstrap.app.presentation.SummaryRewards +import org.openstrap.app.ui.workout.FamilySummary import org.openstrap.app.ui.workout.PhoneSummary import org.openstrap.app.ui.workout.SummaryCopy import org.openstrap.app.ui.workout.ZONE_COLORS import org.openstrap.app.ui.workout.ZONE_NAMES +import org.openstrap.app.ui.glow.draw.LightAngle +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.EdgeStatus +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.glow.draw.chart +import org.openstrap.app.ui.glow.draw.Edge /* - * The summary right after a workout (spec 6.3, "Right after"): it leads with the calm down, the heart rate drop drawing - * as it happens on a toned-down glow, then the workout, its Load against your usual, time in zones and the splits. A - * personal best is one line on the Load card and a short swell of the glow, no modal. Past workouts open their own - * page ([GlowWorkoutDetailScreen]), which shares the Load card and the lists. + * The summary right after a workout (workouts spec 2.8): the sport and when, a hero with the one number that matters for + * that kind of workout and three figures, then its own section ([FamilySection]), then what it earned. Under them the + * heart rate drop, drawing as it happens, and Load against your usual. A personal best ticks and swells the hero's + * glow once, no modal. Past workouts open their own page ([GlowWorkoutDetailScreen]), which shares the Load card and + * the lists. */ +/** Where Done has got to: the summary, a personal best's card, the goal moment. */ +private enum class SummaryStep { Summary, Best, Moment } + /** How much higher the hero's light reaches at the top of a personal best's swell. */ private const val SwellLevel = 0.25f @@ -73,7 +93,7 @@ private const val BeforeS = 60L * The summary right after [summary]'s workout ended. [calmDown] is the heart rate the strap sent since, for this * workout or another (then ignored), null when nothing is live (a debug preview). [liveRoute] is the route still in * memory, so a GPS workout whose route wasn't kept still gets its splits. [workouts] are all of them, for your usual - * Load and a personal best; [usualDrop] your usual heart rate drop. + * Load, the last time and this week; [usualDrop] your usual heart rate drop. */ @Composable fun GlowWorkoutSummaryScreen( @@ -87,20 +107,68 @@ fun GlowWorkoutSummaryScreen( onDelete: () -> Unit, onTrim: (from: Long, to: Long) -> Unit, onUndoTrim: () -> Unit, + /** What it earned ([RewardsCopy.summary]), null while it's worked out. */ + rewards: SummaryRewards? = null, + celebrations: Celebrations? = null, ) { - BackHandler(onBack = onDone) val a = summary?.analysis + // Done goes through the celebrations it earned (workouts spec 3.4): a personal best's card, then the goal moment + // once a week, then away. + var step by rememberSaveable { mutableStateOf(SummaryStep.Summary) } + var settings by rememberSaveable { mutableStateOf(false) } + val moment = rewards != null && celebrations != null && rewards.goalMet && celebrations.goalMoment && celebrations.momentWeek != rewards.weekStart + fun next(from: SummaryStep) { + step = when { + from < SummaryStep.Best && rewards?.best != null -> SummaryStep.Best + from < SummaryStep.Moment && moment -> SummaryStep.Moment.also { celebrations.markMoment(rewards.weekStart) } + else -> { onDone(); return } + } + } + val done = { next(step) } + if (step == SummaryStep.Best && rewards?.best != null && a != null) { + BestCardScreen(rewards.best, a.sportName, contentPadding, onContinue = done) + return + } + if (step == SummaryStep.Moment && rewards != null) { + val starts = remember(workouts) { workouts.filter { it.startedByYou }.map { it.shownStart } } + GoalMomentScreen(rewards.week.count, rewards.week.goal, rewards.inARow, starts, contentPadding, onSettings = { settings = true }, onContinue = done) + if (settings && celebrations != null) CelebrationsSheet( + celebrations.goalMoment, celebrations.weeklyRecap, celebrations.setGoalMoment, celebrations.setWeeklyRecap, onDismiss = { settings = false }, + ) + return + } + BackHandler(onBack = done) var info by rememberSaveable { mutableStateOf(false) } - // Done stays on the bottom edge, Delete as quiet text under it; nothing to delete until the workout has loaded. + val hero = remember(summary, workouts) { + if (summary == null || a == null) null + else summaryHero(FamilySummary.familyOf(a.workout.sportType, summary.structure), summary, Workouts.usualLoad(workouts.filter { it.workout.start != a.workout.start }.map { it.workout.trainingLoad })) + } + // Share opens the share screen (workouts spec 2.9) in the summary's place; close comes back here. + var sharing by rememberSaveable { mutableStateOf(false) } + if (sharing && summary != null && hero != null) { + WorkoutShareScreen(summary, hero, rewards?.earned.orEmpty(), liveRoute, workouts, contentPadding, onClose = { sharing = false }) + return + } + // Share and Done stay on the bottom edge; Delete waits at the end of the page. Nothing to share until the workout + // has loaded. WorkoutPage( Color.Transparent, contentPadding, - pinned = { if (a != null) SummaryActions(justEnded = true, onDone = onDone, onDelete = onDelete) else WorkoutButton("Done", Color.White, Color.Black, onDone) }, + pinned = { + if (a != null && hero != null) SummaryButtons(onShare = { sharing = true }, onDone = done) + else WorkoutButton("Done", Color.White, Color.Black, onDone) + }, ) { - // The page title, "Run saved": the sport's short name (spec 6.3). - Text( - SummaryCopy.savedTitle(a?.workout?.sportType), color = Color.White, style = GlowType.pageTitle, maxLines = 1, - modifier = Modifier.padding(start = 4.dp, top = 12.dp), - ) + // The sport, then when, with Done beside them (board SummaryRun). + Row(Modifier.padding(start = 4.dp, top = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(a?.sportName ?: "Workout", color = Color.White, style = GlowType.pageTitle, maxLines = 1) + if (a != null) Text( + FamilySummary.whenLine(a.shownStart, System.currentTimeMillis() / 1000), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + style = GlowType.secondary, modifier = Modifier.padding(top = 2.dp), + ) + } + TopDone(done) + } if (summary == null || a == null) { Note("Loading…") return@WorkoutPage @@ -108,20 +176,15 @@ fun GlowWorkoutSummaryScreen( val w = a.workout val others = remember(workouts, w.start) { workouts.filter { it.workout.start != w.start }.sortedByDescending { it.workout.start } } val usualLoad = remember(others) { Workouts.usualLoad(others.map { it.workout.trainingLoad }) } - val best = remember(a, others) { - fun of(x: WorkoutAnalysis) = BestCandidate( - x.workout.start, x.workout.sportType, x.activeSeconds, - x.distanceMeters?.takeIf { !x.distanceFromSteps }, - ) - SummaryCopy.personalBest(of(a), others.map(::of)) - } + val earned = rewards?.earned.orEmpty() // A personal best ticks once and swells the hero's glow once; with animations off, only the tick. val haptics = rememberHaptics() val animationsOn = rememberAnimationsOn() val swell = remember { Animatable(0f) } var swelled by rememberSaveable(w.start) { mutableStateOf(false) } - LaunchedEffect(best) { - if (best == null || swelled) return@LaunchedEffect + val anyBest = earned.any { it.kind == EarnedLine.Kind.Best } + LaunchedEffect(anyBest) { + if (!anyBest || swelled) return@LaunchedEffect swelled = true haptics.perform(HapticEvent.PersonalBest) if (animationsOn) { @@ -129,16 +192,23 @@ fun GlowWorkoutSummaryScreen( swell.animateTo(0f, tween(900)) } } - CalmDownHero(summary, calmDown?.takeIf { it.start == w.start }, usualDrop, Workouts.effort(w.trainingLoad, usualLoad), swell.value) { info = true } - WorkoutCard(summary) - LoadCard(a, usualLoad, best) - val zones = remember(a) { WorkoutRecorder.liveZoneSeconds(a.heartRate.filter { it.timestamp <= a.end }.map { it.timestamp to it.bpm }, a.maxHeartRate) } - if (zones.sum() > 0) ZonesList(zones, a.maxHeartRate) - // Right under the zones it would look the same, so it sits on a neutral edge card instead (spec 2.4). - CompositionLocalProvider(LocalEdgeOverride provides if (zones.sum() > 0) EdgeOverride(edge = Edge.Bottom) else EdgeOverride()) { - SplitsList(summary, liveRoute) + val family = FamilySummary.familyOf(w.sportType, summary.structure) + val effort = Workouts.effort(w.trainingLoad, usualLoad) + SummaryHeroCard(hero ?: summaryHero(family, summary, usualLoad), familyColor(family), effort) { swell.value } + val weekStarts = remember(workouts) { workouts.filter { it.startedByYou }.map { it.shownStart } } + val sectionOnEdge = FamilySection(family, summary, liveRoute, others, weekStarts, week = rewards?.week) + EarnedChips(earned) + // The drop only with heart rate to show: recorded in the workout, or arriving live after it. + val live = calmDown?.takeIf { it.start == w.start } + val dropShown = summary.recorded.isNotEmpty() || !live?.readings.isNullOrEmpty() + if (dropShown) CalmDownHero(summary, live, usualDrop, effort, 0f) { info = true } + // Load is lit from the left: what comes before it (the hero, the drop) is lit from below, so two cards in a row + // never look the same. Under the section's own edge card, lit from the left or right, Load takes the bottom. + CompositionLocalProvider(LocalEdgeOverride provides EdgeOverride(edge = if (sectionOnEdge && !dropShown) Edge.Bottom else Edge.Left)) { + LoadCard(a, usualLoad, best = null) } TrimSection(summary, onTrim, onUndoTrim) + DeleteWorkout(PHONE_DELETE, onDelete) } if (info) InfoSheet( Info("Heart rate drop", SummaryCopy.DROP_MEANING, emptyList(), "", "", ""), MetricColors.heart, null, null, @@ -146,6 +216,30 @@ fun GlowWorkoutSummaryScreen( ) } +/** + * The summary's hero (workouts spec 2.8): a glow in the workout's [color] lit from below as high as its [effort], the + * one number in dots, then a sentence or three figures in text. [swell] lifts the light for a personal best. + */ +@Composable +internal fun SummaryHeroCard(hero: SummaryHero, color: Color, effort: Float, swell: () -> Float = { 0f }) { + // The swell is drawn over the card's own light ([GlowSwell]), so the light at rest stays the cache key and a best's + // swell neither composes the hero again nor renders a new glow each frame. + val style = GlowStyle.chart(color).copy(lightAngle = LightAngle.Below, lightLevel = effort.coerceAtMost(1f)) + val lift = remember(effort, swell) { GlowSwell((effort + SwellLevel).coerceAtMost(1f), swell) } + GlowCard(style, Modifier.fillMaxWidth(), swell = lift) { + Column { + Row(verticalAlignment = Alignment.Bottom) { + DotMatrixText(hero.value, dot = DotSize.Hero) + // The word after a number (spec 2.3). + Text(hero.unit, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) + } + hero.sentence?.let { HeroSentence(it) } + hero.line?.let { Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 6.dp)) } + if (hero.figures.isNotEmpty()) SummaryFigures(hero.figures, Modifier.padding(top = 18.dp)) + } + } +} + /** * The calm down, the hero right after a workout (spec 6.3): a level glow in heart red lit from below as high as the * workout's [effort] (spec 2.1, Levels), "Heart rate drop" with its (i) ([onInfo]), what the drop says @@ -174,7 +268,7 @@ private fun CalmDownHero(summary: PhoneSummary, live: CalmDown?, usualDrop: Int? GlowCard(style, Modifier.fillMaxWidth()) { Column { GlowHeader( - "Heart rate drop", trailing = "Since ${clockAt(a.end)}", + "Heart rate drop", trailing = "Since ${clock(a.end)}", trailingContent = { Spacer(Modifier.width(10.dp)); RoundButton(AppIcons.Info, "About the heart rate drop", onInfo, size = 32.dp) }, ) HeroSentence(line.text) @@ -228,34 +322,10 @@ private fun DropLine(readings: List>, end: Long, modifier: Modif } } -/** The workout: active time, distance and the average, its calories and kcal target, and the map when the route was kept. Lit from the left, about earlier. */ -@Composable -private fun WorkoutCard(summary: PhoneSummary) { - val a = summary.analysis - val w = a.workout - val active = a.activeSeconds - EdgeCard(MetricColors.heart, Modifier.fillMaxWidth(), kind = EdgeKind.Earlier) { - EdgeTitle(a.sportName, "${clockAt(a.shownStart)}–${clockAt(a.end)}") - EdgeValue( - workoutClock(active), null, - SummaryCopy.workoutLine(summary.distanceMeters, summary.distanceFromSteps, w.averageHeartRate, (a.durationSeconds - active).coerceAtLeast(0)), - ) - // Its calories, active first, under the line (calories spec 4.2). - SummaryCopy.kcalLine(a.activeKcal?.let(Math::round), w.calories)?.let { - Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 4.dp)) - } - // A kcal target says whether it was reached, and when (calories spec 4.2). - SummaryCopy.targetLine(a.target, a.targetReachedSeconds, a.activeKcal?.let(Math::round))?.let { - Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(top = 4.dp)) - } - RouteMap(summary, Modifier.padding(top = 14.dp)) - } -} - -/** The kept route in its zones' colours; nothing without a route. */ +/** The kept route in its zones' colours; nothing without a route that goes somewhere. */ @Composable internal fun RouteMap(summary: PhoneSummary, modifier: Modifier = Modifier) { - if (summary.route.size < 2) return + if (!Route.mappable(summary.route)) return val stretches = remember(summary) { Route.byZone(summary.route, summary.history, summary.analysis.maxHeartRate) } Box(modifier.fillMaxWidth().height(170.dp).clip(GlowLayout.TileShape).background(Raised)) { WorkoutMap(stretches, routeOnly = false, Modifier.fillMaxSize()) @@ -298,8 +368,8 @@ internal fun LoadCard(a: WorkoutAnalysis, usual: Float?, best: String?, drop: St /** Time in zones: the zone bar, then each zone with time in it, hardest first. */ @Composable -internal fun ZonesList(zones: IntArray, maxHr: Int) { - ListPanel(title = "Time in zones", note = "Max $maxHr bpm") { +internal fun ZonesList(zones: IntArray, maxHr: Int, note: String = "Max $maxHr bpm") { + ListPanel(title = "Time in zones", note = note) { ZoneBar(zones.toList(), ZONE_COLORS, Modifier.padding(top = 2.dp, bottom = 6.dp)) var first = true for (z in Workouts.ZONES - 1 downTo 0) { @@ -316,10 +386,11 @@ internal fun ZonesList(zones: IntArray, maxHr: Int) { /** * Splits for a GPS workout (each kilometre's time and average heart rate), with the laps under them when any were - * marked; laps alone without GPS; sets for strength. Nothing without any of them. + * marked; laps alone without GPS; sets for strength. Nothing without any of them. With [markFastest] the fastest + * kilometre is named in the note and its row lit (workouts spec 2.8). Returns whether it showed anything. */ @Composable -internal fun SplitsList(summary: PhoneSummary, liveRoute: List) { +internal fun SplitsList(summary: PhoneSummary, liveRoute: List, markFastest: Boolean = false): Boolean { val a = summary.analysis val maxHr = a.maxHeartRate val route = summary.route.ifEmpty { liveRoute.filter { it.t in a.shownStart..a.end } } @@ -330,12 +401,21 @@ internal fun SplitsList(summary: PhoneSummary, liveRoute: List) { val sets = summary.sets.filter { it.end != null } // Each row's heart takes the zone its average heart rate sat in. fun dot(bpm: Int?) = bpm?.let { zoneColor(it, maxHr) } ?: EdgeNeutral + val fastest = if (markFastest) FamilySummary.fastestSplit(rows) else null when { rows.isNotEmpty() || laps.isNotEmpty() -> ListPanel( title = if (rows.isNotEmpty()) "Splits" else "Laps", - note = if (rows.isNotEmpty()) summary.distanceMeters?.let { km(it) + if (it >= 1000) " km" else "" } else "${laps.size}", + note = if (rows.isNotEmpty()) { + fastest?.let { "Fastest: ${rows[it].name.lowercase()}" } + ?: summary.distanceMeters?.let { Units.distance(it, Units.Precision.Workout) } + } else "${laps.size}", ) { - rows.forEachIndexed { i, r -> ListRow(r.name, dot(r.averageBpm), r.time, r.averageBpm?.let { "Average $it bpm" }, divider = i > 0, icon = AppIcons.Heart) } + if (markFastest) SplitBars(rows, fastest) else rows.forEachIndexed { i, r -> + ListRow( + r.name, dot(r.averageBpm), r.time, if (i == fastest) "Fastest" + (r.averageBpm?.let { ", average $it bpm" } ?: "") else r.averageBpm?.let { "Average $it bpm" }, + divider = i > 0, icon = AppIcons.Heart, offNormal = i == fastest, + ) + } if (rows.isNotEmpty() && laps.isNotEmpty()) WorkoutDayHeader("Laps") laps.forEachIndexed { i, lap -> ListRow( @@ -352,5 +432,50 @@ internal fun SplitsList(summary: PhoneSummary, liveRoute: List) { ) } } + else -> return false + } + return true +} + +/** + * Each kilometre as a bar as long as its speed against the [fastest] one's (board SummaryRun): the distance's lime, + * the fastest white and its row bold. A part kilometre at the end has its time and no bar. + */ +@Composable +private fun SplitBars(rows: List, fastest: Int?) { + val seconds = rows.map { it.seconds } + val best = rows.indices.filter { rows[it].kind == SplitRow.Kind.Whole && seconds[it] > 0 }.minOfOrNull { seconds[it] } + val bar = familyColor(Workouts.Family.Distance).copy(alpha = 0.5f) + rows.forEachIndexed { i, r -> + val lit = i == fastest + val text = if (lit) Color.White else Color.White.copy(alpha = GlowType.SECONDARY_ALPHA) + val style = if (lit) GlowType.secondary.copy(fontWeight = FontWeight.SemiBold) else GlowType.secondary + Row(Modifier.fillMaxWidth().padding(vertical = 7.dp), verticalAlignment = Alignment.CenterVertically) { + Text(r.name, color = text, style = style, maxLines = 1, modifier = Modifier.widthIn(min = 56.dp).padding(end = 8.dp)) + Box(Modifier.weight(1f).height(10.dp)) { + if (best != null && r.name.startsWith("Km ") && seconds[i] > 0) Box( + Modifier.fillMaxWidth(best.toFloat() / seconds[i]).fillMaxHeight().clip(CircleShape).background(if (lit) Color.White else bar), + ) + } + Text(r.time, color = Color.White, style = GlowType.secondary.copy(fontWeight = FontWeight.SemiBold), maxLines = 1, modifier = Modifier.padding(start = 14.dp)) + } } } + +/** The Share and Done pair on a summary's bottom edge (boards SummaryRun to SummaryGame): Done is the way on, in white. */ +@Composable +internal fun SummaryButtons(onShare: () -> Unit, onDone: () -> Unit) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + WorkoutButton("Share", Raised, Color.White, onShare, Modifier.weight(1f), icon = AppIcons.UploadSimple) + WorkoutButton("Done", Color.White, Color.Black, onDone, Modifier.weight(1f), icon = AppIcons.Tick02) + } +} + +/** Done as a small pill beside the title, to leave without scrolling to the bottom. */ +@Composable +internal fun TopDone(onDone: () -> Unit) { + Box( + Modifier.height(40.dp).clip(CircleShape).background(Raised).pressClickable(onDone, CircleShape).padding(horizontal = 18.dp), + contentAlignment = Alignment.Center, + ) { Text("Done", color = Color.White, style = GlowType.secondary.copy(fontWeight = FontWeight.SemiBold), maxLines = 1) } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GoalsAndBests.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/GoalsAndBests.kt new file mode 100644 index 00000000..717756e9 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/GoalsAndBests.kt @@ -0,0 +1,334 @@ +package org.openstrap.app.ui.glow + +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.Units +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.runtime.remember +import androidx.compose.foundation.layout.height +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.TextStyle +import java.util.Locale +import org.openstrap.app.data.Rewards +import org.openstrap.app.ui.dashboard.ScreenList +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.presentation.RewardsCopy +import org.openstrap.app.presentation.RewardsView +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.glow.draw.level +import org.openstrap.app.ui.glow.draw.Edge + +/* + * The Goals and bests page from You (workouts spec 3.5, board AchievementsV1), All badges, and the weekly recap the + * Monday notification opens (board WeeklyRecap). + */ + +/** The goal colour: met weeks, the goal moment. */ +private val GoalColor = MetricColors.good + +/** What a badge's art reads on these pages: the first weekly goal shows the goal it was met at, or this week's while locked. */ +private fun RewardsView.face(b: Rewards.Badge): String = + earned.firstOrNull { it.id == b.id }?.let { RewardsCopy.face(b, Rewards.goalAt(it, weeks, firstDay, ZoneId.systemDefault())) } + ?: RewardsCopy.face(b, goal) + +/** The days of [first]'s week, each a letter and whether a workout in [view] counted on it. */ +private fun weekDots(view: RewardsView, first: LocalDate, zone: ZoneId = ZoneId.systemDefault()): List> { + val days = view.counted.map { Instant.ofEpochSecond(it.shownStart).atZone(zone).toLocalDate() }.toSet() + return (0L until 7L).map { first.plusDays(it).let { d -> d.dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.getDefault()) to (d in days) } } +} + +/** + * A week against its goal on a glow lit as far as the count is toward it: the count in dots, "of 4 workouts", its days, + * and [line] under them. [trailing] goes on the title row. + */ +@Composable +private fun GoalWeekCard( + title: String, count: Int, goal: Int, dots: List>, line: String, + /** The week as a bar of the goal's workouts (board AchievementsV1) rather than its days (WeeklyRecap). */ + bar: Boolean = false, trailing: @Composable (() -> Unit)? = null, +) { + val style = GlowStyle.level(GoalColor, (count.toFloat() / goal).coerceIn(0f, 1f)) + GlowCard(style, Modifier.fillMaxWidth()) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(title, color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + trailing?.invoke() + } + Row(Modifier.padding(top = GlowLayout.TitleGap), verticalAlignment = Alignment.Bottom) { + DotMatrixText("$count", dot = DotSize.Hero) + Text("of $goal workouts", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.padding(start = 8.dp)) + } + if (bar) GoalBar(count, goal, Modifier.padding(top = 18.dp)) else WeekDots(dots, Color.White, Modifier.padding(top = 18.dp)) + Text(line, color = Color.White, style = GlowType.body, modifier = Modifier.padding(top = 14.dp)) + } + } +} + +/** The week's workouts against the goal as a bar of [goal] segments, [count] of them white. */ +@Composable +private fun GoalBar(count: Int, goal: Int, modifier: Modifier = Modifier) { + androidx.compose.foundation.Canvas( + modifier.fillMaxWidth().height(8.dp).semantics { contentDescription = "$count of $goal workouts" }, + ) { + val n = goal.coerceIn(1, 14) + val gap = 6.dp.toPx() + val w = (size.width - gap * (n - 1)) / n + val corner = androidx.compose.ui.geometry.CornerRadius(size.height / 2) + for (i in 0 until n) drawRoundRect( + Color.White.copy(alpha = if (i < count) 1f else 0.2f), androidx.compose.ui.geometry.Offset(i * (w + gap), 0f), + androidx.compose.ui.geometry.Size(w, size.height), corner, + ) + } +} + +/** A week's distance shows from a tenth of a km, the first the figure can write. */ +private const val MIN_WEEK_METERS = 100f + +/** + * The share card for the week from [first]: the count against the goal, its days, the hours, km and active kcal, and + * chips for the weeks in a row and the week's first best. + */ +private fun weekStory(view: RewardsView, first: LocalDate): StoryCard.Week { + val week = view.week(first) + val count = week?.count ?: 0 + val goal = week?.goal ?: view.goal + val zone = ZoneId.systemDefault() + val from = first.atStartOfDay(zone).toEpochSecond() + val to = first.plusDays(7).atStartOfDay(zone).toEpochSecond() + val mine = view.all.filter { it.startedByYou && it.shownStart in from until to } + val seconds = mine.sumOf { it.activeSeconds } + val meters = mine.sumOf { (it.meters ?: 0f).toDouble() }.toFloat() + val kcal = Math.round(mine.mapNotNull { it.activeKcal }.sum()) + val totals = if (mine.isEmpty()) emptyList() else listOfNotNull( + WeekFigure(AppIcons.Timer, Units.clockDuration(seconds), null, "Hours"), + meters.takeIf { it >= MIN_WEEK_METERS }?.let { WeekFigure(AppIcons.Route01, Units.km(it, Units.Precision.Day), "km", "Distance") }, + kcal.takeIf { it > 0 }?.let { WeekFigure(AppIcons.Fire, Units.count(it), null, "Active kcal") }, + ) + val run = Rewards.inARow(view.weeks, first) + val best = view.bests.firstOrNull { it.date in from until to } + val chips = listOfNotNull( + if (week?.met == true && run > 0) StoryChip("$run ${if (run == 1) "week" else "weeks"} in a row", best = false, met = true) else null, + best?.let { StoryChip("${RewardsCopy.bestTitle(it.kind)}, ${RewardsCopy.bestValue(it.kind, it.value)}", best = true, met = false) }, + ) + return StoryCard.Week( + "${DayWords.dayMonth(first)} to ${DayWords.dayMonth(first.plusDays(6))}", "$count", + "${if (count == 1) "workout" else "workouts"}, goal $goal", weekDots(view, first), totals, chips, + ) +} + +/** The weekly goal on its own, in a sheet: the pencil on the Goals and bests page and the recap. */ +@Composable +fun WeeklyGoalSheet(goal: Int, onSave: (Int) -> Unit, onDismiss: () -> Unit) { + var value by rememberSaveable { mutableIntStateOf(goal) } + PageSheet(onDismiss, "Weekly goal", note = "A change applies to this week. Past weeks keep the goal they had.") { + org.openstrap.app.ui.device.WorkoutGoalField(value) { value = it } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PageButton("Cancel", onDismiss, Modifier.weight(1f)) + PageButton("Save", { onSave(value) }, Modifier.weight(1f), primary = true) + } + } +} + +/** + * Goals and bests (board AchievementsV1): this week against the goal with the weeks in a row and the best run, the + * personal bests, the badges earned with the next one to earn, and the ways on. + */ +@Composable +fun GoalsAndBestsPage( + view: RewardsView?, + contentPadding: PaddingValues, + onGoal: () -> Unit, + onAllBadges: () -> Unit, + onRecap: () -> Unit, + onCelebrations: () -> Unit, + onBack: () -> Unit, +) { + BackHandler(onBack = onBack) + ScreenList(contentPadding, gutter = GlowLayout.Gutter) { + item { PageHeader("Goals and bests", onBack) } + if (view == null) { + item { Note("Loading…") } + return@ScreenList + } + item { + val week = view.week(view.thisWeek) + GoalWeekCard( + "This week", week?.count ?: 0, view.goal, weekDots(view, view.thisWeek), RewardsCopy.runLine(view.inARow, view.bestRun), bar = true, + ) { PageButton("Goal ${view.goal}", onGoal, icon = AppIcons.PencilEdit) } + } + item { + // Lit from the left in gold, so it doesn't sit as a list right under the badges' panel's look. + CompositionLocalProvider(LocalEdgeOverride provides EdgeOverride(edge = Edge.Left, color = BestColor)) { + ListPanel(title = "Personal bests") { + val shown = view.bests.sortedBy { order(it.kind) } + if (shown.isEmpty()) Text("Your bests show here after your first workouts.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(vertical = 10.dp)) + shown.forEachIndexed { i, b -> + ListRow( + RewardsCopy.bestTitle(b.kind), BestColor, RewardsCopy.bestValue(b.kind, b.value), shortDate(b.date), divider = i > 0, + icon = AppIcons.FlagCheckered, + ) + } + } + } + } + item { + val earned = view.earned.sortedByDescending { it.earnedAt }.take(5).mapNotNull { r -> Rewards.badge(r.id)?.let { it to shortDate(r.earnedAt) } } + val next = view.next?.let { b -> view.progress[b.id]?.let { (have, need) -> b to RewardsCopy.progressLine(have, need, b.unit) } } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + "Badges, ${view.earned.size} of ${Rewards.BADGES.size}", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, + modifier = Modifier.padding(start = 4.dp, top = 6.dp), + ) + // The last three earned and the next one, so the row is always four. + BadgeRow(earned.take(if (next != null) 3 else 4) + listOfNotNull(next), locked = { b -> view.earned.none { it.id == b.id } }, face = view::face) + } + } + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + PageButton("All badges", onAllBadges, Modifier.fillMaxWidth(), icon = AppIcons.CheckCircle) + PageButton("Last week", onRecap, Modifier.fillMaxWidth(), icon = AppIcons.CalendarBlank) + PageButton("Celebrations", onCelebrations, Modifier.fillMaxWidth(), icon = AppIcons.SlidersHorizontal) + } + } + } +} + +/** Bests in a fixed order: runs, then the longest workout, lifts, steps. */ +private fun order(kind: String): Int = Rewards.Kind.fastestMeters(kind) + ?: when { + kind == Rewards.Kind.LONGEST_RUN -> 100_000 + kind == Rewards.Kind.LONGEST_WORKOUT -> 100_001 + Rewards.Kind.liftName(kind) != null -> 100_002 + else -> 100_003 + } + +/** "30 Oct", or "30 Oct 2025" in another year. */ +private fun shortDate(epoch: Long): String { + val d = Instant.ofEpochSecond(epoch).atZone(ZoneId.systemDefault()).toLocalDate() + return if (d.year == LocalDate.now().year) DayWords.dayMonth(d) else DayWords.dayMonthYear(d) +} + +/** Every badge by group: earned ones in colour with their date, locked ones dim with what they need. */ +@Composable +fun AllBadgesPage(view: RewardsView?, contentPadding: PaddingValues, onBack: () -> Unit) { + BackHandler(onBack = onBack) + ScreenList(contentPadding, gutter = GlowLayout.Gutter) { + item { PageHeader("All badges", onBack, subtitle = view?.let { "${it.earned.size} of ${Rewards.BADGES.size} earned" }) } + if (view == null) return@ScreenList + val earned = view.earned.associateBy { it.id } + Rewards.Group.entries.forEach { group -> + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + when (group) { + Rewards.Group.Firsts -> "Firsts" + Rewards.Group.Milestones -> "Milestones" + Rewards.Group.Weekly -> "Weekly goal" + Rewards.Group.Kinds -> "Something new" + }, + color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(start = 4.dp, top = 6.dp), + ) + BadgeGrid( + Rewards.BADGES.filter { it.group == group }.map { b -> + b to (earned[b.id]?.let { shortDate(it.earnedAt) } ?: view.progress[b.id]?.let { (have, need) -> RewardsCopy.progressLine(have, need, b.unit) }) + }, + locked = { it.id !in earned }, face = view::face, + ) + } + } + } + } +} + +/** + * The weekly recap (board WeeklyRecap) for the week from [first]: the goal and the days, the weeks in a row, the bests + * set that week, and its time, distance and active kcal. Next week's goal can be changed from here. + */ +@Composable +fun WeeklyRecapPage(view: RewardsView?, first: LocalDate, contentPadding: PaddingValues, onGoal: () -> Unit, onBack: () -> Unit) { + // Share week opens the share screen in the recap's place; close comes back here. + var sharing by rememberSaveable { mutableStateOf(false) } + if (sharing && view != null) { + val card = remember(view, first) { weekStory(view, first) } + WeekShareScreen(card, "OpenStrap_week_$first.png") { sharing = false } + return + } + BackHandler(onBack = onBack) + ScreenList(contentPadding, gutter = GlowLayout.Gutter) { + item { PageHeader("Your week", onBack, subtitle = RewardsCopy.weekRange(first)) } + if (view == null) return@ScreenList + val week = view.week(first) + val count = week?.count ?: 0 + val goal = week?.goal ?: view.goal + item { + val run = Rewards.inARow(view.weeks, first) + GoalWeekCard( + if (week?.met == true) "Goal met" else "Weekly goal", count, goal, weekDots(view, first), + if (run > 0) "Weekly streak: $run ${if (run == 1) "week" else "weeks"} in a row" else "${(goal - count).coerceAtLeast(0)} short of your goal", + ) + } + val zone = ZoneId.systemDefault() + val from = first.atStartOfDay(zone).toEpochSecond() + val to = first.plusDays(7).atStartOfDay(zone).toEpochSecond() + val highlights = view.bests.filter { it.date in from until to } + if (highlights.isNotEmpty()) item { + ListPanel(title = "Highlights") { + highlights.forEachIndexed { i, b -> + ListRow( + "${RewardsCopy.bestTitle(b.kind)}, ${RewardsCopy.bestValue(b.kind, b.value)}", BestColor, + DayWords.weekdayShort(Instant.ofEpochSecond(b.date).atZone(zone).toLocalDate()), null, + divider = i > 0, icon = AppIcons.FlagCheckered, + ) + } + } + } + item { + val mine = view.all.filter { it.startedByYou && it.shownStart in from until to } + val seconds = mine.sumOf { it.activeSeconds } + val meters = mine.sumOf { (it.meters ?: 0f).toDouble() }.toFloat() + val kcal = mine.mapNotNull { it.activeKcal }.sum() + EdgeCard(GoalColor, Modifier.fillMaxWidth(), kind = EdgeKind.Earlier) { + EdgeTitle("In all", "${mine.size} ${if (mine.size == 1) "workout" else "workouts"}") + SummaryFigures( + listOf( + Units.clockDuration(seconds) to "Hours", + Units.km(meters, Units.Precision.Day) to "km", + Units.count(Math.round(kcal)) to "Active kcal", + ), + Modifier.padding(top = 12.dp), + ) + } + } + item { + // Board WeeklyRecap: next week's goal and Share week side by side, Share the bright one. + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PageButton("Next week: ${view.goal}", onGoal, Modifier.weight(1f), icon = AppIcons.PencilEdit) + PageButton("Share week", { sharing = true }, Modifier.weight(1f), primary = true, icon = AppIcons.UploadSimple) + } + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/HeartZonesScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/HeartZonesScreen.kt new file mode 100644 index 00000000..a1a5700c --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/HeartZonesScreen.kt @@ -0,0 +1,172 @@ +package org.openstrap.app.ui.glow + +import org.openstrap.app.ui.glow.draw.EdgeSurface +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.HeartZones +import org.openstrap.app.data.MaxHeartRate +import org.openstrap.app.data.Workouts +import org.openstrap.app.ui.dashboard.ScreenList +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.workout.HeartZonesCopy +import org.openstrap.app.ui.workout.ZONE_COLORS +import org.openstrap.app.ui.workout.ZONE_NAMES +import org.openstrap.app.ui.glow.draw.DotSize + +/** + * Heart rate zones (board SettingsHeartZones), from Ready's Your zones and the You page: your max heart rate as the + * hero, lit in heart red from the left, with where it comes from and Set it myself; then the five zones as a plain + * list with their heart rates. One set for every sport. A zone opens a sheet to move its edges; [onZones] stores the + * zones and [onMax] your own max (null learns it again). [max] is null until it's worked out. + */ +@Composable +fun HeartZonesScreen( + max: MaxHeartRate?, + zones: HeartZones, + contentPadding: PaddingValues, + onMax: (Int?) -> Unit, + onZones: (HeartZones) -> Unit, + onBack: () -> Unit, +) { + BackHandler(onBack = onBack) + var editing by rememberSaveable { mutableStateOf(null) } + var settingMax by rememberSaveable { mutableStateOf(false) } + ScreenList(contentPadding, gutter = GlowLayout.Gutter) { + item { PageHeader(HeartZonesCopy.TITLE, onBack) } + item { MaxHero(max) { settingMax = true } } + item { + PageRows { + (0 until Workouts.ZONES).forEach { z -> + ZoneRow(z, if (max != null) HeartZonesCopy.zoneBpm(zones, z, max.bpm) else "", divider = z > 0) { if (max != null) editing = z } + } + } + } + item { Text(HeartZonesCopy.NOTE, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(horizontal = 4.dp)) } + if (!zones.standard) item { + PageButton(HeartZonesCopy.RESET, { onZones(HeartZones.STANDARD) }, Modifier.fillMaxWidth(), icon = AppIcons.ArrowCounterClockwise) + } + } + val zone = editing + if (zone != null && max != null) ZoneSheet(zone, zones, max.bpm, onZones) { editing = null } + if (settingMax && max != null) MaxSheet(max, { onMax(it); settingMax = false }) { settingMax = false } +} + +/** The max as the page's hero: its Doto number on heart red lit from the left, where it comes from, and Set it myself. */ +@Composable +private fun MaxHero(max: MaxHeartRate?, onSet: () -> Unit) { + Column( + Modifier.fillMaxWidth().clip(GlowLayout.CardShape).background(EdgeSurface) + .drawBehind { + val r = size.width * HERO_LIGHT_REACH + drawCardLight(MetricColors.heart, Offset(0f, size.height / 2), r, r, HeroLight) + } + .border(1.dp, MetricColors.heart.copy(alpha = 0.3f), GlowLayout.CardShape) + .padding(horizontal = 20.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(HeartZonesCopy.MAX_TITLE, color = Color.White.copy(alpha = 0.8f), style = GlowType.body) + Row(verticalAlignment = Alignment.Bottom) { + DotMatrixText(max?.bpm?.toString() ?: "0", dot = DotSize.Hero, color = if (max != null) Color.White else Color.White.copy(alpha = 0.2f)) + Spacer(Modifier.width(8.dp)) + Text("bpm", color = Color.White.copy(alpha = 0.8f), style = GlowType.body) + } + if (max != null) Text(HeartZonesCopy.source(max), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + PageButton(HeartZonesCopy.SET_IT, onSet, enabled = max != null, icon = AppIcons.PencilEdit) + } +} + +/** How far the max hero's light reaches from the left edge, in card widths. */ +private const val HERO_LIGHT_REACH = 1.1f + +/** The max hero's light: heart red at 55% on the edge, 12% halfway out. */ +private val HeroLight = listOf(0f to 0.55f, 0.5f to 0.12f) + +/** A zone on the list: its colour as a short bar, its number and name, and its heart rates. */ +@Composable +private fun ZoneRow(zone: Int, bpm: String, divider: Boolean, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth().clickable(onClick = onClick) + .topHairline(divider, RowHairline) + .padding(vertical = GlowLayout.RowPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.width(6.dp).height(34.dp).clip(RoundedCornerShape(3.dp)).background(ZONE_COLORS[zone])) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + RowName(HeartZonesCopy.zoneName(zone)) + Text(ZONE_NAMES[zone], color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary) + } + Text(bpm, color = Color.White.copy(alpha = 0.85f), style = GlowType.body) + Spacer(Modifier.width(RowCaretGap)) + TapCaret(ROW_CARET_ALPHA) + } +} + +/** Moving a zone's edges: where it starts and where it ends, a bpm at a time. The zone beside each edge moves too. */ +@Composable +private fun ZoneSheet(zone: Int, zones: HeartZones, maxHr: Int, onZones: (HeartZones) -> Unit, onDismiss: () -> Unit) { + PageSheet(onDismiss, "${HeartZonesCopy.zoneName(zone)}, ${ZONE_NAMES[zone]}", note = HeartZonesCopy.edgesNote(zone)) { + val start = zones.startBpm(zone, maxHr) + BpmStepper(HeartZonesCopy.STARTS_AT, start, { onZones(zones.withStart(zone, start - 1, maxHr)) }, { onZones(zones.withStart(zone, start + 1, maxHr)) }) + zones.endBpm(zone, maxHr)?.let { end -> + BpmStepper(HeartZonesCopy.ENDS_AT, end, { onZones(zones.withEnd(zone, end - 1, maxHr)) }, { onZones(zones.withEnd(zone, end + 1, maxHr)) }) + } + } +} + +/** A heart rate with round minus and plus either side of it. */ +@Composable +private fun BpmStepper(label: String, bpm: Int, onLess: () -> Unit, onMore: () -> Unit) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, color = Color.White, style = GlowType.body, modifier = Modifier.weight(1f)) + RoundIcon(AppIcons.Minus, "$label lower", onLess) + Text("$bpm bpm", color = Color.White, style = GlowType.sentence, modifier = Modifier.width(110.dp), textAlign = androidx.compose.ui.text.style.TextAlign.Center) + RoundIcon(AppIcons.Plus, "$label higher", onMore) + } +} + +/** Setting your max yourself, on the number keyboard; Learn it again when it's already yours. */ +@Composable +private fun MaxSheet(max: MaxHeartRate, onMax: (Int?) -> Unit, onDismiss: () -> Unit) { + var text by rememberSaveable { mutableStateOf(max.bpm.toString()) } + val value = HeartZonesCopy.parseMax(text) + PageSheet(onDismiss, HeartZonesCopy.SET_TITLE, note = HeartZonesCopy.SET_NOTE) { + PageField( + text, { v -> text = v.filter { it.isDigit() }.take(3) }, HeartZonesCopy.SET_FIELD, unit = "bpm", error = text.isNotBlank() && value == null, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (max.source == MaxHeartRate.Source.Set) PageButton(HeartZonesCopy.LEARN_IT, { onMax(null) }, Modifier.weight(1f), icon = AppIcons.ArrowCounterClockwise) + PageButton(HeartZonesCopy.SAVE, { onMax(value) }, Modifier.weight(1f), enabled = value != null, primary = true, icon = AppIcons.Tick02) + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/InfoSheet.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/InfoSheet.kt index 6a70c40b..79dd8c78 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/InfoSheet.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/InfoSheet.kt @@ -141,7 +141,7 @@ private fun Fold(title: String, text: String, divider: Boolean = true) { val turn by animateFloatAsState(if (open) 180f else 0f, label = "fold caret") Column( Modifier.fillMaxWidth() - .let { if (divider) it.drawBehind { drawLine(RowHairline, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) } else it }, + .topHairline(divider, RowHairline), ) { Row( Modifier.fillMaxWidth().clickable(onClickLabel = if (open) "Fold" else "Open") { open = !open }.padding(vertical = 16.dp), diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/LocalGlowMood.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/LocalGlowMood.kt new file mode 100644 index 00000000..d4e55eca --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/LocalGlowMood.kt @@ -0,0 +1,12 @@ +package org.openstrap.app.ui.glow + +import androidx.compose.runtime.staticCompositionLocalOf +import org.openstrap.app.ui.glow.draw.GlowMood + +/** + * The mood for the cards on this screen: every [GlowCard] and [EdgeCard] under it follows. Only Today (by its moment) + * and the Sleep tab provide one, everywhere else it's [GlowMood.Day]. + */ +// Moved as is from GlowStyle.kt: the screen's light is ambient by nature, every card under a screen follows it. +@Suppress("CompositionLocalAllowlist", "ComposeCompositionLocalUsage") +val LocalGlowMood = staticCompositionLocalOf { GlowMood.Day } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/PageParts.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/PageParts.kt index 872b0824..77fdcdf7 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/PageParts.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/PageParts.kt @@ -1,5 +1,6 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.TimeOfDay import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -65,6 +66,8 @@ import org.openstrap.app.ui.components.GlassSheet import org.openstrap.app.ui.components.pressClickable import org.openstrap.app.ui.theme.MetricColors import org.openstrap.app.ui.theme.AppIcons +import java.time.ZoneId +import org.openstrap.app.ui.theme.ControlSurface /* * The settings pages (the Strap page and what it opens, Profile, You) in the glow language's quiet tier: black, with @@ -73,9 +76,7 @@ import org.openstrap.app.ui.theme.AppIcons */ private val PanelShape = GlowLayout.CardShape -private val PanelSurface = Color(0xFF0F0F11) -/** The Control surface (spec 2.2): buttons, chips, pills and text fields on a page. */ -internal val ControlSurface = Color(0xFF1F1F22) +private val PanelSurface = ListSurface private val Hairline = Color.White.copy(alpha = 0.05f) /** A [PageRow] whose page is open beside the list. */ private val SelectedTint = Color.White.copy(alpha = 0.08f) @@ -206,7 +207,7 @@ fun PageRow( } } .let { if (onClick != null) it.clickable(enabled = enabled, onClick = onClick) else it } - .let { if (divider) it.drawBehind { drawLine(Hairline, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) } else it } + .topHairline(divider, Hairline) .alpha(if (enabled) 1f else 0.45f) .padding(vertical = GlowLayout.RowPadding), verticalAlignment = Alignment.CenterVertically, @@ -325,7 +326,7 @@ fun PageMenuRow( var open by remember { mutableStateOf(false) } Column( Modifier.fillMaxWidth() - .let { if (divider) it.drawBehind { drawLine(Hairline, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) } else it }, + .topHairline(divider, Hairline), ) { Box(Modifier.fillMaxWidth()) { Row( @@ -377,7 +378,7 @@ private val MenuSurface = Color(0xFF232326) fun PageSwitchRow(title: String, subtitle: String?, checked: Boolean, onChange: (Boolean) -> Unit, divider: Boolean = true, enabled: Boolean = true) { Row( Modifier.fillMaxWidth() - .let { if (divider) it.drawBehind { drawLine(Hairline, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) } else it } + .topHairline(divider, Hairline) .padding(vertical = GlowLayout.RowPadding), verticalAlignment = Alignment.CenterVertically, ) { @@ -589,3 +590,10 @@ fun PageChip(text: String, selected: Boolean, onClick: () -> Unit, modifier: Mod fun PageNote(text: String, color: Color = Color.White.copy(alpha = 0.6f)) { Text(text, color = color, style = GlowType.secondary) } + +/** A list row's divider: a 1 dp [color] line along its top, when [show] (every row but the first). */ +internal fun Modifier.topHairline(show: Boolean, color: Color = RowHairline): Modifier = + if (show) drawBehind { drawLine(color, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) } else this + +/** A time of day from [epoch] seconds in the phone's zone, "07:45" or "7:45 AM" by the phone's setting. */ +internal fun clock(epoch: Long): String = TimeOfDay.format(epoch, ZoneId.systemDefault()) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/ReadyCardio.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/ReadyCardio.kt new file mode 100644 index 00000000..a8f29f34 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/ReadyCardio.kt @@ -0,0 +1,835 @@ +package org.openstrap.app.ui.glow + +import org.openstrap.app.ui.glow.draw.EdgeSurface +import kotlinx.coroutines.flow.collectLatest +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.HeartZones +import org.openstrap.app.data.Structure +import org.openstrap.app.data.WorkoutSetup +import org.openstrap.app.data.WorkoutSetup.GoalKind +import org.openstrap.app.data.WorkoutTarget +import org.openstrap.app.data.Workouts +import org.openstrap.app.data.db.SavedWorkout +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.ui.components.pressClickable +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.workout.StartCopy +import org.openstrap.app.ui.workout.WorkoutCopy +import org.openstrap.app.ui.workout.ZONE_COLORS +import java.time.LocalTime +import org.openstrap.app.ui.glow.draw.DotSize + +/* + * Ready for distance and cardio sports (board ReadyFinal): the heart, where you are, then the goal, typed in place. + * No settings sheet: everything it held is a row here or went with the design. + */ + +/** Where Ready's heart light sits, in card heights from the top, and its size in card widths and heights. */ +private const val HEART_LIGHT_BELOW = 1.25f +private const val HEART_LIGHT_WIDTH = 1.2f +private const val HEART_LIGHT_HEIGHT = 1.1f + +/** Ready's heart light at full strength: 70% in the middle, 18% halfway out. */ +private val HeartLight = listOf(0f to 0.7f, 0.5f to 0.18f) + +/** + * Ready's heart: the live bpm in dots on a card lit from below in [color] (heart red, or the family's colour), as + * Today's hero, "bpm, live from your strap" beside it. While the strap is away, what's wrong and that you can start + * anyway, on a dimmer light. + */ +@Composable +internal fun ReadyHeartCard(connection: ConnectionState, liveHeartRate: Int?, color: Color = MetricColors.heart) { + val hero = WorkoutCopy.readyHero(connection, liveHeartRate) + val bpm = liveHeartRate.takeIf { hero.live } + val light = if (bpm != null) 1f else 0.4f + Row( + Modifier.fillMaxWidth().clip(GlowLayout.CardShape).background(EdgeSurface) + .drawBehind { + // An ellipse 1.2 widths across and 1.1 heights tall, centred a quarter of the height below the card. + drawCardLight( + color, Offset(size.width / 2, size.height * HEART_LIGHT_BELOW), + size.width * HEART_LIGHT_WIDTH, size.height * HEART_LIGHT_HEIGHT, + HeartLight.map { (at, alpha) -> at to alpha * light }, + ) + } + .border(1.dp, color.copy(alpha = 0.3f), GlowLayout.CardShape) + .padding(horizontal = 20.dp, vertical = 18.dp), + verticalAlignment = Alignment.Bottom, + ) { + if (bpm != null) { + DotMatrixText(bpm.toString(), dot = DotSize.Hero) + Spacer(Modifier.width(14.dp)) + Text("bpm, live from your strap", color = Color.White.copy(alpha = 0.8f), style = GlowType.body, modifier = Modifier.weight(1f)) + } else Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(hero.title, color = Color.White, style = GlowType.sentence) + Text(hero.line, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + } + } +} + +/** + * Where you are and the switch rows, on one quiet panel. For a sport with an indoor side ([sides]), its title is the + * side you're on: outside with a green pin and the GPS line, or indoors with its amber icon and what you get without + * GPS; its pill names the other side and switches to it ([onSwitch]). An outdoor sport whose location is off says so, + * with Turn on location. Then Ask to end when I stop, and Save a map of my run where GPS is used ([keepRoute] not null). + */ +@Composable +internal fun ReadyWhere( + type: Int, + sides: Workouts.Sides?, + indoor: Boolean, + outdoor: Boolean, + locationOn: Boolean, + gpsLine: String, + onSwitch: () -> Unit, + onTurnOnLocation: () -> Unit, + askToEnd: Boolean, + onAskToEnd: (Boolean) -> Unit, + keepRoute: Boolean?, + onKeepRoute: (Boolean) -> Unit, +) { + Column(Modifier.fillMaxWidth().clip(GlowLayout.CardShape).background(ListSurface).padding(horizontal = 18.dp)) { + var rows = 0 + if (sides != null) { + PlaceRow( + placeIcon(sides, indoor), if (indoor) IndoorTint else TrendGood, StartCopy.place(sides, indoor), + when { + indoor -> StartCopy.indoorNote(type) + !locationOn -> WorkoutCopy.LOCATION_OFF + else -> gpsLine + }, + ) { PlacePill(placeIcon(sides, !indoor), StartCopy.otherPlace(sides, indoor), onSwitch) } + rows++ + } else if (outdoor && !locationOn) { + PlaceRow(SportIconsLocation, TrendBad, WorkoutCopy.LOCATION_OFF, WorkoutCopy.LOCATION_OFF_NOTE, trailing = null) + rows++ + } + if (outdoor && !locationOn) { + PageButton(StartCopy.TURN_ON_LOCATION, onTurnOnLocation, Modifier.fillMaxWidth().padding(bottom = 14.dp), icon = SportIconsLocation) + } + PageSwitchRow(StartCopy.ASK_TO_END, StartCopy.ASK_TO_END_NOTE, askToEnd, onAskToEnd, divider = rows > 0) + if (keepRoute != null) PageSwitchRow(StartCopy.keepRoute(type), WorkoutCopy.routeNote(keepRoute), keepRoute, onKeepRoute) + } +} + +private val SportIconsLocation: ImageVector get() = org.openstrap.app.ui.workout.SportIcons.Location + +/** Amber, for an indoor side: zone 3's yellow, as the board has it. */ +private val IndoorTint = ZONE_COLORS[2] + +/** Each side's icon: the pin outdoors, then the treadmill, the pool or a house. */ +private fun placeIcon(sides: Workouts.Sides, indoor: Boolean): ImageVector = when { + !indoor -> SportIconsLocation + sides.indoor == 2 -> AppIcons.Treadmill + sides.indoor == 6 -> AppIcons.PersonSimpleSwim + else -> AppIcons.HouseSimple +} + +/** A where row: the icon in a tile tinted its [tint], the title and its note, then [trailing]. */ +@Composable +private fun PlaceRow(icon: ImageVector, tint: Color, title: String, note: String, trailing: (@Composable () -> Unit)?) { + Row(Modifier.fillMaxWidth().padding(vertical = GlowLayout.RowPadding), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(tint.copy(alpha = 0.15f)), contentAlignment = Alignment.Center) { + Icon(icon, null, tint = tint, modifier = Modifier.size(24.dp)) + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + RowName(title) + Text(note, color = Color.White.copy(alpha = 0.65f), style = GlowType.secondary) + } + if (trailing != null) { + Spacer(Modifier.width(12.dp)) + trailing() + } + } +} + +/** The pill at the where row's end: the other side's icon and name. Tapping it goes there. */ +@Composable +private fun PlacePill(icon: ImageVector, label: String, onClick: () -> Unit) { + Row( + Modifier.height(36.dp).clip(CircleShape).background(Raised).pressClickable(onClick, CircleShape) + .semantics { role = Role.Button; contentDescription = label }.padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, tint = Color.White, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(label, color = Color.White, style = GlowType.body, maxLines = 1) + } +} + +/* -------------------------------------------- The goal card ---------------------------------------------- */ + +/** What the goal card shows and changes: the kind chosen and each kind's value, kept while you flip between them. */ +internal data class GoalDraft( + val kind: GoalKind, + val meters: Int, + val seconds: Long, + val zones: WorkoutTarget.Zones, + val reps: Structure.Reps, +) + +/** + * The goal (board ReadyFinal): the kinds in one pill ([kinds]; Just go named for the sport), and under it the chosen + * kind's value with what the strap will do. A value is typed in place on the number keyboard; [typing] says when, so + * Start can make way for the keyboard. Intervals save by name ([onSave]) and open a saved one ([saved]). + */ +@Composable +internal fun ReadyGoal( + type: Int, + kinds: List, + hasDistance: Boolean, + draft: GoalDraft, + onDraft: (GoalDraft) -> Unit, + typing: Boolean, + onTyping: (Boolean) -> Unit, + zones: HeartZones, + maxHr: Int?, + onYourZones: () -> Unit, + saved: List, + onSave: (String, Structure.Reps) -> Unit, +) { + PlanChoice( + Workouts.family(type), kinds.map { it to PlanOption(kindIcon(it, type), kindName(it, type), kindNote(it, hasDistance)) }, + { it == draft.kind }, { onDraft(draft.copy(kind = it)) }, typing, onTyping, + ) { + when (draft.kind) { + GoalKind.None -> GoalNote(StartCopy.noGoalNote(hasDistance)) + GoalKind.Distance -> DistanceGoal(draft.meters, { onDraft(draft.copy(meters = it)) }, typing, onTyping) + GoalKind.Time -> TimeGoal(draft.seconds, { onDraft(draft.copy(seconds = it)) }, typing, onTyping) + GoalKind.Zone -> ZoneGoal(draft.zones, { onDraft(draft.copy(zones = it)) }, zones, maxHr, onYourZones) + GoalKind.Intervals -> IntervalsGoal(draft.reps, { onDraft(draft.copy(reps = it)) }, typing, onTyping, saved, onSave) + } + } +} + +/** One choice in a plan card's pill and its sheet: its icon, its name, and what the strap does with it. */ +internal class PlanOption(val icon: ImageVector, val name: String, val note: String) + +/** + * [ReadyPlanCard] over values rather than places: each of [choices] is what it sets and how it reads, the first that + * [isChosen] is ticked and shown, and picking one hands its value to [onChoose]. Where none is chosen (a value typed + * that's no preset) the pill shows [other], the first choice unless said. + */ +@Composable +internal fun PlanChoice( + family: Workouts.Family, + choices: List>, + isChosen: (T) -> Boolean, + onChoose: (T) -> Unit, + typing: Boolean, + onTyping: (Boolean) -> Unit, + other: () -> PlanOption = { choices[0].second }, + content: @Composable ColumnScope.() -> Unit, +) { + val at = choices.indexOfFirst { isChosen(it.first) }.takeIf { it >= 0 } + ReadyPlanCard(family, choices.map { it.second }, at?.let { choices[it].second } ?: other(), at, { onChoose(choices[it].first) }, typing, onTyping, content) +} + +/** + * The one plan card every Ready has (boards ReadyFinal, ReadyKindPill, ReadyKindSheet): the family's label over it + * ([StartCopy.planTitle]), then the choice in one pill ([shown], which may be none of [options] once a value is typed), + * opening a sheet of every option with what each does, the one [chosen] ticked; then [content], the chosen one's + * value. While typing ([typing]) the whole card scrolls up over the keyboard, presets and note too. + */ +@Composable +private fun ReadyPlanCard( + family: Workouts.Family, + options: List, + shown: PlanOption, + chosen: Int?, + onChoose: (Int) -> Unit, + typing: Boolean, + onTyping: (Boolean) -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + Text(StartCopy.planTitle(family), color = Color.White.copy(alpha = 0.6f), style = GlowType.body, modifier = Modifier.padding(horizontal = 4.dp)) + val inView = remember { BringIntoViewRequester() } + val ime = WindowInsets.ime + val density = LocalDensity.current + // Once the keyboard has stopped moving. Its height is read in the effect, not in composition, so the card isn't + // composed again for every frame of the keyboard rising. + LaunchedEffect(typing) { + if (!typing) return@LaunchedEffect + androidx.compose.runtime.snapshotFlow { ime.getBottom(density) }.collectLatest { keyboard -> + if (keyboard > 0) { kotlinx.coroutines.delay(80); inView.bringIntoView() } + } + } + Column( + Modifier.fillMaxWidth().bringIntoViewRequester(inView).clip(GlowLayout.CardShape).background(ListSurface).border(1.dp, RowHairline, GlowLayout.CardShape).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + var choosing by rememberSaveable { mutableStateOf(false) } + KindPill(shown.icon, shown.name, StartCopy.planHint(family)) { onTyping(false); choosing = true } + if (choosing) PageSheet({ choosing = false }, StartCopy.planSheet(family)) { + Column { + options.forEachIndexed { i, o -> + KindRow(o.icon, o.name, o.note, i == chosen, divider = i > 0) { + // The one already chosen keeps what was typed into it. + if (i != chosen) onChoose(i) + choosing = false + } + } + } + } + content() + } +} + +private fun kindName(k: GoalKind, type: Int): String = when (k) { + GoalKind.None -> StartCopy.noGoal(type) + GoalKind.Distance -> StartCopy.DISTANCE + GoalKind.Time -> StartCopy.TIME + GoalKind.Zone -> StartCopy.ZONE + GoalKind.Intervals -> StartCopy.INTERVALS +} + +private fun kindIcon(k: GoalKind, type: Int): ImageVector = when (k) { + GoalKind.None -> org.openstrap.app.ui.workout.SportIcons.of(type) + GoalKind.Distance -> AppIcons.Route01 + GoalKind.Time -> AppIcons.Timer + GoalKind.Zone -> AppIcons.Pulse01 + GoalKind.Intervals -> AppIcons.ChartBar +} + +private fun kindNote(k: GoalKind, hasDistance: Boolean): String = when (k) { + GoalKind.None -> StartCopy.noGoalNote(hasDistance) + GoalKind.Distance -> StartCopy.DISTANCE_KIND_NOTE + GoalKind.Time -> StartCopy.TIME_KIND_NOTE + GoalKind.Zone -> StartCopy.ZONE_KIND_NOTE + GoalKind.Intervals -> StartCopy.INTERVALS_KIND_NOTE +} + +/** The plan's choice in one pill (board ReadyKindPill): its icon in lime, its name and a caret, opening the kinds' sheet. */ +@Composable +private fun KindPill(icon: ImageVector, label: String, hint: String, onClick: () -> Unit) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Row( + Modifier.height(40.dp).clip(CircleShape).background(Raised) + .pressClickable(onClick, CircleShape).semantics { role = Role.Button; contentDescription = "$label. $hint" } + .padding(start = 12.dp, end = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(icon, null, tint = TypingLime, modifier = Modifier.size(18.dp)) + Text(label, color = Color.White, style = GlowType.body, maxLines = 1) + Icon(AppIcons.CaretDown, null, tint = Color.White, modifier = Modifier.size(14.dp)) + } + } +} + +/** A plan's choice in the sheet (board ReadyKindSheet): its icon on a tile, its name, what the strap does, a tick when chosen. */ +@Composable +private fun KindRow(icon: ImageVector, label: String, note: String, on: Boolean, divider: Boolean, onClick: () -> Unit) { + Row( + Modifier.fillMaxWidth() + .topHairline(divider, RowHairline) + .clickable(onClick = onClick).semantics { selected = on }.padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Box( + Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(if (on) TypingLime.copy(alpha = 0.14f) else Raised), + contentAlignment = Alignment.Center, + ) { Icon(icon, null, tint = if (on) TypingLime else Color.White, modifier = Modifier.size(20.dp)) } + Column(Modifier.weight(1f)) { + Text(label, color = Color.White, style = GlowType.body) + Text(note, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, modifier = Modifier.padding(top = 2.dp)) + } + if (on) Icon(AppIcons.CheckCircle, null, tint = TypingLime, modifier = Modifier.size(20.dp)) + } +} + +/** The line under a goal: what the strap will do. */ +@Composable +internal fun GoalNote(text: String) = + Text(text, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + +/** A row of presets under a value, each as wide as the others, on a hairline; the one matching the value ([on]) in white. */ +@Composable +internal fun Presets(presets: List>, on: T?, onPick: (T) -> Unit) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + presets.forEach { (v, label) -> + val chosen = v == on + Box( + Modifier.weight(1f).height(34.dp).clip(CircleShape).border(1.dp, if (chosen) Color.White else Color.White.copy(alpha = 0.15f), CircleShape) + .pressClickable({ onPick(v) }, CircleShape).semantics { role = Role.Button; selected = chosen }, + contentAlignment = Alignment.Center, + ) { Text(label, color = if (chosen) Color.White else Color.White.copy(alpha = 0.8f), style = GlowType.secondary, maxLines = 1) } + } + } +} + +/** A value between round minus and plus buttons. */ +@Composable +private fun ValueStepper(less: String, more: String, onLess: () -> Unit, onMore: () -> Unit, value: @Composable RowScope.() -> Unit) = + MinusPlus(less, more, onLess, onMore, Modifier.fillMaxWidth(), buttonSize = 52.dp, gap = 12.dp, value = value) + +/** A value to tap and type: its numbers and units, "Tap to type" under them ([hint]). */ +@Composable +private fun TapValue(parts: List>, hint: String?, onClick: () -> Unit) { + Column( + Modifier.clip(RoundedCornerShape(12.dp)).clickable(onClickLabel = StartCopy.TAP_TO_TYPE, onClick = onClick).padding(horizontal = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row { + parts.forEachIndexed { i, (n, unit) -> + if (i > 0) Spacer(Modifier.width(6.dp)) + Text(n, color = Color.White, style = GlowType.goalValue, modifier = Modifier.alignByBaseline()) + Spacer(Modifier.width(4.dp)) + Text(unit, color = Color.White.copy(alpha = 0.75f), style = GlowType.body, modifier = Modifier.alignByBaseline()) + } + } + if (hint != null) Text(hint, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.caption, modifier = Modifier.padding(top = 4.dp)) + } +} + +/** + * A number typed in place: [GlowType.goalValue] (or [style]) on the phone's number keyboard, with the lime caret and + * a lime underline while it has focus ([focused]), a faint one otherwise. [unit] follows it. + */ +@Composable +private fun TypedNumber( + value: TextFieldValue, + onValue: (TextFieldValue) -> Unit, + unit: String?, + focused: Boolean, + field: Modifier, + keyboard: KeyboardType, + ime: ImeAction, + onIme: () -> Unit, + style: TextStyle = GlowType.goalValue, + modifier: Modifier = Modifier, +) { + Row( + modifier.drawBehind { + val y = size.height - 1.dp.toPx() + drawLine(if (focused) TypingLime else Color.White.copy(alpha = 0.2f), Offset(0f, y), Offset(size.width, y), 2.dp.toPx()) + }.padding(start = 6.dp, end = 6.dp, bottom = 6.dp), + ) { + BasicTextField( + value, onValue, field.width(IntrinsicSize.Min).widthIn(min = 18.dp).alignByBaseline(), + textStyle = style.copy(color = Color.White), cursorBrush = SolidColor(TypingLime), singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = keyboard, imeAction = ime), + keyboardActions = KeyboardActions(onDone = { onIme() }, onNext = { onIme() }), + ) + if (unit != null) { + Spacer(Modifier.width(4.dp)) + Text(unit, color = Color.White.copy(alpha = 0.75f), style = GlowType.body, modifier = Modifier.alignByBaseline()) + } + } +} + +/** Keeps a [TypedValue]'s numbers as typed: each one's text and selection, three plain values a number. */ +private val TextFieldValuesSaver = androidx.compose.runtime.saveable.listSaver, Any>( + save = { l -> l.flatMap { listOf(it.text, it.selection.start, it.selection.end) } }, + restore = { v -> v.chunked(3) { (t, a, b) -> TextFieldValue(t as String, TextRange(a as Int, b as Int)) } }, +) + +/** The caret and underline of a number being typed. */ +private val TypingLime = MetricColors.steps + +/** [text] with all of it selected, so the first key typed replaces it. */ +private fun selected(text: String) = TextFieldValue(text, TextRange(0, text.length)) + +/** + * While typing, which field has focus ([focused], null for none). Once none has had it for a moment (focus moving + * from one field to the next passes through none), typing is over and [onDone] reads what was typed. + */ +@Composable +private fun EndTyping(typing: Boolean, focused: Int?, everFocused: Boolean, onDone: () -> Unit) { + LaunchedEffect(typing, focused, everFocused) { + if (typing && everFocused && focused == null) { + kotlinx.coroutines.delay(120) + onDone() + } + } +} + +/** Distance: minus and plus by half a km, the value in km to tap and type ([TypedValue]), the presets, and its note. */ +@Composable +private fun DistanceGoal(meters: Int, onMeters: (Int) -> Unit, typing: Boolean, onTyping: (Boolean) -> Unit) { + val focusManager = LocalFocusManager.current + // What's typed so far, for the note while typing, set as it's typed, never during composition (audit K6). + var typed by rememberSaveable { mutableStateOf(null) } + // A step or preset while typing drops what was typed. + fun stop() { onTyping(false); focusManager.clearFocus() } + ValueStepper("Shorter", "Longer", { stop(); onMeters(StartCopy.stepDistance(meters, up = false)) }, { stop(); onMeters(StartCopy.stepDistance(meters, up = true)) }) { + TypedValue( + listOf(NumberField(StartCopy.distanceValue(meters), "km", length = 6, decimal = true)), typing, onTyping, + onDone = { t -> StartCopy.parseDistance(t[0])?.let(onMeters) }, hint = StartCopy.TAP_TO_TYPE, + onTexts = { typed = it.firstOrNull() }, + ) { + Row { Number(0) } + } + } + Presets(StartCopy.DISTANCE_PRESETS, meters.takeIf { !typing }) { stop(); onMeters(it) } + GoalNote(StartCopy.distanceNote(if (typing) typed?.let(StartCopy::parseDistance) ?: meters else meters)) +} + +/** + * Time: minus and plus by 5 minutes, the value in hours and minutes to tap and type (hours, then on to the minutes by + * itself), the [presets], and when it would end if you started now, or [note] in its place (mind and body's length). + */ +@Composable +internal fun TimeGoal( + seconds: Long, + onSeconds: (Long) -> Unit, + typing: Boolean, + onTyping: (Boolean) -> Unit, + presets: List> = StartCopy.TIME_PRESETS, + note: String? = null, +) { + val focusManager = LocalFocusManager.current + val context = LocalContext.current + // What's typed outlives a rotation, a fold and process death (audit K7). Focus doesn't, so typing starts again on + // the hours, as it does when tapped. + var hours by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + var minutes by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + var focused by remember { mutableStateOf(null) } + var everFocused by remember { mutableStateOf(false) } + // Set once the hour is typed and the minutes have focus, until the minutes let it go. + var hoursDone by remember { mutableStateOf(false) } + val hoursFocus = remember { FocusRequester() } + val minutesFocus = remember { FocusRequester() } + fun finish() { + if (!typing) return + StartCopy.parseTime(hours.text, minutes.text)?.let(onSeconds) + onTyping(false) + } + fun stop() { onTyping(false); focusManager.clearFocus() } + EndTyping(typing, focused, everFocused) { finish() } + ValueStepper("Shorter", "Longer", { stop(); onSeconds(StartCopy.stepTime(seconds, up = false)) }, { stop(); onSeconds(StartCopy.stepTime(seconds, up = true)) }) { + if (typing) { + LaunchedEffect(Unit) { hoursFocus.requestFocus() } + fun Modifier.tracked(i: Int) = onFocusChanged { if (it.isFocused) { focused = i; everFocused = true } else if (focused == i) focused = null } + TypedNumber( + hours, { v -> + // Once on to the minutes, the hours field can still send its old figure as it lets go: ignored. + if (hoursDone) return@TypedNumber + // The figure just typed, whether the keyboard replaced the selected hour or added after it. + hours = v.copy(text = v.text.filter { it.isDigit() }.takeLast(1), selection = TextRange(1).takeIf { v.text.length > 1 } ?: v.selection) + // One figure of hours, then on to the minutes. + if (hours.text.length == 1 && v.text != selectedText(v)) { hoursDone = true; minutesFocus.requestFocus() } + }, "h", focused == 0, Modifier.focusRequester(hoursFocus).tracked(0), KeyboardType.Number, ImeAction.Next, { minutesFocus.requestFocus() }, + ) + Spacer(Modifier.width(10.dp)) + TypedNumber( + minutes, { v -> minutes = v.copy(text = v.text.filter { it.isDigit() }.take(3)) }, "min", focused == 1, + Modifier.focusRequester(minutesFocus).tracked(1).onFocusChanged { if (!it.isFocused) hoursDone = false }, KeyboardType.Number, ImeAction.Done, { focusManager.clearFocus() }, + ) + } else TapValue(StartCopy.timeParts(seconds), StartCopy.TAP_TO_TYPE) { + hours = selected("${seconds / 3600}"); minutes = selected("${seconds / 60 % 60}"); everFocused = false; hoursDone = false; onTyping(true) + } + } + Presets(presets, seconds.takeIf { !typing }) { stop(); onSeconds(it) } + val shown = if (typing) StartCopy.parseTime(hours.text, minutes.text) ?: seconds else seconds + // Ticks with the clock, so the end time stays right while Ready shows. + val now by produceState(LocalTime.now()) { while (true) { kotlinx.coroutines.delay(15_000); value = LocalTime.now() } } + val ends = StartCopy.endsNear(now, shown, android.text.format.DateFormat.is24HourFormat(context)) + GoalNote(if (typing) StartCopy.timeTypingNote(ends) else note ?: ends) +} + +/** The text a field's change would leave selected, to tell a key typed from the selection only moving. */ +private fun selectedText(v: TextFieldValue): String = v.text.substring(v.selection.min, v.selection.max) + +/** + * Zone: the chosen zone or range ("Zone 2 to 3") with its heart rates and names from your zones, five buttons in the + * zones' colours with where each starts, its note, and Your zones to open the Heart rate zones page. + */ +@Composable +private fun ZoneGoal(goal: WorkoutTarget.Zones, onGoal: (WorkoutTarget.Zones) -> Unit, zones: HeartZones, maxHr: Int?, onYourZones: () -> Unit) { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(goal.label, color = Color.White, style = GlowType.smallValue) + if (maxHr != null) Text(StartCopy.zoneRangeLine(goal, zones, maxHr), color = Color.White.copy(alpha = 0.7f), style = GlowType.body, textAlign = TextAlign.Center) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + (0 until Workouts.ZONES).forEach { z -> + val on = z in goal.low..goal.high + val color = ZONE_COLORS[z] + Column( + Modifier.weight(1f).height(56.dp).clip(RoundedCornerShape(14.dp)).background(if (on) color else ZoneOff) + .drawBehind { if (!on) drawRect(color, Offset(0f, size.height - 3.dp.toPx()), androidx.compose.ui.geometry.Size(size.width, 3.dp.toPx())) } + .pressClickable({ onGoal(WorkoutSetup.pickZone(goal, z)) }, RoundedCornerShape(14.dp)) + .semantics { selected = on; role = Role.Button }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val ink = if (on) Color.Black else Color.White.copy(alpha = 0.75f) + Text("Z${z + 1}", color = ink, style = GlowType.body.copy(fontWeight = FontWeight.SemiBold)) + if (maxHr != null) Text("${zones.startBpm(z, maxHr)}", color = ink.copy(alpha = ink.alpha * 0.75f), style = GlowType.caption) + } + } + } + GoalNote(StartCopy.ZONE_NOTE) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + PageButton(StartCopy.YOUR_ZONES, onYourZones, icon = AppIcons.PencilEdit) + } +} + +/** A zone button not chosen: dark, with its colour along the bottom. */ +private val ZoneOff = Color(0xFF1A1A1D) + +/** + * Intervals: "6 × 400 m" over "with 90 s rest between". Any number taps to type, then on to the next: how many, each + * rep (in m, km or min, chosen while you type it), then the rest. The presets, what the strap will do, and Save by + * name and Saved, for Choose's chips and here. + */ +@Composable +private fun IntervalsGoal( + reps: Structure.Reps, + onReps: (Structure.Reps) -> Unit, + typing: Boolean, + onTyping: (Boolean) -> Unit, + saved: List, + onSave: (String, Structure.Reps) -> Unit, +) { + var unit by rememberSaveable { mutableStateOf(StartCopy.repUnit(reps)) } + var sheet by rememberSaveable { mutableStateOf(null) } + fun typed(t: List): Structure.Reps { + val withCount = StartCopy.parseCount(t[0])?.let { reps.copy(rounds = it) } ?: reps + val withRep = StartCopy.parseRep(t[1], unit, withCount) ?: withCount + return StartCopy.parseRest(t[2])?.let { withRep.copy(rest = it) } ?: withRep + } + val (n, u) = StartCopy.repParts(reps) + val (restN, restU) = StartCopy.restParts(reps.rest) + TypedValue( + listOf( + NumberField("${reps.rounds}", null, length = 2), + NumberField(n, u, editUnit = unit.label, length = 5, decimal = true), + NumberField(restN, restU, edit = "${reps.rest}", editUnit = "s", style = GlowType.goalValueMinor), + ), + typing, onTyping, onDone = { onReps(typed(it)) }, onStart = { unit = StartCopy.repUnit(reps) }, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Number(0) + Times() + Number(1) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ValueWords("with") + Number(2) + ValueWords("rest between") + } + if (focused == 1) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Text(StartCopy.EACH_REP_IN, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary) + Spacer(Modifier.width(10.dp)) + StartCopy.RepUnit.entries.forEach { e -> + UnitChip(e.label, e == unit) { unit = e } + Spacer(Modifier.width(6.dp)) + } + } + } + if (typing) { + GoalNote(StartCopy.INTERVALS_TYPING) + return + } + Presets(StartCopy.INTERVAL_PRESETS.map { it to StartCopy.structureName(it) }, reps.copy(warmUp = false)) { onReps(it.warm(reps.warmUp)) } + GoalNote(StartCopy.intervalsNote(reps)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally)) { + PageButton(StartCopy.SAVE_INTERVALS, { sheet = SAVE }, icon = AppIcons.Plus) + if (saved.isNotEmpty()) PageButton(StartCopy.SAVED_INTERVALS, { sheet = SAVED }, icon = AppIcons.FolderOpen) + } + when (sheet) { + SAVE -> SaveIntervalsSheet(reps, { name -> onSave(name, reps); sheet = null }) { sheet = null } + SAVED -> PageSheet({ sheet = null }, StartCopy.SAVED_INTERVALS) { + Column { + saved.forEachIndexed { i, w -> + val s = Structure.parse(w.structure) as? Structure.Reps ?: return@forEachIndexed + PageRow(w.name, value = StartCopy.structureName(s), divider = i > 0, onClick = { onReps(s); sheet = null }) + } + } + } + } +} + +/** + * One number of a value typed in place: as it shows ([shown] and its [unit]) and as it's typed ([edit] in + * [editUnit], the rest typed in seconds where it shows "2 min"), in [style], up to [length] figures, with a decimal + * point when [decimal]. + */ +internal data class NumberField( + val shown: String, + val unit: String?, + val edit: String = shown, + val editUnit: String? = unit, + val style: TextStyle = GlowType.goalValue, + val length: Int = 3, + val decimal: Boolean = false, +) + +/** What a [TypedValue]'s lines have: its numbers ([Number]), what's typed so far ([typed], null at rest), the number being typed ([focused]). */ +internal class TypedValueScope(val typed: List?, val focused: Int?, private val number: @Composable RowScope.(Int) -> Unit) { + @Composable + fun RowScope.Number(i: Int) = number(i) +} + +/** + * A value of several numbers, each typed in place (board ReadyFinalReps): [lines] lay out its numbers ([NumberField], + * by index) among words. Any number taps to type ([onStart] first), then Next goes on to the next; once none has + * focus, [onDone] reads what was typed, each number as text. [onTexts] hears each change to what's typed, as it's + * typed. "Tap a number to type it" ([hint]) under it at rest. What's typed, and which number typing started on, outlive + * a rotation, a fold and process death (audit K7). + */ +@Composable +internal fun TypedValue( + fields: List, + typing: Boolean, + onTyping: (Boolean) -> Unit, + onDone: (List) -> Unit, + onStart: () -> Unit = {}, + hint: String? = StartCopy.TAP_A_NUMBER, + onTexts: (List) -> Unit = {}, + lines: @Composable TypedValueScope.() -> Unit, +) { + val focusManager = LocalFocusManager.current + var texts by rememberSaveable(fields.size, stateSaver = TextFieldValuesSaver) { mutableStateOf(List(fields.size) { TextFieldValue() }) } + var focused by remember { mutableStateOf(null) } + var everFocused by remember { mutableStateOf(false) } + var first by rememberSaveable { mutableIntStateOf(0) } + val focus = remember(fields.size) { List(fields.size) { FocusRequester() } } + EndTyping(typing, focused, everFocused) { + if (typing) { + onDone(texts.map { it.text }) + onTyping(false) + } + } + if (typing) LaunchedEffect(Unit) { focus[first].requestFocus() } + val number: @Composable RowScope.(Int) -> Unit = { i -> + val f = fields[i] + val last = i == fields.lastIndex + if (typing) TypedNumber( + texts[i], { v -> + val typed = v.text.filter { c -> c.isDigit() || f.decimal && (c == '.' || c == ',') }.take(f.length) + texts = texts.toMutableList().also { it[i] = v.copy(text = typed) } + onTexts(texts.map { it.text }) + }, + f.editUnit, focused == i, + Modifier.focusRequester(focus[i]).onFocusChanged { if (it.isFocused) { focused = i; everFocused = true } else if (focused == i) focused = null }, + if (f.decimal) KeyboardType.Decimal else KeyboardType.Number, if (last) ImeAction.Done else ImeAction.Next, + { if (last) focusManager.clearFocus() else focus[i + 1].requestFocus() }, + style = f.style, modifier = Modifier.alignByBaseline(), + ) else TapNumber(f.shown, f.unit, f.style) { + texts = fields.map { selected(it.edit) } + onTexts(texts.map { it.text }) + first = i; everFocused = false + onStart() + onTyping(true) + } + } + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(if (typing) 10.dp else 8.dp)) { + TypedValueScope(texts.map { it.text }.takeIf { typing }, focused.takeIf { typing }, number).lines() + if (!typing && hint != null) Text(hint, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.caption) + } +} + +/** Words between a typed value's numbers, on their baseline. */ +@Composable +internal fun RowScope.ValueWords(text: String) = + Text(text, color = Color.White.copy(alpha = 0.7f), style = GlowType.body, modifier = Modifier.alignByBaseline()) + +/** The × between a count and what's counted: "6 × 400 m". */ +@Composable +internal fun RowScope.Times() = + Text("×", color = Color.White.copy(alpha = 0.5f), style = GlowType.goalValueMinor, modifier = Modifier.alignByBaseline().padding(horizontal = 4.dp)) + +private const val SAVE = 0 +private const val SAVED = 1 + +/** One number of the intervals to tap and type, with its [unit] after it, on the shared baseline. */ +@Composable +internal fun RowScope.TapNumber(n: String, unit: String?, style: TextStyle, onClick: () -> Unit) { + Row(Modifier.alignByBaseline().clip(RoundedCornerShape(10.dp)).clickable(onClickLabel = StartCopy.TAP_TO_TYPE, onClick = onClick).padding(horizontal = 4.dp)) { + Text(n, color = Color.White, style = style, modifier = Modifier.alignByBaseline()) + if (unit != null) { + Spacer(Modifier.width(4.dp)) + Text(unit, color = Color.White.copy(alpha = 0.75f), style = GlowType.body, modifier = Modifier.alignByBaseline()) + } + } +} + +/** A rep's unit while it's typed: white when chosen. */ +@Composable +private fun UnitChip(label: String, on: Boolean, onClick: () -> Unit) { + Box( + Modifier.height(30.dp).clip(CircleShape).background(if (on) Color.White else Raised) + .clickable(onClick = onClick).semantics { selected = on }.padding(horizontal = 14.dp), + contentAlignment = Alignment.Center, + ) { + Text(label, color = if (on) Color.Black else Color.White.copy(alpha = 0.85f), style = GlowType.secondary.copy(fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal)) + } +} + +/** Saving [reps] by name, to find on Choose and here. */ +@Composable +private fun SaveIntervalsSheet(reps: Structure.Reps, onSave: (String) -> Unit, onDismiss: () -> Unit) { + var name by rememberSaveable { mutableStateOf("") } + PageSheet(onDismiss, "${StartCopy.SAVE_INTERVALS} ${StartCopy.structureName(reps)}", note = StartCopy.SAVE_NOTE) { + PageField(name, { name = it.take(40) }, null, placeholder = StartCopy.NAME_HINT) + PageButton(StartCopy.SAVE_INTERVALS, { onSave(name.trim()) }, Modifier.fillMaxWidth(), enabled = name.isNotBlank(), primary = true, icon = AppIcons.Plus) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/ReadyPlans.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/ReadyPlans.kt new file mode 100644 index 00000000..758b4292 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/ReadyPlans.kt @@ -0,0 +1,207 @@ +package org.openstrap.app.ui.glow + +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.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.Structure +import org.openstrap.app.data.Workouts +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.workout.SportIcons +import org.openstrap.app.ui.workout.StartCopy + +/* + * Ready for strength, HIIT, mind and body and games (boards ReadyFinalStrength, ReadyFinalHiit, ReadyFinalMind, + * ReadyFinalGames), on the run Ready's skeleton: the heart card, one plan card ([PlanChoice]) and a few switch + * rows ([ReadyRows]). + */ + +/** + * Strength's plan: Open or Plan in the pill. A plan is "5 sets" over "with 90 s rest between", each number typed in + * place, then the sets presets. Open has no sets to count but still rests after each one, so it keeps the rest. + */ +@Composable +internal fun StrengthPlanCard( + type: Int, + sets: Int?, + onSets: (Int?) -> Unit, + planSets: Int, + restSeconds: Long, + onRest: (Long) -> Unit, + typing: Boolean, + onTyping: (Boolean) -> Unit, +) { + val choices = listOf( + false to PlanOption(SportIcons.of(type), StartCopy.OPEN, StartCopy.OPEN_SETS_KIND_NOTE), + true to PlanOption(AppIcons.ChartBar, StartCopy.PLAN, StartCopy.PLAN_KIND_NOTE), + ) + PlanChoice(Workouts.Family.Strength, choices, { it == (sets != null) }, { onSets(if (it) planSets else null) }, typing, onTyping) { + val (restN, restU) = StartCopy.restParts(restSeconds) + val rest = NumberField(restN, restU, edit = "$restSeconds", editUnit = "s", style = GlowType.goalValueMinor) + if (sets == null) { + TypedValue(listOf(rest), typing, onTyping, onDone = { t -> StartCopy.parseSetRest(t[0])?.let(onRest) }) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Number(0) + ValueWords("rest after each set") + } + } + GoalNote(if (typing) StartCopy.STRENGTH_TYPING else StartCopy.OPEN_SETS_NOTE) + return@PlanChoice + } + TypedValue( + listOf(NumberField("$sets", "sets", length = 2), rest), typing, onTyping, + onDone = { t -> + StartCopy.parseSets(t[0])?.let(onSets) + StartCopy.parseSetRest(t[1])?.let(onRest) + }, + ) { + Row { Number(0) } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ValueWords("with") + Number(1) + ValueWords("rest between") + } + } + if (!typing) Presets(StartCopy.SET_PRESETS, sets) { onSets(it) } + GoalNote(if (typing) StartCopy.STRENGTH_TYPING else StartCopy.STRENGTH_STATUS) + } +} + +/** + * HIIT's plan: the presets are the pill (Tabata, 40 / 20, Every minute, Free); once a number is typed the pill says + * what it is ("30 / 15"). Timed rounds read "20 s on 10 s off" over "8 rounds 4:00 in all" with work and rest in + * proportion under them; Every minute reads "60 s a round" over its rounds. Free has no timer: laps mark the rounds. + */ +@Composable +internal fun HiitPlanCard(type: Int, rounds: Structure?, onChange: (Structure?) -> Unit, typing: Boolean, onTyping: (Boolean) -> Unit) { + val choices = StartCopy.PRESETS.map { it.rounds to PlanOption(it.icon ?: SportIcons.of(type), it.name, it.note) } + PlanChoice( + Workouts.Family.Intervals, choices, { it?.code == rounds?.warm(false)?.code }, { onChange(it?.warm(rounds?.warmUp == true)) }, typing, onTyping, + other = { PlanOption(if (rounds is Structure.EveryMinute) AppIcons.Alarm else AppIcons.Timer, StartCopy.hiitName(rounds!!), "") }, + ) { + when (rounds) { + is Structure.Timed -> { + TypedValue( + listOf( + NumberField("${rounds.work}", "s on"), NumberField("${rounds.rest}", "s off"), + NumberField("${rounds.rounds}", "rounds", length = 2, style = GlowType.goalValueMinor), + ), + typing, onTyping, onDone = { onChange(StartCopy.typedTimed(rounds, it)) }, + ) { + val now = typed?.let { StartCopy.typedTimed(rounds, it) } ?: rounds + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Number(0) + Number(1) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Number(2) + StartCopy.totalLine(now)?.let { ValueWords(it) } + } + } + if (!typing) RoundsStrip(rounds) + GoalNote(if (typing) StartCopy.HIIT_TYPING else StartCopy.INTERVALS_STATUS) + } + is Structure.EveryMinute -> { + TypedValue( + listOf(NumberField("${rounds.period}", "s a round"), NumberField("${rounds.rounds}", "rounds", length = 2, style = GlowType.goalValueMinor)), + typing, onTyping, onDone = { onChange(StartCopy.typedEveryMinute(rounds, it)) }, + ) { + val now = typed?.let { StartCopy.typedEveryMinute(rounds, it) } ?: rounds + Row { Number(0) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Number(1) + StartCopy.totalLine(now)?.let { ValueWords(it) } + } + } + GoalNote(if (typing) StartCopy.EVERY_TYPING else StartCopy.INTERVALS_STATUS) + } + else -> GoalNote(StartCopy.HIIT_FREE_NOTE) + } + } +} + +/** Mind and body's length: Open or Time in the pill; a time is the run's Time card with its own presets and note. */ +@Composable +internal fun MindPlanCard(type: Int, seconds: Long?, onSeconds: (Long?) -> Unit, lastSeconds: Long, typing: Boolean, onTyping: (Boolean) -> Unit) { + val choices = listOf( + false to PlanOption(SportIcons.of(type), StartCopy.OPEN, StartCopy.MIND_OPEN_KIND_NOTE), + true to PlanOption(AppIcons.Timer, StartCopy.TIME, StartCopy.MIND_TIME_KIND_NOTE), + ) + PlanChoice(Workouts.Family.MindBody, choices, { it == (seconds != null) }, { onSeconds(if (it) lastSeconds else null) }, typing, onTyping) { + if (seconds == null) GoalNote(StartCopy.MIND_OPEN_NOTE) + else TimeGoal(seconds, { onSeconds(it) }, typing, onTyping, StartCopy.MIND_PRESETS, StartCopy.MIND_STATUS) + } +} + +/** + * A game's match: Free play, Halves or Quarters in the pill; periods read "2 × 40 min" over "with a 10 min break", + * each number typed in place (in minutes). Another count of periods says so in the pill ("3 periods"). + */ +@Composable +internal fun GamePlanCard(type: Int, periods: Structure.Periods?, onChange: (Structure.Periods?) -> Unit, typing: Boolean, onTyping: (Boolean) -> Unit) { + val choices = listOf( + null to PlanOption(SportIcons.of(type), StartCopy.FREE_PLAY, StartCopy.FREE_PLAY_KIND_NOTE), + Structure.HALVES to PlanOption(AppIcons.Hourglass, StartCopy.periodsName(Structure.HALVES), StartCopy.HALVES_KIND_NOTE), + Structure.QUARTERS to PlanOption(AppIcons.Timer, StartCopy.periodsName(Structure.QUARTERS), StartCopy.QUARTERS_KIND_NOTE), + ) + // By the count of periods alone, so typed lengths keep Halves or Quarters ticked. + PlanChoice( + Workouts.Family.Games, choices, { it?.rounds == periods?.rounds }, onChange, typing, onTyping, + other = { PlanOption(AppIcons.Timer, StartCopy.periodsName(periods!!), "") }, + ) { + if (periods != null) { + TypedValue( + listOf( + NumberField("${periods.rounds}", null, length = 1), NumberField("${periods.length / 60}", "min", length = 2), + NumberField("${periods.rest / 60}", "min", length = 2, style = GlowType.goalValueMinor), + ), + typing, onTyping, onDone = { onChange(StartCopy.typedPeriods(periods, it)) }, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Number(0) + Times() + Number(1) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + ValueWords("with a") + Number(2) + ValueWords("break") + } + } + } + GoalNote(if (typing) StartCopy.GAMES_TYPING else StartCopy.gamesNote(periods)) + } +} + +/** Ready's switch rows under the plan card, on a plain panel. */ +@Composable +internal fun ReadyRows(content: @Composable ColumnScope.() -> Unit) { + Column(Modifier.fillMaxWidth().clip(GlowLayout.CardShape).background(ListSurface).padding(horizontal = 18.dp), content = content) +} + +/** Work and rest along the rounds, each as wide as its time: work in heart red, rest in sleep blue. */ +@Composable +private fun RoundsStrip(s: Structure.Timed) { + Row( + Modifier.fillMaxWidth().height(10.dp).semantics { contentDescription = "${s.rounds} rounds of ${s.work} seconds work and ${s.rest} seconds rest" }, + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + repeat(s.rounds) { i -> + Box(Modifier.weight(s.work.toFloat()).height(10.dp).clip(CircleShape).background(MetricColors.heart)) + if (i < s.rounds - 1 && s.rest > 0) Box(Modifier.weight(s.rest.toFloat()).height(10.dp).clip(CircleShape).background(MetricColors.sleepDeep)) + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/ReadyScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/ReadyScreen.kt new file mode 100644 index 00000000..da44f48e --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/ReadyScreen.kt @@ -0,0 +1,328 @@ +package org.openstrap.app.ui.glow + +import android.content.Intent +import android.provider.Settings +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import org.openstrap.app.data.HeartZones +import org.openstrap.app.data.SportSettings +import org.openstrap.app.data.Strength +import org.openstrap.app.data.WorkoutPlan +import org.openstrap.app.data.WorkoutSetup +import org.openstrap.app.data.WorkoutTarget +import org.openstrap.app.data.SportTypes +import org.openstrap.app.data.Workouts +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutLocation +import org.openstrap.app.ui.components.HapticEvent +import org.openstrap.app.ui.components.pressClickable +import org.openstrap.app.ui.components.rememberHaptics +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.workout.StartCopy +import org.openstrap.app.ui.workout.familyColor +import org.openstrap.app.data.Structure +import androidx.compose.ui.unit.Dp +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Arrangement +import org.openstrap.app.device.locationAvailable + +/* -------------------------------------------- Ready screen ------------------------------------------- */ + +/** + * Ready (workouts spec 2.2): the same frame for every sport. Close and the sport as a pill (tap to change it); the + * live heart rate, what the strap or phone will do, the sport's one choice, and Start named for the sport. Recording + * starts even when the strap is away; the strap's history fills in after it syncs. + * + * Everything opens on what was chosen last time for the sport ([settings]); Ready never asks what it can answer + * itself. A sport with an outdoor and an indoor side saves the type of the side chosen ([Workouts.Sides]) and opens + * on the side last chosen ([sides], saved through [onSide]). An outdoor sport warms GPS up while Ready shows; Start + * never waits for it. + * + * No sport has a settings sheet: the heart on its own card, then one plan card typed in place, and switch rows for + * the rest. A distance or cardio sport (board ReadyFinal) has where you are with Ask to end when I stop, then the goal + * ([ReadyGoal]); a zone goal reads your zones ([maxHeartRate], [HeartZones.current]) and Your zones opens them + * ([onYourZones]). Run intervals save by name ([saved], [onSaveIntervals]) and open on rounds picked on Choose + * ([structure]). + * + * Strength, HIIT, mind and body and games (boards ReadyFinalStrength, ReadyFinalHiit, ReadyFinalMind, + * ReadyFinalGames) light the heart card in the family's colour, then their plan: the sets and rest, the rounds, the + * length, or the match ([StrengthPlanCard] and the others); then their switches, Ask to end when I stop last. [onStart] gets the workout to + * record and the settings to open on next time. + */ +@Composable +fun GlowWorkoutReadyScreen( + sportType: Int, + pickSport: Boolean, + connection: ConnectionState, + liveHeartRate: Int?, + contentPadding: PaddingValues, + onSport: (Int) -> Unit, + onStart: (WorkoutPlan, SportSettings) -> Unit, + onBack: () -> Unit, + settings: SportSettings = SportSettings(), + recentSports: List = emptyList(), + sides: Map = emptyMap(), + onSide: (sport: Int, indoor: Boolean) -> Unit = { _, _ -> }, + onTurnOnLocation: (then: () -> Unit) -> Unit = { it() }, + /** The max heart rate zones are shares of, once worked out, for a zone goal's heart rates. */ + maxHeartRate: Int? = null, + onYourZones: () -> Unit = {}, + /** Run intervals saved by name, and saving one ([onSaveIntervals], its name and code). */ + saved: List = emptyList(), + onSaveIntervals: (name: String, structure: String) -> Unit = { _, _ -> }, + /** Rounds to open on instead of last time's, from a saved workout picked on Choose. */ + structure: String? = null, +) { + var picking by rememberSaveable { mutableStateOf(pickSport) } + val pair = Workouts.sidesOf(sportType) + // The side picked on this screen; until then the one you last chose for the sport, or its default. + var chosenIndoor by rememberSaveable(sportType) { mutableStateOf(null) } + val indoor = chosenIndoor ?: Workouts.indoorSide(sportType, sides) + val type = pair?.type(indoor == true) ?: sportType + val family = Workouts.family(type) + val cardio = family == Workouts.Family.Distance || family == Workouts.Family.IndoorCardio + // Last time's settings until changed here. Keyed on them too: the stored ones load a moment after the screen opens. + // Each choice is keyed on the stored field it starts from, so settings arriving again, or another field changing, + // keeps what was just picked or typed (audit K7). + val key = Workouts.tileType(sportType) + var askToEnd by rememberSaveable(key, settings.askToEnd) { mutableStateOf(settings.askToEnd) } + var keepRoute by rememberSaveable(key, settings.keepRoute) { mutableStateOf(settings.keepRoute) } + val lastRest = Strength.Rest.parse(settings.rest) ?: Strength.Rest.DEFAULT + var sets by rememberSaveable(key, settings.sets) { mutableStateOf(settings.sets) } + var restSeconds by rememberSaveable(key, settings.rest) { mutableStateOf(lastRest.seconds) } + var restBelow by rememberSaveable(key, settings.rest) { mutableStateOf(lastRest.belowBpm) } + // The level End rest early turns back on at, and the sets Plan opens on after Open. + val belowLevel = rememberSaveable(key, settings.rest) { lastRest.belowBpm ?: StartCopy.DEFAULT_BELOW } + var planSets by rememberSaveable(key, settings.sets) { mutableStateOf(settings.sets ?: StartCopy.DEFAULT_SETS) } + var log by rememberSaveable(key, settings.log) { mutableStateOf(settings.log) } + // HIIT's rounds (Tabata unless last time was another), or a game's periods; null is Free, or free play. + var structureCode by rememberSaveable(key, settings.structure, structure) { + mutableStateOf(structure ?: settings.structure ?: Structure.TABATA.code.takeIf { family == Workouts.Family.Intervals }) + } + // A cardio sport's goal, or mind and body's length: the kind chosen and each kind's value, from last time's goal or reps. + var draft by rememberSaveable(key, settings.goal, settings.structure, structure, stateSaver = GoalDraftSaver) { + mutableStateOf(goalDraft(settings, structure)) + } + // Typing outlives a rotation or a fold, so the half-typed value comes back with its keyboard. + var typing by rememberSaveable { mutableStateOf(false) } + val context = LocalContext.current + var locationOn by remember { mutableStateOf(locationAvailable(context)) } + LifecycleResumeEffect(Unit) { + locationOn = locationAvailable(context) + onPauseOrDispose { } + } + val outdoor = Workouts.gpsOffered(sportType, indoor) + // GPS wherever it's offered and the phone's location can be used; the indoor side is the way without it. + val useGps = outdoor && locationOn + val strength = family == Workouts.Family.Strength + val mind = family == Workouts.Family.MindBody + val hasDistance = useGps || type in SportTypes.ON_FOOT + val kinds = WorkoutSetup.goalKinds(type, hasDistance) + // A kind that doesn't fit the side now (a distance indoors on a bike) shows as no goal. + val kind = draft.kind.takeIf { it in kinds } ?: WorkoutSetup.GoalKind.None + // Rounds only where they fit: HIIT, a game's periods, and reps for a run (from the goal card). + val rounds = if (cardio) draft.reps.takeIf { kind == WorkoutSetup.GoalKind.Intervals && Structure.fits(it, type) } + else Structure.parse(structureCode)?.takeIf { Structure.fits(it, type) } + val goal = when { + cardio -> when (kind) { + WorkoutSetup.GoalKind.Distance -> WorkoutTarget.Distance(draft.meters) + WorkoutSetup.GoalKind.Time -> WorkoutTarget.Time(draft.seconds) + WorkoutSetup.GoalKind.Zone -> draft.zones + else -> null + } + mind -> WorkoutTarget.Time(draft.seconds).takeIf { draft.kind == WorkoutSetup.GoalKind.Time } + // Strength, HIIT and games set no goal here; one saved from before still counts where nothing replaces it. + else -> WorkoutTarget.parse(settings.goal)?.takeIf { rounds == null && WorkoutSetup.offered(it, hasDistance, kcal = false) } + } + // GPS warms up while Ready shows, so the fix is often there by Start. + var accuracy by remember(useGps) { mutableStateOf(null) } + if (useGps) DisposableEffect(Unit) { + val location = WorkoutLocation(context) { accuracy = it.accuracy } + location.start() + onDispose { location.stop() } + } + val haptics = rememberHaptics() + if (picking) { + SportPicker(type, recentSports, contentPadding, onPick = { onSport(it); picking = false }, onBack = { if (pickSport) onBack() else picking = false }) + return + } + BackHandler(onBack = onBack) + val focusManager = LocalFocusManager.current + // Back while typing puts the keyboard and the caret away first. + BackHandler(enabled = typing) { focusManager.clearFocus() } + val start = { + haptics.perform(HapticEvent.WorkoutStart) + val rest = Strength.Rest(restSeconds, restBelow) + onStart( + WorkoutPlan( + type, askToEnd, goal.takeIf { !strength }, useGps, useGps && keepRoute, + rest.takeIf { strength }, sets.takeIf { strength }, logSets = strength && log, structure = rounds?.code, + ), + settings.copy( + goal = if (strength) settings.goal else goal?.code, askToEnd = askToEnd, gps = true, keepRoute = keepRoute, + rest = if (strength) rest.code else settings.rest, sets = if (strength) sets else settings.sets, + log = if (strength) log else settings.log, structure = rounds?.code, + ), + ) + } + WorkoutPage( + Color.Transparent, contentPadding, + // While a goal is typed the keyboard has the bottom of the screen. + pinned = if (typing) null else ({ WorkoutButton(StartCopy.startLabel(type), Color.White, Color.Black, start, icon = AppIcons.PlayFill, height = 64.dp) }), + ) { + Box(Modifier.fillMaxWidth()) { + RoundIcon(AppIcons.X, "Close", onBack, Modifier.align(Alignment.CenterStart)) + Box(Modifier.align(Alignment.Center)) { Pill(Workouts.shortName(type), null, onClick = { picking = true }, caret = true) } + } + if (cardio) { + ReadyHeartCard(connection, liveHeartRate) + ReadyWhere( + type, pair, indoor == true, outdoor, locationOn, StartCopy.gpsStatus(accuracy), + onSwitch = { + val next = indoor != true + chosenIndoor = next + onSide(pair!!.outdoor, next) + }, + onTurnOnLocation = { + if (!WorkoutLocation.granted(context)) onTurnOnLocation { locationOn = locationAvailable(context) } + else context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) + }, + askToEnd = askToEnd, onAskToEnd = { askToEnd = it }, + keepRoute = keepRoute.takeIf { useGps }, onKeepRoute = { keepRoute = it }, + ) + val zones by HeartZones.current.collectAsState() + ReadyGoal( + type, kinds, hasDistance, draft.copy(kind = kind), { draft = it }, typing, { typing = it }, zones, maxHeartRate, onYourZones, + saved.filter { Workouts.tileType(it.sport) == key }, + onSave = { name, reps -> onSaveIntervals(name, reps.code) }, + ) + return@WorkoutPage + } + ReadyHeartCard(connection, liveHeartRate, familyColor(family)) + when (family) { + Workouts.Family.Strength -> StrengthPlanCard( + type, sets, { sets = it; if (it != null) planSets = it }, planSets, restSeconds, { restSeconds = it }, typing, { typing = it }, + ) + Workouts.Family.Intervals -> HiitPlanCard(type, rounds, { structureCode = it?.code }, typing, { typing = it }) + Workouts.Family.MindBody -> MindPlanCard( + type, draft.seconds.takeIf { draft.kind == WorkoutSetup.GoalKind.Time }, + { draft = if (it == null) draft.copy(kind = WorkoutSetup.GoalKind.None) else draft.copy(kind = WorkoutSetup.GoalKind.Time, seconds = it) }, + draft.seconds, typing, { typing = it }, + ) + else -> GamePlanCard(type, rounds as? Structure.Periods, { structureCode = it?.code }, typing, { typing = it }) + } + ReadyRows { + var divider = false + if (strength) { + PageSwitchRow( + StartCopy.END_REST_EARLY, StartCopy.endRestNote(restBelow ?: belowLevel), restBelow != null, + { restBelow = if (it) belowLevel else null }, divider = false, + ) + PageSwitchRow(StartCopy.LOG_SETS, StartCopy.LOG_SETS_NOTE, log, { log = it }) + divider = true + } + if (rounds != null && family == Workouts.Family.Intervals) { + PageSwitchRow(StartCopy.WARM_UP, StartCopy.WARM_UP_NOTE, rounds.warmUp, { structureCode = rounds.warm(it).code }, divider = false) + divider = true + } + PageSwitchRow(StartCopy.ASK_TO_END, StartCopy.ASK_TO_END_NOTE, askToEnd, { askToEnd = it }, divider = divider) + } + } +} + +/** What a cardio sport's goal card opens on: last time's goal or reps ([structure] first, from Choose), each kind's default else. */ +private fun goalDraft(settings: SportSettings, structure: String?): GoalDraft { + val goal = WorkoutTarget.parse(settings.goal) + val reps = Structure.parse(structure ?: settings.structure) as? Structure.Reps + return GoalDraft( + WorkoutSetup.goalKind(goal, reps), + (goal as? WorkoutTarget.Distance)?.meters ?: WorkoutSetup.DEFAULT_DISTANCE.meters, + (goal as? WorkoutTarget.Time)?.seconds ?: WorkoutSetup.DEFAULT_TIME.seconds, + goal as? WorkoutTarget.Zones ?: WorkoutSetup.DEFAULT_ZONES, + reps ?: StartCopy.DEFAULT_INTERVALS, + ) +} + +/** Keeps the goal card's draft across process death, as the kind, the values and the reps' code. */ +private val GoalDraftSaver = androidx.compose.runtime.saveable.listSaver( + save = { d -> listOf(d.kind.ordinal, d.meters, d.seconds, d.zones.low, d.zones.high, d.reps.code) }, + restore = { v -> + GoalDraft( + WorkoutSetup.GoalKind.entries[v[0] as Int], v[1] as Int, v[2] as Long, WorkoutTarget.Zones(v[3] as Int, v[4] as Int), + Structure.parse(v[5] as String) as? Structure.Reps ?: StartCopy.DEFAULT_INTERVALS, + ) + }, +) + +/** + * A round button with its icon, 44 dp on the Control surface as on the workout pages' top bars; a stepper's are 52 dp, + * the map's sit on [surface] over the map. + */ +@Composable +internal fun RoundIcon( + icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, onClick: () -> Unit, modifier: Modifier = Modifier, + size: Dp = 44.dp, surface: Color = Raised, ink: Color = Color.White, iconSize: Dp = 18.dp, +) { + Box( + modifier.size(size).clip(CircleShape).background(surface).pressClickable(onClick, CircleShape), + contentAlignment = Alignment.Center, + ) { Icon(icon, label, tint = ink, modifier = Modifier.size(iconSize)) } +} + +/** Minus, the [value] between, and plus: Ready's goal steppers ([buttonSize] 52 dp) and the log card's. */ +@Composable +internal fun MinusPlus( + less: String, more: String, onLess: () -> Unit, onMore: () -> Unit, modifier: Modifier = Modifier, + buttonSize: Dp = 44.dp, gap: Dp = 0.dp, value: @Composable RowScope.() -> Unit, +) { + val iconSize = if (buttonSize > 44.dp) 20.dp else 18.dp + Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(gap)) { + RoundIcon(AppIcons.Minus, less, onLess, size = buttonSize, iconSize = iconSize) + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, content = value) + RoundIcon(AppIcons.Plus, more, onMore, size = buttonSize, iconSize = iconSize) + } +} + +/** The strap in one line with its dot, on Choose: "Strap ready, 72 bpm". */ +@Composable +internal fun StrapLine(connection: ConnectionState, liveHeartRate: Int?) { + Row(Modifier.padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Canvas(Modifier.size(6.dp)) { drawCircle(if (connection is ConnectionState.Connected) TrendGood else TrendBad) } + Spacer(Modifier.width(8.dp)) + Text(StartCopy.strapLine(connection, liveHeartRate), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + } +} + +/** The label over a row of chips, in Caption. */ +@Composable +internal fun ChipLabel(text: String) = + Text(text, color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.caption, modifier = Modifier.padding(start = 4.dp)) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/ScoreDetail.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/ScoreDetail.kt index 7958a989..ae23c4b6 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/ScoreDetail.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/ScoreDetail.kt @@ -1,5 +1,8 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.RecoveryCopy +import org.openstrap.app.presentation.Units import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,11 +19,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.flowOf +import org.openstrap.app.presentation.chargeShown import org.openstrap.app.data.DailyMetric import org.openstrap.app.data.History import org.openstrap.app.data.Recovery import org.openstrap.app.data.RecoveryResult import org.openstrap.app.ui.components.detailNumber +import org.openstrap.app.ui.dashboard.ActivityHistory import org.openstrap.app.ui.dashboard.DailyHistory import org.openstrap.app.ui.dashboard.DayUiState import org.openstrap.app.ui.dashboard.Detail @@ -36,9 +41,7 @@ import org.openstrap.app.ui.dashboard.colorOf import org.openstrap.app.ui.theme.MetricColors import java.time.LocalDate import java.time.LocalTime -import java.time.format.DateTimeFormatter -import java.time.format.TextStyle -import java.util.Locale +import org.openstrap.app.ui.glow.draw.DotSize /* * Recovery's and Charge's details (spec 6.1) in the same template as a metric's, and the way to the Sleep tab's @@ -62,23 +65,22 @@ fun GlowScoreDetailScreen( onSelectDay: (LocalDate) -> Unit, contentPadding: PaddingValues, onBack: () -> Unit, - /** For the Activity details: the workouts, each day's Heart points, and the way to the profile. */ + /** For the Activity details: the workouts, each day's Heart points and stored energy, and the way to the profile. */ workouts: List = emptyList(), - paiHistory: PaiHistory? = null, + activity: ActivityHistory = ActivityHistory(), onEditProfile: () -> Unit = {}, - energyHistory: EnergyHistory? = null, - /** For the Naps detail: keeps a nap's own times, the strap's nap first. */ - onSetNapTimes: (org.openstrap.app.data.db.SleepSession, Long, Long) -> Unit = { _, _, _ -> }, + /** For the Sleep score and Naps details: moving, deleting and restoring a sleep or nap. */ + sleepEditing: SleepEditing = SleepEditing(), ) { val r = recovery?.takeIf { it.date == state.date } when (score) { Score.Recovery -> RecoveryDetail(state, r, strapScore, history, onOpen, onSwitch, onSelectDay, contentPadding, onBack) Score.Charge -> ChargeDetail(state, r, history, onOpen, onSwitch, onSelectDay, contentPadding, onBack) - Score.Sleep -> SleepScoreDetail(state, r, history, onOpen, onSwitch, onSelectDay, contentPadding, onBack) + Score.Sleep -> SleepScoreDetail(state, r, history, onOpen, onSwitch, onSelectDay, contentPadding, onBack, sleepEditing) Score.Regularity -> RegularityDetail(state, r, history, onOpen, onSwitch, onSelectDay, contentPadding, onBack) - Score.Naps -> NapsDetail(state, r, onOpen, onSwitch, onSelectDay, contentPadding, onBack, onSetNapTimes) + Score.Naps -> NapsDetail(state, r, onOpen, onSwitch, onSelectDay, contentPadding, onBack, sleepEditing) Score.HeartPoints, Score.ActiveMinutes, Score.Load, Score.Vo2Max, Score.Recover, Score.Energy, Score.Distance -> ActivityScoreDetail( - score, ActivityDetailContext(state, r, workouts, paiHistory, history, onOpen, onSwitch, onSelectDay, onEditProfile, contentPadding, onBack, energyHistory), + score, ActivityDetailContext(state, r, workouts, activity, history, onOpen, onSwitch, onSelectDay, onEditProfile, contentPadding, onBack), ) } } @@ -121,7 +123,7 @@ private fun RecoveryDetail( val withData = week.filter { it.second != null }.map { it.first }.toSet() + later val known = week.mapNotNull { it.second } - val headline = scrub ?: when { + val headline = when { range == Range.Week && known.isNotEmpty() -> DetailHeadline(Math.round(known.average()).toString(), "Average of the last 7 days", null) range == Range.Week -> DetailHeadline.none("No scores", "In the last 7 days") score != null -> DetailHeadline(score.toString(), if (isToday) "this morning" else "that morning", TodayCopy.recoveryWord(score, alert)) @@ -133,9 +135,9 @@ private fun RecoveryDetail( Score.Recovery.title, color, siblings(Detail.OfScore(Score.Recovery), onSwitch), onInfo = { info = true }, onBack = onBack, contentPadding = contentPadding, headline = headline, strip = DetailCopy.strip(state.date, today, withData), onDay = onSelectDay, - ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, + ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, scrubbed = { scrub }, // The arc gauge (chart kit, spec 2.7) holds the day's score, centred, the number inside the half circle. - heroValue = (score ?: estimate)?.takeIf { range == Range.Day && scrub == null }?.let { shown -> + heroValue = (score ?: estimate)?.takeIf { range == Range.Day }?.let { shown -> { ArcGauge(shown / 100f, color, key = state.date) { DetailScoreNumber(shown) } } }, ) { @@ -160,7 +162,7 @@ private fun RecoveryDetail( all.forEachIndexed { i, c -> ContributorRow( TabCopy.factorName(c.factor), MetricColors.readiness, TabCopy.contributorWord(c.score), c.score / 100f, - offNormal = TabCopy.contributorOff(c.score), divider = i > 0, detail = readable(c.detail), + offNormal = TabCopy.contributorOff(c.score), divider = i > 0, detail = RecoveryCopy.detail(c.detail), ) } } @@ -172,9 +174,11 @@ private fun RecoveryDetail( val values = week.map { it.second?.toFloat() } // TODO(step 6): your normal as a band, once the app keeps a normal of the score; every day stays dim until then. NormalTrend( - values, DetailCopy.levels(values, null), color, labels = week.map { shortDay(it.first) }, + values, DetailCopy.levels(values, null), color, labels = week.map { DayWords.weekdayShort(it.first) }, onScrub = { i -> - scrub = i?.let { week[it] }?.let { (d, v) -> v?.let { DetailHeadline(it.toString(), d.format(DateTimeFormatter.ofPattern("EEE d MMM")), TodayCopy.recoveryWord(it, false)) } } + scrub = i?.let { week[it] }?.let { (d, v) -> + v?.let { DetailHeadline(it.toString(), DayWords.short(d), TodayCopy.recoveryWord(it, false)) } + } }, ) } @@ -190,10 +194,7 @@ private fun RecoveryDetail( ) } -/** A contributor's reading as the engine words it ("52 ms, -8% vs usual"). */ -private fun readable(detail: String): String? = detail.takeIf { it.isNotBlank() } -private fun shortDay(d: LocalDate) = d.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) /* ----------------------------------------------- Charge --------------------------------------------- */ @@ -225,13 +226,13 @@ private fun ChargeDetail( // The latest Charge is where the curve ends, which can be well before now while the strap catches up. val lastAt = c?.points?.lastOrNull { it.state != Recovery.State.NoData }?.t val stale = isToday && lastAt != null && System.currentTimeMillis() / 1000 - lastAt > 30 * 60 - val headline = scrub ?: when { + val headline = when { c == null || !c.anchored -> DetailHeadline.none("Waiting", "After your first night") now == null -> DetailHeadline.none("No reading", "Needs a few hours of heart rate") else -> DetailHeadline( now.toString(), when { - stale -> "at ${clockAt(lastAt)}" + stale -> "at ${clock(lastAt)}" isToday -> "now" else -> "at the end of the day" }, @@ -247,7 +248,7 @@ private fun ChargeDetail( Score.Charge.title, color, siblings(Detail.OfScore(Score.Charge), onSwitch), onInfo = { info = true }, onBack = onBack, contentPadding = contentPadding, headline = headline, strip = DetailCopy.strip(state.date, today, withData), onDay = onSelectDay, - ranges = listOf(Range.Day), range = Range.Day, onRange = {}, + ranges = listOf(Range.Day), range = Range.Day, onRange = {}, scrubbed = { scrub }, ) { item { if (c == null || now == null || c.points.size < 2) DetailEmpty(TabCopy.chargeLine(now, atWake, c?.anchored == true)) @@ -260,7 +261,10 @@ private fun ChargeDetail( GlowLineChart( values, height = if (hours < SHORT_CURVE_HOURS) 110.dp else 200.dp, labels = HOURS, color = color, showAverage = false, yRange = 0f..100f, onScrub = { i -> - scrub = i?.let { values[it] }?.let { v -> DetailHeadline(chargeShown(v).toString(), "at ${clockAt(day.first + i * step)}", Narrative.chargeHeadline(chargeShown(v))) } + scrub = i?.let { values[it] }?.let { v -> + val shown = chargeShown(v) + DetailHeadline(shown.toString(), "at ${clock(day.first + i * step)}", Narrative.chargeHeadline(shown)) + } }, ) // A figure the day doesn't have (no waking without a night's sleep) is left out, the others share the row. @@ -296,18 +300,6 @@ private fun ChargeDetail( ) } -/** Averages charge samples into evenly spaced buckets across [range]; empty buckets are gaps. */ -private fun bucket(points: List, range: LongRange, seconds: Long): List { - val n = ((range.last - range.first + 1) / seconds).toInt().coerceAtLeast(2) - val sums = FloatArray(n) - val counts = IntArray(n) - points.forEach { - val i = ((it.t - range.first) / seconds).toInt() - if (i in 0 until n) { sums[i] += it.value; counts[i]++ } - } - return List(n) { if (counts[it] == 0) null else sums[it] / counts[it] } -} - /* ---------------------------------------------- Related --------------------------------------------- */ /** @@ -319,7 +311,7 @@ private fun ScoreRelated(score: Score, state: DayUiState, r: RecoveryResult?, hi val ready = r?.readiness // Before the first night Charge is only the simulation's starting guess: a dash, as on the Recovery tab's battery. val charge = r?.charge?.takeIf { it.anchored }?.latest?.let(::chargeShown) - val chargeWaiting = if (r?.charge?.anchored == false) org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT else "Nothing for this day" + val chargeWaiting = if (r?.charge?.anchored == false) org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT else NO_VALUE val asleep = state.asleepSeconds val metrics = if (score == Score.Recovery) listOf(Metric.Hrv, Metric.RestingHr) else listOf(Metric.Stress) ListPanel(Modifier.padding(top = 8.dp), title = "Related") { @@ -328,11 +320,11 @@ private fun ScoreRelated(score: Score, state: DayUiState, r: RecoveryResult?, hi onClick = { onOpen(Detail.OfScore(Score.Charge)) }, divider = false, icon = Score.Charge.rowIcon(), ) else ListRow( "Recovery", MetricColors.readiness, ready?.score?.toString(), - ready?.score?.let { TodayCopy.recoveryWord(it, ready.strainAlert) } ?: "Nothing for this day", + ready?.score?.let { TodayCopy.recoveryWord(it, ready.strainAlert) } ?: NO_VALUE, onClick = { onOpen(Detail.OfScore(Score.Recovery)) }, divider = false, icon = Score.Recovery.rowIcon(), ) ListRow( - "Last night", MetricColors.sleepDeep, asleep?.let(::clockDuration), if (asleep == null) "No sleep recorded" else "Asleep", + "Last night", MetricColors.sleepDeep, asleep?.let { Units.duration(it) }, if (asleep == null) "No sleep recorded" else "Asleep", onClick = { onOpen(Detail.OfScore(Score.Sleep)) }, icon = Score.Sleep.rowIcon(), ) metrics.forEach { m -> @@ -340,7 +332,7 @@ private fun ScoreRelated(score: Score, state: DayUiState, r: RecoveryResult?, hi val v = dayValue(m, state) val status = History.status(v, normal) ListRow( - m.title, colorOf(m), v?.let(m::format), if (v == null) "Nothing for this day" else DetailCopy.statusWord(status) ?: m.unit.ifEmpty { null }, + m.title, colorOf(m), v?.let(m::format), if (v == null) NO_VALUE else DetailCopy.statusWord(status) ?: m.unit.ifEmpty { null }, offNormal = status != null && status != 0, onClick = { onOpen(Detail.OfMetric(m)) }, icon = m.rowIcon(), ) } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/ShareCards.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/ShareCards.kt new file mode 100644 index 00000000..c7e382d5 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/ShareCards.kt @@ -0,0 +1,651 @@ +package org.openstrap.app.ui.glow + +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.ui.glow.draw.EdgeSurface +import androidx.compose.ui.graphics.asAndroidPath +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.foundation.Canvas +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.IntrinsicSize +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +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.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import java.time.Instant +import java.time.ZoneId +import kotlin.math.cos +import org.openstrap.app.data.Phases +import org.openstrap.app.data.Route +import org.openstrap.app.data.Strength +import org.openstrap.app.data.Structure +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.Workouts +import org.openstrap.app.ui.glow.draw.DotFont +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.presentation.EarnedLine +import org.openstrap.app.ui.workout.FamilySummary +import org.openstrap.app.ui.workout.PhoneSummary +import org.openstrap.app.ui.workout.ZONE_COLORS +import org.openstrap.app.ui.workout.familyColor +import org.openstrap.app.ui.glow.draw.DotSize + +/* + * The story cards a workout shares as (workouts spec 2.9, boards ShareRun to ShareGame): 9 by 16, one for each kind + * of workout, lit from below in the kind's colour or laid over your photo with a dark fade so the text reads. Drawn at + * [StoryWidth] by [StoryHeight] and scaled, so the preview and the saved 1080 by 1920 picture are the same card. + */ + +/** The card's own width, in its own dp: the saved picture is three pixels to each. */ +internal val StoryWidth = 360.dp + +/** The card's own height, 16 to [StoryWidth]'s 9. */ +internal val StoryHeight = 640.dp + +/** A chip on a card: the best it set, else the weekly goal. */ +internal class StoryChip(val text: String, val best: Boolean, val met: Boolean) + +/** What a share card says, by kind of workout. Every text is ready to show. */ +internal sealed interface StoryCard { + val family: Workouts.Family + + /** A run, ride or walk: distance, time and pace (or what it has), a best, the sport and the date. */ + class Run(val stats: List, val chip: StoryChip?, val footer: String) : StoryCard { + override val family = Workouts.Family.Distance + } + + /** Strength: kg lifted in dots, each exercise's sets and top weight, then the total. */ + class Lift(val whenLine: String, val sport: String, val value: String, val unit: String, val rows: List>, val chip: StoryChip?) : StoryCard { + override val family = Workouts.Family.Strength + } + + /** Indoor cardio: the time in dots, the zone strip, average heart rate and kcal, and a chip. */ + class Indoor(val date: String, val sport: String, val time: String, val zones: List, val line: String?, val chip: StoryChip?) : StoryCard { + override val family = Workouts.Family.IndoorCardio + } + + /** Intervals: rounds in dots, a column for each round's peak, the peak and the work. */ + class Rounds(val whenLine: String, val sport: String, val done: String, val unit: String, val peaks: List, val maxHeartRate: Int, val line: String?) : StoryCard { + override val family = Workouts.Family.Intervals + } + + /** Mind and body: minutes in dots and how the heart settled, over its line. */ + class Calm(val sport: String, val whenLine: String, val minutes: String, val unit: String, val line: String?, val heart: List) : StoryCard { + override val family = Workouts.Family.MindBody + } + + /** + * A week (the recap's Share week): the [range], the workouts [count] in dots with the [goalLine], its [days] as + * the app's week dots, the [totals] and up to two chips, the streak and a best. + */ + class Week(val range: String, val count: String, val goalLine: String, val days: List>, val totals: List, val chips: List) : StoryCard { + // Not a kind of workout: the card is lit in the goal green, as the recap is. + override val family = Workouts.Family.Distance + } + + /** A game: minutes in dots, Load and the peak. */ + class Game(val sport: String, val minutes: String, val unit: String, val load: String?, val peak: String?) : StoryCard { + override val family = Workouts.Family.Games + } +} + +/** A week's total on its card: its icon, value, the small unit after it, and its label. */ +internal class WeekFigure(val icon: androidx.compose.ui.graphics.vector.ImageVector, val value: String, val unit: String?, val label: String) + +/** A run's figure: its value, the small unit after it, and its label. */ +internal class StoryStat(val value: String, val unit: String?, val label: String) + +/** + * The card for [summary]'s workout of [family], from the [hero] its summary shows, what it [earned] and your + * [usualLoad] for a game's Load. [now] names the day. + */ +internal fun storyCard(family: Workouts.Family, summary: PhoneSummary, hero: SummaryHero, earned: List, usualLoad: Float?, now: Long, zone: ZoneId = ZoneId.systemDefault()): StoryCard { + val a = summary.analysis + val active = a.activeSeconds + val readings = FamilySummary.readings(summary) + val whenLine = FamilySummary.whenLine(a.shownStart, now, zone) + val date = DayWords.monthDay(Instant.ofEpochSecond(a.shownStart).atZone(zone).toLocalDate()) + val avg = a.workout.averageHeartRate + val kcal = kcalFigure(a) + val (minutes, minUnit) = minutesFigure(active) + val chip = earned.firstOrNull { it.kind == EarnedLine.Kind.Best }?.let { StoryChip(it.text, best = true, met = false) } + ?: earned.firstOrNull { it.kind == EarnedLine.Kind.Goal }?.let { StoryChip(listOfNotNull(it.text, it.value).joinToString(", "), best = false, met = it.met) } + return when (family) { + Workouts.Family.Distance -> { + val m = summary.distanceMeters?.takeIf { it > 0f } + val pace = hero.pace + val stats = if (m != null) listOfNotNull( + StoryStat(hero.value, hero.unit, "Distance"), + StoryStat(workoutClock(active), null, "Time"), + pace?.let { StoryStat(it, "/km", "Pace") } ?: avg?.let { StoryStat("$it", null, "Avg bpm") }, + ) else listOfNotNull( + StoryStat(workoutClock(active), null, "Time"), + avg?.let { StoryStat("$it", null, "Avg bpm") }, + kcal?.let { StoryStat(it, null, "Active kcal") }, + ) + StoryCard.Run(stats, chip, "${a.sportName}, $date") + } + Workouts.Family.Strength -> { + val lifted = Strength.kgLifted(summary.sets) + val done = FamilySummary.setsDone(summary.sets) + val total = listOf("$minutes $minUnit") + if (done > 0) listOf("$done ${if (done == 1) "set" else "sets"}") else emptyList() + StoryCard.Lift( + whenLine, a.sportName, hero.value, if (lifted != null) "kg" else hero.unit, + exerciseRows(summary.sets).take(MAX_EXERCISES) + ("Total" to total.joinToString(", ")), chip, + ) + } + Workouts.Family.IndoorCardio -> StoryCard.Indoor( + date, a.sportName, dotClock(active), a.zoneSeconds, + listOfNotNull(avg?.let { "$it avg bpm" }, kcal?.let { "$it kcal" }).joinToString(", ").ifEmpty { null }, chip, + ) + Workouts.Family.Intervals -> { + val done = Phases.roundsDone(summary.rounds) + val reps = summary.structure is Structure.Reps + val planned = (summary.structure as? Structure.Timed)?.rounds ?: (summary.structure as? Structure.Reps)?.rounds + val word = if (reps) "reps" else "rounds" + val lines = FamilySummary.rounds(summary.rounds, readings) + val peak = lines.mapNotNull { it.peak }.maxOrNull() ?: readings.maxOfOrNull { it.second } + val work = FamilySummary.workSeconds(summary.rounds) + val workText = if (work >= 60) "${work / 60} min of work" else "$work s of work" + if (done == 0) StoryCard.Rounds(whenLine, a.sportName, minutes, minUnit, emptyList(), a.maxHeartRate, peak?.let { "Peak $it bpm" }) + else StoryCard.Rounds( + whenLine, a.sportName, "$done", if (planned != null && planned >= done) "of $planned $word" else word, + lines.map { it.peak }, a.maxHeartRate, listOfNotNull(peak?.let { "Peak $it bpm" }, workText).joinToString(", "), + ) + } + Workouts.Family.MindBody -> { + val settled = FamilySummary.settled(readings, a.shownStart, a.end) + StoryCard.Calm( + a.sportName, whenLine, minutes, minUnit, + settled?.let { (f, t) -> FamilySummary.settledShort(f, t) }, heartLine(readings), + ) + } + Workouts.Family.Games -> { + val load = a.workout.trainingLoad + StoryCard.Game( + a.sportName, minutes, minUnit, + load?.let { FamilySummary.loadLine(it, usualLoad) }, + readings.maxOfOrNull { it.second }?.let { "Peak $it" }, + ) + } + } +} + +/** Exercises a strength card names; the total follows them. */ +private const val MAX_EXERCISES = 4 + +/** + * Each named exercise in the order first done: "5 × 10, top 65 kg" when every set had the same reps, else "4 sets, + * top 65 kg", without the top when nothing was weighed. Sets without a name aren't listed. + */ +internal fun exerciseRows(sets: List): List> { + val done = sets.filter { it.end != null && !it.exercise.isNullOrBlank() } + return done.groupBy { it.exercise!!.trim().lowercase() }.values.map { mine -> + val n = mine.size + val reps = mine.map { it.reps }.distinct().singleOrNull() + val top = mine.mapNotNull { it.weightKg }.maxOrNull() + val count = if (reps != null) "$n × $reps" else "$n ${if (n == 1) "set" else "sets"}" + mine.first().exercise!!.trim() to (count + (top?.let { ", top ${Strength.kgText(it)} kg" } ?: "")) + } +} + +/** [readings] as 40 averages, for a calm card's heart line; empty under ten readings. */ +private fun heartLine(readings: List>): List { + if (readings.size < 10) return emptyList() + return FamilySummary.heartLine(readings, 40) +} + +/** How a run's route shows on its card. Map, the route over a map, is to come. */ +internal enum class StoryRoute { Off, Line } + +/** The route's geometry on a card: its ends cut for privacy, and fitted into a box. Pure, so it's tested. */ +internal object StoryRouteShape { + /** What "Hide start and end" cuts from each end of the line, in metres. */ + const val HIDE_M = 200.0 + + /** + * [points] less the first and last [meters] along the way, cut between fixes so the line stops where the distance + * runs out. Empty when nothing is left between the two. + */ + fun trimEnds(points: List, meters: Double = HIDE_M): List { + if (points.size < 2) return emptyList() + val along = DoubleArray(points.size) + for (i in 1 until points.size) along[i] = along[i - 1] + Route.meters(points[i - 1], points[i]) + val from = meters + val to = along.last() - meters + if (to <= from) return emptyList() + fun at(d: Double): Route.Point { + val i = (1 until points.size).first { along[it] >= d } + val a = points[i - 1] + val b = points[i] + val span = along[i] - along[i - 1] + val f = if (span <= 0) 0.0 else (d - along[i - 1]) / span + return a.copy(t = a.t + ((b.t - a.t) * f).toLong(), lat = a.lat + (b.lat - a.lat) * f, lon = a.lon + (b.lon - a.lon) * f) + } + return listOf(at(from)) + points.indices.filter { along[it] > from && along[it] < to }.map { points[it] } + at(to) + } + + /** + * [points] in a [width] by [height] box, north up, to scale (longitude narrowed by the latitude) and centred. + * Empty without two points apart. + */ + fun fit(points: List, width: Float, height: Float): List { + if (points.size < 2) return emptyList() + val narrow = cos(Math.toRadians(points.sumOf { it.lat } / points.size)) + val xs = points.map { it.lon * narrow } + val ys = points.map { -it.lat } + val spanX = xs.max() - xs.min() + val spanY = ys.max() - ys.min() + if (spanX <= 0 && spanY <= 0) return emptyList() + val scale = minOf(if (spanX > 0) width / spanX else Double.MAX_VALUE, if (spanY > 0) height / spanY else Double.MAX_VALUE) + val left = (width - spanX * scale) / 2 + val top = (height - spanY * scale) / 2 + return points.indices.map { i -> Offset((left + (xs[i] - xs.min()) * scale).toFloat(), (top + (ys[i] - ys.min()) * scale).toFloat()) } + } +} + +/** + * The card for [card], at [StoryWidth] by [StoryHeight] in whatever density it's given: over [photo] with the dark + * fade, else lit from below in its colour, and a run's [route] as a glowing white line when there is one. + */ +@Composable +internal fun StoryCardView(card: StoryCard, photo: ImageBitmap?, route: List, modifier: Modifier = Modifier) { + Box(modifier.size(StoryWidth, StoryHeight).background(EdgeSurface)) { + if (photo != null) { + Image(photo, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) + PhotoFade(top = card !is StoryCard.Run && card !is StoryCard.Game) + } else StoryGlow(storyColor(card), if (card is StoryCard.Calm) 0.6f else 0.95f) + if (route.size >= 2) RouteLine(route, Modifier.fillMaxWidth().padding(horizontal = 48.dp).padding(top = 110.dp).height(240.dp)) + Box(Modifier.fillMaxSize().padding(24.dp)) { + when (card) { + is StoryCard.Run -> RunStory(card) + is StoryCard.Lift -> LiftStory(card) + is StoryCard.Indoor -> IndoorStory(card) + is StoryCard.Rounds -> RoundsStory(card) + is StoryCard.Calm -> CalmStory(card) + is StoryCard.Game -> GameStory(card) + is StoryCard.Week -> WeekStory(card) + } + } + } +} + +/** A card's light: its kind's colour, except indoor cardio's, which is heart red like the board. */ +private fun storyColor(card: StoryCard): Color = when (card) { + is StoryCard.Indoor -> MetricColors.heart + is StoryCard.Week -> MetricColors.good + else -> familyColor(card.family) +} + +/** Where the board's light sits, in card heights from the top, and its size in card widths and heights. */ +private const val STORY_LIGHT_BELOW = 1.12f +private const val STORY_LIGHT_WIDTH = 1.3f +private const val STORY_LIGHT_HEIGHT = 0.65f + +/** The light's middle stop, as a share of its strength, and its fixed rim before it fades out. */ +private const val STORY_LIGHT_MID_AT = 0.38f +private const val STORY_LIGHT_MID = 0.47f +private const val STORY_LIGHT_RIM_AT = 0.7f +private const val STORY_LIGHT_RIM = 0.08f + +/** + * The board's light from below: an ellipse wider than the card centred under its foot, from [strength] of [color] to + * black at its edge. + */ +@Composable +private fun StoryGlow(color: Color, strength: Float) { + Box( + Modifier.fillMaxSize().drawBehind { + drawCardLight( + color, Offset(size.width / 2, size.height * STORY_LIGHT_BELOW), + size.width * STORY_LIGHT_WIDTH, size.height * STORY_LIGHT_HEIGHT, + listOf(0f to strength, STORY_LIGHT_MID_AT to strength * STORY_LIGHT_MID, STORY_LIGHT_RIM_AT to STORY_LIGHT_RIM), + ) + }, + ) +} + +/** The dark fade over a photo: up from the foot so the figures read, and down from the top when the card has text there. */ +@Composable +private fun PhotoFade(top: Boolean) { + Box(Modifier.fillMaxSize()) { + if (top) Box( + Modifier.fillMaxWidth().fillMaxHeight(0.3f).background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = 0.6f), Color.Transparent))), + ) + Box( + Modifier.align(Alignment.BottomCenter).fillMaxWidth().fillMaxHeight(if (top) 0.62f else 0.32f).background( + Brush.verticalGradient(0f to Color.Transparent, 0.35f to Color.Black.copy(alpha = 0.55f), 1f to Color.Black.copy(alpha = 0.85f)), + ), + ) + } +} + +/** The route as a white line with a soft white glow round it, fitted into the space it's given. */ +@Composable +private fun RouteLine(route: List, modifier: Modifier) { + Canvas(modifier) { + val points = StoryRouteShape.fit(route, size.width, size.height) + if (points.size < 2) return@Canvas + val path = Path().apply { moveTo(points[0].x, points[0].y); points.drop(1).forEach { lineTo(it.x, it.y) } } + // The glow: one blurred stroke under the line, so it fades smoothly instead of in steps. + drawIntoCanvas { canvas -> + val glow = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + style = android.graphics.Paint.Style.STROKE + strokeWidth = 8.dp.toPx() + strokeCap = android.graphics.Paint.Cap.ROUND + strokeJoin = android.graphics.Paint.Join.ROUND + color = android.graphics.Color.argb(150, 255, 255, 255) + maskFilter = android.graphics.BlurMaskFilter(8.dp.toPx(), android.graphics.BlurMaskFilter.Blur.NORMAL) + } + canvas.nativeCanvas.drawPath(path.asAndroidPath(), glow) + } + drawPath(path, Color.White, style = Stroke(4.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) + } +} + +/** A card's number in dots at [size], shrunk to fit [maxWidth] when it's long. */ +@Composable +private fun StoryNumber(text: String, size: DotSize, maxWidth: Dp) { + val needed = (size.dot + size.gap) * DotFont.columns(text) - size.gap + DotMatrixText(text, dot = size, scale = if (needed > maxWidth) maxWidth / needed else 1f) +} + +/** The dotted number with its word after it, on the baseline. */ +@Composable +private fun NumberAndUnit(value: String, unit: String, size: DotSize, maxWidth: Dp = 250.dp) { + Row(verticalAlignment = Alignment.Bottom) { + StoryNumber(value, size, maxWidth) + Text(unit, color = Color.White.copy(alpha = 0.8f), style = GlowType.body, maxLines = 1, modifier = Modifier.padding(start = 10.dp)) + } +} + +/** "OpenStrap", with the ring before it on the calm and game cards. */ +@Composable +private fun Brand(ring: Boolean = false) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (ring) { + Canvas(Modifier.size(14.dp)) { drawCircle(Color.White.copy(alpha = 0.8f), radius = size.minDimension * 0.34f, style = Stroke(2.4.dp.toPx())) } + Spacer(Modifier.width(6.dp)) + } + Text("OpenStrap", color = if (ring) Color.White.copy(alpha = 0.8f) else Color.White, style = GlowType.storyBrand, maxLines = 1) + } +} + +/** A best or the weekly goal on a dark pill, its icon in its colour. */ +@Composable +private fun StoryChipView(chip: StoryChip) { + val (icon, tint) = if (chip.best) AppIcons.FlagCheckered to MetricColors.calories else AppIcons.Target to if (chip.met) MetricColors.good else Color.White + Row( + Modifier.height(30.dp).clip(CircleShape).background(Color.Black.copy(alpha = 0.45f)).padding(start = 10.dp, end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, tint = tint, modifier = Modifier.size(15.dp)) + Text(chip.text, color = Color.White, style = ChipText, maxLines = 1, modifier = Modifier.padding(start = 7.dp)) + } +} + +private val ChipText = GlowType.secondary.copy(fontWeight = FontWeight.Medium) + +/** White for a card's dimmer lines, as on the boards. */ +private const val StoryDim = 0.7f + +/** Board ShareRun: everything at the foot, the best over the three figures, the sport and date beside the brand. */ +@Composable +private fun RunStory(card: StoryCard.Run) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(14.dp, Alignment.Bottom)) { + card.chip?.let { StoryChipView(it) } + Row(Modifier.fillMaxWidth()) { + card.stats.forEach { s -> + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.Bottom) { + Text(s.value, color = Color.White, style = GlowType.storyStat, maxLines = 1) + s.unit?.let { Text(" $it", color = Color.White, style = GlowType.body, maxLines = 1, modifier = Modifier.padding(bottom = 3.dp)) } + } + Text(s.label, color = Color.White.copy(alpha = StoryDim), style = GlowType.secondary, maxLines = 1) + } + } + } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(card.footer, color = Color.White.copy(alpha = StoryDim), style = GlowType.secondary, maxLines = 1, modifier = Modifier.weight(1f)) + Brand() + } + } +} + +/** When, then the sport large, as the strength and HIIT cards open. */ +@Composable +private fun StoryHeading(whenLine: String, sport: String) { + Text(whenLine, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1) + Text(sport, color = Color.White, style = GlowType.storyTitle, maxLines = 1, modifier = Modifier.padding(top = 4.dp)) +} + +/** Board ShareStrength: kg lifted in dots, each exercise and the total as rows, the best and the brand at the foot. */ +@Composable +private fun LiftStory(card: StoryCard.Lift) { + Column(Modifier.fillMaxSize()) { + StoryHeading(card.whenLine, card.sport) + Box(Modifier.padding(top = 30.dp)) { NumberAndUnit(card.value, card.unit, DotSize.Detail) } + Column(Modifier.padding(top = 26.dp)) { + card.rows.forEachIndexed { i, (name, text) -> + if (i > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(Color.White.copy(alpha = 0.12f))) + Row(Modifier.fillMaxWidth().padding(vertical = 9.dp), verticalAlignment = Alignment.CenterVertically) { + Text(name, color = Color.White, style = GlowType.body, maxLines = 1, modifier = Modifier.weight(1f).padding(end = 12.dp)) + Text(text, color = Color.White.copy(alpha = 0.8f), style = GlowType.body, maxLines = 1) + } + } + } + Spacer(Modifier.weight(1f)) + FootRow(card.chip) + } +} + +/** A chip, if any, on the left and the brand on the right. */ +@Composable +private fun FootRow(chip: StoryChip?) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { chip?.let { StoryChipView(it) } } + Brand() + } +} + +/** Board ShareIndoor: the brand and date, the time in dots in the middle, then the zones, the figures and a chip. */ +@Composable +private fun IndoorStory(card: StoryCard.Indoor) { + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { Brand() } + Text(card.date, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1) + } + Spacer(Modifier.weight(1f)) + Text(card.sport, color = Color.White, style = GlowType.cardTitle, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + Box(Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 10.dp), contentAlignment = Alignment.Center) { StoryNumber(card.time, DotSize.Detail, 310.dp) } + Text("Workout time", color = Color.White.copy(alpha = StoryDim), style = GlowType.secondary, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.weight(1f)) + ZoneStrip(card.zones) + card.line?.let { Text(it, color = Color.White, style = GlowType.sentence, maxLines = 1, modifier = Modifier.padding(top = 14.dp)) } + card.chip?.let { Box(Modifier.padding(top = 14.dp)) { StoryChipView(it) } } + } +} + +/** + * The week (mockups/share-week-days.html, option A): the brand and the dates, "My week" and the count in dots in the + * middle with the goal under it, the days as the app's week dots, then the totals and the chips. + */ +@Composable +private fun WeekStory(card: StoryCard.Week) { + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { Brand() } + Text(card.range, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1) + } + Spacer(Modifier.weight(1f)) + Text("My week", color = Color.White, style = GlowType.cardTitle, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + Box(Modifier.fillMaxWidth().padding(top = 18.dp, bottom = 10.dp), contentAlignment = Alignment.Center) { StoryNumber(card.count, DotSize.Live, 310.dp) } + Text(card.goalLine, color = Color.White.copy(alpha = StoryDim), style = GlowType.secondary, maxLines = 1, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) + WeekDots(card.days, Color.White, Modifier.padding(top = 28.dp), dot = 34.dp) + Spacer(Modifier.weight(1f)) + if (card.totals.isNotEmpty()) WeekFigures(card.totals) + if (card.chips.isNotEmpty()) Row(Modifier.padding(top = 14.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + card.chips.take(2).forEach { StoryChipView(it) } + } + } +} + +/** + * The week's totals (mockups/share-week-totals.html, option A with icons): each its icon over the value large with its + * unit, the label under, thin rules between them. + */ +@Composable +private fun WeekFigures(figures: List) { + Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { + figures.forEachIndexed { i, f -> + if (i > 0) Box(Modifier.padding(end = 14.dp).width(1.dp).fillMaxHeight().background(Color.White.copy(alpha = 0.18f))) + Column(Modifier.weight(1f)) { + Icon(f.icon, null, tint = Color.White.copy(alpha = 0.85f), modifier = Modifier.size(18.dp)) + Row(Modifier.padding(top = 8.dp), verticalAlignment = Alignment.Bottom) { + Text(f.value, color = Color.White, style = GlowType.storyStat, maxLines = 1) + f.unit?.let { Text(" $it", color = Color.White, style = GlowType.body, maxLines = 1, modifier = Modifier.padding(bottom = 3.dp)) } + } + Text(f.label, color = Color.White.copy(alpha = StoryDim), style = GlowType.secondary, maxLines = 1) + } + } + } +} + +/** Time in each zone as one strip, each zone as long as its share and in its colour, ends rounded. */ +@Composable +private fun ZoneStrip(zones: List) { + val total = zones.sum() + if (total <= 0) return + Row(Modifier.fillMaxWidth().height(10.dp), horizontalArrangement = Arrangement.spacedBy(3.dp)) { + val shown = zones.indices.filter { zones[it] > 0 } + shown.forEachIndexed { i, z -> + val shape = RoundedCornerShape( + topStart = if (i == 0) 5.dp else 2.dp, bottomStart = if (i == 0) 5.dp else 2.dp, + topEnd = if (i == shown.lastIndex) 5.dp else 2.dp, bottomEnd = if (i == shown.lastIndex) 5.dp else 2.dp, + ) + Box(Modifier.weight(zones[z].toFloat() / total).fillMaxHeight().clip(shape).background(ZONE_COLORS[z])) + } + } +} + +/** Board ShareHiit: rounds in dots, a column for each round's peak, the line under them and the brand. */ +@Composable +private fun RoundsStory(card: StoryCard.Rounds) { + Column(Modifier.fillMaxSize()) { + StoryHeading(card.whenLine, card.sport) + Box(Modifier.padding(top = 30.dp)) { NumberAndUnit(card.done, card.unit, DotSize.Detail, maxWidth = 200.dp) } + if (card.peaks.isNotEmpty()) PeakColumns(card.peaks, card.maxHeartRate, Modifier.padding(top = 30.dp)) + card.line?.let { Text(it, color = Color.White, style = GlowType.sentence, maxLines = 2, modifier = Modifier.padding(top = 16.dp)) } + Spacer(Modifier.weight(1f)) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { Brand() } + } +} + +/** + * Each round's peak as a column, as tall as it sits between the lowest and highest: white once it reached the top + * zone, else in its zone's colour, faint without a reading. + */ +@Composable +private fun PeakColumns(peaks: List, maxHr: Int, modifier: Modifier) { + val known = peaks.filterNotNull() + val lo = (known.minOrNull() ?: 0) - 8 + val hi = known.maxOrNull() ?: 1 + Row(modifier.fillMaxWidth().height(72.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.Bottom) { + peaks.forEach { p -> + val share = if (p == null || hi <= lo) 0.3f else 0.35f + 0.65f * (p - lo) / (hi - lo) + val zone = WorkoutRecorder.liveZone(p, maxHr) + val color = when { + p == null -> Color.White.copy(alpha = 0.25f) + zone == null || zone >= ZONE_COLORS.lastIndex -> Color.White + else -> ZONE_COLORS[zone] + } + Box(Modifier.weight(1f).fillMaxHeight(share).clip(RoundedCornerShape(5.dp)).background(color)) + } + } +} + +/** Board ShareMind: the sport and when, minutes in dots low in the middle, how the heart settled over its line. */ +@Composable +private fun CalmStory(card: StoryCard.Calm) { + Column(Modifier.fillMaxSize()) { + Text(card.sport, color = Color.White.copy(alpha = 0.85f), style = GlowType.body.copy(fontWeight = FontWeight.Medium), maxLines = 1) + Text(card.whenLine, color = Color.White.copy(alpha = 0.55f), style = GlowType.secondary, maxLines = 1, modifier = Modifier.padding(top = 2.dp)) + Spacer(Modifier.weight(1f)) + NumberAndUnit(card.minutes, card.unit, DotSize.Detail) + card.line?.let { Text(it, color = Color.White, style = GlowType.sentence, maxLines = 1, modifier = Modifier.padding(top = 16.dp)) } + if (card.heart.count { it != null } >= 2) CalmLine(card.heart, Modifier.padding(top = 16.dp)) + Spacer(Modifier.weight(1f)) + Brand(ring = true) + } +} + +/** The heart rate through the session as one soft white line. */ +@Composable +private fun CalmLine(values: List, modifier: Modifier) { + Canvas(modifier.fillMaxWidth().height(40.dp)) { + val known = values.filterNotNull() + val lo = known.min() - 2 + val hi = known.max() + 2 + val path = Path() + var started = false + values.forEachIndexed { i, v -> + v ?: return@forEachIndexed + val x = i / (values.size - 1f) * size.width + val y = size.height - (v - lo) / (hi - lo) * size.height + if (!started) path.moveTo(x, y) else path.lineTo(x, y) + started = true + } + drawPath(path, Color.White.copy(alpha = 0.55f), style = Stroke(2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) + } +} + +/** Board ShareGame: everything at the foot over the photo, the sport, minutes in dots, Load and the peak, the brand. */ +@Composable +private fun GameStory(card: StoryCard.Game) { + Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Bottom) { + Text(card.sport, color = Color.White, style = GlowType.body.copy(fontWeight = FontWeight.Medium), maxLines = 1) + Box(Modifier.padding(top = 12.dp)) { NumberAndUnit(card.minutes, card.unit, DotSize.Hero) } + card.load?.let { GameLine(it, Modifier.padding(top = 14.dp)) } + card.peak?.let { GameLine(it, Modifier.padding(top = 2.dp)) } + Box(Modifier.padding(top = 16.dp)) { Brand(ring = true) } + } +} + +/** One of a game card's lines under the minutes. */ +@Composable +private fun GameLine(text: String, modifier: Modifier) = + Text(text, color = Color.White, style = GlowType.sentence, maxLines = 1, modifier = modifier) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/ShareScreen.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/ShareScreen.kt new file mode 100644 index 00000000..9a230723 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/ShareScreen.kt @@ -0,0 +1,337 @@ +package org.openstrap.app.ui.glow + +import android.content.ClipData +import android.content.ContentValues +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Environment +import android.provider.MediaStore +import android.util.Log +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +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.PaddingValues +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import java.io.File +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import org.openstrap.app.data.Route +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.Workouts +import org.openstrap.app.ui.components.pressClickable +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.presentation.EarnedLine +import org.openstrap.app.ui.workout.FamilySummary +import org.openstrap.app.ui.workout.PhoneSummary + +/* + * The share screen (workouts spec 2.9, boards ShareRun to ShareGame): the workout's story card, a photo of yours + * behind it if you like, a run's route over it, then Save to the gallery or Share to another app, both as the card + * drawn at 1080 by 1920. + */ + +private const val TAG = "ShareScreen" + +/** The saved picture's size in px: three to each of the card's dp. */ +private const val STORY_PX_PER_DP = 3f + +/** Room under the card for the photo button and its gap. */ +private val PhotoRoom = 64.dp + +/** How long "Saved" stays on the button. */ +private const val SAVED_MS = 2_000L + +/** What the card is being drawn for. */ +private enum class Export { Save, Share } + +/** + * The share screen for [summary]'s workout, from the [hero] its summary shows and what it [earned]. [liveRoute] is the + * route still in memory when the workout's wasn't kept; [workouts] are all of them, for a game's usual Load. + */ +@Composable +internal fun WorkoutShareScreen( + summary: PhoneSummary, hero: SummaryHero, earned: List, liveRoute: List, workouts: List, + contentPadding: PaddingValues, onClose: () -> Unit, +) { + val a = summary.analysis + val card = remember(summary, hero, earned, workouts) { + val usualLoad = Workouts.usualLoad(workouts.filter { it.workout.start != a.workout.start }.map { it.workout.trainingLoad }) + storyCard(FamilySummary.familyOf(a.workout.sportType, summary.structure), summary, hero, earned, usualLoad, System.currentTimeMillis() / 1000) + } + val route = remember(summary, liveRoute) { summary.route.ifEmpty { liveRoute.filter { it.t in a.shownStart..a.end } } } + ShareScreen(card, route, "OpenStrap_${a.workout.start}.png", contentPadding, onClose) +} + +/** + * Hides the app's tab bar while it's given true: the share screen covers the whole screen, even from a page that sits + * over the tabs (the weekly recap). + */ +val LocalHideTabBar = androidx.compose.runtime.staticCompositionLocalOf<(Boolean) -> Unit> { {} } + +/** The share screen for a week's [card], from the recap's Share week. */ +@Composable +internal fun WeekShareScreen(card: StoryCard.Week, fileName: String, onClose: () -> Unit) = + // Just the navigation bar (the page adds the status bar itself): the recap's padding leaves room for the tab bar + // this screen hides. + ShareScreen(card, emptyList(), fileName, androidx.compose.foundation.layout.WindowInsets.navigationBars.asPaddingValues(), onClose) + +/** + * The card for [card] in the middle at 9 by 16, close and the title over it, the photo button and a run's route + * choices under it, then Save and Share. [route] is the workout's, drawn only on a run; the picture saves as [fileName]. + */ +@Composable +private fun ShareScreen(card: StoryCard, route: List, fileName: String, contentPadding: PaddingValues, onClose: () -> Unit) { + BackHandler(onBack = onClose) + val hideTabBar = LocalHideTabBar.current + androidx.compose.runtime.DisposableEffect(Unit) { + hideTabBar(true) + onDispose { hideTabBar(false) } + } + val context = LocalContext.current + var photoUri by rememberSaveable { mutableStateOf(null) } + val photo by produceState(null, photoUri) { value = photoUri?.let { decodePhoto(context, it) } } + var routeMode by rememberSaveable { mutableStateOf(StoryRoute.Line) } + var hideEnds by rememberSaveable { mutableStateOf(true) } + val hasRoute = card is StoryCard.Run && Route.mappable(route) + val shown = remember(route, hasRoute, routeMode, hideEnds) { + if (!hasRoute || routeMode == StoryRoute.Off) emptyList() else if (hideEnds) StoryRouteShape.trimEnds(route) else route + } + val pick = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> if (uri != null) photoUri = uri } + + // Save and Share draw the card again off screen at 1080 by 1920, record it, and turn the recording into a PNG. + var export by remember { mutableStateOf(null) } + var saved by remember { mutableStateOf(null) } + val layer = rememberGraphicsLayer() + val recorded = remember(export) { CompletableDeferred() } + fun save() { + export = Export.Save + } + LaunchedEffect(export) { + val doing = export ?: return@LaunchedEffect + // Clearing export ends this effect, so it waits until the picture has gone where it's going. + try { + recorded.await() + val picture = runCatching { layer.toImageBitmap().asAndroidBitmap().let { if (it.config == Bitmap.Config.HARDWARE) it.copy(Bitmap.Config.ARGB_8888, false) else it } } + .onFailure { Log.w(TAG, "drawing the card failed", it) }.getOrNull() + when { + picture == null -> if (doing == Export.Save) saved = false + doing == Export.Save -> saved = withContext(Dispatchers.IO) { StoryFiles.saveToGallery(context, picture, fileName) } + else -> withContext(Dispatchers.IO) { StoryFiles.forSharing(context, picture) }?.let { uri -> + val send = Intent(Intent.ACTION_SEND).setType("image/png").putExtra(Intent.EXTRA_STREAM, uri) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION).also { it.clipData = ClipData.newRawUri(null, uri) } + context.startActivity(Intent.createChooser(send, null)) + } + } + } finally { + export = null + } + } + LaunchedEffect(saved) { + if (saved == null) return@LaunchedEffect + delay(SAVED_MS) + saved = null + } + + WorkoutPage( + Color.Transparent, contentPadding, scroll = false, + pinned = { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val saveLabel = when (saved) { true -> "Saved"; false -> "Not saved"; null -> "Save" } + WorkoutButton(saveLabel, Raised, Color.White, { if (export == null) save() }, Modifier.weight(1f), icon = if (saved == true) AppIcons.Tick02 else AppIcons.DownloadSimple) + WorkoutButton("Share", Color.White, Color.Black, { if (export == null) export = Export.Share }, Modifier.weight(1f), icon = AppIcons.UploadSimple) + } + }, + ) { + // Close on the left, the title in the middle (board ShareRun). + Box(Modifier.fillMaxWidth().height(44.dp), contentAlignment = Alignment.Center) { + Box(Modifier.align(Alignment.CenterStart)) { RoundButton(AppIcons.X, "Close", onClose, size = 44.dp) } + Text("Share", color = Color.White, style = GlowType.cardTitle, maxLines = 1) + // The picture for Save and Share: the same card at 1080 by 1920, recorded but never drawn on screen. + if (export != null) Box( + Modifier.layout { measurable, _ -> + val p = measurable.measure(Constraints()) + layout(0, 0) { p.place(0, 0) } + }, + ) { + CompositionLocalProvider(LocalDensity provides Density(STORY_PX_PER_DP, 1f)) { + StoryCardView(card, photo, shown, Modifier.drawWithContent { layer.record { this@drawWithContent.drawContent() }; recorded.complete(Unit) }) + } + } + } + // The card as large as the space allows, at 9 by 16, with the photo button right under it, so the button + // never sits in the fade over Save and Share. + BoxWithConstraints(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.TopCenter) { + val height = minOf(maxHeight - PhotoRoom, maxWidth * 16f / 9f, MaxPreviewHeight) + val width = height * 9f / 16f + val density = LocalDensity.current + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.size(width, height).clip(GlowLayout.TileShape)) { + CompositionLocalProvider(LocalDensity provides Density(density.density * (width / StoryWidth), 1f)) { + StoryCardView(card, photo, shown) + } + if (photo != null) Box(Modifier.align(Alignment.TopEnd).padding(10.dp)) { + RoundButton(AppIcons.X, "Remove the photo", { photoUri = null }, size = 32.dp) + } + } + Spacer(Modifier.height(12.dp)) + PhotoButton(if (photo != null) "Change photo" else "Add a photo", Modifier) { + pick.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + } + } + if (hasRoute) { + Row(Modifier.fillMaxWidth().height(34.dp), verticalAlignment = Alignment.CenterVertically) { + Text("Route", color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1, modifier = Modifier.width(84.dp)) + RouteChoice(routeMode) { routeMode = it } + } + if (routeMode == StoryRoute.Line) Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text("Hide start and end", color = Color.White, style = GlowType.body, maxLines = 1, modifier = Modifier.weight(1f)) + GlowSwitch(hideEnds, { hideEnds = it }) + } + } + } +} + +/** The tallest the preview grows on a big screen. */ +private val MaxPreviewHeight = 560.dp + +/** The photo pill under the card: a picture icon and what it does. */ +@Composable +private fun PhotoButton(label: String, modifier: Modifier, onClick: () -> Unit) { + Row( + modifier.height(44.dp).clip(CircleShape).background(Raised).pressClickable(onClick, CircleShape).padding(start = 16.dp, end = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(AppIcons.Image, null, tint = Color.White, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(label, color = Color.White, style = GlowType.body.copy(fontWeight = FontWeight.Medium), maxLines = 1) + } +} + +/** Off or Line on a dark pill, the chosen one white (board ShareRun). */ +@Composable +private fun androidx.compose.foundation.layout.RowScope.RouteChoice(selected: StoryRoute, onSelect: (StoryRoute) -> Unit) { + Row(Modifier.weight(1f).fillMaxHeight().clip(CircleShape).background(Raised).padding(3.dp), horizontalArrangement = Arrangement.spacedBy(3.dp)) { + StoryRoute.entries.forEach { mode -> + val on = mode == selected + Box( + Modifier.weight(1f).fillMaxHeight().clip(CircleShape).background(if (on) Color.White else Color.Transparent) + .pressClickable({ onSelect(mode) }, CircleShape) + .semantics { role = Role.RadioButton; this.selected = on }, + contentAlignment = Alignment.Center, + ) { + Text( + mode.name, color = if (on) Color.Black else Color.White.copy(alpha = 0.75f), + style = if (on) GlowType.secondary.copy(fontWeight = FontWeight.SemiBold) else GlowType.secondary, maxLines = 1, textAlign = TextAlign.Center, + ) + } + } + } +} + +/** [uri] from the photo picker as a bitmap no bigger than the saved picture needs, upright; null when it can't be read. */ +private suspend fun decodePhoto(context: Context, uri: Uri): ImageBitmap? = withContext(Dispatchers.IO) { + runCatching { + val source = ImageDecoder.createSource(context.contentResolver, uri) + ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + val w = info.size.width + val h = info.size.height + // Enough to cover 1080 by 1920 once cropped, never more. + val scale = maxOf(1080f / w, 1920f / h) + if (scale < 1f) decoder.setTargetSize((w * scale).toInt().coerceAtLeast(1), (h * scale).toInt().coerceAtLeast(1)) + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + }.asImageBitmap() + }.onFailure { Log.w(TAG, "reading the photo failed", it) }.getOrNull() +} + +/** Where a card's picture goes: the gallery, or a cached file another app can read for a moment. */ +internal object StoryFiles { + /** The gallery folder, under Pictures. */ + const val FOLDER = "OpenStrap" + + /** The FileProvider's authority after the package name, as in the manifest. */ + const val AUTHORITY_SUFFIX = ".share" + + /** Writes [bitmap] as a PNG named [name] to Pictures/OpenStrap, and true once it's there. */ + fun saveToGallery(context: Context, bitmap: Bitmap, name: String): Boolean = runCatching { + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, name) + put(MediaStore.Images.Media.MIME_TYPE, "image/png") + put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/" + FOLDER) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY), values) ?: return false + try { + val written = resolver.openOutputStream(uri)?.use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } == true + check(written) { "nothing written" } + resolver.update(uri, ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }, null, null) + } catch (e: Exception) { + resolver.delete(uri, null, null) + throw e + } + true + }.onFailure { Log.w(TAG, "saving the card failed", it) }.getOrDefault(false) + + /** [bitmap] as a PNG in the cache, behind the app's FileProvider; null when it can't be written. */ + fun forSharing(context: Context, bitmap: Bitmap): Uri? = runCatching { + val dir = File(context.cacheDir, "share").apply { mkdirs() } + val file = File(dir, "workout.png") + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + FileProvider.getUriForFile(context, context.packageName + AUTHORITY_SUFFIX, file) + }.onFailure { Log.w(TAG, "writing the card to share failed", it) }.getOrNull() +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/SleepDetail.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/SleepDetail.kt index 54a7bdb6..cd5002d5 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/SleepDetail.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/SleepDetail.kt @@ -1,19 +1,27 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.dotFormat +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.SleepEditCopy +import org.openstrap.app.presentation.Units import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text -import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimeInput import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -24,22 +32,26 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openstrap.app.data.DailyMetric import org.openstrap.app.data.DayStat import org.openstrap.app.data.History -import org.openstrap.app.data.NapTimes import org.openstrap.app.data.Recovery import org.openstrap.app.data.RecoveryResult +import org.openstrap.app.data.SleepEdits import org.openstrap.app.data.SleepHistory import org.openstrap.app.data.Summaries import org.openstrap.app.data.db.SleepSession -import org.openstrap.app.ui.alarms.AlarmClockColors import org.openstrap.app.ui.components.ChartPoint +import org.openstrap.app.ui.components.pressClickable import org.openstrap.app.ui.dashboard.DailyHistory import org.openstrap.app.ui.dashboard.DayUiState import org.openstrap.app.ui.dashboard.Detail @@ -61,9 +73,9 @@ import org.openstrap.app.ui.theme.MetricColors import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.TextStyle import java.util.Locale +import org.openstrap.app.ui.glow.draw.stageColor +import org.openstrap.app.ui.theme.ControlSurface /* * The Sleep tab's details (spec 5.2 and 6.1) in the metric template: the Sleep score with the night it came from, @@ -71,17 +83,16 @@ import java.util.Locale * of in step 5 is here: what shaped the score, the night's heart rate and breathing, and each nap's heart rate. */ -private fun clock(epoch: Long) = Instant.ofEpochSecond(epoch).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm")) - -private fun shortDay(d: LocalDate) = d.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) - -private val DAY = DateTimeFormatter.ofPattern("EEE d MMM") - /** The days of the strip with a main sleep: the nights the day loads, and later days with breathing rate (measured in sleep). */ @Composable private fun sleepDays(state: DayUiState, history: DailyHistory): Set { val today = LocalDate.now() - return state.nights.filter { it.session != null }.map { it.date }.toSet() + daysWithData(DailyMetric.Breathing, state.date, today, history) + // The loaded nights are sleep as shown (a deleted night has none); breathing only fills in days past them. The day + // shown goes by its own night, as the page says it, so a dot never stands beside "No sleep recorded". + val loaded = state.nights.map { it.date }.toSet() + val days = state.nights.filter { it.session != null }.map { it.date }.toSet() + + daysWithData(DailyMetric.Breathing, state.date, today, history).filter { it !in loaded } + return if (state.loaded && state.sleep == null) days - state.date else days } /** The lines in a detail's body that aren't the one sentence: a reading's caption, a chart's title. */ @@ -112,10 +123,15 @@ private fun ChartTitle(title: String, note: String?, modifier: Modifier = Modifi internal fun SleepScoreDetail( state: DayUiState, r: RecoveryResult?, history: DailyHistory, onOpen: (Detail) -> Unit, onSwitch: (Detail) -> Unit, onSelectDay: (LocalDate) -> Unit, contentPadding: PaddingValues, onBack: () -> Unit, + editing: SleepEditing, ) { val session = state.sleep val score = session?.score val color = MetricColors.sleepDeep + var adjusting by remember(state.date) { mutableStateOf(false) } + var deleting by remember(state.date) { mutableStateOf(false) } + // The strap's session just deleted, while its Undo shows. + var undo by remember { mutableStateOf(null) } // TODO(step 6): Month and Year wait for a stored history of the strap's sleep scores; the day loads only 14 nights. val ranges = listOf(Range.Day, Range.Week) var rangeIndex by rememberSaveable { mutableIntStateOf(0) } @@ -131,87 +147,228 @@ internal fun SleepScoreDetail( val normal = History.normal(before, state.date.minusDays(1), SLEEP_NIGHTS - 1) val known = week.mapNotNull { it.second } - val headline = scrub ?: when { + val headline = when { range == Range.Week && known.isNotEmpty() -> DetailHeadline(Math.round(known.average()).toString(), "Average of the last 7 nights", null) range == Range.Week -> DetailHeadline.none("No scores", "In the last 7 nights") score != null -> DetailHeadline(score.toString(), SleepCopy.scoreLabel(isToday), TabCopy.sleepScoreWord(score)) + session == null && state.deletedSleep != null -> DetailHeadline.none("No score", SleepCopy.noScoreDeleted(isToday)) else -> SleepCopy.noScore(session != null, isToday).let { (word, why) -> DetailHeadline.none(word, why) } } - DetailTemplate( - Score.Sleep.title, color, siblings(Detail.OfScore(Score.Sleep), onSwitch), onInfo = { info = true }, onBack = onBack, - contentPadding = contentPadding, headline = headline, - strip = DetailCopy.strip(state.date, today, sleepDays(state, history)), onDay = onSelectDay, - ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, - // The horizon arc (chart kit, spec 2.7) holds the night's score, centred, the number under its crest. - heroValue = if (range == Range.Day && score != null && scrub == null) { - { HorizonArc(score / 100f, color, key = state.date) { DetailScoreNumber(score) } } - } else null, - ) { - if (range == Range.Day) { - if (session == null) { - item { DetailEmpty(TabCopy.noNight(state.naps.isNotEmpty(), isToday)) } - } else { - val asleep = state.asleepSeconds ?: (session.end - session.start) - item { - Column(Modifier.padding(top = 8.dp)) { - if (state.sleepStages.isNotEmpty()) GlowSleepStages(state.sleepStages, 150.dp) - state.sleepBreakdown?.let { StageBars(it, Modifier.padding(top = 18.dp)) } - DetailFigures( - listOf(clock(session.start) to SleepCopy.FELL_ASLEEP, clock(session.end) to SleepCopy.WOKE, clockDuration(asleep) to SleepCopy.ASLEEP), - Modifier.padding(top = 20.dp), - ) + UndoHost(undo?.let { SleepCopy.deleted(false, isToday, clock(it.start)) }, contentPadding, onUndo = { undo?.let(editing.restore); undo = null }, onGone = { undo = null }) { + DetailTemplate( + Score.Sleep.title, color, siblings(Detail.OfScore(Score.Sleep), onSwitch), onInfo = { info = true }, onBack = onBack, + contentPadding = contentPadding, headline = headline, + strip = DetailCopy.strip(state.date, today, sleepDays(state, history)), onDay = onSelectDay, + ranges = ranges, range = range, onRange = { rangeIndex = ranges.indexOf(it) }, + // The horizon arc (chart kit, spec 2.7) holds the night's score, centred, the number under its crest. A + // scrub takes its place in the hero alone, so the rest of the detail stays put (audit K4). + heroValue = if (range == Range.Day && score != null) { + { HorizonArc(score / 100f, color, key = state.date) { DetailScoreNumber(score) } } + } else null, + scrubbed = { scrub }, + ) { + if (range == Range.Day) { + val deleted = state.deletedSleep + if (session == null && deleted != null) { + item { DeletedNight(SleepCopy.deletedNight(clock(deleted.start), clock(deleted.end), isToday)) { editing.restore(deleted.strap) } } + } else if (session == null) { + item { DetailEmpty(TabCopy.noNight(state.naps.isNotEmpty(), isToday)) } + } else { + val asleep = state.asleepSeconds ?: (session.end - session.start) + val detected = state.sleepAsDetected + val moved = detected != null && (detected.start != session.start || detected.end != session.end) + item { + Column(Modifier.padding(top = 8.dp)) { + // Moved, the strap's whole night shows with what's outside the user's times dimmed. + if (moved && state.strapSleepStages.isNotEmpty()) GlowSleepStages(state.strapSleepStages, 150.dp, kept = session.start..session.end) + else if (state.sleepStages.isNotEmpty()) GlowSleepStages(state.sleepStages, 150.dp) + state.sleepBreakdown?.let { StageBars(it, Modifier.padding(top = 18.dp)) } + DetailFigures( + listOf( + clock(session.start) to SleepCopy.FELL_ASLEEP, clock(session.end) to SleepCopy.WOKE, + Units.duration(asleep) to SleepCopy.ASLEEP, + ), + Modifier.padding(top = 20.dp), + ) + if (detected != null) SleepEditActions( + if (moved) SleepCopy.yourTimes(clock(detected.start), clock(detected.end)) else null, + onAdjust = { adjusting = true }, onDelete = { deleting = true }, Modifier.padding(top = 20.dp), + ) + } } - } - item { - val others = state.nights.takeLast(SleepHistory.WEEK_NIGHTS).filter { it.date != state.date }.mapNotNull { it.asleepSeconds } - Column { - DetailSentence(SleepCopy.night(Narrative.sleepHeadline(session, asleep), asleep, others)) - TabCopy.woke(session.wakeCount)?.let { Caption(it, Modifier.padding(top = 6.dp)) } + item { + val others = state.nights.takeLast(SleepHistory.WEEK_NIGHTS).filter { it.date != state.date }.mapNotNull { it.asleepSeconds } + Column { + DetailSentence(SleepCopy.night(Narrative.sleepHeadline(session, asleep), asleep, others)) + TabCopy.woke(session.wakeCount)?.let { Caption(it, Modifier.padding(top = 6.dp)) } + } } - } - // What shaped the score: time asleep against your need and staying asleep, against sleep guidance. - val need = r?.sleepNeed ?: Recovery.SLEEP_NEED_MINUTES - val waso = Recovery.wasoMinutes(state.sleepStages.map { Triple(it.start, it.end, it.stage) }) - val factors = SleepCopy.factors(Narrative.sleepContributors(session, asleep, need, waso), asleep, need, waso) - if (factors.isNotEmpty()) item { - ListPanel(Modifier.padding(top = 8.dp), title = "What shaped the score") { - factors.forEachIndexed { i, f -> - ContributorRow(f.name, color, f.word, f.fraction, offNormal = f.off, divider = i > 0, detail = f.detail, onClick = { info = true }) + // What shaped the score: time asleep against your need and staying asleep, against sleep guidance. + val need = r?.sleepNeed ?: Recovery.SLEEP_NEED_MINUTES + val waso = Recovery.wasoMinutes(state.sleepStages.map { Triple(it.start, it.end, it.stage) }) + val factors = SleepCopy.factors(Narrative.sleepContributors(session, asleep, need, waso), asleep, need, waso) + if (factors.isNotEmpty()) item { + ListPanel(Modifier.padding(top = 8.dp), title = "What shaped the score") { + factors.forEachIndexed { i, f -> + ContributorRow(f.name, color, f.word, f.fraction, offNormal = f.off, divider = i > 0, detail = f.detail, onClick = { info = true }) + } } } + item { NightLines(state, session.start..session.end) { scrub = it } } } - item { NightLines(state, session.start..session.end) { scrub = it } } - } - } else { - item { - if (known.isEmpty()) DetailEmpty("No sleep scores in the last 7 nights yet.") - else Column { - val values = week.map { it.second } - NormalTrend( - values, DetailCopy.levels(values, normal), color, labels = week.map { shortDay(it.first) }, - band = normal?.let { it.low..it.high }, bandLabel = normal?.let { DetailCopy.bandLabel(it) { v -> Math.round(v).toString() } }, - onScrub = { i -> - scrub = i?.let { week[it] }?.let { (d, v) -> v?.let { DetailHeadline(Math.round(it).toString(), d.format(DAY), TabCopy.sleepScoreWord(Math.round(it))) } } - }, - ) - if (normal == null) Caption(DetailCopy.learningNormal(before.size, nightly = true)) + } else { + item { + if (known.isEmpty()) DetailEmpty("No sleep scores in the last 7 nights yet.") + else Column { + val values = week.map { it.second } + NormalTrend( + values, DetailCopy.levels(values, normal), color, labels = week.map { DayWords.weekdayShort(it.first) }, + band = normal?.let { it.low..it.high }, bandLabel = normal?.let { DetailCopy.bandLabel(it) { v -> Math.round(v).toString() } }, + onScrub = { i -> + scrub = i?.let { week[it] }?.let { (d, v) -> + v?.let { Math.round(it) }?.let { score -> + DetailHeadline(score.toString(), DayWords.short(d), TabCopy.sleepScoreWord(score)) + } + } + }, + ) + if (normal == null) Caption(DetailCopy.learningNormal(before.size, nightly = true)) + } + } + val values = week.map { it.second } + DetailCopy.span(Range.Week, DetailCopy.levels(values, normal), nightly = true, normalKnown = normal != null, days = before.size)?.let { + item { DetailSentence(it) } } } - val values = week.map { it.second } - DetailCopy.span(Range.Week, DetailCopy.levels(values, normal), nightly = true, normalKnown = normal != null, days = before.size)?.let { - item { DetailSentence(it) } - } + item { SleepRelated(Score.Sleep, state, r, onOpen) } } - item { SleepRelated(Score.Sleep, state, r, onOpen) } } + val detected = state.sleepAsDetected + // A move of 15 minutes or more has the strap's score adjusted for the user's times (edit spec 5). + val ownScore = session != null && detected != null && score != null && !SleepEdits.keepsScore(detected, session.start, session.end) if (info) InfoSheet( InfoCopy.of(Score.Sleep), color, score?.let(InfoCopy::sleepBar), score?.let { InfoCopy.HERE }, - onDismiss = { info = false }, notes = InfoCopy.sleepNotes(r?.sleepNeed ?: Recovery.SLEEP_NEED_MINUTES), + onDismiss = { info = false }, + notes = listOfNotNull(SleepCopy.SCORE_FROM_YOUR_TIMES.takeIf { ownScore }) + InfoCopy.sleepNotes(r?.sleepNeed ?: Recovery.SLEEP_NEED_MINUTES), + ) + if (adjusting && session != null && detected != null) SleepTimesSheet( + session, detected, state.sleepHeartRate, state.sleepNeighbours.filter { it.nap || it.start != session.start }, + onSave = { from, to -> adjusting = false; editing.setTimes(detected, from, to) }, onDismiss = { adjusting = false }, + ) + if (deleting && session != null && detected != null) DeleteSleepSheet( + SleepCopy.deleteTitle(false, isToday), SleepCopy.span(clock(session.start), clock(session.end)), + onDelete = { deleting = false; editing.delete(detected); undo = detected }, onDismiss = { deleting = false }, ) } +/** + * What the Sleep score and Naps details can do to a sleep or nap, each given the strap's own session: keep the user's + * times for it, delete it, and bring it back (Undo and Restore). Equal when its actions are (the same view model's + * references are), so a detail given an equal one skips (audit K11). + */ +@androidx.compose.runtime.Immutable +data class SleepEditing( + val setTimes: (SleepSession, Long, Long) -> Unit = { _, _, _ -> }, + val delete: (SleepSession) -> Unit = {}, + val restore: (SleepSession) -> Unit = {}, +) + +/** + * Under a sleep's or nap's figures: the note that the times are the user's ([note], with a pencil, when they are), + * then Adjust times and Delete side by side, Delete in the warning colour. + */ +@Composable +private fun SleepEditActions(note: String?, onAdjust: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier) { + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (note != null) EditedNote(note) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PageButton("Adjust times", onAdjust, Modifier.weight(1f), icon = AppIcons.PencilEdit) + PageButton("Delete", onDelete, Modifier.weight(1f), color = MetricColors.warn, icon = AppIcons.Trash) + } + } +} + +/** + * Asked once before a sleep or nap goes: [title], its [times], what a delete does and where to bring it back, then + * Cancel and Delete. + */ +@Composable +private fun DeleteSleepSheet(title: String, times: String, onDelete: () -> Unit, onDismiss: () -> Unit) { + PageSheet(onDismiss, title) { + Text("$times. ${SleepCopy.DELETE_WHAT}", color = Color.White.copy(alpha = 0.8f), style = GlowType.body) + PageNote(SleepCopy.DELETE_RESTORE) + Row(Modifier.fillMaxWidth().padding(top = 4.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PageButton("Cancel", onDismiss, Modifier.weight(1f)) + PageButton("Delete", onDelete, Modifier.weight(1f), color = MetricColors.warn, icon = AppIcons.Trash) + } + } +} + +/** In a deleted night's place: No sleep recorded, which times were deleted and what Recovery does, and Restore. */ +@Composable +private fun DeletedNight(text: String, onRestore: () -> Unit) { + Column( + Modifier.fillMaxWidth().padding(top = 8.dp) + .border(1.5.dp, Color.White.copy(alpha = 0.14f), GlowLayout.CardShape).padding(20.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(SleepCopy.NO_SLEEP_TITLE, color = Color.White, style = GlowType.cardTitle, maxLines = 1) + Text(text, color = Color.White.copy(alpha = 0.75f), style = GlowType.body) + PageButton("Restore", onRestore, Modifier.padding(top = 4.dp), icon = AppIcons.ArrowCounterClockwise) + } +} + +/** + * [content] with a snackbar at its foot while [message] is set: the message and Undo, for [UNDO_MS], then [onGone]. + * Light on the dark page so it reads as passing, not as another card. + */ +@Composable +private fun UndoHost(message: String?, contentPadding: PaddingValues, onUndo: () -> Unit, onGone: () -> Unit, content: @Composable () -> Unit) { + Box(Modifier.fillMaxSize()) { + content() + if (message != null) { + androidx.compose.runtime.LaunchedEffect(message) { + kotlinx.coroutines.delay(UNDO_MS) + onGone() + } + Row( + Modifier.align(Alignment.BottomCenter).padding(bottom = contentPadding.calculateBottomPadding() + 16.dp, start = 16.dp, end = 16.dp) + .fillMaxWidth().height(56.dp).background(SnackbarSurface, CircleShape).padding(start = 22.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(message, color = Color.Black, style = GlowType.body, maxLines = 1, modifier = Modifier.weight(1f)) + Row( + Modifier.height(40.dp).clip(CircleShape).background(Color.Black).pressClickable(onUndo, CircleShape) + .semantics { role = Role.Button }.padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.material3.Icon(AppIcons.ArrowCounterClockwise, null, Modifier.size(16.dp), tint = Color.White) + Text("Undo", color = Color.White, style = GlowType.body, modifier = Modifier.padding(start = 6.dp)) + } + } + } + } +} + +/** How long a delete's Undo shows. */ +private const val UNDO_MS = 8_000L + +private val SnackbarSurface = Color(0xFFF2F2F4) + +/** "Your times. The strap saw 23:20 to 07:21." on the list surface, with a pencil. */ +@Composable +private fun EditedNote(text: String) { + Row( + Modifier.fillMaxWidth().background(ListSurface, RoundedCornerShape(16.dp)).padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + androidx.compose.material3.Icon(AppIcons.PencilEdit, null, Modifier.size(16.dp), tint = Color.White.copy(alpha = 0.7f)) + Text(text, color = Color.White, style = GlowType.secondary, modifier = Modifier.padding(start = 10.dp).weight(1f)) + } +} + /** * Stage bars (chart kit): each stage's name, a bar in its colour as long as its share of the night, and its time. * Under the hypnogram, deepest first. @@ -248,8 +405,9 @@ private fun StageBars(b: Summaries.SleepBreakdown, modifier: Modifier = Modifier */ @Composable private fun NightLines(state: DayUiState, window: LongRange, onScrub: (DetailHeadline?) -> Unit) { - val hr = state.sleepHeartRate.filter { it.x in window } - val breath = state.breathRate.filter { it.x in window } + // Kept while the readings and the window are, so the lines' own remember holds across scrubs (audit K4). + val hr = remember(state.sleepHeartRate, window) { state.sleepHeartRate.filter { it.x in window } } + val breath = remember(state.breathRate, window) { state.breathRate.filter { it.x in window } } if (hr.isEmpty() && breath.isEmpty()) return val labels = listOf(clock(window.first), clock(window.last)) var hrAt by remember(window) { mutableStateOf(null) } @@ -260,7 +418,8 @@ private fun NightLines(state: DayUiState, window: LongRange, onScrub: (DetailHea ScrubLine(hr, window, 130.dp, labels, MetricColors.heart, "bpm") { hrAt = it; onScrub(it) } } if (breath.isNotEmpty()) { - ChartTitle("Breathing rate", "Average %.1f br/min".format(breath.map { it.y }.average()), scrubbed = breathAt) + val average = "Average ${Units.decimal(breath.map { it.y }.average(), 1)} br/min" + ChartTitle("Breathing rate", average, scrubbed = breathAt) ScrubLine(breath, window, 100.dp, labels, MetricColors.sleepLight, "br/min", decimals = 1) { breathAt = it; onScrub(it) } } } @@ -270,28 +429,25 @@ private fun NightLines(state: DayUiState, window: LongRange, onScrub: (DetailHea @Composable private fun ScrubLine( points: List, window: LongRange, height: Dp, labels: List, color: Color, unit: String, decimals: Int = 0, + span: LongRange? = null, + spanColor: Color = color, onScrub: (DetailHeadline?) -> Unit, ) { val step = 5 * 60L - val values = remember(points, window) { evenly(points, window, step) } + val values = remember(points, window) { bucket(points, window, step) } + val length = (window.last - window.first).coerceAtLeast(1).toFloat() GlowLineChart( values, height = height, labels = labels, color = color, showAverage = false, - onScrub = { i -> onScrub(i?.let { values[it] }?.let { v -> DetailHeadline("%.${decimals}f".format(v), "$unit at ${clock(window.first + i * step)}", null) }) }, + span = span?.let { (it.first - window.first) / length..(it.last - window.first) / length }, spanColor = spanColor, + onScrub = { i -> + val headline = i?.let { values[it] }?.let { v -> + DetailHeadline(dotFormat("%.${decimals}f", v), "$unit at ${clock(window.first + i * step)}", null) + } + onScrub(headline) + }, ) } -/** Averages timed points into evenly spaced buckets across [range]; empty buckets are gaps. */ -private fun evenly(points: List, range: LongRange, seconds: Long): List { - val n = ((range.last - range.first + 1) / seconds).toInt().coerceAtLeast(2) - val sums = FloatArray(n) - val counts = IntArray(n) - points.forEach { - val i = ((it.x - range.first) / seconds).toInt() - if (i in 0 until n) { sums[i] += it.y; counts[i]++ } - } - return List(n) { if (counts[it] == 0) null else sums[it] / counts[it] } -} - /* ---------------------------------------------- Regularity ------------------------------------------ */ /** @@ -369,128 +525,175 @@ private fun minutesClock(minutes: Int): String = org.openstrap.app.ui.dashboard. internal fun NapsDetail( state: DayUiState, r: RecoveryResult?, onOpen: (Detail) -> Unit, onSwitch: (Detail) -> Unit, onSelectDay: (LocalDate) -> Unit, contentPadding: PaddingValues, onBack: () -> Unit, - onSetNapTimes: (SleepSession, Long, Long) -> Unit, + editing: SleepEditing, ) { val naps = state.naps.sortedBy { it.start } - // The nap whose times are being adjusted, by its start as shown. + // The nap whose times are being adjusted, or that's being deleted, by its start as shown. var adjusting by remember(state.date) { mutableStateOf(null) } + var deleting by remember(state.date) { mutableStateOf(null) } + // The strap's nap just deleted and its start as shown, while its Undo shows. + var undo by remember { mutableStateOf?>(null) } val total = naps.sumOf { it.end - it.start } val color = MetricColors.sleepDeep val today = LocalDate.now() val isToday = state.date == today var scrub by remember(state.date) { mutableStateOf(null) } var info by rememberSaveable { mutableStateOf(false) } - val headline = scrub ?: if (naps.isEmpty()) SleepCopy.noNaps(isToday).let { (word, why) -> DetailHeadline.none(word, why) } + val headline = if (naps.isEmpty()) SleepCopy.noNaps(isToday).let { (word, why) -> DetailHeadline.none(word, why) } else DetailHeadline(clockDuration(total), SleepCopy.napsLabel(isToday), null) // TODO(step 6): dots under the other days of the strip need the week's naps; the day loads only its own. - DetailTemplate( - Score.Naps.title, color, siblings(Detail.OfScore(Score.Naps), onSwitch), onInfo = { info = true }, onBack = onBack, - contentPadding = contentPadding, headline = headline, - strip = DetailCopy.strip(state.date, today, if (naps.isEmpty()) emptySet() else setOf(state.date)), onDay = onSelectDay, - ranges = listOf(Range.Day), range = Range.Day, onRange = {}, - ) { - if (naps.isEmpty()) item { DetailEmpty(if (isToday) "No naps recorded today." else "No naps recorded that day.") } - else { - item { DetailSentence(SleepCopy.naps(naps.size, total)) } - naps.forEachIndexed { i, nap -> - item(key = nap.start) { - Column( - Modifier.fillMaxWidth().padding(top = 12.dp) - .let { if (i > 0) it.drawBehind { drawLine(RowHairline, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) } else it }, - ) { - ChartTitle("${clock(nap.start)}–${clock(nap.end)}", TodayCopy.duration(nap.end - nap.start)) - val detected = state.napsAsDetected[nap.start] - if (detected != null) { - if (detected.start != nap.start || detected.end != nap.end) { - Caption("Your times. The strap saw ${clock(detected.start)}–${clock(detected.end)}.") + UndoHost(undo?.let { SleepCopy.deleted(true, isToday, clock(it.second)) }, contentPadding, onUndo = { undo?.let { editing.restore(it.first) }; undo = null }, onGone = { undo = null }) { + DetailTemplate( + Score.Naps.title, color, siblings(Detail.OfScore(Score.Naps), onSwitch), onInfo = { info = true }, onBack = onBack, + contentPadding = contentPadding, headline = headline, + strip = DetailCopy.strip(state.date, today, if (naps.isEmpty()) emptySet() else setOf(state.date)), onDay = onSelectDay, + ranges = listOf(Range.Day), range = Range.Day, onRange = {}, scrubbed = { scrub }, + ) { + if (naps.isEmpty()) item { DetailEmpty(if (isToday) "No naps recorded today." else "No naps recorded that day.") } + else { + item { DetailSentence(SleepCopy.naps(naps.size, total)) } + naps.forEachIndexed { i, nap -> + item(key = nap.start) { + Column( + Modifier.fillMaxWidth().padding(top = 12.dp) + .topHairline(i > 0, RowHairline), + ) { + ChartTitle(SleepCopy.span(clock(nap.start), clock(nap.end)), TodayCopy.duration(nap.end - nap.start)) + val detected = state.napsAsDetected[nap.start] + val stages = state.napStages[nap.start].orEmpty() + // Heart rate as in the hour before and no deep sleep: maybe sitting still, said, never removed. + val deep = state.napBreakdowns[nap.start]?.byStage?.get(org.openstrap.app.data.db.SleepStage.DEEP) ?: 0L + SleepEdits.stillHeartRate(state.heartRate.map { it.x to it.y }, nap.start, nap.end, deep)?.let { + Caption(SleepCopy.stillNap(it), Modifier.padding(bottom = 10.dp)) } - PageButton("Adjust times", { adjusting = nap.start }, Modifier.padding(top = 4.dp, bottom = 14.dp), icon = AppIcons.PencilEdit) - } - val stages = state.napStages[nap.start].orEmpty() - if (stages.isEmpty()) Caption(SleepCopy.NAP_NO_STAGES) - else { - StageStrip(stages, height = 8.dp) - state.napBreakdowns[nap.start]?.let { b -> - val figures = listOf( - org.openstrap.app.data.db.SleepStage.DEEP, org.openstrap.app.data.db.SleepStage.REM, - org.openstrap.app.data.db.SleepStage.LIGHT, org.openstrap.app.data.db.SleepStage.AWAKE, - ).mapNotNull { st -> b.byStage[st]?.takeIf { it > 0 }?.let { clockDuration(it) to SLEEP_NAMES[st].orEmpty() } } - if (figures.isNotEmpty()) DetailFigures(figures, Modifier.padding(top = 14.dp)) + if (stages.isEmpty()) Caption(SleepCopy.NAP_NO_STAGES) + else { + StageStrip(stages, height = 8.dp) + state.napBreakdowns[nap.start]?.let { b -> + val figures = listOf( + org.openstrap.app.data.db.SleepStage.DEEP, org.openstrap.app.data.db.SleepStage.REM, + org.openstrap.app.data.db.SleepStage.LIGHT, org.openstrap.app.data.db.SleepStage.AWAKE, + ).mapNotNull { st -> b.byStage[st]?.takeIf { it > 0 }?.let { Units.duration(it) to SLEEP_NAMES[st].orEmpty() } } + if (figures.isNotEmpty()) DetailFigures(figures, Modifier.padding(top = 14.dp)) + } } - } - // The strap sends no average for a nap, so its heart rate is the day's readings in its window. - val window = nap.start..nap.end - val hr = state.heartRate.filter { it.x in window } - if (hr.isNotEmpty()) { - Box(Modifier.padding(top = 16.dp)) { - ScrubLine(hr, window, 100.dp, listOf(clock(nap.start), clock(nap.end)), MetricColors.heart, "bpm") { scrub = it } + if (detected != null) { + val moved = detected.start != nap.start || detected.end != nap.end + SleepEditActions( + if (moved) SleepCopy.yourTimes(clock(detected.start), clock(detected.end)) else null, + onAdjust = { adjusting = nap.start }, onDelete = { deleting = nap.start }, Modifier.padding(top = 16.dp), + ) + } + // The strap sends no average for a nap, so its heart rate is the day's readings in its window. + val window = nap.start..nap.end + val hr = remember(state.heartRate, window) { state.heartRate.filter { it.x in window } } + if (hr.isNotEmpty()) { + Box(Modifier.padding(top = 16.dp)) { + ScrubLine(hr, window, 100.dp, listOf(clock(nap.start), clock(nap.end)), MetricColors.heart, "bpm") { scrub = it } + } + Caption(SleepCopy.napHeart(hr.minOf { it.y }.toInt(), hr.map { it.y }.average().toInt()), Modifier.padding(top = 6.dp, bottom = 8.dp)) } - Caption(SleepCopy.napHeart(hr.minOf { it.y }.toInt(), hr.map { it.y }.average().toInt()), Modifier.padding(top = 6.dp, bottom = 8.dp)) } } } } + item { SleepRelated(Score.Naps, state, r, onOpen) } } - item { SleepRelated(Score.Naps, state, r, onOpen) } } if (info) InfoSheet(InfoCopy.of(Score.Naps), color, null, null, onDismiss = { info = false }) + // A nap gone from the day (deleted elsewhere, or moved) closes its sheet, cleared once the composition is applied + // rather than during it (audit K6). adjusting?.let { start -> val nap = naps.firstOrNull { it.start == start } val detected = state.napsAsDetected[start] - if (nap == null || detected == null) adjusting = null - else NapTimesSheet(nap, detected, state.heartRate, onSave = { from, to -> adjusting = null; onSetNapTimes(detected, from, to) }, onDismiss = { adjusting = null }) + if (nap == null || detected == null) androidx.compose.runtime.SideEffect { adjusting = null } + else SleepTimesSheet( + nap, detected, state.heartRate, state.sleepNeighbours.filter { !it.nap || it.start != start }, + onSave = { from, to -> adjusting = null; editing.setTimes(detected, from, to) }, onDismiss = { adjusting = null }, + ) + } + deleting?.let { start -> + val nap = naps.firstOrNull { it.start == start } + val detected = state.napsAsDetected[start] + if (nap == null || detected == null) androidx.compose.runtime.SideEffect { deleting = null } + else DeleteSleepSheet( + SleepCopy.deleteTitle(true, isToday), SleepCopy.span(clock(nap.start), clock(nap.end)), + onDelete = { deleting = null; editing.delete(detected); undo = detected to nap.start }, onDismiss = { deleting = null }, + ) } } /** - * A nap's own times: Fell asleep and Woke up on the alarms' clock, with the heart rate from an hour before to an - * hour after the strap's nap, so the drop into sleep and the rise out of it can be found by scrubbing. The strap's - * times stay one tap away. + * A sleep's or nap's own times, one sheet for both: Fell asleep and Woke up as two boxes typed on the number pad, with + * the heart rate from two hours before to two hours after the strap's session and the chosen span shaded on it, so the + * drop into sleep and the rise out of it can be found by scrubbing. [others] are the day's other sleep and naps as shown, which the + * times mustn't run into. The strap's times stay one tap away. */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun NapTimesSheet(nap: SleepSession, detected: SleepSession, heartRate: List, onSave: (Long, Long) -> Unit, onDismiss: () -> Unit) { +private fun SleepTimesSheet( + shown: SleepSession, detected: SleepSession, heartRate: List, others: List, + onSave: (Long, Long) -> Unit, onDismiss: () -> Unit, +) { val zone = ZoneId.systemDefault() fun local(t: Long) = Instant.ofEpochSecond(t).atZone(zone) - val startClock = rememberTimePickerState(local(nap.start).hour, local(nap.start).minute, is24Hour = true) - val endClock = rememberTimePickerState(local(nap.end).hour, local(nap.end).minute, is24Hour = true) + val startClock = rememberTimePickerState(local(shown.start).hour, local(shown.start).minute, is24Hour = true) + val endClock = rememberTimePickerState(local(shown.end).hour, local(shown.end).minute, is24Hour = true) var pickingEnd by remember { mutableStateOf(false) } var scrub by remember { mutableStateOf(null) } // A clock left on the minute it opened at keeps the time to the second, so the strap's own times stay its own. fun picked(c: androidx.compose.material3.TimePickerState, was: Long, near: Long): Long = - if (c.hour == local(was).hour && c.minute == local(was).minute) was else NapTimes.atNear(c.hour * 60 + c.minute, near, zone) - val start = picked(startClock, nap.start, detected.start) - val end = picked(endClock, nap.end, detected.end) - val problem = NapTimes.problem(detected, start, end, Instant.now().epochSecond) - val window = (detected.start - 3600)..(detected.end + 3600) - val hr = heartRate.filter { it.x in window } - - PageSheet(onDismiss, "Nap times", note = "The strap saw ${clock(detected.start)}–${clock(detected.end)}.") { + if (c.hour == local(was).hour && c.minute == local(was).minute) was else SleepEdits.atNear(c.hour * 60 + c.minute, near, zone) + val start = picked(startClock, shown.start, detected.start) + val end = picked(endClock, shown.end, detected.end) + val problem = SleepEdits.check(detected, start, end, Instant.now().epochSecond, others)?.let { SleepEditCopy.text(it, zone) } + val window = (detected.start - 2 * 3600)..(detected.end + 2 * 3600) + val hr = remember(heartRate, window) { heartRate.filter { it.x in window } } + + PageSheet(onDismiss, SleepCopy.timesTitle(detected.nap), note = SleepCopy.strapSaw(clock(detected.start), clock(detected.end))) { if (hr.isNotEmpty()) Column { Text( - scrub?.let { "${it.value} ${it.label}" } ?: "Heart rate around the nap. It drops as you fall asleep.", - color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1, + scrub?.let { "${it.value} ${it.label}" } ?: SleepCopy.TIMES_HINT, + color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 2, ) Box(Modifier.padding(top = 8.dp)) { - ScrubLine(hr, window, 90.dp, listOf(clock(window.first), clock(window.last)), MetricColors.heart, "bpm") { scrub = it } + ScrubLine( + hr, window, 90.dp, listOf(clock(window.first), clock(window.last)), MetricColors.heart, "bpm", + span = if (end > start) start..end else null, spanColor = MetricColors.sleepDeep, + ) { scrub = it } } } GlowSegments(listOf("Fell asleep ${clock(start)}", "Woke up ${clock(end)}"), if (pickingEnd) 1 else 0, { pickingEnd = it == 1 }) - TimePicker(if (pickingEnd) endClock else startClock, Modifier.align(Alignment.CenterHorizontally), colors = AlarmClockColors()) + // Hours and minutes as two boxes, typed on the number pad: no dial, so the sheet stays short. + TimeInput(if (pickingEnd) endClock else startClock, Modifier.align(Alignment.CenterHorizontally), colors = SleepTimeColors()) if (problem != null) PageNote(problem, color = MetricColors.warn) - else Text("A nap of ${TodayCopy.duration(end - start)}.", color = Color.White, style = GlowType.body) + else Text(SleepCopy.timesResult(detected.nap, end - start), color = Color.White, style = GlowType.body) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { PageButton("Cancel", onDismiss, Modifier.weight(1f)) PageButton("Save", { onSave(start, end) }, Modifier.weight(1f), enabled = problem == null, primary = true) } - if (nap.start != detected.start || nap.end != detected.end) { - PageTextButton("Use the strap's times", { onSave(detected.start, detected.end) }, Modifier.align(Alignment.CenterHorizontally)) + if (shown.start != detected.start || shown.end != detected.end) { + PageButton( + "Use the strap's times", { onSave(detected.start, detected.end) }, Modifier.fillMaxWidth(), + icon = AppIcons.ArrowCounterClockwise, + ) } } } +/** The box being typed in lit in the sleep colour, the other plain, as on the alarms' clock otherwise. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SleepTimeColors() = androidx.compose.material3.TimePickerDefaults.colors( + containerColor = Color.Transparent, + timeSelectorSelectedContainerColor = MetricColors.sleepDeep.copy(alpha = 0.25f), + timeSelectorUnselectedContainerColor = ControlSurface, + timeSelectorSelectedContentColor = Color.White, + timeSelectorUnselectedContentColor = Color.White, +) + /* ----------------------------------------------- Related -------------------------------------------- */ /** The rows under a sleep detail: the other two sleep details and Recovery, each with the day's value, opening it. */ @@ -503,7 +706,7 @@ private fun SleepRelated(current: Score, state: DayUiState, r: RecoveryResult?, val rows = buildList<@Composable (Boolean) -> Unit> { if (current != Score.Sleep) add { divider -> ListRow( - Score.Sleep.title, MetricColors.sleepDeep, score?.toString(), score?.let(TabCopy::sleepScoreWord) ?: "Nothing for this night", + Score.Sleep.title, MetricColors.sleepDeep, score?.toString(), score?.let(TabCopy::sleepScoreWord) ?: NO_NIGHT_VALUE, onClick = { onOpen(Detail.OfScore(Score.Sleep)) }, divider = divider, icon = Score.Sleep.rowIcon(), ) } @@ -515,7 +718,7 @@ private fun SleepRelated(current: Score, state: DayUiState, r: RecoveryResult?, } if (current != Score.Naps) add { divider -> ListRow( - Score.Naps.title, MetricColors.sleepDeep, naps.takeIf { it.isNotEmpty() }?.let { n -> clockDuration(n.sumOf { it.end - it.start }) }, + Score.Naps.title, MetricColors.sleepDeep, naps.takeIf { it.isNotEmpty() }?.let { n -> Units.duration(n.sumOf { it.end - it.start }) }, if (naps.isEmpty()) "None that day" else TabCopy.naps(naps.size, clock(naps.minOf { it.start })), onClick = { onOpen(Detail.OfScore(Score.Naps)) }, divider = divider, icon = Score.Naps.rowIcon(), ) @@ -523,7 +726,7 @@ private fun SleepRelated(current: Score, state: DayUiState, r: RecoveryResult?, add { divider -> ListRow( Score.Recovery.title, MetricColors.readiness, ready?.score?.toString(), - ready?.score?.let { TodayCopy.recoveryWord(it, ready.strainAlert) } ?: "Nothing for this day", + ready?.score?.let { TodayCopy.recoveryWord(it, ready.strainAlert) } ?: NO_VALUE, onClick = { onOpen(Detail.OfScore(Score.Recovery)) }, divider = divider, icon = Score.Recovery.rowIcon(), ) } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/StrapDrawing.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/StrapDrawing.kt index 340dfb9b..9a4dc9a3 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/StrapDrawing.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/StrapDrawing.kt @@ -5,9 +5,12 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -31,53 +34,70 @@ import androidx.compose.ui.graphics.graphicsLayer */ @Composable internal fun StrapDrawing(led: Color, lit: Boolean, syncing: Boolean, modifier: Modifier) { - val turn = if (syncing) { + // The turn is a State read only in draw, so a syncing strap redraws each frame without recomposing. + val turn: State = if (syncing) { val t = rememberInfiniteTransition(label = "strap sync") t.animateFloat( 0f, 360f, infiniteRepeatable(tween(2400, easing = LinearEasing)), label = "strap sync turn", - ).value - } else 0f - Canvas(modifier.graphicsLayer { alpha = if (lit) 1f else 0.45f }) { - // Drawn on a 120 by 150 grid, the card slot's shape. - val k = size.width / 120f - fun o(x: Float, y: Float) = Offset(x * k, y * k) - val white = Color.White - // The band: a faint weave between its two edges, which fade out at the card's top and bottom. - for (row in 0 until 25) { - val y = row * 6f - for (x in listOf(44f, 52f, 60f, 68f)) drawLine(white.copy(alpha = 0.09f), o(x, y + 1.5f), o(x + 3f, y + 1.5f), k, StrokeCap.Round) - for (x in listOf(48f, 56f, 64f, 72f)) drawLine(white.copy(alpha = 0.09f), o(x, y + 4.5f), o(x + 3f, y + 4.5f), k, StrokeCap.Round) - } - val edge = Brush.verticalGradient( - 0f to white.copy(alpha = 0f), 0.22f to white.copy(alpha = 0.55f), 0.78f to white.copy(alpha = 0.55f), 1f to white.copy(alpha = 0f), - startY = 0f, endY = 150f * k, ) - drawLine(edge, o(43f, 0f), o(43f, 150f), 1.4f * k) - drawLine(edge, o(77f, 0f), o(77f, 150f), 1.4f * k) - // The lugs, where the band meets the pod. - for ((x, y) in listOf(43f to 36f, 77f to 36f, 43f to 110f, 77f to 110f)) drawLine(white.copy(alpha = 0.5f), o(x, y), o(x, y + 4f), 1.2f * k, StrokeCap.Round) - // The pod, its bevel and one highlight on the top left. - drawRoundRect(Color(0xFF0C0F0E), o(34f, 40f), Size(52f * k, 70f * k), CornerRadius(9f * k)) - drawRoundRect(white.copy(alpha = 0.8f), o(34f, 40f), Size(52f * k, 70f * k), CornerRadius(9f * k), style = Stroke(1.5f * k)) - drawRoundRect(white.copy(alpha = 0.2f), o(39f, 45f), Size(42f * k, 60f * k), CornerRadius(5f * k), style = Stroke(k)) - val highlight = Path().apply { - moveTo(40f * k, 58f * k); lineTo(40f * k, 52f * k) - arcTo(Rect(40f * k, 47f * k, 50f * k, 57f * k), 180f, 90f, false) - lineTo(55f * k, 47f * k) - } - drawPath(highlight, white.copy(alpha = 0.45f), style = Stroke(1.2f * k, cap = StrokeCap.Round)) - // The light. - val c = o(60f, 75f) - if (lit) { - drawCircle(Brush.radialGradient(listOf(led.copy(alpha = 0.55f), led.copy(alpha = 0f)), c, 14f * k), 14f * k, c) - drawCircle(led, 3.2f * k, c) - if (syncing) rotate(turn, c) { - drawArc(led, -90f, 75f, false, o(49f, 64f), Size(22f * k, 22f * k), style = Stroke(1.4f * k, cap = StrokeCap.Round)) + } else NoTurn + Spacer( + modifier.graphicsLayer { alpha = if (lit) 1f else 0.45f }.drawWithCache { + // Drawn on a 120 by 150 grid, the card slot's shape. + val k = size.width / 120f + fun o(x: Float, y: Float) = Offset(x * k, y * k) + val white = Color.White + val edge = Brush.verticalGradient( + 0f to white.copy(alpha = 0f), 0.22f to white.copy(alpha = 0.55f), + 0.78f to white.copy(alpha = 0.55f), 1f to white.copy(alpha = 0f), + startY = 0f, endY = 150f * k, + ) + val highlight = Path().apply { + moveTo(40f * k, 58f * k); lineTo(40f * k, 52f * k) + arcTo(Rect(40f * k, 47f * k, 50f * k, 57f * k), 180f, 90f, false) + lineTo(55f * k, 47f * k) } - } else drawCircle(white.copy(alpha = 0.6f), 3.2f * k, c, style = Stroke(k)) - } + val c = o(60f, 75f) + val glow = Brush.radialGradient(listOf(led.copy(alpha = 0.55f), led.copy(alpha = 0f)), c, 14f * k) + onDrawBehind { + // The band: a faint weave between its two edges, which fade out at the card's top and bottom. + for (row in 0 until 25) { + val y = row * 6f + for (x in WeaveOdd) drawLine(white.copy(alpha = 0.09f), o(x, y + 1.5f), o(x + 3f, y + 1.5f), k, StrokeCap.Round) + for (x in WeaveEven) drawLine(white.copy(alpha = 0.09f), o(x, y + 4.5f), o(x + 3f, y + 4.5f), k, StrokeCap.Round) + } + drawLine(edge, o(43f, 0f), o(43f, 150f), 1.4f * k) + drawLine(edge, o(77f, 0f), o(77f, 150f), 1.4f * k) + // The lugs, where the band meets the pod. + for ((x, y) in Lugs) drawLine(white.copy(alpha = 0.5f), o(x, y), o(x, y + 4f), 1.2f * k, StrokeCap.Round) + // The pod, its bevel and one highlight on the top left. + drawRoundRect(Color(0xFF0C0F0E), o(34f, 40f), Size(52f * k, 70f * k), CornerRadius(9f * k)) + drawRoundRect(white.copy(alpha = 0.8f), o(34f, 40f), Size(52f * k, 70f * k), CornerRadius(9f * k), style = Stroke(1.5f * k)) + drawRoundRect(white.copy(alpha = 0.2f), o(39f, 45f), Size(42f * k, 60f * k), CornerRadius(5f * k), style = Stroke(k)) + drawPath(highlight, white.copy(alpha = 0.45f), style = Stroke(1.2f * k, cap = StrokeCap.Round)) + // The light. + if (lit) { + drawCircle(glow, 14f * k, c) + drawCircle(led, 3.2f * k, c) + if (syncing) rotate(turn.value, c) { + drawArc(led, -90f, 75f, false, o(49f, 64f), Size(22f * k, 22f * k), style = Stroke(1.4f * k, cap = StrokeCap.Round)) + } + } else drawCircle(white.copy(alpha = 0.6f), 3.2f * k, c, style = Stroke(k)) + } + }, + ) } +/** The weave's dashes on the rows that start at the band's left edge, and on the rows between them. */ +private val WeaveOdd = floatArrayOf(44f, 52f, 60f, 68f) +private val WeaveEven = floatArrayOf(48f, 56f, 64f, 72f) + +/** Where the four lugs start, on the 120 by 150 grid. */ +private val Lugs = listOf(43f to 36f, 77f to 36f, 43f to 110f, 77f to 110f) + +/** The resting turn when the strap isn't syncing. */ +private val NoTurn: State = mutableFloatStateOf(0f) + /** The strap's own light, a soft green, as it glows when connected. */ internal val StrapLight = Color(0xFF7DE0C0) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/SummarySections.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/SummarySections.kt new file mode 100644 index 00000000..62d38087 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/SummarySections.kt @@ -0,0 +1,679 @@ +package org.openstrap.app.ui.glow + +import org.openstrap.app.data.Weeks +import org.openstrap.app.presentation.dotFormat +import org.openstrap.app.presentation.Units +import org.openstrap.app.presentation.Pace +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import java.time.LocalDate +import java.time.format.TextStyle +import java.util.Locale +import org.openstrap.app.data.Rewards +import org.openstrap.app.data.Route +import org.openstrap.app.data.Structure +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.Workouts +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.presentation.EarnedLine +import org.openstrap.app.ui.workout.ExerciseSets +import org.openstrap.app.ui.workout.FamilySummary +import org.openstrap.app.ui.workout.PeriodLine +import org.openstrap.app.ui.workout.PhoneSummary +import org.openstrap.app.ui.workout.RoundLine +import org.openstrap.app.ui.workout.ZONE_COLORS +import org.openstrap.app.ui.workout.ZONE_NAMES +import org.openstrap.app.ui.workout.familyColor +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.Edge + +/* + * The summary's parts that differ by kind of workout (workouts spec 2.8, boards SummaryRun to SummaryGame): the hero's + * one number and three figures, then each kind's own section. Cards alternate their look down the page (an edge card + * in the kind's colour, then a quiet one), so each section says whether it ended on an edge card for the card under it. + */ + +/** + * The hero: its one number in dots and its word, then a sentence or three figures in text. A figure's value is null + * when it's missing (no heart rate, no profile for kcal): it shows the waiting dash. + */ +internal class SummaryHero(val value: String, val unit: String, val figures: List> = emptyList(), val sentence: String? = null, val line: String? = null, val pace: String? = null) + +/** A workout's active kcal as its summary and share card write it: "1,312". */ +internal fun kcalFigure(a: WorkoutAnalysis): String? = a.activeKcal?.let { Units.count(Math.round(it)) } + +/** A workout's length in whole minutes, or seconds under one, with its unit: "42" to "min". */ +internal fun minutesFigure(active: Long): Pair = if (active >= 60) "${active / 60}" to "min" else "$active" to "s" + +/** + * What the hero shows for [summary]'s [family]; [usualLoad] names a game's Load. When the kind's own number is missing + * (no heart rate for an indoor workout, no sets or rounds), the hero falls back to the minutes, for every kind. + */ +internal fun summaryHero(family: Workouts.Family, summary: PhoneSummary, usualLoad: Float?): SummaryHero { + val a = summary.analysis + val active = a.activeSeconds + val readings = FamilySummary.readings(summary) + val peak = readings.maxOfOrNull { it.second }?.toString() + val avg = a.workout.averageHeartRate?.toString() + val kcal = kcalFigure(a) + val time = workoutClock(active) + val (minutes, minUnit) = minutesFigure(active) + fun byTime(figures: List>) = SummaryHero(minutes, minUnit, listOf(time to "Time") + figures) + return when (family) { + Workouts.Family.Distance -> { + val m = summary.distanceMeters + val gps = FamilySummary.gpsDistance(m, summary.distanceFromSteps) + val pace = Pace.clock(Pace.of(gps, active)) + when { + m == null || m <= 0f -> byTime(listOf(avg to "Avg bpm", kcal to "Active kcal")) + else -> SummaryHero( + if (m < Units.METERS_PER_KM) m.toInt().toString() else Units.km(m, Units.Precision.Workout, Units.DOT_LOCALE), + if (m < Units.METERS_PER_KM) "m" else "km", + listOf(time to "Time", if (pace != null) pace to "Avg pace" else kcal to "Active kcal", avg to "Avg bpm"), + pace = pace, + ) + } + } + Workouts.Family.IndoorCardio -> + if (avg != null) SummaryHero(avg, "avg bpm", listOf(time to "Time", kcal to "Active kcal", peak to "Peak")) + else byTime(listOf(kcal to "Active kcal", peak to "Peak")) + Workouts.Family.Strength -> { + val lifted = org.openstrap.app.data.Strength.kgLifted(summary.sets) + val sets = FamilySummary.setsDone(summary.sets) + val rest = FamilySummary.averageRest(summary.sets)?.let(::workoutClock) + // Without a log the hero is the sets, and kg lifted isn't shown (spec 2.8); without sets, the minutes. + when { + lifted != null -> SummaryHero( + dotFormat("%,d", Math.round(lifted)), "kg lifted", listOf(time to "Time", "$sets" to "Sets", rest to "Avg rest"), + ) + sets > 0 -> SummaryHero("$sets", if (sets == 1) "set" else "sets", listOf(time to "Time", rest to "Avg rest", peak to "Peak")) + else -> byTime(listOf(avg to "Avg bpm", peak to "Peak")) + } + } + Workouts.Family.Intervals -> { + val done = org.openstrap.app.data.Phases.roundsDone(summary.rounds) + val reps = summary.structure is Structure.Reps + val hard = a.zoneSeconds.drop(3).sum() + if (done == 0) byTime(listOf(avg to "Avg bpm", peak to "Peak")) + else SummaryHero( + "$done", if (reps) "reps done" else "rounds done", + listOf( + workoutClock(FamilySummary.workSeconds(summary.rounds)) to (if (reps) "Rep time" else "Work time"), peak to "Peak", + (if (readings.isEmpty()) null else "${hard / 60} min") to "In zone 4 to 5", + ), + ) + } + Workouts.Family.MindBody -> { + val settled = FamilySummary.settled(readings, a.shownStart, a.end) + SummaryHero( + minutes, minUnit, sentence = settled?.let { (f, t) -> FamilySummary.settledLine(f, t) }, + line = listOfNotNull(a.workout.averageHeartRate?.let { "Average $it bpm" }, a.activeKcal?.let { "${Math.round(it)} active kcal" }).joinToString(", ").ifEmpty { null }, + ) + } + Workouts.Family.Games -> { + val load = a.workout.trainingLoad + SummaryHero( + minutes, if (minUnit == "min") "min played" else "s played", + listOf(load?.toString() to (load?.let { FamilySummary.loadLabel(it, usualLoad) } ?: "Load"), kcal to "Active kcal", peak to "Peak bpm"), + ) + } + } +} + +/** The hero's figures: values in Row value type over their labels, the last one to the right; a missing one is the dash. */ +@Composable +internal fun SummaryFigures(figures: List>, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth()) { + figures.forEachIndexed { i, (value, label) -> + val end = i == figures.lastIndex && i > 0 + Column(Modifier.weight(1f), horizontalAlignment = if (end) Alignment.End else Alignment.Start) { + if (value != null) Text(value, color = Color.White, style = GlowType.rowValue, maxLines = 1) + else WaitingDash(Modifier.padding(vertical = 10.5.dp)) + Text(label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + } + } + } +} + +/** + * The section for [family] (boards SummaryRun to SummaryGame): an edge card in the kind's colour, then a quiet card. + * [week] is this week's count toward the goal, for the line under a calm session's week. Returns whether it ended on + * an edge card, so the card under it can take another edge. + */ +@Composable +internal fun FamilySection( + family: Workouts.Family, summary: PhoneSummary, liveRoute: List, others: List, weekStarts: List, + /** False on a past workout's page, whose heart rate card has the line already: the lines under it stay. */ + heartLine: Boolean = true, + week: Rewards.WeekLine? = null, +): Boolean { + val a = summary.analysis + val readings = remember(summary) { FamilySummary.readings(summary) } + // The stored time in zones, as the hero and the share card read it, so the page shows one total. + val zones = remember(a) { a.zoneSeconds.toIntArray() } + when (family) { + Workouts.Family.Distance -> { + RouteCard(summary) + return SplitsList(summary, liveRoute, markFastest = true) + } + Workouts.Family.IndoorCardio -> { + // Board SummaryIndoor: the zones lit from the right in the zone you were mostly in, the heart rate quiet + // under it, then how it compares with last time as a line of its own. + val zonesShown = zones.sum() > 0 + if (zonesShown) ZonesEdgeCard(zones) + val kcal = a.activeKcal?.let(Math::round) + val target = org.openstrap.app.ui.workout.SummaryCopy.targetLine(a.target, a.targetReachedSeconds, kcal)?.let { + it to FamilySummary.targetReached(a.target, a.targetReachedSeconds, kcal) + } + val heartShown = IndoorHeartCard(readings, target, chart = heartLine) + FamilySummary.versusLast(a, others)?.let { VersusLast(it.replaceFirstChar { c -> c.lowercase() }) } + return zonesShown && !heartShown + } + Workouts.Family.Strength -> { + val groups = remember(summary) { FamilySummary.exerciseSets(summary.sets, readings) } + if (groups.isNotEmpty()) SetsCard(groups, a.maxHeartRate) + val below = summary.restBelow + val rest = remember(summary) { FamilySummary.restRecovery(summary.sets, readings, below) } + val restShown = below != null && rest != null + if (restShown) SentenceCard("Rest", FamilySummary.restLine(rest, below, summary.restBefore)) + return groups.isNotEmpty() && !restShown + } + Workouts.Family.Intervals -> { + val reps = summary.structure is Structure.Reps + val lines = remember(summary) { FamilySummary.rounds(summary.rounds, readings) } + // Without a heart rate the peaks are empty: a run's reps show their times instead, timed rounds nothing. + val peaks = lines.any { it.peak != null } + val times = summary.rounds.mapNotNull { r -> r.workEnd?.let { it - r.workStart } } + if (peaks) RoundsCard(lines, if (reps) "Each rep" else "Each round", a.maxHeartRate) + else if (reps && times.isNotEmpty()) RepTimesCard(times) + val rest = (summary.structure as? Structure.Timed)?.rest ?: (summary.structure as? Structure.Reps)?.rest + val line = FamilySummary.roundsLine(lines, rest) + if (line != null) SentenceCard("Recovery", line) + return (peaks || (reps && times.isNotEmpty())) && line == null + } + Workouts.Family.MindBody -> { + val settled = FamilySummary.settled(readings, a.shownStart, a.end) + HeartLineCard(readings, MetricColors.evening, settled, chart = heartLine) + val day = java.time.Instant.ofEpochSecond(a.shownStart).atZone(java.time.ZoneId.systemDefault()).toLocalDate() + WeekDaysPanel(weekStarts, note = week?.let { FamilySummary.weekGoalLine(it.count, it.goal) }, day = day, color = MetricColors.evening) + return false + } + Workouts.Family.Games -> { + // Named out of the periods it was set up with, not the ones played, so ending early doesn't rename them. + val of = (summary.structure as? org.openstrap.app.data.Structure.Periods)?.rounds ?: summary.rounds.count { it.workEnd != null } + val periods = remember(summary) { FamilySummary.periods(summary.rounds, readings, a.maxHeartRate, of) } + val read = FamilySummary.gameRead(readings, a.shownStart, a.end) + val periodsShown = periods.isNotEmpty() || read != null + if (periodsShown) PeriodsCard(periods, of, read) + if (zones.sum() > 0) ZoneStripPanel(zones) + return periodsShown && zones.sum() <= 0 + } + } +} + +/** The kept route in its zones' colours with the climb; nothing without a route that goes somewhere. */ +@Composable +private fun RouteCard(summary: PhoneSummary) { + if (!Route.mappable(summary.route)) return + EdgeCard(MetricColors.steps, Modifier.fillMaxWidth(), kind = EdgeKind.Earlier) { + EdgeTitle("Route", Route.climb(summary.route)?.let { "Climb ${Math.round(it)} m" }) + RouteMap(summary, Modifier.padding(top = 12.dp)) + } +} + +/** A quiet card with its [title] and one [sentence] under it: Rest after the sets, Recovery after the rounds. */ +@Composable +private fun SentenceCard(title: String, sentence: String) { + QuietPanel { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + PanelTitle(title) + Text(sentence, color = Color.White.copy(alpha = 0.85f), style = GlowType.body) + } + } +} + +/** + * The sets by exercise (board SummaryStrength), lit from the left in strength's orange: each set's number, what was + * lifted, its peak and a bar as long as the peak against the hardest set's, in the zone it reached. + */ +@Composable +private fun SetsCard(groups: List, maxHr: Int) { + val shares = remember(groups) { FamilySummary.peakShares(groups.flatMap { g -> g.sets.map { it.peak } }) } + EdgeCard(familyColor(Workouts.Family.Strength), Modifier.fillMaxWidth(), kind = EdgeKind.Earlier) { + EdgeTitle("Sets", "Peak heart rate") + var i = 0 + groups.forEachIndexed { gi, g -> + if (g.name != null) Text( + g.name, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1, + modifier = Modifier.padding(top = if (gi == 0) 10.dp else 8.dp, bottom = 2.dp), + ) else if (gi == 0) Spacer(Modifier.height(8.dp)) + g.sets.forEach { s -> + val share = shares[i++] + Row(Modifier.fillMaxWidth().height(24.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + "${s.number}", color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.secondary, maxLines = 1, + modifier = Modifier.width(22.dp), + ) + Text(s.text, color = Color.White, style = GlowType.secondary, maxLines = 1, modifier = Modifier.width(100.dp)) + Text( + s.peak?.let { "peak $it" }.orEmpty(), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1, + modifier = Modifier.width(72.dp), + ) + Box(Modifier.weight(1f).height(6.dp)) { + if (share != null) Box(Modifier.fillMaxWidth(share).fillMaxHeight().clip(CircleShape).background(zoneColor(s.peak, maxHr))) + } + } + } + } + } +} + +/** Rounds shown with their figures; past this many there's no room, and the columns stand alone. */ +private const val LabelledRounds = 10 + +/** + * Each round as a column (board SummaryHiit), lit from the left in heart red: its peak over it, as tall as the peak + * against the hardest round's, in the zone it reached, the hardest outlined. Under a hairline, how far the heart rate + * came down in the rest after each. + */ +@Composable +private fun RoundsCard(lines: List, title: String, maxHr: Int) { + val shares = remember(lines) { FamilySummary.peakShares(lines.map { it.peak }) } + val hardest = remember(lines) { FamilySummary.hardestRound(lines) } + val labelled = lines.size <= LabelledRounds + val gap = if (labelled) 6.dp else 3.dp + val shape = RoundedCornerShape(5.dp) + EdgeCard(familyColor(Workouts.Family.Intervals), Modifier.fillMaxWidth(), kind = EdgeKind.Earlier) { + EdgeTitle(title, "Peak, then rest") + Row(Modifier.fillMaxWidth().height(84.dp).padding(top = 14.dp), horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.Bottom) { + lines.forEachIndexed { i, r -> + Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (labelled && r.peak != null) Text( + "${r.peak}", color = Color.White, maxLines = 1, + style = if (i == hardest) GlowType.caption.copy(fontWeight = FontWeight.SemiBold) else GlowType.caption, + ) + shares[i]?.let { share -> + Box( + Modifier.fillMaxWidth().height(RoundColumnHeight * share).clip(shape).background(zoneColor(r.peak, maxHr)) + .let { if (i == hardest) it.border(2.dp, Color.White, shape) else it }, + ) + } + } + } + } + if (labelled && lines.any { it.drop != null }) { + Box(Modifier.fillMaxWidth().padding(top = 8.dp).height(1.dp).background(Color.White.copy(alpha = 0.1f))) + Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(gap)) { + lines.forEach { r -> + Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { + if (r.drop != null) { + Text("down", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.caption, maxLines = 1) + Text("${r.drop}", color = Color.White, style = GlowType.body, maxLines = 1) + } + } + } + } + } + } +} + +/** + * A run's reps without a heart rate: each rep's time as a column, the fastest full height, bright and outlined, the + * rest as tall as the fastest against them, with the time under each. + */ +@Composable +private fun RepTimesCard(times: List) { + val fastest = times.filter { it > 0 }.minOrNull() ?: return + val best = times.indexOf(fastest) + val labelled = times.size <= LabelledRounds + val gap = if (labelled) 6.dp else 3.dp + val shape = RoundedCornerShape(5.dp) + val color = familyColor(Workouts.Family.Intervals) + EdgeCard(color, Modifier.fillMaxWidth(), kind = EdgeKind.Earlier) { + EdgeTitle("Each rep", "Time") + Row(Modifier.fillMaxWidth().height(RoundColumnHeight + 14.dp).padding(top = 14.dp), horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.Bottom) { + times.forEachIndexed { i, t -> + val share = if (t > 0) (fastest.toFloat() / t).coerceIn(0.3f, 1f) else 0.3f + Box( + Modifier.weight(1f).height(RoundColumnHeight * share).clip(shape).background(if (i == best) color else color.copy(alpha = 0.45f)) + .let { if (i == best) it.border(2.dp, Color.White, shape) else it }, + ) + } + } + if (labelled) Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(gap)) { + times.forEachIndexed { i, t -> + Text( + workoutClock(t), color = if (i == best) Color.White else Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), maxLines = 1, + style = if (i == best) GlowType.caption.copy(fontWeight = FontWeight.SemiBold) else GlowType.caption, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, modifier = Modifier.weight(1f), + ) + } + } + } +} + +/** The hardest round's column; the rest are as tall as their peak against it. */ +private val RoundColumnHeight = 52.dp + +/** + * A game's halves or periods (board SummaryGame), lit from the left in the games colour: each one's hard efforts, a + * bar as long as its average heart rate against the highest, that one full colour, and the average beside it. Then + * how the game went. + */ +@Composable +private fun PeriodsCard(periods: List, of: Int, read: String?) { + val color = familyColor(Workouts.Family.Games) + val shares = remember(periods) { FamilySummary.peakShares(periods.map { it.averageBpm }) } + EdgeCard(color, Modifier.fillMaxWidth(), kind = EdgeKind.Earlier) { + EdgeTitle(when (of) { 2 -> "Each half"; 4 -> "Each quarter"; else -> "Each period" }, null) + periods.forEachIndexed { i, p -> + Column(Modifier.fillMaxWidth().padding(top = if (i == 0) 14.dp else 12.dp)) { + Row(Modifier.fillMaxWidth()) { + Text(p.name, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1, modifier = Modifier.weight(1f)) + Text( + "${p.hardEfforts} hard ${if (p.hardEfforts == 1) "effort" else "efforts"}", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + style = GlowType.secondary, maxLines = 1, + ) + } + Row(Modifier.fillMaxWidth().padding(top = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f).height(10.dp).clip(CircleShape).background(Raised)) { + shares[i]?.let { share -> + Box(Modifier.fillMaxWidth(share).fillMaxHeight().clip(CircleShape).background(if (share >= 1f) color else color.copy(alpha = 0.6f))) + } + } + p.averageBpm?.let { + Text("$it", color = Color.White, style = GlowType.rowValue, maxLines = 1, modifier = Modifier.padding(start = 12.dp)) + Text(" avg", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + } + } + } + } + read?.let { Text(it, color = Color.White.copy(alpha = 0.8f), style = GlowType.secondary, modifier = Modifier.padding(top = 14.dp)) } + } +} + +/** + * Time in zones as one strip (boards SummaryGame, SummaryIndoor): a rounded segment for each zone with time in it, as + * long as the time. + */ +@Composable +private fun ZoneStrip(zones: IntArray, modifier: Modifier = Modifier) { + val shape = RoundedCornerShape(4.dp) + Row(modifier.fillMaxWidth().height(12.dp), horizontalArrangement = Arrangement.spacedBy(3.dp)) { + zones.forEachIndexed { z, s -> if (s > 0) Box(Modifier.weight(s.toFloat()).fillMaxHeight().clip(shape).background(ZONE_COLORS[z])) } + } +} + +/** + * Time in zones on a quiet card (board SummaryGame): the zone with the most time in the title row, the strip, then + * each zone's dot and minutes under it, the first with its unit. + */ +@Composable +internal fun ZoneStripPanel(zones: IntArray) { + val minutes = remember(zones) { FamilySummary.zoneMinutes(zones) } + QuietPanel { + PanelTitle("Time in zones", FamilySummary.mostlyZone(zones)) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + ZoneStrip(zones) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + var first = true + zones.indices.filter { zones[it] > 0 }.forEach { z -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).background(ZONE_COLORS[z], CircleShape)) + Text( + if (first) "${minutes[z]} min" else "${minutes[z]}", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + style = GlowType.caption, maxLines = 1, modifier = Modifier.padding(start = 5.dp), + ) + } + first = false + } + } + } + } +} + +/** + * Time in zones on an edge card lit from the right in the zone you were mostly in (board SummaryIndoor): the strip, + * then each zone with time in it by name and minutes, that zone's row bold. + */ +@Composable +private fun ZonesEdgeCard(zones: IntArray) { + val minutes = remember(zones) { FamilySummary.zoneMinutes(zones) } + val most = zones.indices.maxByOrNull { zones[it] } ?: 0 + CompositionLocalProvider(LocalEdgeOverride provides EdgeOverride(edge = Edge.Right)) { + EdgeCard(ZONE_COLORS[most], Modifier.fillMaxWidth()) { + EdgeTitle("Time in zones", FamilySummary.mostlyZone(zones)) + ZoneStrip(zones, Modifier.padding(top = 10.dp, bottom = 6.dp)) + zones.indices.filter { zones[it] > 0 }.forEach { z -> + val style = if (z == most) GlowType.secondary.copy(fontWeight = FontWeight.SemiBold) else GlowType.secondary + Row(Modifier.fillMaxWidth().height(24.dp), verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(8.dp).background(ZONE_COLORS[z], CircleShape)) + Text("Zone ${z + 1}, ${ZONE_NAMES[z].lowercase()}", color = Color.White, style = style, maxLines = 1, modifier = Modifier.weight(1f).padding(start = 10.dp)) + Text("${minutes[z]} min", color = Color.White, style = style.copy(fontFeatureSettings = "tnum"), maxLines = 1) + } + } + } + } +} + +/** + * The heart rate of an indoor workout on a quiet card (board SummaryIndoor): its range in the title row, the line in + * heart red with its peak marked, then the kcal [target] with a tick when it was reached. Without the line ([chart] + * off, or too few readings) the target alone. Returns whether it showed. + */ +@Composable +private fun IndoorHeartCard(readings: List>, target: Pair?, chart: Boolean): Boolean { + val line = chart && readings.size >= 10 + if (!line && target == null) return false + val values = remember(readings) { FamilySummary.heartLine(readings, HeartPoints, window = 3) } + QuietPanel { + if (line) Column { + PanelTitle("Heart rate", "${readings.minOf { it.second }} to ${readings.maxOf { it.second }}") + SmoothLine(values, MetricColors.heart, Modifier.padding(top = 8.dp), height = 64.dp, peak = true) + } + target?.let { (text, reached) -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(20.dp).background((if (reached) MetricColors.good else Color.White).copy(alpha = 0.16f), CircleShape), + contentAlignment = Alignment.Center, + ) { Icon(if (reached) AppIcons.Tick02 else AppIcons.Target, null, tint = if (reached) MetricColors.good else Color.White, modifier = Modifier.size(12.dp)) } + Text(text, color = Color.White, style = GlowType.secondary, modifier = Modifier.padding(start = 8.dp)) + } + } + } + return true +} + +/** "vs last time:" and how this one compares, as a line of its own under the cards (board SummaryIndoor). */ +@Composable +private fun VersusLast(line: String) { + Text( + buildAnnotatedString { + withStyle(SpanStyle(color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA))) { append("vs last time: ") } + append(line) + }, + color = Color.White, style = GlowType.secondary, modifier = Modifier.padding(horizontal = 4.dp), + ) +} + +/** How many points a heart line is drawn through. */ +private const val HeartPoints = 60 + +/** + * The heart rate as a smooth line in [color], no grid: a dot at each end with [ends], or at the highest point with + * [peak], and a hairline along the foot with [peak] (boards SummaryMind, SummaryIndoor). Gaps are bridged. + */ +@Composable +private fun SmoothLine(values: List, color: Color, modifier: Modifier = Modifier, height: Dp, ends: Boolean = false, peak: Boolean = false) { + val points = values.withIndex().filter { it.value != null }.map { it.index to it.value!! } + if (points.size < 2) return + Canvas(modifier.fillMaxWidth().height(height)) { + val inset = 4.dp.toPx() + val lo = points.minOf { it.second } + val hi = points.maxOf { it.second }.let { if (it - lo < 1f) lo + 1f else it } + fun at(i: Int, v: Float) = Offset(inset + i / (values.size - 1f) * (size.width - 2 * inset), inset + (hi - v) / (hi - lo) * (size.height - 2 * inset)) + val p = points.map { (i, v) -> at(i, v) } + if (peak) drawLine(Color.White.copy(alpha = 0.12f), Offset(0f, size.height - 0.5f), Offset(size.width, size.height - 0.5f), 1.dp.toPx()) + // Through the midpoints, each reading the control point, so the line bends without overshooting. + val path = Path().apply { + moveTo(p[0].x, p[0].y) + for (k in 1 until p.size) { + val m = Offset((p[k - 1].x + p[k].x) / 2, (p[k - 1].y + p[k].y) / 2) + quadraticTo(p[k - 1].x, p[k - 1].y, m.x, m.y) + } + lineTo(p.last().x, p.last().y) + } + drawPath(path, color, style = Stroke(2.5.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) + val dot = 4.dp.toPx() + if (ends) { + drawCircle(color, dot, p.first()) + drawCircle(color, dot, p.last()) + } + if (peak) drawCircle(color, 3.5.dp.toPx(), p[points.indices.maxBy { points[it].second }]) + } +} + +/** + * The heart rate over a calm session on an edge card lit from the left (board SummaryMind): how far it came down in + * the title row, a smooth line with its two ends marked, then the start and end figures ([settled]). Without them the + * range in the title row and the line alone; with [chart] off, the figures alone. + */ +@Composable +private fun HeartLineCard(readings: List>, color: Color, settled: Pair? = null, chart: Boolean = true) { + if (readings.size < 10 || (!chart && settled == null)) return + val values = remember(readings) { FamilySummary.heartLine(readings, HeartPoints, window = 9) } + EdgeCard(color, Modifier.fillMaxWidth(), kind = EdgeKind.Earlier) { + val drop = settled?.let { FamilySummary.settledDrop(it.first, it.second) } + EdgeTitle("Heart rate", drop?.let { "down $it" } ?: "${readings.minOf { it.second }} to ${readings.maxOf { it.second }}") + if (chart) SmoothLine(values, color, Modifier.padding(top = 14.dp), height = 74.dp, ends = true) + if (settled != null) Row(Modifier.fillMaxWidth().padding(top = 8.dp)) { + Column(Modifier.weight(1f)) { + Text("${settled.first}", color = Color.White, style = GlowType.rowValue) + Text("At the start", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + } + Column(horizontalAlignment = Alignment.End) { + Text("${settled.second}", color = Color.White, style = GlowType.rowValue) + Text("At the end", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + } + } + } +} + +/** + * The days of [day]'s week as round dots from the locale's first day (board SummaryMind): a day with a workout filled + * in [color], today ringed, and the [note] under them. + */ +@Composable +internal fun WeekDaysPanel(weekStarts: List, note: String? = null, day: LocalDate = LocalDate.now(), color: Color = Color.White) { + val firstDay = remember { Weeks.firstDay() } + val days = remember(weekStarts, day) { FamilySummary.weekDays(weekStarts, day, firstDay) } + val today = LocalDate.now() + val thisWeek = days.any { it.first == today } + QuietPanel { + PanelTitle(if (thisWeek) "This week" else "That week") + WeekDots( + days.map { (d, on) -> d.dayOfWeek.getDisplayName(TextStyle.NARROW, Locale.getDefault()) to on }, color, + today = days.indexOfFirst { it.first == today }.takeIf { it >= 0 }, ticks = false, dot = 24.dp, + ) + val text = note ?: days.count { it.second }.let { n -> "$n ${if (n == 1) "day" else "days"} with a workout ${if (thisWeek) "this" else "that"} week." } + Text(text, color = Color.White, style = GlowType.body) + } +} + +/** + * A week as seven round marks, each day's letter under it; a day with a workout is filled, with a tick in it when + * [ticks]. The [today] mark is ringed and its letter white. + */ +@Composable +internal fun WeekDots( + days: List>, color: Color = Color.White, modifier: Modifier = Modifier, today: Int? = null, ticks: Boolean = true, dot: Dp = 28.dp, +) { + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + days.forEachIndexed { i, (letter, on) -> + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + Modifier.size(dot) + // Today's ring sits just outside the mark, so it keeps its size. + .let { if (i == today) it.drawBehind { drawCircle((if (on) color else Color.White).copy(alpha = 0.3f), radius = size.minDimension / 2 + 3.dp.toPx()) } else it } + .background(if (on) color else Color.White.copy(alpha = 0.08f), CircleShape), + contentAlignment = Alignment.Center, + ) { if (on && ticks) Icon(AppIcons.Tick02, null, tint = Color.Black, modifier = Modifier.size(16.dp)) } + Text( + letter, color = if (i == today) Color.White else Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.caption, + textAlign = TextAlign.Center, + ) + } + } + } +} + +/** + * What this workout earned (workouts spec 2.8, boards SummaryRun to SummaryGame): "Earned", then each new best, badge + * and the weekly goal as a chip, its icon in its colour. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun EarnedChips(lines: List) { + if (lines.isEmpty()) return + Column(Modifier.fillMaxWidth().padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Earned", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, modifier = Modifier.padding(start = 4.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + lines.forEach { l -> + val (icon, color) = when (l.kind) { + EarnedLine.Kind.Best -> AppIcons.FlagCheckered to MetricColors.calories + EarnedLine.Kind.Badge -> AppIcons.CheckCircle to MetricColors.good + EarnedLine.Kind.Goal -> AppIcons.Target to if (l.met) MetricColors.good else Color.White + } + Row( + Modifier.clip(CircleShape).background(Raised).padding(horizontal = 14.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, null, tint = color, modifier = Modifier.size(16.dp)) + Text( + listOfNotNull(l.text, l.value).joinToString(", "), color = Color.White, style = GlowType.secondary, maxLines = 1, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/TabParts.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/TabParts.kt index a1d7c3ba..2da32a91 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/TabParts.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/TabParts.kt @@ -32,6 +32,7 @@ import org.openstrap.app.ui.dashboard.Hue import org.openstrap.app.ui.dashboard.TabSlot import org.openstrap.app.ui.dashboard.Tier import org.openstrap.app.ui.dashboard.items +import org.openstrap.app.ui.glow.draw.DotSize /* * What the Recovery, Sleep and Activity tabs share: laying out a tab's planned cards ([tabCards]) with the edge each @@ -66,11 +67,8 @@ internal fun GlowBlocks.tabCards(slots: List>, focus: C? = null, CompositionLocalProvider(LocalEdgeOverride provides slot.look.edgeOverride()) { draw(slot.card, m) } } } - when (pane) { - Pane.Full -> full(block) - Pane.Left -> left(block) - Pane.Right -> right(block) - } + // Keyed on its cards, so a card keeps its saved state when one is added or hidden above it (audit K5). + add(pane, GlowBlocks.cardsKey(row.items().map { "${slots[it].card}" }), block) } } @@ -114,7 +112,7 @@ internal fun ContributorRow( Modifier.fillMaxWidth() .then(opening?.modifier ?: Modifier) .let { if (onClick != null) it.clickable(onClick = opening?.onClick ?: onClick) else it } - .let { if (divider) it.drawBehind { drawLine(RowHairline, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) } else it } + .topHairline(divider, RowHairline) .padding(vertical = GlowLayout.RowPadding), verticalAlignment = Alignment.CenterVertically, ) { @@ -145,7 +143,7 @@ internal fun ContributorRow( internal fun LinkRow(text: String, onClick: () -> Unit, divider: Boolean = true) { Row( Modifier.fillMaxWidth().clickable(onClick = onClick) - .let { if (divider) it.drawBehind { drawLine(RowHairline, Offset(0f, 0f), Offset(size.width, 0f), 1.dp.toPx()) } else it } + .topHairline(divider, RowHairline) .padding(vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/TodayCards.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/TodayCards.kt index 4c96729e..a04ced58 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/TodayCards.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/TodayCards.kt @@ -1,5 +1,8 @@ package org.openstrap.app.ui.glow +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.dotFormat +import org.openstrap.app.presentation.Units import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -35,13 +38,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import java.time.Instant import java.time.ZoneId -import java.time.format.DateTimeFormatter import kotlin.math.roundToInt import org.openstrap.app.data.CheckIn import org.openstrap.app.data.Workouts import org.openstrap.app.data.db.HealthAlertRow import org.openstrap.app.ui.components.cardNumber -import org.openstrap.app.ui.components.formatDuration import org.openstrap.app.ui.components.pressClickable import org.openstrap.app.ui.dashboard.DailyHistory import org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT @@ -55,15 +56,17 @@ import org.openstrap.app.ui.dashboard.Score import org.openstrap.app.ui.dashboard.TodayCopy import org.openstrap.app.ui.theme.MetricColors import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.glow.draw.LightAngle +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.EdgeStatus +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.DotSize /* * The pieces Today is built from (spec 4.2): the hero's parts, strips, edge cards and lists. Each card has one home * on the screen and opens its detail. The words come from [TodayCopy]. */ -private val CLOCK = DateTimeFormatter.ofPattern("HH:mm") -internal fun clockAt(epoch: Long): String = Instant.ofEpochSecond(epoch).atZone(ZoneId.systemDefault()).format(CLOCK) - /** The smallest height a card of [size] takes: its grid height, so long text grows the card instead of clipping. */ internal fun Modifier.gridSize(size: CardSize): Modifier = GlowGrid.height(size)?.let { heightIn(min = it) } ?: this @@ -242,9 +245,9 @@ private fun alertMetric(a: HealthAlertRow) = when (a.type) { 3 -> Metric.Stress; internal fun AlertsStrip(alerts: List, onOpenMetric: (Metric) -> Unit, modifier: Modifier = Modifier) { val newest = alerts.maxByOrNull { it.timestamp } ?: return TodayStrip(MetricColors.heart, EdgeKind.Value, modifier, status = EdgeStatus.Above, onClick = { onOpenMetric(alertMetric(newest)) }) { - StripLine("From your strap", if (alerts.size > 1) "${alerts.size - 1} more today" else clockAt(newest.timestamp)) + StripLine("From your strap", if (alerts.size > 1) "${alerts.size - 1} more today" else clock(newest.timestamp)) Text( - alertText(newest) + if (alerts.size > 1) " at ${clockAt(newest.timestamp)}." else ".", + alertText(newest) + if (alerts.size > 1) " at ${clock(newest.timestamp)}." else ".", color = Color.White.copy(alpha = 0.85f), style = GlowType.secondary, ) Text( @@ -287,8 +290,8 @@ internal fun EdgeValue( if (status != null) Text(status, color = statusColor, style = GlowType.secondary, modifier = Modifier.padding(top = 6.dp), maxLines = 2, overflow = TextOverflow.Ellipsis) } -/** A length of time as dot-matrix digits: "7:40". */ -internal fun clockDuration(seconds: Long): String = "%d:%02d".format(seconds / 3600, (seconds % 3600) / 60) +/** A length of time as dot-matrix digits, the clock look the big figures have: "7:40". Text uses [Units.duration]. */ +internal fun clockDuration(seconds: Long): String = Units.clockDuration(seconds, Units.DOT_LOCALE) /** * Last night in full, on waking: the score in dots with its word and time asleep under it, as the Sleep tab's hero has @@ -299,7 +302,7 @@ internal fun clockDuration(seconds: Long): String = "%d:%02d".format(seconds / 3 internal fun LastNightCard(state: DayUiState, firstNights: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, stuck: Boolean = false) { val s = state.sleep EdgeCard(MetricColors.sleepDeep, modifier.fillMaxWidth().gridSize(CardSize.Wide), kind = EdgeKind.Sleep, onClick = onClick) { - EdgeTitle("Last night", s?.let { "${clockAt(it.start)}–${clockAt(it.end)}" }) + EdgeTitle("Last night", s?.let { "${clock(it.start)}–${clock(it.end)}" }) val asleep = state.asleepSeconds val score = s?.score // A night the strap gave no score says so in words, with no dash where the score would be. @@ -344,7 +347,7 @@ internal fun HeartCard( ) { EdgeTitle("Heart rate", state.restingHeartRate?.let { "Resting $it" }, marker = upTo) val at = scrubbed?.second ?: state.heartRate.lastOrNull()?.x - EdgeValue(now?.toString(), "bpm", TodayCopy.heartAt(live != null && scrubbed == null, at?.let(::clockAt))) + EdgeValue(now?.toString(), "bpm", TodayCopy.heartAt(live != null && scrubbed == null, at?.let(::clock))) if (state.heartRate.size >= 2) { Spacer(Modifier.height(10.dp)) GlowLineChart( @@ -387,9 +390,9 @@ internal fun ActivityCard( // already climbs toward the goal, and the sentence gives the share. progress = if (chart || goal <= 0) null else state.steps / goal.toFloat(), onClick = onClick, ) { - EdgeTitle("Steps", "Goal %,d".format(goal), marker = upTo, tag = tag) + EdgeTitle("Steps", "Goal ${Units.count(goal)}", marker = upTo, tag = tag) val sentence = org.openstrap.app.ui.dashboard.ActivityCopy.activityLine(state.steps, goal, pace, isToday) - if (count) EdgeValue("%,d".format(state.steps), "steps", sentence) + if (count) EdgeValue(dotFormat("%,d", state.steps), "steps", sentence) else Text(sentence, color = Color.White, style = GlowType.body, modifier = Modifier.padding(top = 10.dp), maxLines = 2, overflow = TextOverflow.Ellipsis) if (chart) { Spacer(Modifier.height(10.dp)) @@ -471,10 +474,10 @@ internal fun ChargeWideCard( @Composable internal fun WorkoutCard(w: org.openstrap.app.data.WorkoutAnalysis, onClick: () -> Unit, modifier: Modifier = Modifier) { EdgeCard(MetricColors.heart, modifier.fillMaxWidth().gridSize(CardSize.Wide), kind = EdgeKind.Earlier, onClick = onClick) { - EdgeTitle(Workouts.sportName(w.workout.sportType), "${clockAt(w.shownStart)}–${clockAt(w.end)}") + EdgeTitle(Workouts.sportName(w.workout.sportType), "${clock(w.shownStart)}–${clock(w.end)}") val mostly = w.zoneSeconds.withIndex().filter { it.value > 0 }.maxByOrNull { it.value }?.index EdgeValue( - workoutClock(w.activeSeconds.coerceAtLeast(0)), "long", + dotClock(w.activeSeconds.coerceAtLeast(0)), "long", listOfNotNull(w.workout.averageHeartRate?.let { "Average $it bpm" }, mostly?.let { "mostly zone ${it + 1}" }) .joinToString(", ").replaceFirstChar { it.uppercase() }.ifEmpty { null }, ) @@ -507,10 +510,9 @@ internal fun WeekCard( val asleep = state.nights.filter { it.date in start.plusDays(1)..end.plusDays(1) }.mapNotNull { it.asleepSeconds } val zone = ZoneId.systemDefault() val done = workouts.count { Instant.ofEpochSecond(it.workout.start).atZone(zone).toLocalDate() in start..end } - val range = DateTimeFormatter.ofPattern("d MMM") EdgeCard(EdgeNeutral, modifier.fillMaxWidth().gridSize(CardSize.Wide), kind = EdgeKind.Value, onClick = onClick) { - EdgeTitle("Your week", "${start.format(range)}–${end.format(range)}") - EdgeValue(stepsNow?.let { "%,d".format(it.roundToInt()) }, "steps a day", null) + EdgeTitle("Your week", "${DayWords.dayMonth(start)}–${DayWords.dayMonth(end)}") + EdgeValue(stepsNow?.let { dotFormat("%,d", it.roundToInt()) }, "steps a day", null) val night = asleep.takeIf { it.isNotEmpty() }?.average()?.toLong()?.let { TodayCopy.duration(it) + " asleep a night" } val workoutsDone = if (done == 1) "1 workout" else "$done workouts" Text( diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/WorkoutCapsule.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/WorkoutCapsule.kt new file mode 100644 index 00000000..ce579e06 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/WorkoutCapsule.kt @@ -0,0 +1,289 @@ +package org.openstrap.app.ui.glow + +import org.openstrap.app.presentation.WorkoutColors +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.Workouts +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.components.pressClickable +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.workout.WorkoutCopy +import org.openstrap.app.ui.workout.LiveGlance +import org.openstrap.app.ui.workout.GlanceTint +import org.openstrap.app.ui.workout.GlanceAction +import org.openstrap.app.ui.workout.ZONE_COLORS +import org.openstrap.app.ui.theme.MetricColors +import androidx.compose.ui.text.style.TextOverflow +import org.openstrap.app.ui.glow.draw.LightAngle +import org.openstrap.app.ui.glow.draw.GlowMotion +import org.openstrap.app.ui.glow.draw.heroStyle +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.theme.ControlSurface + +/* ------------------------------------- Rest of the app meanwhile ------------------------------------- */ + +/** The colour a [LiveGlance] is lit in ([GlanceTint]). */ +internal fun glanceColor(g: LiveGlance): Color = when (g.tint) { + GlanceTint.Zone -> g.zone?.let { ZONE_COLORS[it] } ?: PausedColor + GlanceTint.Work -> MetricColors.heart + GlanceTint.Rest -> Color(WorkoutColors.REST_GREEN) + GlanceTint.Mind -> MetricColors.evening + GlanceTint.Paused -> PausedColor + GlanceTint.Asking -> MetricColors.warn +} + +/** An action's icon on the bar and the hero. */ +private fun actionIcon(a: GlanceAction) = when (a) { + GlanceAction.Pause -> AppIcons.PauseFill + GlanceAction.Resume, GlanceAction.Open, GlanceAction.KeepGoing -> AppIcons.PlayFill + GlanceAction.End -> AppIcons.StopFill + GlanceAction.SetDone -> AppIcons.Tick02 + GlanceAction.StartSet -> AppIcons.PlayFill + GlanceAction.AddRest -> AppIcons.Plus + GlanceAction.Skip -> AppIcons.PlayFill +} + +/** + * The workout tucked away (workouts spec 2.10, board CapsuleStates): a 64 dp bar above the tab bar ringed in the + * glance's colour, a dot pulsing at the heart rate, the glance's two lines, and its next action. Where that is Pause it + * stays one round tap, beside the bpm in dots; otherwise it's a white pill named for it ("Resume", "Keep going"), + * with End beside it while the stop question is open. A goal fills a thin bar along the bottom edge. Tapping the bar + * brings the live screen back. + */ +@Composable +fun WorkoutCapsule(glance: LiveGlance, onClick: () -> Unit, onAction: (GlanceAction) -> Unit, modifier: Modifier = Modifier) { + val color = glanceColor(glance) + val shape = RoundedCornerShape(50) + val quiet = glance.tint == GlanceTint.Mind + Box( + // Mind and body is quieter: 56 dp and no glow. + modifier.fillMaxWidth().height(if (quiet) 56.dp else 64.dp).then(if (quiet) Modifier else Modifier.shadow(24.dp, shape, ambientColor = color, spotColor = color)) + .clip(shape).background(Raised).border(BorderStroke(1.5.dp, color), shape).pressClickable(onClick, shape), + ) { + Row(Modifier.fillMaxSize().padding(start = 18.dp, end = 10.dp), verticalAlignment = Alignment.CenterVertically) { + PulseDot(color, glance.bpm, 12.dp) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(glance.line1, color = Color.White, style = MediumBody.copy(fontFeatureSettings = "tnum"), maxLines = 1, overflow = TextOverflow.Ellipsis) + glance.line2?.let { + Text(it, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + if (glance.action == GlanceAction.Pause) { + glance.bpm?.let { + DotMatrixText("$it", dot = DotSize.Edge, color = color) + Spacer(Modifier.width(12.dp)) + } + Box( + Modifier.size(44.dp).clip(CircleShape).background(color).pressClickable({ onAction(GlanceAction.Pause) }, CircleShape), + contentAlignment = Alignment.Center, + ) { Icon(AppIcons.PauseFill, "Pause", tint = Color.Black, modifier = Modifier.size(18.dp)) } + } else { + glance.second?.let { second -> + BarPill(glance.secondLabel ?: "", false, endDot = second == GlanceAction.End) { onAction(second) } + Spacer(Modifier.width(8.dp)) + } + BarPill(glance.shortLabel ?: glance.actionLabel, true, icon = actionIcon(glance.action)) { onAction(glance.action) } + } + } + // A goal fills along the bottom edge, inside the bar's curve. + glance.progress?.let { p -> + Canvas(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(3.dp).padding(horizontal = 28.dp)) { + drawRect(Color.White.copy(alpha = 0.12f)) + drawRect(color, size = size.copy(width = size.width * p.fraction)) + } + } + } +} + +/** A pill on the bar: white with its [icon] for the next action ([primary]), else dark, End with its red dot. */ +@Composable +private fun BarPill(label: String, primary: Boolean, icon: androidx.compose.ui.graphics.vector.ImageVector? = null, endDot: Boolean = false, onClick: () -> Unit) { + val shape = RoundedCornerShape(50) + Row( + Modifier.height(44.dp).clip(shape).background(if (primary) Color.White else ControlSurface).pressClickable(onClick, shape) + .padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon(icon, null, tint = if (primary) Color.Black else Color.White, modifier = Modifier.size(14.dp)) + Spacer(Modifier.width(6.dp)) + } + if (endDot) { + Canvas(Modifier.size(8.dp)) { drawCircle(MetricColors.heart) } + Spacer(Modifier.width(6.dp)) + } + Text(label, color = if (primary) Color.Black else Color.White, style = MediumBody, maxLines = 1) + } +} + +/** + * The Today hero while a workout records and its live screen is tucked away (workouts spec 2.10, board HomeLiveRun): + * a glow card in the glance's colour lit from below; the sport and the total time; the glance's figure in the card's + * one dotted number; the goal's bar with the goal and what's left under it; three figures; then the buttons: Pause (or + * the glance's second action) as a dark round button and its action as the white one, "Open run" while it runs. + * Tapping the card elsewhere opens the live screen. + */ +@Composable +internal fun LiveHero(glance: LiveGlance, sportType: Int, clock: String, onOpen: () -> Unit, onAction: (GlanceAction) -> Unit, modifier: Modifier) { + val color = glanceColor(glance) + val style = heroStyle(color, LightAngle.Below).let { s -> glance.bpm?.let { s.copy(motion = GlowMotion.Heartbeat(it)) } ?: s } + GlowCard(style, modifier, onClick = onOpen) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Canvas(Modifier.size(8.dp)) { drawCircle(color) } + Spacer(Modifier.width(8.dp)) + Text(Workouts.sportName(sportType), color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + Text(clock, color = Color.White, style = GlowType.sentence.copy(fontFeatureSettings = "tnum"), maxLines = 1) + } + Spacer(Modifier.height(18.dp)) + Row(verticalAlignment = Alignment.Bottom) { + if (glance.figure != null) DotMatrixText(glance.figure, dot = DotSize.Hero) else WaitingDash(Modifier.padding(vertical = 12.dp)) + glance.unit?.let { + Spacer(Modifier.width(10.dp)) + Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.sentence) + } + } + if (glance.line1.isNotEmpty() && glance.tint in setOf(GlanceTint.Paused, GlanceTint.Asking)) { + Spacer(Modifier.height(8.dp)) + Text(glance.line1 + (glance.line2?.let { ". $it" } ?: ""), color = Color.White, style = GlowType.body) + } + glance.progress?.let { p -> + Spacer(Modifier.height(16.dp)) + Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(50)).background(Color.Black.copy(alpha = 0.4f))) { + Box(Modifier.fillMaxWidth(p.fraction).height(6.dp).clip(RoundedCornerShape(50)).background(Color.White)) + } + } + if (glance.goal != null || glance.toGo != null) { + Spacer(Modifier.height(8.dp)) + Row { + glance.goal?.let { Text(it, color = Color.White, style = GlowType.secondary, modifier = Modifier.weight(1f)) } + glance.toGo?.let { Text(it, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) } + } + } + if (glance.figures.isNotEmpty()) { + Spacer(Modifier.height(18.dp)) + Row(Modifier.fillMaxWidth()) { + glance.figures.forEach { (value, label) -> + Column(Modifier.weight(1f)) { + if (value != null) Text(value, color = Color.White, style = GlowType.sentence.copy(fontFeatureSettings = "tnum"), maxLines = 1) + else WaitingDash(Modifier.padding(vertical = 8.dp)) + Text(label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + } + } + } + } + glance.next?.let { next -> + Spacer(Modifier.height(16.dp)) + Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(Color.Black.copy(alpha = 0.4f)).padding(14.dp)) { + Text("Next", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + Text(next, color = Color.White, style = GlowType.body) + } + } + Spacer(Modifier.height(22.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + // Pause dark and round, or the second action as a dark pill; the next action white beside it. + val dark = glance.second ?: GlanceAction.Pause.takeIf { glance.action == GlanceAction.Pause } + dark?.let { a -> + val label = glance.secondLabel.takeIf { a == glance.second } + Box( + Modifier.height(52.dp).then(if (label == null) Modifier.size(52.dp) else Modifier).clip(RoundedCornerShape(50)) + .background(Color.Black.copy(alpha = 0.45f)).pressClickable({ onAction(a) }, RoundedCornerShape(50)) + .padding(horizontal = if (label == null) 0.dp else 18.dp), + contentAlignment = Alignment.Center, + ) { + if (label != null) Text(label, color = Color.White, style = GlowType.body, maxLines = 1) + else Icon(actionIcon(a), "Pause", tint = Color.White, modifier = Modifier.size(18.dp)) + } + Spacer(Modifier.width(12.dp)) + } + val white = if (glance.action == GlanceAction.Pause) GlanceAction.Open else glance.action + val label = if (white == GlanceAction.Open) WorkoutCopy.openLabel(sportType) else glance.actionLabel + PageButton(label, { onAction(white) }, Modifier.weight(1f), primary = true, icon = actionIcon(white)) + } + } + } +} + +/** A dot in [color] that beats at [bpm]; still without one. */ +@Composable +fun PulseDot(color: Color, bpm: Int?, size: Dp) { + val beat = if (bpm != null) { + val period = (60_000 / bpm).coerceIn(250, 2000) + rememberInfiniteTransition(label = "beat").animateFloat( + 1f, 1f, + infiniteRepeatable(keyframes { durationMillis = period; 1.5f at period * 15 / 100; 1f at period * 40 / 100 }, RepeatMode.Restart), + label = "dot", + ) + } else null + // Read while drawing, so the beat doesn't compose the dot again every frame. + Canvas(Modifier.size(size).graphicsLayer { val s = beat?.value ?: 1f; scaleX = s; scaleY = s }) { drawCircle(color) } +} + +/** + * The live screen tucking away into its capsule above the tab bar, and back, in the app's screen motion: the screen + * drops a quarter of its height and fades while the capsule rises a little and fades in, and the reverse. [capsule] + * gets the modifier that places it; [capsuleBottom] is its distance from the bottom edge. [capsuleHidden] keeps it + * away while the Today hero shows the workout, so the two are never on screen together (workouts spec 2.10). + */ +@Composable +fun WorkoutMorph( + minimized: Boolean, + capsuleBottom: androidx.compose.ui.unit.Dp, + full: @Composable () -> Unit, + capsule: @Composable (Modifier) -> Unit, + capsuleHidden: Boolean = false, +) { + val swift = org.openstrap.app.ui.components.Motion.SWIFT + val move = androidx.compose.animation.core.tween(swift, easing = org.openstrap.app.ui.components.Motion.easing) + val fadeIn = androidx.compose.animation.fadeIn(androidx.compose.animation.core.tween(swift)) + val fadeOut = androidx.compose.animation.fadeOut(androidx.compose.animation.core.tween(org.openstrap.app.ui.components.Motion.QUICK)) + Box(Modifier.fillMaxSize()) { + androidx.compose.animation.AnimatedVisibility( + !minimized, Modifier.fillMaxSize(), + enter = androidx.compose.animation.slideInVertically(move) { it / 4 } + fadeIn, + exit = androidx.compose.animation.slideOutVertically(move) { it / 4 } + fadeOut, + ) { Box(Modifier.fillMaxSize().background(Color.Black)) { full() } } + androidx.compose.animation.AnimatedVisibility( + minimized && !capsuleHidden, Modifier.align(Alignment.BottomCenter), + enter = androidx.compose.animation.slideInVertically(move) { it / 2 } + fadeIn, + exit = androidx.compose.animation.slideOutVertically(move) { it / 2 } + fadeOut, + ) { capsule(Modifier.widthIn(max = 440.dp).padding(start = 16.dp, end = 16.dp, bottom = capsuleBottom)) } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/WorkoutMap.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/WorkoutMap.kt index 13bec5cd..e5aa0b83 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/WorkoutMap.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/WorkoutMap.kt @@ -1,6 +1,12 @@ package org.openstrap.app.ui.glow import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -41,11 +47,23 @@ private const val BLANK_STYLE = """{"version":8,"sources":{},"layers":[{"id":"bg /** * A workout's route on a map, each stretch in its heart-rate zone's colour ([Route.byZone]), with a white dot where * you are (or ended): [here] when given, so it shows before there's a line (standing still makes none), else the - * line's end. [routeOnly] loads no map tiles: the tile server never learns where the map is. The camera fits the - * whole route as it grows, or centres on the dot until then. + * line's end, ringed in [hereColor] when given; the start is a hollow dot. [routeOnly] loads no map tiles: the tile + * server never learns where the map is. While [follow], the camera fits the whole route as it grows inside + * [fitPadding] (clear of what's drawn over the map), or centres on the dot until then; a drag on the map calls + * [onPanned], so the caller can stop following. */ @Composable -fun WorkoutMap(stretches: List, Int>>, routeOnly: Boolean, modifier: Modifier = Modifier, here: Route.Point? = null) { +fun WorkoutMap( + stretches: List, Int>>, routeOnly: Boolean, modifier: Modifier = Modifier, here: Route.Point? = null, + fitPadding: PaddingValues = PaddingValues(horizontal = 28.dp, vertical = 24.dp), + /** How far up from the map's bottom edge the OpenStreetMap credit sits, clear of a panel drawn over it. */ + creditBottom: Dp = 18.dp, + hereColor: Color? = null, + follow: Boolean = true, + onPanned: () -> Unit = {}, +) { + val panned by rememberUpdatedState(onPanned) + val density = LocalDensity.current val context = LocalContext.current val lifecycle = LocalLifecycleOwner.current.lifecycle var map by remember { mutableStateOf(null) } @@ -58,12 +76,17 @@ fun WorkoutMap(stretches: List, Int>>, routeOnly: Boolean m.uiSettings.isRotateGesturesEnabled = false m.uiSettings.isTiltGesturesEnabled = false m.uiSettings.isCompassEnabled = false + // Fitting a route only metres long would zoom to single buildings; streets stay in view. + m.setMaxZoomPreference(MAX_ZOOM) // MapLibre's licence doesn't ask for its logo; the ⓘ stays, for the OpenStreetMap credit the tiles need. m.uiSettings.isLogoEnabled = false // In the bottom-right corner, inset to clear the card's rounded edge. val inset = (18 * context.resources.displayMetrics.density).toInt() m.uiSettings.attributionGravity = android.view.Gravity.BOTTOM or android.view.Gravity.END - m.uiSettings.setAttributionMargins(0, 0, inset, inset) + m.uiSettings.setAttributionMargins(0, 0, inset, with(density) { creditBottom.roundToPx() }) + m.addOnCameraMoveStartedListener { reason -> + if (reason == MapLibreMap.OnCameraMoveStartedListener.REASON_API_GESTURE) panned() + } map = m } } @@ -91,6 +114,15 @@ fun WorkoutMap(stretches: List, Int>>, routeOnly: Boolean m.setStyle(builder) { s -> s.addSource(GeoJsonSource(SOURCE, geoJson(emptyList()))) s.addSource(GeoJsonSource(HERE, geoJson(emptyList()))) + s.addSource(GeoJsonSource(START, geoJson(emptyList()))) + s.addLayer( + CircleLayer(START_DOT, START).withProperties( + PropertyFactory.circleRadius(5f), + PropertyFactory.circleColor(0xFF121316.toInt()), + PropertyFactory.circleStrokeWidth(2f), + PropertyFactory.circleStrokeColor(Color.White.toArgb()), + ), + ) s.addLayer( LineLayer(LINE, SOURCE).withProperties( PropertyFactory.lineColor(Expression.get("color")), @@ -99,6 +131,13 @@ fun WorkoutMap(stretches: List, Int>>, routeOnly: Boolean PropertyFactory.lineJoin(Property.LINE_JOIN_ROUND), ), ) + s.addLayer( + CircleLayer(HALO, HERE).withProperties( + PropertyFactory.circleRadius(18f), + PropertyFactory.circleOpacity(0.25f), + PropertyFactory.visibility(Property.NONE), + ), + ) s.addLayer( CircleLayer(DOT, HERE).withProperties( PropertyFactory.circleRadius(6f), @@ -110,29 +149,48 @@ fun WorkoutMap(stretches: List, Int>>, routeOnly: Boolean style = s } } - LaunchedEffect(style, stretches, here) { + LaunchedEffect(style, hereColor) { + style?.getLayerAs(HALO)?.setProperties( + *listOfNotNull( + PropertyFactory.visibility(if (hereColor != null) Property.VISIBLE else Property.NONE), + hereColor?.let { PropertyFactory.circleColor(it.toArgb()) }, + ).toTypedArray(), + ) + } + LaunchedEffect(style, stretches, here, follow) { val s = style ?: return@LaunchedEffect s.getSourceAs(SOURCE)?.setGeoJson(geoJson(stretches)) val last = here ?: stretches.lastOrNull()?.first?.lastOrNull() - s.getSourceAs(HERE)?.setGeoJson( - last?.let { """{"type":"Feature","geometry":{"type":"Point","coordinates":[${coord(it)}]},"properties":{}}""" } - ?: """{"type":"FeatureCollection","features":[]}""", - ) + s.getSourceAs(HERE)?.setGeoJson(point(last)) val all = stretches.flatMap { it.first } + s.getSourceAs(START)?.setGeoJson(point(all.firstOrNull()?.takeIf { all.size >= 2 })) + if (!follow) return@LaunchedEffect if (all.size >= 2) { val bounds = LatLngBounds.Builder().includes(all.map { LatLng(it.lat, it.lon) }).build() - runCatching { map?.easeCamera(CameraUpdateFactory.newLatLngBounds(bounds, 80)) } + val px = with(density) { listOf(fitPadding.calculateLeftPadding(LayoutDirection.Ltr), fitPadding.calculateTopPadding(), fitPadding.calculateRightPadding(LayoutDirection.Ltr), fitPadding.calculateBottomPadding()).map { it.roundToPx() } } + runCatching { map?.easeCamera(CameraUpdateFactory.newLatLngBounds(bounds, px[0], px[1], px[2], px[3])) } } else if (last != null) { - map?.easeCamera(CameraUpdateFactory.newLatLngZoom(LatLng(last.lat, last.lon), 16.0)) + map?.easeCamera(CameraUpdateFactory.newLatLngZoom(LatLng(last.lat, last.lon), MAX_ZOOM)) } } AndroidView({ view }, modifier) } +/** Close enough to read the streets, far enough to see where they go. */ +private const val MAX_ZOOM = 16.5 + private const val SOURCE = "route" private const val HERE = "here" private const val LINE = "route-line" private const val DOT = "here-dot" +private const val HALO = "here-halo" +private const val START = "start" +private const val START_DOT = "start-dot" + +/** One point as GeoJSON, or nothing. */ +private fun point(p: Route.Point?): String = + p?.let { """{"type":"Feature","geometry":{"type":"Point","coordinates":[${coord(it)}]},"properties":{}}""" } + ?: """{"type":"FeatureCollection","features":[]}""" private fun coord(p: Route.Point) = String.format(Locale.ROOT, "%.7f,%.7f", p.lon, p.lat) @@ -141,7 +199,7 @@ private fun geoJson(stretches: List, Int>>): String = bui append("""{"type":"FeatureCollection","features":[""") stretches.forEachIndexed { i, (points, zone) -> if (i > 0) append(',') - val color = if (zone in ZONE_COLORS.indices) ZONE_COLORS[zone] else Color(0xFF9AA0B4) + val color = if (zone in ZONE_COLORS.indices) ZONE_COLORS[zone] else PausedColor val hex = String.format(Locale.ROOT, "#%06X", color.toArgb() and 0xFFFFFF) append("""{"type":"Feature","properties":{"color":"$hex"},"geometry":{"type":"LineString","coordinates":[""") append(points.joinToString(",") { "[${coord(it)}]" }) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/CanvasDraw.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/CanvasDraw.kt index b4b627ae..c9117ac7 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/CanvasDraw.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/CanvasDraw.kt @@ -23,11 +23,6 @@ import androidx.compose.ui.graphics.toArgb import kotlin.math.abs import kotlin.math.ceil import kotlin.random.Random -import org.openstrap.app.ui.glow.CoreShape -import org.openstrap.app.ui.glow.DotSize -import org.openstrap.app.ui.glow.GlowStyle -import org.openstrap.app.ui.glow.stageColor - /* * The shared drawing on an android.graphics.Canvas: the same light, dots and charts the app's cards draw, for places * Compose can't draw, the home screen widgets (widgets spec 6.2). GlowCard renders its cached layers with these very @@ -224,32 +219,6 @@ fun androidPath(ops: List): Path = Path().apply { } } -/** - * A day's line (GlowLineChart without its text): grid rows, your normal's band, the line with its halo and soft fill, - * the dot on the latest reading, in [color]. [showAverage] adds the solid average line. - */ -fun drawLineChart(canvas: Canvas, g: LineChartGeometry, color: Color, density: Float, showAverage: Boolean = false) { - val hair = ChartInk.HAIRLINE_DP * density - g.grid.forEach { y -> canvas.drawLine(0f, y, g.width, y, stroke(ChartInk.Grid, hair)) } - if (g.bandTopLeft != null && g.bandSize != null) { - val t = g.bandTopLeft - canvas.drawRect(t.x, t.y, t.x + g.bandSize.width, t.y + g.bandSize.height, fill(color.copy(alpha = ChartInk.NORMAL_BAND_ALPHA))) - } - g.dots.forEach { canvas.drawCircle(it.x, it.y, LineChartLook.LONE_DOT_DP * density, fill(color)) } - g.runs.forEach { pts -> - val line = androidPath(smoothOps(pts)) - val area = Path().apply { addPath(line); lineTo(pts.last().x, g.plotBottom); lineTo(pts.first().x, g.plotBottom); close() } - canvas.drawPath(area, Paint(Paint.ANTI_ALIAS_FLAG).apply { - shader = LinearGradient(0f, g.top, 0f, g.plotBottom, color.copy(alpha = LineChartLook.FILL_ALPHA).toArgb(), Color.Transparent.toArgb(), Shader.TileMode.CLAMP) - }) - canvas.drawPath(line, roundStroke(color.copy(alpha = LineChartLook.HALO_ALPHA), LineChartLook.HALO_DP * density)) - canvas.drawPath(line, roundStroke(color, LineChartLook.LINE_DP * density)) - } - if (showAverage) canvas.drawLine(0f, g.averageY, g.width, g.averageY, stroke(ChartInk.Target, hair)) - canvas.drawCircle(g.last.x, g.last.y, LineChartLook.END_HALO_DP * density, fill(color.copy(alpha = LineChartLook.END_HALO_ALPHA))) - canvas.drawCircle(g.last.x, g.last.y, LineChartLook.END_DOT_DP * density, fill(color)) -} - /** The ghost line (steps or Energy so far): your usual by now in white at 35%, today in [color] ending in a dot. */ fun drawGhostLine(canvas: Canvas, g: GhostLineGeometry, color: Color, density: Float) { g.usual?.let { canvas.drawPath(androidPath(polylineOps(it)), roundStroke(Color.White.copy(alpha = GhostLineLook.USUAL_ALPHA), GhostLineLook.USUAL_DP * density)) } @@ -259,20 +228,6 @@ fun drawGhostLine(canvas: Canvas, g: GhostLineGeometry, color: Color, density: F canvas.drawCircle(end.x, end.y, g.endDot, fill(color)) } -/** - * The hypnogram without its names and times: a hairline per row and the stages as round bands, each in its row's - * colour from [rowColors] (Awake at row 0), or white brighter the deeper, joined by thin lines, when it's null. - */ -fun drawHypnogram(canvas: Canvas, g: HypnogramGeometry, density: Float, rowColors: List? = null) { - val hair = ChartInk.HAIRLINE_DP * density - g.rowY.forEach { y -> canvas.drawLine(g.gridX0, y, g.width, y, stroke(ChartInk.Grid, hair)) } - if (rowColors == null) g.joins.forEach { j -> canvas.drawLine(j.from.x, j.from.y, j.to.x, j.to.y, stroke(Color.White.copy(alpha = HypnogramLook.JOIN_ALPHA), hair)) } - g.bands.forEach { b -> - val color = rowColors?.getOrNull(b.stage.row) ?: Color.White.copy(alpha = b.whiteAlpha) - canvas.drawRoundRect(b.topLeft.x, b.topLeft.y, b.topLeft.x + b.size.width, b.topLeft.y + b.size.height, b.corner, b.corner, fill(color)) - } -} - /** * The stage bar (chart kit) across [width] px from ([left], [top]): [totals] (from [stageTotals]) as segments in the * stage colours, [heightDp] high with 2 dp gaps and 3 dp corners. @@ -374,10 +329,6 @@ fun renderToBitmap(width: Float, height: Float, draw: (Canvas) -> Unit): Bitmap return bmp } -/** A whole glow card's background as a bitmap, [width] by [height] px. */ -fun renderGlow(style: GlowStyle, width: Int, height: Int, density: Float, grainSeed: Int = 0): Bitmap = - renderToBitmap(width.toFloat(), height.toFloat()) { drawGlow(it, style, width.toFloat(), height.toFloat(), density, grainSeed) } - /** A whole edge card's background as a bitmap. */ fun renderEdge(paint: EdgePaint): Bitmap = renderToBitmap(paint.width, paint.height) { drawEdge(it, paint) } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ChartSpec.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ChartSpec.kt index 09dc4a63..9ea21849 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ChartSpec.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ChartSpec.kt @@ -9,6 +9,7 @@ import kotlin.math.sign import org.openstrap.app.data.db.SleepStage import org.openstrap.app.ui.glow.GlowLayout import org.openstrap.app.ui.glow.GlowStage +import org.openstrap.app.ui.theme.MetricColors /* * The charts widgets share with their cards (widgets spec 6.2), as plain geometry: the points of a line and its end @@ -472,3 +473,11 @@ fun stageStripSegments(spans: List, w StageStripSegment(s.stage, x0, x1.coerceAtLeast(x0 + 0.5f)) } } + +/** A sleep stage's colour, as on the strap's own charts. */ +internal fun stageColor(stage: Int): Color = when (stage) { + org.openstrap.app.data.db.SleepStage.DEEP -> MetricColors.sleepDeep + org.openstrap.app.data.db.SleepStage.REM -> MetricColors.sleepRem + org.openstrap.app.data.db.SleepStage.AWAKE -> MetricColors.sleepAwake + else -> MetricColors.sleepLight +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ComposeDraw.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ComposeDraw.kt index 5a7c44e4..ea416319 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ComposeDraw.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/ComposeDraw.kt @@ -58,21 +58,41 @@ fun DrawScope.drawDots(layout: DotLayout, color: Color) { fun DrawScope.chartHairline(y: Float, color: Color = ChartInk.Grid, x0: Float = 0f, x1: Float = size.width) = drawLine(color, Offset(x0, y), Offset(x1, y), strokeWidth = ChartInk.HAIRLINE_DP * density) +/** + * A line chart's runs as paths, each smooth line with the fill under it, and the fill's brush in [color]: built once + * per size and data (in a drawWithCache block) so a redraw, a scrub say, allocates none of them. + */ +class LineChartPaths(val lines: List, val fills: List, val fill: Brush, val halo: Stroke, val stroke: Stroke) + +/** [g]'s runs as [LineChartPaths] in [color], at [density]. */ +fun lineChartPaths(g: LineChartGeometry, color: Color, density: Float): LineChartPaths { + val lines = g.runs.map { pts -> composePath(smoothOps(pts)) } + val fills = g.runs.mapIndexed { i, pts -> + Path().apply { addPath(lines[i]); lineTo(pts.last().x, g.plotBottom); lineTo(pts.first().x, g.plotBottom); close() } + } + return LineChartPaths( + lines, fills, + Brush.verticalGradient( + listOf(color.copy(alpha = LineChartLook.FILL_ALPHA), Color.Transparent), startY = g.top, endY = g.plotBottom, + ), + Stroke(LineChartLook.HALO_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round), + Stroke(LineChartLook.LINE_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round), + ) +} + /** * A day's line, under its average and labels: the grid rows, your normal's band, lone readings as dots, and each run - * as a smooth line with a halo over a soft fill, in [color]. + * as a smooth line with a halo over a soft fill, in [color], from its [paths]. */ -fun DrawScope.drawLineChartBody(g: LineChartGeometry, color: Color) { +fun DrawScope.drawLineChartBody(g: LineChartGeometry, paths: LineChartPaths, color: Color) { g.grid.forEach { chartHairline(it) } if (g.bandTopLeft != null && g.bandSize != null) drawRect(color.copy(alpha = ChartInk.NORMAL_BAND_ALPHA), g.bandTopLeft, g.bandSize) g.dots.forEach { drawCircle(color, LineChartLook.LONE_DOT_DP * density, it) } - g.runs.forEach { pts -> - val line = composePath(smoothOps(pts)) - val fill = Path().apply { addPath(line); lineTo(pts.last().x, g.plotBottom); lineTo(pts.first().x, g.plotBottom); close() } - drawPath(fill, Brush.verticalGradient(listOf(color.copy(alpha = LineChartLook.FILL_ALPHA), Color.Transparent), startY = g.top, endY = g.plotBottom)) + paths.lines.forEachIndexed { i, line -> + drawPath(paths.fills[i], paths.fill) // A faint halo just wider than the line. - drawPath(line, color.copy(alpha = LineChartLook.HALO_ALPHA), style = Stroke(LineChartLook.HALO_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round)) - drawPath(line, color, style = Stroke(LineChartLook.LINE_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round)) + drawPath(line, color.copy(alpha = LineChartLook.HALO_ALPHA), style = paths.halo) + drawPath(line, color, style = paths.stroke) } } @@ -82,16 +102,22 @@ fun DrawScope.drawLineChartEnd(g: LineChartGeometry, color: Color) { drawCircle(color, LineChartLook.END_DOT_DP * density, g.last) } -/** The ghost line: your usual by now in white at 35%, today in [color] ending in a dot. */ -fun DrawScope.drawGhostLine(g: GhostLineGeometry, color: Color) { - g.usual?.let { u -> - drawPath(composePath(polylineOps(u)), Color.White.copy(alpha = GhostLineLook.USUAL_ALPHA), style = Stroke(GhostLineLook.USUAL_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round)) - } +/** The ghost line's two paths, built once per size and data (in a drawWithCache block), not on every draw. */ +class GhostLinePaths(val usual: Path?, val today: Path?, val usualStroke: Stroke, val todayStroke: Stroke) + +/** [g]'s lines as [GhostLinePaths], at [density]. */ +fun ghostLinePaths(g: GhostLineGeometry, density: Float) = GhostLinePaths( + g.usual?.let { composePath(polylineOps(it)) }, + g.today.takeIf { it.size >= 2 }?.let { composePath(polylineOps(it)) }, + Stroke(GhostLineLook.USUAL_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round), + Stroke(GhostLineLook.LINE_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round), +) + +/** The ghost line: your usual by now in white at 35%, today in [color] ending in a dot, from its [paths]. */ +fun DrawScope.drawGhostLine(g: GhostLineGeometry, paths: GhostLinePaths, color: Color) { + paths.usual?.let { drawPath(it, Color.White.copy(alpha = GhostLineLook.USUAL_ALPHA), style = paths.usualStroke) } if (g.today.isEmpty()) return - if (g.today.size >= 2) drawPath( - composePath(polylineOps(g.today)), - color, style = Stroke(GhostLineLook.LINE_DP * density, cap = StrokeCap.Round, join = StrokeJoin.Round), - ) + paths.today?.let { drawPath(it, color, style = paths.todayStroke) } drawCircle(color, g.endDot, g.today.last()) } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/DotSpec.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/DotSpec.kt index 62b60286..10c4f40b 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/DotSpec.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/DotSpec.kt @@ -2,8 +2,8 @@ package org.openstrap.app.ui.glow.draw import androidx.compose.runtime.Immutable import androidx.compose.ui.geometry.Offset -import org.openstrap.app.ui.glow.DotSize - +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp /* * The dot-matrix numerals as plain geometry: which dots a string lights and where, for a dot size. DotMatrixText draws * them in Compose and CanvasDraw on an android.graphics.Canvas, so a widget's number is the card's (widgets spec 6.2). @@ -89,3 +89,36 @@ fun dotLayout(text: String, dot: Float, density: Float): DotLayout { /** [dotLayout] for one of the six [DotSize]s. */ fun dotLayout(text: String, size: DotSize, density: Float): DotLayout = dotLayout(text, size.dot.value, density) + +/** + * The six dot sizes of dot-matrix numbers (spec 2.3), by where the number sits. [DotMatrixText] takes only these, so + * the same kind of number is the same size on every screen. + */ +enum class DotSize(val dot: Dp) { + /** Four or more figures side by side ([FigureRow]). List rows are plain text ([GlowType.rowValue]). */ + Row(2.4.dp), + + /** Edge cards and Smalls. */ + Edge(3.dp), + + /** Talls, the second glow, glow cards that aren't the hero. */ + Second(3.6.dp), + + /** The hero's number. */ + Hero(4.5.dp), + + /** A detail's header. */ + Detail(6.dp), + + /** The live workout. */ + Live(9.dp), + ; + + /** The space between dots: a fixed share of the dot, so every size has the same texture. */ + val gap: Dp get() = dot * DOT_GAP + + companion object { + /** The gap between dots as a share of the dot. */ + const val DOT_GAP = 0.38f + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/EdgeSpec.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/EdgeSpec.kt new file mode 100644 index 00000000..b39b0a96 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/EdgeSpec.kt @@ -0,0 +1,58 @@ +package org.openstrap.app.ui.glow.draw + +import androidx.compose.runtime.Immutable + +/** Which edge of an [EdgeCard] the light comes in from. */ +enum class Edge { Top, Bottom, Left, Right } + +/** A value against your normal. */ +enum class EdgeStatus { Usual, Above, Below } + +/** What an edge card is about. With its [EdgeStatus] it decides where the light sits. */ +enum class EdgeKind { + /** A value read against your normal: resting heart rate, HRV, skin temperature. */ + Value, + + /** Progress toward a goal or your usual by now: steps, Load so far. Gets a progress bar. */ + Progress, + + /** Live, now: heart rate today. */ + Live, + + /** About earlier: Recovery after the morning, Habit Age. */ + Earlier, + + /** Sleep: last night, regularity, breathing disturbances. Lit from the top, like a night sky. */ + Sleep, + + /** A level, like Charge: the light fills from the left across to the value, in place of a progress bar. */ + Level, +} + +/** Where an edge card's light sits and how it's drawn. */ +@Immutable +data class EdgeLight( + val edge: Edge, + /** Off your normal: a deeper, stronger glow. */ + val strong: Boolean, + /** A 4 dp progress bar inside the card. */ + val progressBar: Boolean, +) + +/** + * Where the light sits for a card of [kind] whose value is [status] (spec 2.4). A plain value lights the bottom when + * usual or below your normal and the top when above. The other kinds keep their own edge (progress bottom, live + * right, earlier left, sleep top). Off your normal, any of them glows stronger. + */ +fun edgeLight(status: EdgeStatus, kind: EdgeKind = EdgeKind.Value): EdgeLight { + val off = status != EdgeStatus.Usual + val edge = when (kind) { + EdgeKind.Value -> if (status == EdgeStatus.Above) Edge.Top else Edge.Bottom + EdgeKind.Progress -> Edge.Bottom + EdgeKind.Live -> Edge.Right + EdgeKind.Earlier -> Edge.Left + EdgeKind.Sleep -> Edge.Top + EdgeKind.Level -> Edge.Left + } + return EdgeLight(edge, strong = off, progressBar = kind == EdgeKind.Progress) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowStyle.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/GlowStyle.kt similarity index 91% rename from app/src/main/kotlin/org/openstrap/app/ui/glow/GlowStyle.kt rename to app/src/main/kotlin/org/openstrap/app/ui/glow/draw/GlowStyle.kt index a3260cea..ce43b071 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/GlowStyle.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/GlowStyle.kt @@ -1,7 +1,6 @@ -package org.openstrap.app.ui.glow +package org.openstrap.app.ui.glow.draw import androidx.compose.runtime.Immutable -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.luminance @@ -11,6 +10,7 @@ import kotlin.math.PI import kotlin.math.abs import kotlin.math.cos import kotlin.math.sin +import org.openstrap.app.ui.glow.GlowLayout /** Shape of the dark core: a rounded rectangle that follows the card, or a soft oval. */ enum class CoreShape { Rounded, Radial } @@ -61,12 +61,6 @@ enum class GlowMood(val glow: Float, val edge: Float, val tone: Boolean, val bel Night(glow = 0.65f, edge = 0.8f, tone = true, belowOnly = true), } -/** - * The mood for the cards on this screen; every [GlowCard] and [EdgeCard] under it follows. Only Today (by its moment) - * and the Sleep tab provide one; everywhere else it's [GlowMood.Day]. - */ -val LocalGlowMood = staticCompositionLocalOf { GlowMood.Day } - /** * The "glow" design language: a card lit from its rim, with a dark core floating inside it and an * optional second-tone band between the two. Everything is derived from one [color]. @@ -309,12 +303,9 @@ private const val SECOND_REACH = 0.28f /** The handful of colours a [GlowStyle] is drawn with, all derived from its main colour. */ @Immutable internal class GlowPalette(style: GlowStyle) { - var light: Color - private set - var mid: Color - private set - var tone: Color - private set + val light: Color + val mid: Color + val tone: Color val core: Color init { @@ -322,20 +313,18 @@ internal class GlowPalette(style: GlowStyle) { // Near-grey colours stay neutral (a dark grey glow); anything coloured gets enough saturation to glow. val s = if (s0 < 0.12f) s0 else s0.coerceIn(0.35f, 0.9f) // A rim not far above the body in brightness, so the light melts in rather than sitting on top. - light = hsl(h, s * 0.9f, (l + 0.03f).coerceIn(0.45f, 0.6f)) + val rim = hsl(h, s * 0.9f, (l + 0.03f).coerceIn(0.45f, 0.6f)) // Same lightness looks far brighter in yellow-green than in red or blue, so the body and band // are darkened until their actual brightness keeps white text readable, whatever the hue. // Only a small hue step from the rim, so the card reads as one colour in different light. - mid = capped(h - 4f, s * 0.85f, style.bodyLightness, MID_MAX_LUMINANCE) - tone = capped(h + style.toneShift, s * 0.8f, style.bodyLightness - 0.04f, TONE_MAX_LUMINANCE) + val body = capped(h - 4f, s * 0.85f, style.bodyLightness, MID_MAX_LUMINANCE) + val band = capped(h + style.toneShift, s * 0.8f, style.bodyLightness - 0.04f, TONE_MAX_LUMINANCE) core = hsl(h, 0.7f, style.coreLightness) // A dimmer mood darkens the whole lit area, like turning the light down. val k = style.light().brightness.coerceIn(0f, 1f) - if (k < 1f) { - light = lerp(Color.Black, light, k) - mid = lerp(Color.Black, mid, k) - tone = lerp(Color.Black, tone, k) - } + light = if (k < 1f) lerp(Color.Black, rim, k) else rim + mid = if (k < 1f) lerp(Color.Black, body, k) else body + tone = if (k < 1f) lerp(Color.Black, band, k) else band } companion object { @@ -370,3 +359,19 @@ private fun hslOf(c: Color): Triple { } * 60f return Triple(h, s, l) } + +/** + * The hero's recipe, L4 (spec 2.1): the rim at full from the one side the moment places, no bevel, and the dark core + * opposite the light. [rim] raises or calms it for the moment (spec 4.2: 120% before a workout, 80% after one). The + * Moment widget lights its background with the same recipe (widgets spec 3.1). + */ +internal fun heroStyle(color: Color, lightAngle: Float?, rim: Float = 1f) = GlowStyle.Bloom.copy( + lightAngle = lightAngle, color = color, + coreShape = CoreShape.Radial, coreX = 0.5f, coreY = 0.6f, coreWidth = 0.95f, coreHeight = 0.75f, + coreBlur = 30.dp, toneWidth = 16.dp, toneShift = -10f, glowStrength = rim, bevel = 0f, +) + +/** The workout window's rim, raised for effort, and the calmer one after a workout (spec 4.2). */ +internal const val WINDOW_RIM = 1.2f +internal const val AFTER_RIM = 0.8f +internal const val EVENING_BODY = 0.46f diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/LightSpec.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/LightSpec.kt index 380906d9..ab2900c0 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/LightSpec.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/draw/LightSpec.kt @@ -6,21 +6,10 @@ import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp import kotlin.math.abs -import org.openstrap.app.ui.glow.CoreShape -import org.openstrap.app.ui.glow.Edge -import org.openstrap.app.ui.glow.EdgeLight import org.openstrap.app.ui.glow.GlowLayout -import org.openstrap.app.ui.glow.GlowMood -import org.openstrap.app.ui.glow.GlowPalette -import org.openstrap.app.ui.glow.GlowStyle -import org.openstrap.app.ui.glow.LEVEL_STEP import org.openstrap.app.ui.glow.LevelFade import org.openstrap.app.ui.glow.SoftGlow import org.openstrap.app.ui.glow.StrongGlow -import org.openstrap.app.ui.glow.levelEdge -import org.openstrap.app.ui.glow.light -import org.openstrap.app.ui.glow.lightVector - /* * The light of the glow and edge cards as plain numbers: where each gradient runs, its colours and stops, the rim * shadows, the core and the hairline. Nothing here draws. A card in the app and a widget's bitmap both read these, so @@ -122,7 +111,7 @@ fun glowPaint(style: GlowStyle, w: Float, h: Float, density: Float): GlowPaint { val rims = mutableListOf() if (light.rim > 0f) { light.level?.let { level -> - val dark = levelEdge(level) * (if (light.across) w else h) - LEVEL_STEP.value * density + val dark = levelEdge(level) * (if (light.across) w else h) - levelStep(if (light.across) w else h, density) rimClip = when { light.across -> Rect(0f, 0f, w - dark, h) light.fromAbove -> Rect(0f, 0f, w, h - dark) @@ -156,7 +145,7 @@ fun glowPaint(style: GlowStyle, w: Float, h: Float, density: Float): GlowPaint { // unlit part and there's never a hard edge. // Measured from the unlit side: the top, the bottom when lit from above, the right when lit from the left. val edge = levelEdge(level) * (if (light.across) w else h) - val step = LEVEL_STEP.value * density + val step = levelStep(if (light.across) w else h, density) val (y0, y1) = when { light.across -> w + margin to -margin light.fromAbove -> h + margin to -margin @@ -228,12 +217,21 @@ fun glowPaint(style: GlowStyle, w: Float, h: Float, density: Float): GlowPaint { ) } +/** + * How far a level card's light fades into the dark, px, along a card [length] px long: [LEVEL_STEP], or on a tall card + * (half a folded phone) a share of it, so the fade stays soft instead of reading as a line. + */ +internal fun levelStep(length: Float, density: Float): Float = maxOf(LEVEL_STEP.value * density, length * LEVEL_STEP_SHARE) + +/** The least share of a level card's length its light fades over. */ +private const val LEVEL_STEP_SHARE = 0.08f + /** * The card's base fill: the light colour at the lit edge melting into the mid (body) colour along the light's angle, * linearly through the level's fill stops (spec 2.1): on L4 the light holds for the first part of the card and reaches * the body by 45%, on L3 it starts at 70% and is gone by 28%. Light all the way round: the body alone. */ -private fun glowFill(light: org.openstrap.app.ui.glow.GlowLight, p: GlowPalette, w: Float, h: Float): LinearGradientSpec { +private fun glowFill(light: org.openstrap.app.ui.glow.draw.GlowLight, p: GlowPalette, w: Float, h: Float): LinearGradientSpec { val angle = light.angle ?: return LinearGradientSpec(0f, 0f, 0f, h, listOf(p.mid, p.mid), null) val points = light.fillStops.map { it.at.coerceIn(0f, 1f) to it.light.coerceIn(0f, 1f) } val colors = points.map { (_, k) -> lerp(p.mid, p.light, k) } + p.mid @@ -248,7 +246,7 @@ private fun glowFill(light: org.openstrap.app.ui.glow.GlowLight, p: GlowPalette, /* Edge light */ /* ------------------------------------------------------------------------------------------------ */ -/** The edge card's surface, under its light. */ +/** The edge card's surface, under its light, and the one near-black every lit card and share card sits on. */ val EdgeSurface = Color(0xFF0A0A0B) /** A level edge card's lowest light, so an empty one still shows where it fills from. */ diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveDistance.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveDistance.kt new file mode 100644 index 00000000..0d126663 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveDistance.kt @@ -0,0 +1,407 @@ +package org.openstrap.app.ui.glow.live + +import org.openstrap.app.presentation.Units +import org.openstrap.app.presentation.Pace +import androidx.compose.foundation.Canvas +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.horizontalScroll +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import org.openstrap.app.data.Route +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.SportTypes +import org.openstrap.app.data.Workouts +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.components.HapticEvent +import org.openstrap.app.ui.components.rememberHaptics +import org.openstrap.app.ui.glow.DotMatrixText +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.glow.EdgeCard +import org.openstrap.app.ui.glow.GlowType +import org.openstrap.app.ui.glow.MediumBody +import org.openstrap.app.ui.glow.TrendBad +import org.openstrap.app.ui.glow.TrendGood +import org.openstrap.app.ui.glow.activeSecondsState +import org.openstrap.app.ui.glow.workoutColor +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.glow.draw.dotLayout +import org.openstrap.app.ui.workout.LiveView +import org.openstrap.app.ui.workout.WorkoutCopy +import org.openstrap.app.ui.glow.WaitingDash +import org.openstrap.app.ui.glow.workoutClock +import org.openstrap.app.ui.glow.dotClock + +/** + * A distance workout with GPS (workouts spec 2.3, boards Run and Map): two views, swiped, under the moving time. Pace: + * the pace now large on a card lit in the distance lime, the average beside its title and a bar per kilometre, the one + * under way growing; the heart rate on a strip in its zone's colour; then distance, climb and active kcal. Map: the + * route in zone colours with the heart rate on it, then distance, pace and the last split. Opens on the view used last + * ([liveView], saved through [onLiveView]). A ride shows its speed instead of a pace. With [split] the heart rate is on + * the other half of a folded phone, so both views leave out their own; [compact] (a short half) keeps the moving time small + * on top and the distance, pace and kcal in one row. + */ +@Composable +internal fun LiveDistanceScreen( + r: WorkoutState.Recording, + liveHeartRate: Int?, + recent: List>, + stats: WorkoutRecorder.Stats?, + connection: ConnectionState, + contentPadding: PaddingValues, + controls: LiveControls, + route: List, + history: List>, + onLap: () -> Unit, + liveView: Int?, + onLiveView: (Int) -> Unit, + kcal: WorkoutRecorder.Kcal?, + /** Run reps: Skip the rep or the rest, or start the reps after a warm up. */ + onSkip: () -> Unit = {}, + split: Boolean = false, + compact: Boolean = false, +) { + val pager = rememberPagerState(initialPage = LiveView.of(liveView).ordinal) { LiveView.entries.size } + var viewApplied by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(liveView) { + if (!viewApplied && liveView != null) { + pager.scrollToPage(LiveView.of(liveView).ordinal) + viewApplied = true + } + } + // Only a swipe or a dot saves the view, never the page the screen opened on. + val saveView by rememberUpdatedState(onLiveView) + LaunchedEffect(pager) { snapshotFlow { pager.settledPage }.drop(1).collect { saveView(it) } } + val scope = rememberCoroutineScope() + val connected = connection is ConnectionState.Connected + val color = workoutColor(r, liveHeartRate, connected) + val bpm = liveHeartRate.takeIf { connected && !r.paused } + ZoneTick(bpm, r) + val distance = r.workout.distanceMeters ?: 0f + val splits = remember(route.size, r.workout.pauseRanges) { Route.splits(route, r.workout.pauseRanges) } + val climb = remember(route.size) { Route.climb(route) } + val speed = Workouts.tileType(r.workout.sportType) !in SportTypes.ON_FOOT + val pace = remember(route) { Route.currentPace(route) } + val active = activeSecondsState(r) + // Read where it shows, so the screen holds still between seconds. + val averagePace = { Pace.of(distance, active.value) } + val accuracy = route.lastOrNull()?.accuracy + val reps = r.structure is org.openstrap.app.data.Structure.Reps + val gps = WorkoutCopy.gpsPill(accuracy) to if (accuracy != null && accuracy <= Route.MAX_ACCURACY_M) TrendGood else TrendBad + // Kept across recompositions, so the frames and pages that take them can skip while nothing they show changed. + val status = remember(gps) { listOf(gps) } + val readings = remember(history, recent) { WorkoutCopy.readings(history, recent) } + // Reps skip instead of marking laps: the rounds are the laps. + val lap: (@Composable () -> Unit)? = when { + r.paused -> null + reps -> ({ LapButton(onSkip, label = true, text = "Skip") }) + else -> ({ LapButton(onLap, label = true) }) + } + val battery = (connection as? ConnectionState.Connected)?.batteryPercent + if (r.paused) { + LiveFrame( + r, color, connected, contentPadding, controls, lap, time = { active.value }, battery = battery, status = status, + paused = { + LivePaused(r, liveHeartRate.takeIf { connected }, recent, listOf( + Units.km(distance, Units.Precision.Workout) to "km", + (if (speed) speedText(averagePace()) else Pace.clock(averagePace())) to if (speed) "Avg km/h" else "Avg pace", + stats?.average?.toString() to "Avg bpm", + )) + }, + ) {} + return + } + val onPage: (Int) -> Unit = { scope.launch { pager.animateScrollToPage(it) } } + // Only what's above the dots slides: the note, the dots and the controls stay put under the pager, the dots + // following the swipe. A drag on the map pans the map instead. + val density = LocalDensity.current + var bar by remember { mutableStateOf(240.dp) } + Box(Modifier.fillMaxSize()) { + HorizontalPager(pager, Modifier.fillMaxSize(), verticalAlignment = Alignment.Top, beyondViewportPageCount = 1) { page -> + when (LiveView.entries[page]) { + // Reps (board RunIntervals): the total small on top, the rep the screen's number. + LiveView.Heart -> if (reps) LiveFrame( + r, color, connected, contentPadding, controls, lap, time = { active.value }, smallTime = true, battery = battery, + status = status, + fill = true, title = "${Workouts.sportName(r.workout.sportType)}, intervals", bottomRoom = bar, + ) { + RepPage(r, route, readings, bpm, color, Modifier.weight(1f)) + } else LiveFrame( + r, color, connected, contentPadding, controls, lap, time = { active.value }, smallTime = compact, battery = battery, + status = status, fill = true, bottomRoom = bar, + ) { + if (compact) Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + LiveFigures(listOf( + Units.km(distance, Units.Precision.Workout) to "km", + (if (speed) speedText(pace) else Pace.clock(pace)) to if (speed) "km/h" else "Pace /km", + kcal?.active?.toInt()?.toString() to "Active kcal", + )) + } else Column(Modifier.fillMaxWidth().weight(1f), verticalArrangement = Arrangement.spacedBy(14.dp)) { + PaceCard(pace, averagePace, splits, speed, Modifier.weight(1f)) + if (!split) LitHeartPill(bpm, r.maxHeartRate, color, r.paused, 56.dp, Modifier.fillMaxWidth()) + LiveFigures(listOf( + Units.km(distance, Units.Precision.Workout) to "km", + climb?.toInt()?.toString() to "Climb m", + kcal?.active?.toInt()?.toString() to "Active kcal", + )) + } + // Each kilometre gets a line for a few seconds, with one tick. This page stays composed beside the + // map, so the tick comes on either. + KmSplitLine(route, readings, r.workout.pauseRanges, keepRoom = !compact) + } + LiveView.Map -> LiveMapScreen( + r, route, readings, bpm, color, contentPadding, controls, + figures = listOf( + Units.km(distance, Units.Precision.Workout) to "km", + (if (speed) speedText(pace) else Pace.clock(pace)) to if (speed) "km/h" else "Pace /km", + ), + time = { active.value }, bottomRoom = bar, heartPill = !split, + ) + } + } + LiveBottomBar( + r, connected, contentPadding, controls, lap, { pager.currentPage + pager.currentPageOffsetFraction }, LiveView.entries.size, onPage, + onHeight = { if (it != bar) bar = it }, modifier = Modifier.align(Alignment.BottomCenter), + ) + } +} + +/** + * A run's reps (workouts spec 2.5, board RunIntervals): the plan and a bar of the reps, rep N of M and the rest after + * it, the metres to go in the screen's one dotted number (the seconds of a time rep) with the rep's own pace, the heart + * rate strip, then each rep done with its time. A rest shows its seconds left and the rep after it. + */ +@Composable +private fun RepPage(r: WorkoutState.Recording, route: List, readings: List>, bpm: Int?, color: Color, modifier: Modifier) { + val now = nowTicking() + val m = org.openstrap.app.ui.workout.intervalMoment(r, now, bpm, readings, route) ?: return + val structure = r.structure as? org.openstrap.app.data.Structure.Reps ?: return + val repName = org.openstrap.app.ui.workout.StartCopy.repParts(structure).let { (n, unit) -> "$n $unit" } + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) { + Text( + "${org.openstrap.app.ui.workout.StartCopy.structureName(structure)}, ${org.openstrap.app.ui.workout.StartCopy.restText(structure.rest)} rest", + color = Color.White.copy(alpha = 0.8f), style = GlowType.secondary, maxLines = 1, + ) + val underWay = structure.meters?.let { meters -> m.metersLeft?.let { 1f - it / meters } } + RoundBar(m, structure.seconds, underWay) + Row(Modifier.fillMaxWidth()) { + Text( + when (m.phase) { + is org.openstrap.app.data.Phase.Work -> "Rep ${m.round} of ${m.rounds}" + is org.openstrap.app.data.Phase.Rest -> "${m.round - 1} of ${m.rounds} done" + org.openstrap.app.data.Phase.WarmUp -> "Warm up" + org.openstrap.app.data.Phase.Done -> "All ${m.rounds} reps done" + }, + color = Color.White, style = MediumBody, maxLines = 1, modifier = Modifier.weight(1f), + ) + val next = when (m.phase) { + is org.openstrap.app.data.Phase.Work -> m.nextRest?.let { "Then rest ${org.openstrap.app.ui.workout.StartCopy.restText(it)}" } ?: "Last rep" + is org.openstrap.app.data.Phase.Rest -> "Rep ${m.round} next" + org.openstrap.app.data.Phase.WarmUp -> "${m.rounds} reps after it" + org.openstrap.app.data.Phase.Done -> "" + } + Text(next, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + } + Column( + Modifier.fillMaxWidth().weight(1f), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically), + ) { + val work = m.phase is org.openstrap.app.data.Phase.Work + val rest = m.phase is org.openstrap.app.data.Phase.Rest + Text( + when { work -> "$repName rep"; rest -> "Rest"; m.phase == org.openstrap.app.data.Phase.WarmUp -> "Warming up"; else -> "Done" }, + color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, + ) + val (value, unit) = when { + work && m.metersLeft != null -> "${m.metersLeft.toInt()}" to "m to go" + m.left != null -> dotClock(m.left) to if (rest) "rest left" else "left" + else -> null to "" + } + if (value != null) { + DotMatrixText(value, dot = DotSize.Live, scale = 1.2f) + Text(unit, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body) + } + val line = when { + work -> Route.currentPace(route)?.let { pace -> + structure.meters?.let { "Pace ${workoutClock((pace * it / 1000).toLong())} per $repName" } + ?: Pace.clock(pace)?.let { "Pace $it /km" } + } + m.phase == org.openstrap.app.data.Phase.WarmUp -> "Skip starts the reps when you're ready" + m.phase == org.openstrap.app.data.Phase.Done -> "Hold the square to end, or keep going" + else -> null + } + if (line != null) Text(line, color = Color.White, style = GlowType.sentence, textAlign = androidx.compose.ui.text.style.TextAlign.Center) + } + LitHeartPill(bpm, r.maxHeartRate, color, r.paused, 56.dp, Modifier.fillMaxWidth()) + val done = r.rounds.filter { it.workEnd != null } + if (done.isNotEmpty()) Row( + Modifier.fillMaxWidth().horizontalScroll(rememberScrollState(Int.MAX_VALUE)), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + done.forEach { round -> + Row( + Modifier.clip(RoundedCornerShape(50)).background(org.openstrap.app.ui.glow.Raised).padding(horizontal = 14.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Rep ${round.index + 1}", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, maxLines = 1) + Spacer(Modifier.width(8.dp)) + Text(workoutClock(round.workEnd!! - round.workStart), color = Color.White, style = MediumBody, maxLines = 1) + } + } + } + } +} + +/** A tick when the zone changes (spec 3.6); a pause forgets the zone, so resuming in another doesn't tick. */ +@Composable +internal fun ZoneTick(bpm: Int?, r: WorkoutState.Recording) { + val haptics = rememberHaptics() + val zone = WorkoutRecorder.liveZone(bpm, r.maxHeartRate) + var lastZone by remember { mutableStateOf(null) } + LaunchedEffect(zone, r.paused) { + if (WorkoutCopy.zoneTick(lastZone, zone)) haptics.perform(HapticEvent.ZoneChange) + lastZone = if (r.paused) null else zone ?: lastZone + } +} + +/** km/h from seconds per kilometre, one decimal; null with no pace. */ +internal fun speedText(secondsPerKm: Double?, locale: java.util.Locale = java.util.Locale.getDefault()): String? = + secondsPerKm?.takeIf { it > 0 && it < Pace.SLOWEST_S }?.let { String.format(locale, "%.1f", 3600 / it) } + +/** + * The Pace card: "Pace" with the average beside it, the pace now in the card's one dotted number, and a bar per + * kilometre under it ([SplitBars]). Lit in the distance lime from its edge, so it never looks like the heart strip + * under it. + */ +@Composable +private fun PaceCard(pace: Double?, averagePace: () -> Double?, splits: List, speed: Boolean, modifier: Modifier = Modifier) { + EdgeCard(MetricColors.steps, modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth()) { + Text(if (speed) "Speed" else "Pace", color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + AveragePace(averagePace, speed) + } + // The pace sits in the middle of whatever height the card is given, the splits on its floor. + Spacer(Modifier.height(12.dp).weight(1f)) + BoxWithConstraints(Modifier.fillMaxWidth()) { + val room = maxWidth.value + // Centred, as the board has it: the one number the card is for. + Row(Modifier.align(Alignment.Center), verticalAlignment = Alignment.Bottom) { + val now = if (speed) speedText(pace, Units.DOT_LOCALE) else Pace.clock(pace, Units.DOT_LOCALE) + // The live size, shrunk only as far as a walk's 12:30 needs to fit beside its unit. + val fit = now?.let { ((room - UNIT_ROOM) / dotLayout(it, DotSize.Live, 1f).width).coerceIn(0.5f, 1f) } ?: 1f + if (now != null) DotMatrixText(now, dot = DotSize.Live, scale = fit) else WaitingDash(Modifier.padding(vertical = 18.dp)) + Spacer(Modifier.width(8.dp)) + Text(if (speed) "km/h" else "/km", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, maxLines = 1) + } + } + Spacer(Modifier.height(16.dp).weight(1f)) + if (splits.isNotEmpty()) { + SplitBars(splits) + } + } +} + +/** "Avg 5:42" beside the card's title: the average moves with the clock, so it's read in its own scope. */ +@Composable +private fun AveragePace(averagePace: () -> Double?, speed: Boolean) { + val average = averagePace() + val avg = if (speed) speedText(average)?.let { "Avg $it" } else Pace.clock(average)?.let { "Avg $it" } + if (avg != null) Text(avg, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) +} + +/** + * The last five kilometres as bars, taller for faster, the fastest finished one bright with its time in white and bold, + * the rest dim, each with its time under it; the one under way is an outlined full bar filling from the bottom, named + * by its kilometre. + */ +@Composable +private fun SplitBars(splits: List) { + val shown = splits.takeLast(SPLIT_SLOTS) + val done = splits.filter { it.meters >= 1000 } + val fastest = done.minByOrNull { it.pace } + val slowest = done.maxOfOrNull { it.pace } ?: 0.0 + val quickest = fastest?.pace ?: 0.0 + val described = done.takeLast(4).joinToString(", ") { workoutClock(it.seconds) } + Row( + Modifier.fillMaxWidth().semantics { contentDescription = "Kilometre splits: $described" }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + shown.forEach { s -> + val underWay = s.meters < 1000 + // Faster is taller: from 60 % for the slowest to full for the fastest. + val height = when { + underWay -> 1f + slowest <= quickest -> 1f + else -> (0.6f + 0.4f * ((slowest - s.pace) / (slowest - quickest)).toFloat()).coerceIn(0.6f, 1f) + } + val best = s == fastest + Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) { + val lime = MetricColors.steps + Canvas(Modifier.fillMaxWidth().height(30.dp)) { + val h = size.height * height + val corner = CornerRadius(5.dp.toPx()) + if (underWay) { + val filled = size.height * (s.meters / 1000).toFloat().coerceIn(0f, 1f) + drawRoundRect(lime.copy(alpha = 0.35f), Offset(0f, size.height - filled), Size(size.width, filled), corner) + val w = 1.5.dp.toPx() + drawRoundRect( + lime.copy(alpha = 0.6f), Offset(w / 2, w / 2), Size(size.width - w, size.height - w), corner, + style = androidx.compose.ui.graphics.drawscope.Stroke(w), + ) + } else drawRoundRect(if (best) lime else lime.copy(alpha = 0.35f), Offset(0f, size.height - h), Size(size.width, h), corner) + } + Text( + if (underWay) "Km ${s.index + 1}" else workoutClock(s.seconds), + color = if (best) Color.White else Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + style = GlowType.caption.copy(fontWeight = if (best) FontWeight.SemiBold else null), maxLines = 1, + ) + } + } + // Empty slots keep each bar a fifth of the card, so the first kilometre isn't a line across it. + repeat(SPLIT_SLOTS - shown.size) { Spacer(Modifier.weight(1f)) } + } +} + +private const val SPLIT_SLOTS = 5 + +/** Room left beside the pace for "km/h" and its gap, in dp. */ +private const val UNIT_ROOM = 56f diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveFrame.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveFrame.kt new file mode 100644 index 00000000..d5cbc3fc --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveFrame.kt @@ -0,0 +1,495 @@ +package org.openstrap.app.ui.glow.live + +import kotlinx.coroutines.delay +import androidx.compose.runtime.produceState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +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.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.runtime.getValue +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.openstrap.app.data.Workouts +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.components.HapticEvent +import org.openstrap.app.ui.components.pressClickable +import org.openstrap.app.ui.components.rememberHaptics +import org.openstrap.app.ui.glow.* +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.workout.SportIcons +import androidx.activity.compose.BackHandler +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import androidx.compose.foundation.shape.RoundedCornerShape +import org.openstrap.app.ui.workout.WorkoutCopy +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.ui.glow.draw.DotSize + +/** + * The live stats row's figures, unchanged from before spec 6.3 ("Stats row unchanged"): the moving time large in the + * middle and the two figures either side, in Display Light. The only two sizes on the workout pages no role has; a + * live clock read at arm's length isn't one of spec 2.3's. + */ +internal val LiveClock = GlowType.sentence.copy(fontSize = 46.sp, lineHeight = 50.sp, letterSpacing = (-0.02).em, fontFeatureSettings = "tnum") +internal val LiveFigure = GlowType.sentence.copy(fontSize = 26.sp, lineHeight = 30.sp, letterSpacing = (-0.02).em, fontFeatureSettings = "tnum") + +/** What every live screen can do to the workout, passed down as one. */ +@androidx.compose.runtime.Immutable +internal class LiveControls( + val onPause: () -> Unit, + val onResume: () -> Unit, + val onEnd: () -> Unit, + val onMinimize: () -> Unit, + val onEndAtPrompt: () -> Unit, + val onKeepGoing: () -> Unit, +) + +/** + * The frame every live screen shares (workouts spec 2.3, 6): minimize, the sport in the workout's [color] and the + * strap's state along the top, then the moving time large ([time], on the screens built for each sport), the screen's + * own [content], what any workout may need to say (the strap away, "Looks like you've stopped"), and the controls row + * at the bottom in the same place on every screen: [lap] on the left (Lap or Skip, or nothing), Pause or Resume in the + * middle and largest, and the square to hold to end. Back tucks it away. + * + * With [paused] given, a paused workout shows it in place of [content], and the controls become End workout (held, so + * ending still takes a hold) and Resume. [status] adds lines to the strap's on the top right (GPS and its accuracy). + */ +@Composable +internal fun LiveFrame( + r: WorkoutState.Recording, + color: Color, + connected: Boolean, + contentPadding: PaddingValues, + controls: LiveControls, + lap: (@Composable () -> Unit)? = null, + /** The total time, read by its own text so only the clock is composed again each second. */ + time: (() -> Long)? = null, + /** The total time small on the top right instead of large under it (strength, intervals). */ + smallTime: Boolean = false, + battery: Int? = null, + status: List> = emptyList(), + paused: (@Composable ColumnScope.() -> Unit)? = null, + /** The [content] takes the height left above the buttons itself (a weighted pager), so nothing pushes them down. */ + fill: Boolean = false, + /** Pause and the hold to end as two pills under the screen's own big button (strength), not the round row. */ + pillControls: Boolean = false, + /** The pill's words when more than the sport's name ("HIIT, Tabata"). */ + title: String? = null, + backdrop: androidx.compose.ui.graphics.Brush? = null, + /** Mind and body (board LiveMind): the sport's name plain and centred, no strap status, and two dark buttons. */ + quiet: Boolean = false, + /** Under a quiet screen's buttons, the line saying a tap lights it; left out where there's no room for it. */ + tapHint: Boolean = true, + /** + * A page under a bar shared by the pages ([LiveBottomBar]): the note, the prompt and the controls are drawn once + * under the pager, so the page only leaves this much room for them. + */ + bottomRoom: Dp? = null, + content: @Composable ColumnScope.() -> Unit, +) { + BackHandler(onBack = controls.onMinimize) + // The screens built for each sport label their buttons; the older one says to hold the square under them. + val labelled = time != null + WorkoutPage(color, contentPadding, scroll = false, backdrop = backdrop) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + RoundIcon(AppIcons.CaretDown, "Minimize", controls.onMinimize) + if (quiet) { + Text( + title ?: Workouts.sportName(r.workout.sportType), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + style = GlowType.cardTitle, textAlign = TextAlign.Center, maxLines = 1, modifier = Modifier.weight(1f), + ) + // The minimise button's width again, so the name sits in the middle of the screen. + Spacer(Modifier.width(44.dp)) + return@Row + } + Spacer(Modifier.width(10.dp)) + Pill(title ?: Workouts.sportName(r.workout.sportType), color) + Spacer(Modifier.weight(1f)) + Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) { + val strap = when { + r.paused -> "Paused" + !connected -> "Reconnecting" + battery != null && labelled -> "Strap $battery%" + else -> "Strap" + } + if (time != null && smallTime) { + // The total, small, where a set or a round's clock is the screen's number. + ClockText(time, GlowType.sentence.copy(fontFeatureSettings = "tnum"), maxLines = 1) + // Under it, the GPS where there is one (run reps), else that it's the total. + val (under, dot) = status.firstOrNull() ?: ("Total" to if (connected) TrendGood else TrendBad) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(under, color = Color.White.copy(alpha = 0.65f), style = GlowType.secondary, maxLines = 1) + Spacer(Modifier.width(8.dp)) + Canvas(Modifier.size(6.dp)) { drawCircle(dot) } + } + } else (status.takeIf { !r.paused }.orEmpty() + (strap to if (connected) TrendGood else TrendBad)).forEach { (text, dot) -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text, color = Color.White.copy(alpha = 0.65f), style = GlowType.secondary, maxLines = 1) + Spacer(Modifier.width(8.dp)) + Canvas(Modifier.size(6.dp)) { drawCircle(dot) } + } + } + } + } + if (time != null && !smallTime) Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + // Plain digits: the screen's one dot-matrix number is further down. + ClockText(time, LiveClock) + Text("Moving time", color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary) + } + if (r.paused && paused != null) paused() else content() + if (bottomRoom != null && !r.paused) { + Spacer(Modifier.height(bottomRoom)) + return@WorkoutPage + } + if (!connected && !r.paused) { + Note(WorkoutCopy.STRAP_GONE_NOTE) + } + r.endPrompt?.let { at -> StoppedPrompt(r, at, controls.onEndAtPrompt, controls.onKeepGoing) } + if (!fill || r.paused) Spacer(Modifier.weight(1f)) + if (r.paused && paused != null) { + Row(Modifier.align(Alignment.CenterHorizontally).widthIn(max = 420.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + HoldPill("End workout", Modifier.weight(1f), controls.onEnd) + WorkoutButton("Resume", Color.White, Color.Black, controls.onResume, Modifier.weight(1f), icon = AppIcons.PlayFill, height = 64.dp) + } + Text( + "Hold End workout to finish. Resume carries on where you stopped.", color = Color.White.copy(alpha = 0.6f), + style = GlowType.secondary, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(20.dp)) + return@WorkoutPage + } + if (quiet) { + Row(Modifier.align(Alignment.CenterHorizontally), horizontalArrangement = Arrangement.spacedBy(48.dp)) { + // With no [paused] view, a paused quiet screen keeps this row, so it turns into Resume. + val label = if (r.paused) "Resume" else "Pause" + Labelled(label, true) { + FlatButton(Raised, 64.dp, onClick = if (r.paused) controls.onResume else controls.onPause) { + Icon(if (r.paused) AppIcons.PlayFill else AppIcons.PauseFill, label, tint = Color.White, modifier = Modifier.size(20.dp)) + } + } + Labelled("Hold to end", true) { HoldToEnd(controls.onEnd) } + } + if (tapHint) Text( + "Tap anywhere to light the screen", color = Color.White.copy(alpha = 0.45f), style = GlowType.secondary, + modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + return@WorkoutPage + } + if (pillControls) { + Row(Modifier.align(Alignment.CenterHorizontally).widthIn(max = 420.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + WorkoutButton("Pause", Raised, Color.White, controls.onPause, Modifier.weight(1f), icon = AppIcons.PauseFill, height = 52.dp) + HoldPill("Hold to end", Modifier.weight(1f), controls.onEnd, height = 52.dp) + } + Spacer(Modifier.height(8.dp)) + return@WorkoutPage + } + ControlsRow(r, controls, lap, labelled, Modifier.align(Alignment.CenterHorizontally)) + if (!labelled) Text( + "Hold the square to end", color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, + modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, + ) + // A little room under the controls, so they sit up off the screen's edge. + Spacer(Modifier.height(20.dp)) + } +} + +/** + * The controls row, in the same place on every live screen: [lap] on the left (or room for it), Pause or Resume in the + * middle and largest, and the square to hold to end, [labelled] under each on the screens built for each sport. + */ +@Composable +internal fun ControlsRow(r: WorkoutState.Recording, controls: LiveControls, lap: (@Composable () -> Unit)?, labelled: Boolean, modifier: Modifier = Modifier) { + // At most a thumb's reach wide, so on a wide screen (an unfolded or half-folded phone) Lap and End stay by Pause. + Row(modifier.widthIn(max = 360.dp).fillMaxWidth().padding(horizontal = 12.dp), verticalAlignment = Alignment.Top) { + val pauseLabel = if (r.paused) "Resume" else "Pause" + // Pause stays centred with or without the left button. The left button labels itself ([LapButton]). + if (lap != null) lap() else Spacer(Modifier.size(64.dp)) + Spacer(Modifier.weight(1f)) + Labelled(pauseLabel, labelled) { + FlatButton(Color.White, 88.dp, onClick = if (r.paused) controls.onResume else controls.onPause) { + Icon(if (r.paused) AppIcons.PlayFill else AppIcons.PauseFill, pauseLabel, tint = Color.Black, modifier = Modifier.size(30.dp)) + } + } + Spacer(Modifier.weight(1f)) + Labelled("Hold to end", labelled) { HoldToEnd(controls.onEnd) } + } +} + +/** A control on the controls row, with its [label] under it on the screens that label them ([show]). */ +@Composable +private fun Labelled(label: String, show: Boolean, button: @Composable () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + // Every button sits in Pause's height, so the row's buttons stay centred on one line and the labels on another. + Box(Modifier.height(88.dp), contentAlignment = Alignment.Center) { button() } + if (show) Text(label, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1) + } +} + +/** The controls row's left button: Lap, which marks a lap, with its label on the labelled screens. */ +@Composable +internal fun LapButton(onLap: () -> Unit, label: Boolean = false, text: String = "Lap") = Labelled(text, label) { + FlatButton(Raised, 64.dp, onClick = onLap) { + Icon(if (text == "Lap") SportIcons.Lap else AppIcons.SkipFill, text, tint = Color.White, modifier = Modifier.size(22.dp)) + } +} + +/** + * End workout on the paused screen: a pill that ends only once held for [HOLD_MS], filling with red as it's held, so + * a pocket still can't end a workout. + */ +@Composable +private fun HoldPill(label: String, modifier: Modifier, onEnd: () -> Unit, height: androidx.compose.ui.unit.Dp = 64.dp) { + val progress = remember { Animatable(0f) } + val haptics = rememberHaptics() + val shape = androidx.compose.foundation.shape.RoundedCornerShape(50) + Row( + modifier.height(height).clip(shape).background(Raised) + .drawBehind { drawRect(EndRed.copy(alpha = 0.35f), size = size.copy(width = size.width * progress.value)) } + .pointerInput(Unit) { + detectTapGestures(onPress = { + coroutineScope { + val hold = launch { + progress.animateTo(1f, tween(HOLD_MS, easing = LinearEasing)) + haptics.perform(HapticEvent.WorkoutEnd) + onEnd() + } + tryAwaitRelease() + if (progress.value < 1f) { + hold.cancel() + progress.animateTo(0f, tween(200)) + } + } + }) + } + .semantics { contentDescription = "Hold to end the workout" }, + horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, + ) { + Canvas(Modifier.size(10.dp)) { drawCircle(EndRed) } + Spacer(Modifier.width(10.dp)) + Text(label, color = Color.White, style = GlowType.body, maxLines = 1) + } +} + + +/** + * What the run's views share under their pager, fixed while the views slide: the strap note, the stopped prompt, the + * page dots following the swipe ([position]) and the controls. [onHeight] reports its height above the bottom padding. + */ +@Composable +internal fun LiveBottomBar( + r: WorkoutState.Recording, connected: Boolean, contentPadding: PaddingValues, controls: LiveControls, lap: (@Composable () -> Unit)?, + /** The pager's page plus how far it's swiped, read here so a swipe composes only the bar again. */ + position: () -> Float, count: Int, onPage: (Int) -> Unit, onHeight: (Dp) -> Unit, modifier: Modifier = Modifier, +) { + val density = androidx.compose.ui.platform.LocalDensity.current + androidx.compose.foundation.layout.BoxWithConstraints(modifier.fillMaxWidth()) { + val side = org.openstrap.app.ui.glow.GlowLayout.pageSide(maxWidth, org.openstrap.app.ui.glow.LocalPageColumn.current, org.openstrap.app.ui.glow.GlowLayout.Gutter) + Column( + Modifier.fillMaxWidth().padding(bottom = contentPadding.calculateBottomPadding()) + .onSizeChanged { onHeight(with(density) { it.height.toDp() }) } + .padding(start = side, end = side, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + if (!connected) Note(WorkoutCopy.STRAP_GONE_NOTE) + r.endPrompt?.let { at -> StoppedPrompt(r, at, controls.onEndAtPrompt, controls.onKeepGoing) } + val at = position() + Box(Modifier.fillMaxWidth().semantics { contentDescription = "View ${kotlin.math.round(at).toInt() + 1} of $count" }) { + PageDots(kotlin.math.round(at).toInt(), count, at, onPage) + } + ControlsRow(r, controls, lap, labelled = true, Modifier.align(Alignment.CenterHorizontally)) + } + } +} + +/** + * Two small dots for the live views, the current one white; tapping one goes there (the map keeps its own drags). + * With [position] (the pager's page plus how far it's swiped) the white passes from one dot to the next as it slides. + */ +@Composable +internal fun PageDots(current: Int, count: Int, position: Float? = null, onSelect: (Int) -> Unit) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + repeat(count) { i -> + Box( + Modifier.size(28.dp).clip(CircleShape).pressClickable({ onSelect(i) }, CircleShape), + contentAlignment = Alignment.Center, + ) { + val lit = if (position != null) (1f - kotlin.math.abs(position - i)).coerceIn(0f, 1f) else if (i == current) 1f else 0f + Box(Modifier.size(7.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.3f + 0.7f * lit))) + } + } + } +} + +@Composable +internal fun LiveStat(label: String, modifier: Modifier, value: @Composable () -> Unit) { + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { + value() + Text(label, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary) + } +} + +/** The thin rule between the live figures. */ +@Composable +internal fun StatRule() = Box(Modifier.padding(vertical = 6.dp).width(1.dp).fillMaxHeight().background(Color.White.copy(alpha = 0.12f))) + +/** Avg and Max in the same face and size as the moving time beside them, or the dim dash at the same height. */ +@Composable +internal fun LiveValue(text: String?) = + if (text == null) WaitingDash(Modifier.padding(vertical = 13.5.dp)) + else Text(text, color = Color.White, style = LiveFigure) + +/** + * A round button in the glow language: a small glow card lit from its rim in [color], its icon on the dark core. + * [onClick] null leaves the touch handling to [modifier] (the end square's hold). + */ +@Composable +internal fun FlatButton(container: Color, size: Dp, modifier: Modifier = Modifier, onClick: (() -> Unit)? = null, content: @Composable () -> Unit) { + // Flat, like the app's Start and Done: the live screen's card already carries the glow. + Box( + modifier.size(size).clip(CircleShape).background(container) + .then(if (onClick != null) Modifier.pressClickable(onClick, CircleShape) else Modifier), + contentAlignment = Alignment.Center, + ) { content() } +} + +/** "Looks like you've stopped": end where the wearer stopped, or keep going. The strap has buzzed already. */ +@Composable +internal fun StoppedPrompt(r: WorkoutState.Recording, at: Long, onEnd: () -> Unit, onKeep: () -> Unit) { + QuietPanel { + PanelTitle(if (r.inCar) "Looks like you're in a car" else "Looks like you've stopped") + Note( + if (r.inCar) "You last moved at ${clock(at)}. End the ${Workouts.sportName(r.workout.sportType).lowercase()} there, so the drive isn't counted?" + else "Your heart rate has been back near resting since ${clock(at)}. End the ${Workouts.sportName(r.workout.sportType).lowercase()} at ${clock(at)}?", + ) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.weight(1f)) { WorkoutButton("End at ${clock(at)}", Color.White, Color.Black, onEnd) } + Box(Modifier.weight(1f)) { WorkoutButton("Keep going", Raised, Color.White, onKeep) } + } + } +} + +/** The end square needs a one-second hold: a ring fills around it, and letting go early cancels it. */ +@Composable +internal fun HoldToEnd(onEnd: () -> Unit) { + val progress = remember { Animatable(0f) } + val haptics = rememberHaptics() + Box(contentAlignment = Alignment.Center) { + FlatButton( + Raised, 64.dp, + Modifier.pointerInput(Unit) { + detectTapGestures(onPress = { + coroutineScope { + val hold = launch { + progress.animateTo(1f, tween(HOLD_MS, easing = LinearEasing)) + haptics.perform(HapticEvent.WorkoutEnd) + onEnd() + } + tryAwaitRelease() + if (progress.value < 1f) { + hold.cancel() + progress.animateTo(0f, tween(200)) + } + } + }) + }, + ) { + Icon(AppIcons.StopFill, "Hold to end the workout", tint = EndRed, modifier = Modifier.size(20.dp)) + } + Canvas(Modifier.size(64.dp).padding(2.dp)) { + if (progress.value > 0f) drawArc(EndRed, -90f, 360f * progress.value, false, style = Stroke(3.dp.toPx(), cap = StrokeCap.Round)) + } + } +} + +internal val EndRed = MetricColors.heart +private const val HOLD_MS = 2000 + +/** A live clock in white, its own scope: the second it reads composes it again, not the screen around it. */ +@Composable +internal fun ClockText(time: () -> Long, style: androidx.compose.ui.text.TextStyle, maxLines: Int = Int.MAX_VALUE) { + Text(workoutClock(time()), color = Color.White, style = style, maxLines = maxLines) +} + +/** Now in epoch seconds, ticking on each whole second: the clock every live screen counts from. */ +@Composable +internal fun nowTicking(): Long = nowTickingState().value + +/** [nowTicking] as state, read only where the second shows, so what holds it isn't composed again each second. */ +@Composable +internal fun nowTickingState(): androidx.compose.runtime.State = produceState(System.currentTimeMillis() / 1000) { + while (true) { + value = System.currentTimeMillis() / 1000 + delay(1000 - System.currentTimeMillis() % 1000) + } +} + +/** + * The live heart rate on a pill lit in the zone's [color] (pattern D5): a dot beating at it, the bpm in small dots and + * the zone named when [maxHr] is given, all in black on the colour. The pace page's strip and the map's chip, [height] + * apart. Paused or without a reading, it says so. + */ +@Composable +internal fun LitHeartPill(bpm: Int?, maxHr: Int?, color: Color, paused: Boolean, height: Dp, modifier: Modifier = Modifier) { + Row( + modifier.height(height).clip(RoundedCornerShape(50)).background(color).padding(horizontal = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (bpm == null) { + Text(WorkoutCopy.noHeartRate(paused), color = Color.Black, style = MediumBody) + return@Row + } + org.openstrap.app.ui.glow.PulseDot(Color.Black, bpm, 8.dp) + Spacer(Modifier.width(10.dp)) + DotMatrixText("$bpm", dot = DotSize.Edge, color = Color.Black) + Spacer(Modifier.width(6.dp)) + Text("bpm", color = Color.Black, style = MediumBody) + if (maxHr == null) return@Row + Spacer(Modifier.weight(1f)) + WorkoutRecorder.liveZone(bpm, maxHr)?.let { z -> + Text(WorkoutCopy.zoneLine(z), color = Color.Black, style = MediumBody, maxLines = 1) + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveGame.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveGame.kt new file mode 100644 index 00000000..31a06374 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveGame.kt @@ -0,0 +1,146 @@ +package org.openstrap.app.ui.glow.live + +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.Games +import org.openstrap.app.data.Phase +import org.openstrap.app.data.Structure +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.glow.DotMatrixText +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.glow.GlowCard +import org.openstrap.app.ui.glow.draw.GlowMotion +import org.openstrap.app.ui.glow.GlowType +import org.openstrap.app.ui.glow.draw.LightAngle +import org.openstrap.app.ui.glow.WorkoutButton +import org.openstrap.app.ui.glow.activeSecondsState +import org.openstrap.app.ui.glow.draw.heroStyle +import org.openstrap.app.ui.glow.workoutClock +import org.openstrap.app.ui.glow.workoutColor +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.workout.StartCopy +import org.openstrap.app.ui.workout.WorkoutCopy +import org.openstrap.app.ui.workout.ZONE_COLORS + +/** + * Games and sports (workouts spec 2.7, board LiveGame), made for the phone in a bag: the period and the time in it, + * the heart rate in dots on a glow in its zone and the zone named, an effort bar (1 to 5 from the time in zones so + * far), then active kcal, peak bpm and hard efforts (spells of 10 s or more in zone 5). Half time starts the break + * early. The strap buzzes at the end of each period and break, so nothing needs the phone. Free play has no periods. + */ +@Composable +internal fun LiveGameScreen( + r: WorkoutState.Recording, + liveHeartRate: Int?, + readings: List>, + stats: WorkoutRecorder.Stats?, + zoneSeconds: IntArray, + connection: ConnectionState, + contentPadding: PaddingValues, + controls: LiveControls, + kcal: WorkoutRecorder.Kcal?, + onHalfTime: () -> Unit, + /** Split across a fold: the heart rate is on the other half, so only the effort is left of the card. */ + split: Boolean = false, + /** A short half: the total small on top, so the controls keep their labels. */ + compact: Boolean = false, +) { + val now = nowTickingState() + val connected = connection is ConnectionState.Connected + val bpm = liveHeartRate.takeIf { connected && !r.paused } + ZoneTick(bpm, r) + val color = workoutColor(r, liveHeartRate, connected) + val periods = r.structure as? Structure.Periods + // The phase alone picks the buttons; its clock is read in [PeriodRow], so the screen holds still between seconds. + val phase = remember(r) { androidx.compose.runtime.derivedStateOf { r.phase(now.value) } }.value + val active = activeSecondsState(r) + val effort = Games.effort(zoneSeconds) + val hard = remember(readings.size / 10) { Games.hardEfforts(readings, r.maxHeartRate) } + val figures = listOf( + kcal?.active?.toInt()?.toString() to "Active kcal", + stats?.max?.toString() to "Peak bpm", + hard.toString() to "Hard efforts", + ) + LiveFrame( + r, color, connected, contentPadding, controls, time = { active.value }, smallTime = compact || periods != null, + paused = { LivePaused(r, liveHeartRate.takeIf { connected }, readings, figures) }, + ) { + if (periods != null && phase != null) PeriodRow(r, periods, phase, { now.value }, { active.value }) + val style = heroStyle(color, LightAngle.Below).let { s -> bpm?.let { s.copy(motion = GlowMotion.Heartbeat(it)) } ?: s } + if (split) EffortBar(effort) else GlowCard(style, Modifier.fillMaxWidth()) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(verticalAlignment = androidx.compose.ui.Alignment.Bottom) { + if (bpm != null) DotMatrixText("$bpm", dot = DotSize.Detail) else Text(WorkoutCopy.noHeartRate(r.paused, connected), color = Color.White, style = GlowType.sentence) + if (bpm != null) { + Spacer(Modifier.width(8.dp)) + Text("bpm", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body) + } + Spacer(Modifier.weight(1f)) + WorkoutRecorder.liveZone(bpm, r.maxHeartRate)?.let { z -> + Text(WorkoutCopy.zoneLine(z), color = Color.White, style = GlowType.secondary) + } + } + EffortBar(effort) + } + } + LiveFigures(figures) + Spacer(Modifier.weight(1f)) + if (periods != null && phase is Phase.Work) WorkoutButton(StartCopy.breakName(periods.rounds), Color.White, Color.Black, onHalfTime, icon = AppIcons.PauseFill, height = 64.dp) + if (periods != null && phase is Phase.Rest) WorkoutButton("Start ${StartCopy.periodName(phase.round + 1, periods.rounds).lowercase()}", Color.White, Color.Black, onHalfTime, icon = AppIcons.PlayFill, height = 64.dp) + } +} + +/** The period and the time in it, or the break and its time left: its own scope, so only it reads each second. */ +@Composable +private fun PeriodRow(r: WorkoutState.Recording, periods: Structure.Periods, phase: Phase, now: () -> Long, active: () -> Long) { + val (title, clock) = when (phase) { + is Phase.Work -> StartCopy.periodName(phase.round, periods.rounds) to + workoutClock((WorkoutRecorder.activeSeconds(r.workout, now()) - WorkoutRecorder.activeSeconds(r.workout, phase.start)).coerceAtLeast(0)) + is Phase.Rest -> StartCopy.breakName(periods.rounds) to + (workoutClock((phase.endsAt - WorkoutRecorder.activeSeconds(r.workout, now())).coerceAtLeast(0)) + " left") + else -> "Full time" to workoutClock(active()) + } + Row(Modifier.fillMaxWidth()) { + Text(title, color = Color.White, style = GlowType.pageTitle, modifier = Modifier.weight(1f)) + Text(clock, color = Color.White, style = GlowType.pageTitle.copy(fontFeatureSettings = "tnum")) + } +} + +/** Effort 1 to 5 as five steps, lit up to it in the zones' colours, "Effort 3 of 5" beside them. */ +@Composable +private fun EffortBar(effort: Int?) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Row( + Modifier.weight(1f).semantics { contentDescription = effort?.let { "Effort $it of 5" } ?: "No effort yet" }, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + repeat(5) { i -> + val on = effort != null && i < effort + Box(Modifier.weight(1f).height(8.dp).clip(RoundedCornerShape(50)).background(if (on) ZONE_COLORS[i] else Color.Black.copy(alpha = 0.35f))) + } + } + Spacer(Modifier.width(12.dp)) + Text(effort?.let { "Effort $it of 5" } ?: "Effort", color = Color.White, style = GlowType.secondary) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveHeart.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveHeart.kt new file mode 100644 index 00000000..9c0618a1 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveHeart.kt @@ -0,0 +1,324 @@ +package org.openstrap.app.ui.glow.live + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlin.math.cos +import kotlin.math.min +import kotlin.math.sin +import org.openstrap.app.data.Route +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.WorkoutTarget +import org.openstrap.app.data.Workouts +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.components.HapticEvent +import org.openstrap.app.ui.components.rememberHaptics +import org.openstrap.app.ui.glow.* +import org.openstrap.app.ui.workout.WorkoutCopy +import org.openstrap.app.ui.workout.ZONE_COLORS +import org.openstrap.app.ui.glow.draw.GlowMotion +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.glow.draw.chart + +/* -------------------------------------------- Live screen -------------------------------------------- */ + +/** + * The workout as it records, in the current zone's colour. The card beats at the live heart rate on its zone arc, the + * last ten minutes behind the number; the zone, its range and the time spent in it sit under the arc. A GPS workout + * swipes between that card and the map, which keeps the heart rate on a chip, and opens on the view used last + * ([liveView], saved through [onLiveView]). Back tucks it away; ending takes a one-second hold on the square so a + * pocket can't end it. With [split] the heart card is on the other half of a folded phone ([GlowWorkoutGlance]), so + * this half keeps the kind's own screen, without the heart rate where that's its hero, and all its controls. + */ +@Composable +fun GlowWorkoutLiveScreen( + r: WorkoutState.Recording, + liveHeartRate: Int?, + recent: List>, + stats: WorkoutRecorder.Stats?, + zoneSeconds: IntArray, + connection: ConnectionState, + contentPadding: PaddingValues, + onPause: () -> Unit, + onResume: () -> Unit, + onEnd: () -> Unit, + onMinimize: () -> Unit, + onEndAtPrompt: () -> Unit, + onKeepGoing: () -> Unit, + route: List = emptyList(), + history: List> = emptyList(), + onStartSet: () -> Unit = {}, + onSetDone: () -> Unit = {}, + onExtendRest: () -> Unit = {}, + onLap: () -> Unit = {}, + split: Boolean = false, + liveView: Int? = null, + onLiveView: (Int) -> Unit = {}, + kcal: WorkoutRecorder.Kcal? = null, + /** Strength: the exercises logged before, naming the exercise, and the log card's Save. */ + exercises: List = emptyList(), + onExercise: (String?) -> Unit = {}, + onLogSet: (index: Int, exercise: String?, reps: Int, kg: Float) -> Unit = { _, _, _, _ -> }, + /** Rounds: Skip, Skip rest, Done or Start rounds; and +10 s on a rest. */ + onSkip: () -> Unit = {}, + onAddRest: () -> Unit = {}, +) { + // Kept across the once-a-second recompositions, so what takes the controls can skip them. + val controls = remember(onPause, onResume, onEnd, onMinimize, onEndAtPrompt, onKeepGoing) { + LiveControls(onPause, onResume, onEnd, onMinimize, onEndAtPrompt, onKeepGoing) + } + // The merged readings are remembered where a screen takes them: built anew each recomposition, they grow with the + // workout and stop the screens that take them from skipping. + // Each kind of workout has its own screen (workouts spec 2.3), split across a fold too: Skip, +10 s, Half time and + // the log card stay on the half under your hand. Only the screens whose hero is the heart rate leave it to the + // other half. + val family = Workouts.family(r.workout.sportType) + // Under the fold of a phone stood like a laptop the half is short: the screens with a large figure fit a compact one. + BoxWithConstraints(Modifier.fillMaxSize()) { + val compact = maxHeight < CompactHeight + when { + family == Workouts.Family.Distance && r.workout.useGps -> + LiveDistanceScreen(r, liveHeartRate, recent, stats, connection, contentPadding, controls, route, history, onLap, liveView, onLiveView, kcal, onSkip, split, compact) + family == Workouts.Family.Strength -> LiveStrengthScreen( + r, liveHeartRate, remember(history, recent) { WorkoutCopy.readings(history, recent) }, connection, contentPadding, + controls, exercises, + onStartSet, onSetDone, onExtendRest, onExercise, onLogSet, compact, + ) + family == Workouts.Family.Intervals && r.structure != null -> + LiveIntervalsScreen( + r, liveHeartRate, remember(history, recent) { WorkoutCopy.readings(history, recent) }, connection, contentPadding, + controls, onSkip, onAddRest, split, compact, + ) + family == Workouts.Family.MindBody -> LiveMindScreen(r, liveHeartRate, recent, connection, contentPadding, controls, split, compact) + family == Workouts.Family.Games -> + LiveGameScreen( + r, liveHeartRate, remember(history, recent) { WorkoutCopy.readings(history, recent) }, stats, zoneSeconds, connection, + contentPadding, controls, kcal, onSkip, split, compact, + ) + // Indoor cardio, a distance sport without GPS, and HIIT's Free, which has no timer: heart rate only, laps mark + // the rounds. + else -> LiveIndoorScreen(r, liveHeartRate, recent, stats, zoneSeconds, connection, contentPadding, controls, onLap, kcal, split, compact) + } + } +} + +/** Below this height (half a phone stood like a laptop) a live screen shows its figures compact. */ +private val CompactHeight = 480.dp + +/** + * "Km 3 in 5:42, avg 152" under the stats for ten seconds after each kilometre, with one tick. Kilometres done before + * the screen opened have had theirs. + */ +@Composable +internal fun KmSplitLine( + route: List, readings: List>, pauses: List, + /** Its line's height is kept while there's none, so views filling the screen don't shrink when it shows. */ + keepRoom: Boolean = false, +) { + val splits = remember(route.size, pauses) { Route.splits(route, pauses) } + val now = nowTickingState() + val first = route.firstOrNull()?.t + // The second is read by the derived line, so this composes again only when the line comes or goes. + val line = remember(splits, readings, first) { derivedStateOf { WorkoutCopy.splitLine(splits, readings, first, now.value) } }.value + val haptics = rememberHaptics() + var ticked by remember { mutableIntStateOf(WorkoutCopy.wholeKms(splits)) } + LaunchedEffect(line?.index) { + if (line != null && WorkoutCopy.splitTick(line, ticked)) { + haptics.perform(HapticEvent.KmSplit) + ticked = line.index + } + } + if (line != null || keepRoom) Text( + line?.text.orEmpty(), color = Color.White, style = MediumBody.copy(fontFeatureSettings = "tnum"), + modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, + ) +} + +/** Where the zone arc starts (lower left, clockwise from three o'clock) and how far it goes, in degrees. */ +private const val ARC_START = 150f +private const val ARC_SWEEP = 240f +private const val ARC_GAP = 8f + +/** + * The five zones as segments of an arc open at the bottom, only the current one lit, with a marker where [bpm] sits + * (pattern D2). The number goes in its middle. + */ +@Composable +internal fun ZoneArc(bpm: Int?, maxHr: Int, modifier: Modifier) { + val zone = WorkoutRecorder.liveZone(bpm, maxHr) + Canvas(modifier) { + val stroke = 6.dp.toPx() + val halo = 9.dp.toPx() + val r = min(size.width, size.height) / 2 - halo + val topLeft = Offset(center.x - r, center.y - r) + val box = Size(r * 2, r * 2) + val seg = (ARC_SWEEP - ARC_GAP * (Workouts.ZONES - 1)) / Workouts.ZONES + for (z in 0 until Workouts.ZONES) { + val start = ARC_START + z * (seg + ARC_GAP) + val on = z == zone + // The lit segment glows: a wide soft stroke of itself under it. + if (on) drawArc(ZONE_COLORS[z].copy(alpha = 0.3f), start, seg, false, topLeft, box, style = Stroke(stroke * 2.6f, cap = StrokeCap.Round)) + drawArc(ZONE_COLORS[z].copy(alpha = if (on) 1f else 0.22f), start, seg, false, topLeft, box, style = Stroke(stroke, cap = StrokeCap.Round)) + } + if (bpm != null) { + val a = Math.toRadians((ARC_START + WorkoutRecorder.zonePosition(bpm, maxHr) * ARC_SWEEP).toDouble()) + val at = Offset(center.x + r * cos(a).toFloat(), center.y + r * sin(a).toFloat()) + val dot = zone?.let { ZONE_COLORS[it] } ?: Color.White + drawCircle(dot.copy(alpha = 0.3f), radius = halo, center = at) + drawCircle(Color.White, radius = 5.dp.toPx(), center = at) + } + } +} + +/** + * The live heart card: the beating number on its zone arc over the last ten minutes, then the zone, its range and the + * time in it. Its light reaches as high as the zone (spec 2.5); paused, it drops to an outline of the same layout + * (pattern D1). With [fill] it takes all the height it's given and the number grows, for the standing half of a fold. + */ +@Composable +internal fun WorkoutHeartCard( + r: WorkoutState.Recording, + liveHeartRate: Int?, + recent: List>, + zoneSeconds: IntArray, + connected: Boolean, + /** The active seconds, read only by the time target's note, so the card isn't composed again each second. */ + active: () -> Long, + modifier: Modifier, + fill: Boolean = false, + /** The live active kcal, for a kcal target's progress. */ + kcal: Float? = null, + /** The time in each zone as a bar under the zone (indoor cardio, board Main). */ + zoneBar: Boolean = false, +) { + val bpm = liveHeartRate.takeIf { connected && !r.paused } + val zone = WorkoutRecorder.liveZone(bpm, r.maxHeartRate) + val color = workoutColor(r, liveHeartRate, connected) + val style = GlowStyle.chart(color).copy( + motion = if (bpm != null) GlowMotion.Heartbeat(bpm) else GlowMotion.Still, + lightLevel = WorkoutCopy.zoneLightLevel(zone), + outline = r.paused, + ) + GlowCard(style, modifier) { + Column(if (fill) Modifier.fillMaxSize() else Modifier) { + GlowHeader("Heart rate", trailing = bpm?.let { "${it * 100 / r.maxHeartRate}% of max" }) + BoxWithConstraints(Modifier.fillMaxWidth().then(if (fill) Modifier.weight(1f) else Modifier.height(220.dp))) { + val now = recent.lastOrNull()?.first ?: (System.currentTimeMillis() / 1000) + val values = remember(recent) { bucketRecent(recent, now) } + if (values.any { it != null }) { + GlowLineChart(values, Modifier.align(Alignment.BottomCenter).alpha(0.45f), height = 120.dp, showAverage = false) + } + ZoneArc(bpm, r.maxHeartRate, Modifier.matchParentSize()) + // Sized for three digits inside the arc, whatever the reading, so the number never spills past it + // and doesn't jump in size as the heart rate crosses 100. + val inside = (minOf(maxWidth, maxHeight) - 18.dp) * 0.78f + val scale = (inside / dotNumberWidth(3, DotSize.Live)).coerceAtMost(1f) + Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { + if (bpm != null) { + DotMatrixText(bpm.toString(), dot = DotSize.Live, scale = scale) + Spacer(Modifier.height(8.dp)) + Text("bpm, last 10 min behind", color = Color.White.copy(alpha = 0.75f), style = GlowType.secondary) + } else Text( + WorkoutCopy.noHeartRate(r.paused, connected), + color = Color.White, style = GlowType.sentence, + ) + } + } + Row(verticalAlignment = Alignment.Bottom) { + if (zone != null) { + // "Zone 3, aerobic": two text elements, the name a step quieter. + val (name, word) = WorkoutCopy.zoneTitle(zone) + Text(name, color = Color.White, style = GlowType.sentence) + Spacer(Modifier.width(6.dp)) + Text(word, color = Color.White.copy(alpha = 0.75f), style = GlowType.sentence, modifier = Modifier.weight(1f)) + Text(zoneRange(zone, r.maxHeartRate), color = Color.White.copy(alpha = 0.85f), style = GlowType.secondary) + } else Text(if (r.paused) "No zone while paused" else "No zone yet", color = Color.White, style = GlowType.sentence) + } + if (zoneBar) { + Spacer(Modifier.height(12.dp)) + ZoneTimeBar(zoneSeconds, zone) + } + Spacer(Modifier.height(8.dp)) + Row { + zone?.let { Note(WorkoutCopy.zoneTime(zoneSeconds[it])) } + Spacer(Modifier.weight(1f)) + when (val t = WorkoutTarget.parse(r.workout.target)) { + is WorkoutTarget.Zones -> Note("Target ${t.label.removePrefix("Zone ")}, the strap buzzes if you drift") + is WorkoutTarget.Time -> TimeTargetNote(active, t.label) + is WorkoutTarget.Kcal -> Note(WorkoutCopy.kcalProgress(kcal, t)) + is WorkoutTarget.Distance -> Note(WorkoutCopy.distanceProgress(r.workout.distanceMeters, t)) + null -> Unit + } + } + } + } +} + +/** + * The standing half of a foldable half-open during a workout (on a table, or stood up like a book): the live heart + * card as big as the half allows, readable from a step away. The controls stay on the other half, under your hand. + */ +@Composable +fun GlowWorkoutGlance( + r: WorkoutState.Recording, + liveHeartRate: Int?, + recent: List>, + zoneSeconds: IntArray, + connection: ConnectionState, + modifier: Modifier = Modifier, + kcal: WorkoutRecorder.Kcal? = null, +) { + val connected = connection is ConnectionState.Connected + val active = activeSecondsState(r) + Box(modifier.background(Color.Black).statusBarsPadding().padding(horizontal = GlowLayout.Gutter, vertical = 12.dp)) { + WorkoutHeartCard( + r, liveHeartRate, recent, zoneSeconds, connected, { active.value }, Modifier.fillMaxSize(), fill = true, kcal = kcal?.active, + ) + } +} + +/** "12:30 of 30 min" under the heart card: its own scope, so only it reads the time. */ +@Composable +private fun TimeTargetNote(active: () -> Long, target: String) = Note("${workoutClock(active())} of $target") + +private const val BUCKET_S = 5L +private const val WINDOW_S = 600L + +/** The last ten minutes in five-second averages, oldest first; gaps stay null and break the line. */ +internal fun bucketRecent(recent: List>, now: Long): List { + val from = now - WINDOW_S + 1 + return org.openstrap.app.data.Summaries.bucketMeans(recent, (WINDOW_S / BUCKET_S).toInt(), { ((it.first - from) / BUCKET_S).toInt() }, { it.second.toFloat() }) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveIndoor.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveIndoor.kt new file mode 100644 index 00000000..e19410fe --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveIndoor.kt @@ -0,0 +1,101 @@ +package org.openstrap.app.ui.glow.live + +import org.openstrap.app.presentation.Units +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.background +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.glow.activeSecondsState +import org.openstrap.app.ui.glow.workoutColor +import org.openstrap.app.ui.workout.ZONE_COLORS + +/** + * Indoor cardio, and a distance sport without GPS (workouts spec 2.3, board Main): one view under the moving time. + * Heart rate is the hero, on its zone arc with the time in each zone as a bar and the target if one is set; then + * the distance from steps when there is one, the average heart rate and active kcal. + */ +@Composable +internal fun LiveIndoorScreen( + r: WorkoutState.Recording, + liveHeartRate: Int?, + recent: List>, + stats: WorkoutRecorder.Stats?, + zoneSeconds: IntArray, + connection: ConnectionState, + contentPadding: PaddingValues, + controls: LiveControls, + onLap: () -> Unit, + kcal: WorkoutRecorder.Kcal?, + /** Split across a fold: the heart card is on the other half, so this one keeps the time in zones. */ + split: Boolean = false, + /** A short half: the total small on top, so the controls keep their labels. */ + compact: Boolean = false, +) { + val connected = connection is ConnectionState.Connected + val color = workoutColor(r, liveHeartRate, connected) + ZoneTick(liveHeartRate.takeIf { connected && !r.paused }, r) + val active = activeSecondsState(r) + val figures = listOfNotNull( + // "2.14 km from steps", or metres under a kilometre. + r.workout.distanceMeters?.takeIf { it >= Units.SHOWN_FROM_METERS }?.let { m -> + val km = m >= Units.METERS_PER_KM + (if (km) Units.km(m, Units.Precision.Workout) else Units.count(m.toLong())) to + (if (km) "km" else "m") + if (r.workout.distanceFromSteps) " from steps" else "" + }, + stats?.average?.toString() to "Avg bpm", + kcal?.active?.toInt()?.toString() to "Active kcal", + ) + LiveFrame( + r, color, connected, contentPadding, controls, + lap = if (!r.paused) ({ LapButton(onLap, label = true) }) else null, + time = { active.value }, smallTime = compact, battery = (connection as? ConnectionState.Connected)?.batteryPercent, + paused = { LivePaused(r, liveHeartRate.takeIf { connected }, recent, figures) }, + ) { + // Time reps on the treadmill side: the rep above the heart. + if (r.structure is org.openstrap.app.data.Structure.Reps) RepCard(r, emptyList(), recent, liveHeartRate.takeIf { connected && !r.paused }) + if (split) ZoneTimeBar(zoneSeconds, WorkoutRecorder.liveZone(liveHeartRate.takeIf { connected && !r.paused }, r.maxHeartRate)) + else WorkoutHeartCard( + r, liveHeartRate, recent, zoneSeconds, connected, { active.value }, Modifier.fillMaxWidth(), + kcal = kcal?.active, zoneBar = true, + ) + LiveFigures(figures, Modifier.padding(top = 4.dp)) + } +} + +/** + * The time in each zone as one bar split into the five zones' colours, each as wide as its share; the zone you're in + * is white so it stands out against its neighbours, and an empty zone keeps a sliver so the bar always shows all five. + */ +@Composable +internal fun ZoneTimeBar(zoneSeconds: IntArray, current: Int?) { + val total = zoneSeconds.sum().coerceAtLeast(1) + val described = zoneSeconds.withIndex().joinToString(", ") { (z, s) -> "zone ${z + 1} ${if (s < 60) "none" else "${s / 60} min"}" } + Row( + Modifier.fillMaxWidth().height(8.dp).semantics { contentDescription = "Time in each zone: $described" }, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + zoneSeconds.forEachIndexed { z, s -> + val color = when { + z == current -> Color.White + s == 0 -> Color.White.copy(alpha = 0.18f) + else -> ZONE_COLORS[z] + } + Box(Modifier.weight(maxOf(s.toFloat() / total, 0.04f)).height(8.dp).clip(RoundedCornerShape(50)).background(color)) + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveIntervals.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveIntervals.kt new file mode 100644 index 00000000..4bc2d0a8 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveIntervals.kt @@ -0,0 +1,340 @@ +package org.openstrap.app.ui.glow.live + +import org.openstrap.app.presentation.WorkoutColors +import org.openstrap.app.presentation.dotFormat +import androidx.compose.foundation.Canvas +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.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RadialGradientShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.Phase +import org.openstrap.app.data.Route +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.db.AppWorkoutRound +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.glow.DotMatrixText +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.glow.EdgeCard +import org.openstrap.app.ui.glow.GlowType +import org.openstrap.app.ui.glow.Raised +import org.openstrap.app.ui.glow.WorkoutButton +import org.openstrap.app.ui.glow.activeSecondsState +import org.openstrap.app.ui.glow.workoutClock +import org.openstrap.app.ui.glow.dotClock +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.workout.IntervalMoment +import org.openstrap.app.ui.workout.StartCopy +import org.openstrap.app.ui.workout.WorkoutCopy +import org.openstrap.app.ui.workout.ZONE_COLORS +import org.openstrap.app.ui.workout.intervalMoment + +/** Work red and rest green, the intervals' two colours. */ +private val WorkRed = MetricColors.heart +private val RestGreen = Color(WorkoutColors.REST_GREEN) + +/** + * HIIT (workouts spec 2.5, boards HiitWork and HiitRest), with the total small on top and the screen in the phase's + * colour. Work: round N of M as dots, what's next, the seconds left large in dots, the heart rate and zone; Skip on the + * left moves to rest (Done, every minute). Rest: what's done and next, the seconds left, the heart rate and its drop + * since work ended, each round's peak so far, and +10 s. A warm up waits for Start rounds. With [split] the heart rate + * is on the other half of a folded phone, so this half leaves it out, and [compact] (a short half) puts the seconds + * left on one row with the phase and +10 s. + */ +@Composable +internal fun LiveIntervalsScreen( + r: WorkoutState.Recording, + liveHeartRate: Int?, + readings: List>, + connection: ConnectionState, + contentPadding: PaddingValues, + controls: LiveControls, + onSkip: () -> Unit, + onAddRest: () -> Unit, + split: Boolean = false, + compact: Boolean = false, +) { + val now = nowTickingState() + val connected = connection is ConnectionState.Connected + val bpm = liveHeartRate.takeIf { connected && !r.paused } + ZoneTick(bpm, r) + // The phase alone colours the screen and names the left button; the seconds left are read under it ([intervalMoment] + // in the content), so the screen holds still between seconds. + val phase = androidx.compose.runtime.remember(r) { androidx.compose.runtime.derivedStateOf { r.phase(now.value) } }.value + if (r.structure == null || phase == null) return + val color = when { + r.paused -> org.openstrap.app.ui.glow.PausedColor + phase is Phase.Work -> WorkRed + phase is Phase.Rest -> RestGreen + else -> org.openstrap.app.ui.glow.workoutColor(r, liveHeartRate, connected) + } + val skipLabel = when (phase) { + is Phase.Work -> if (r.structure is org.openstrap.app.data.Structure.EveryMinute) "Done" else "Skip" + is Phase.Rest -> "Skip rest" + else -> null + } + // Parsed once with the recording, not on each recomposition. + val structure = r.structure + val plan = structure?.let { StartCopy.structureName(it) } + val active = activeSecondsState(r) + LiveFrame( + r, color, connected, contentPadding, controls, time = { active.value }, smallTime = true, + lap = skipLabel?.takeIf { !r.paused }?.let { label -> { SkipButton(label, onSkip) } }, + paused = { LivePaused(r, liveHeartRate.takeIf { connected }, readings, listOf( + "${org.openstrap.app.data.Phases.roundsDone(r.rounds)}" to "Rounds done", + r.rounds.mapNotNull { it.peakBpm }.maxOrNull()?.toString() to "Peak bpm", + )) }, + fill = true, + title = listOfNotNull(org.openstrap.app.data.Workouts.sportName(r.workout.sportType), plan).joinToString(", "), + backdrop = when { + r.paused -> null + phase is Phase.Work -> WorkGlow + phase is Phase.Rest -> RestGlow + else -> null + }, + ) { + val m = intervalMoment(r, now.value, bpm, readings, emptyList()) ?: return@LiveFrame + RoundBar(m, when (structure) { + is org.openstrap.app.data.Structure.Timed -> structure.work + is org.openstrap.app.data.Structure.EveryMinute -> structure.period + else -> null + }) + when (m.phase) { + is Phase.Work -> PhaseFigure( + "Round ${m.round} of ${m.rounds}", m.nextRest?.let { "Then rest ${StartCopy.restText(it)}" } ?: "Last round", + "Work", m.left?.let { dotFormat("%02d", it) }, if (m.everyMinute) "seconds left in the minute" else "seconds left", compact, + ) + is Phase.Rest -> PhaseFigure( + "${m.round - 1} of ${m.rounds} done", "Round ${m.round} next", "Rest", m.left?.let { dotFormat("%02d", it) }, + bpm?.let { b -> "Heart rate $b" + (m.drop?.takeIf { it > 0 }?.let { ", down $it" } ?: "") } ?: "seconds left", compact, + // Folded, +10 s sits beside the seconds: the half has no room for a row of its own. + action = { WorkoutButton("+10 s", OverGlow, Color.White, onAddRest, Modifier.width(120.dp), height = 44.dp) }, + ) + Phase.WarmUp -> PhaseFigure("Warm up", "${m.rounds} rounds after it", "Warming up", null, "Start the rounds when you're ready", compact) + Phase.Done -> PhaseFigure("All ${m.rounds} rounds done", "Hold the square to end, or keep going", "Done", null, "", compact) + } + if (!split && (m.phase is Phase.Rest || m.phase == Phase.Done)) RoundPeaks(r.rounds, m.rounds) + if (!split && m.phase !is Phase.Rest) HeartStripDark(bpm, r.maxHeartRate, r.paused, Modifier.align(Alignment.CenterHorizontally)) + val tall = if (compact) 48.dp else 64.dp + when (m.phase) { + Phase.WarmUp -> WorkoutButton("Start rounds", Color.White, Color.Black, onSkip, icon = AppIcons.PlayFill, height = tall) + // A pill as high as the heart rate strip and, like it, no wider than its words need. + is Phase.Rest -> if (!compact) WorkoutButton("+10 s", OverGlow, Color.White, onAddRest, Modifier.align(Alignment.CenterHorizontally).width(140.dp), height = 52.dp) + is Phase.Work -> if (m.everyMinute) WorkoutButton("Done", Color.White, Color.Black, onSkip, icon = AppIcons.Tick02, height = tall) + Phase.Done -> Unit + } + } +} + +/** A see-through button colour that reads both on the phase's light and on black. */ +private val OverGlow = Color.White.copy(alpha = 0.16f) + +/** + * The phase's light over the whole screen (boards HiitWork and HiitRest): work rises from the bottom, rest falls from + * the top, strongest at the edge and gone by the middle, so the seconds stay on black. + */ +// Made once: a new brush each second would draw the backdrop again. +private val WorkGlow by lazy { phaseGlow(WorkRed, fromTop = false) } +private val RestGlow by lazy { phaseGlow(RestGreen, fromTop = true) } + +private fun phaseGlow(color: Color, fromTop: Boolean) = object : ShaderBrush() { + override fun createShader(size: Size) = RadialGradientShader( + Offset(size.width / 2f, if (fromTop) -size.height * 0.08f else size.height * 1.08f), + size.height * if (fromTop) 0.7f else 0.88f, + // A white-hot core in the phase's colour, as on the boards. + listOf(lerp(color, Color.White, 0.6f), color.copy(alpha = 0.85f), color.copy(alpha = 0.3f), Color.Transparent), + listOf(0f, 0.3f, 0.62f, 1f), + ) +} + +/** Skip, Done or Skip rest on the controls row's left, labelled under it. */ +@Composable +private fun SkipButton(label: String, onSkip: () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Box(Modifier.height(88.dp), contentAlignment = Alignment.Center) { + FlatButton(OverGlow, 64.dp, onClick = onSkip) { + Icon(if (label == "Done") AppIcons.Tick02 else AppIcons.SkipFill, label, tint = Color.White, modifier = Modifier.size(20.dp)) + } + } + Text(label, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1) + } +} + +/** + * The rounds as a segmented bar: done white, the one under way filling as its [workLength] passes (or by [underWay], + * a run rep's share of its metres), those to go faint. + */ +@Composable +internal fun RoundBar(m: IntervalMoment, workLength: Long?, underWay: Float? = null) { + val done = when (m.phase) { + is Phase.Work, is Phase.Rest -> m.round - 1 + Phase.Done -> m.rounds + Phase.WarmUp -> 0 + } + // How far into the round under way: the work's seconds gone of [workLength], when it runs on a clock. + val under = if (m.phase is Phase.Work && underWay != null) underWay.coerceIn(0f, 1f) + else if (m.phase is Phase.Work && m.left != null && workLength != null && workLength > 0) { + (1f - m.left.toFloat() / workLength).coerceIn(0f, 1f) + } else 0f + Canvas(Modifier.fillMaxWidth().height(6.dp).semantics { contentDescription = "$done of ${m.rounds} rounds done" }) { + val n = m.rounds.coerceIn(1, 20) + val gap = 6.dp.toPx() + val w = (size.width - gap * (n - 1)) / n + val corner = CornerRadius(size.height / 2) + for (i in 0 until n) { + val x = i * (w + gap) + drawRoundRect(Color.White.copy(alpha = if (i < done) 1f else 0.25f), Offset(x, 0f), Size(w, size.height), corner) + if (i == done && under > 0f) drawRoundRect(Color.White, Offset(x, 0f), Size(w * under, size.height), corner) + } + } +} + +/** + * A phase: where you are and what's next, its word large, the seconds in the screen's one dotted number, a line under. + * [compact] (half a folded phone) puts the word, the seconds and the phase's [action] side by side, smaller, with no + * line. + */ +@Composable +private fun ColumnScope.PhaseFigure( + where: String, next: String, word: String, seconds: String?, line: String, compact: Boolean = false, action: (@Composable () -> Unit)? = null, +) { + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp)) { + Text(where, color = Color.White, style = GlowType.body, modifier = Modifier.weight(1f)) + Text(next, color = Color.White.copy(alpha = 0.8f), style = GlowType.secondary) + } + if (compact) { + Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { + Text(word.uppercase(), color = Color.White, style = GlowType.phaseWord, maxLines = 1) + if (seconds != null) DotMatrixText(seconds, dot = DotSize.Detail) + action?.invoke() + } + return + } + Column(Modifier.fillMaxWidth().weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically)) { + Text(word.uppercase(), color = Color.White, style = GlowType.phaseWord) + if (seconds != null) DotMatrixText(seconds, dot = DotSize.Live, scale = 1.4f) + if (line.isNotEmpty()) Text(line, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, textAlign = TextAlign.Center) + } +} + +/** The heart rate on a black pill as wide as its words, with the zone's dot and name: the phase's colour is all around it. */ +@Composable +private fun HeartStripDark(bpm: Int?, maxHr: Int, paused: Boolean, modifier: Modifier = Modifier) { + Row( + modifier.height(52.dp).clip(CircleShape).background(Color.Black).padding(horizontal = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val zone = WorkoutRecorder.liveZone(bpm, maxHr) + Canvas(Modifier.size(8.dp)) { drawCircle(zone?.let { ZONE_COLORS[it] } ?: Color.White) } + Spacer(Modifier.width(10.dp)) + Text(bpm?.let { "$it bpm" } ?: WorkoutCopy.noHeartRate(paused), color = Color.White, style = GlowType.body) + zone?.let { Spacer(Modifier.width(16.dp)); Text(WorkoutCopy.zoneLine(it), color = Color.White, style = GlowType.secondary, maxLines = 1) } + } +} + +/** + * Each round's peak heart rate so far as bars in the peak's zone colour, rounds to come as faint stubs, with how far + * the heart rate came down in each rest. + */ +@Composable +private fun RoundPeaks(rounds: List, total: Int) { + val peaks = rounds.filter { it.workEnd != null }.map { it.peakBpm } + if (peaks.isEmpty()) return + val top = peaks.filterNotNull().maxOrNull() ?: return + val low = (peaks.filterNotNull().minOrNull() ?: top) - 15 + EdgeCard(WorkRed, Modifier.fillMaxWidth()) { + Text("Peak each round", color = Color.White, style = GlowType.cardTitle, maxLines = 1) + Spacer(Modifier.height(12.dp)) + Canvas( + Modifier.fillMaxWidth().height(56.dp).semantics { contentDescription = "Peak heart rate per round: " + peaks.filterNotNull().joinToString(", ") }, + ) { + val n = total.coerceAtLeast(peaks.size) + val gap = 6.dp.toPx() + val w = (size.width - gap * (n - 1)) / n + for (i in 0 until n) { + val p = peaks.getOrNull(i) + val h = if (p == null) 4.dp.toPx() else ((p - low).toFloat() / (top - low).coerceAtLeast(1)).coerceIn(0.15f, 1f) * size.height + val c = if (p == null) Color.White.copy(alpha = 0.12f) else MetricColors.heart + drawRoundRect(c, Offset(i * (w + gap), size.height - h), Size(w, h), CornerRadius(3.dp.toPx())) + } + } + Spacer(Modifier.height(6.dp)) + Text(peaks.filterNotNull().joinToString(", ") + " bpm", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + } +} + +/** + * A run rep (workouts spec 2.5, board RunIntervals), in place of the Pace card: rep N of M, the metres to go (or the + * seconds of a time rep) in dots, the rep's pace now, and the reps done with their times. Rest shows its time left. + */ +@Composable +internal fun RepCard(r: WorkoutState.Recording, route: List, readings: List>, bpm: Int?) { + val now = nowTicking() + val m = intervalMoment(r, now, bpm, readings, route) ?: return + val resting = m.phase is Phase.Rest + EdgeCard(if (resting) RestGreen else MetricColors.steps, Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth()) { + Text( + when (m.phase) { + is Phase.Work -> "Rep ${m.round} of ${m.rounds}" + is Phase.Rest -> "Rest, rep ${m.round} next" + Phase.WarmUp -> "Warm up" + Phase.Done -> "All ${m.rounds} reps done" + }, + color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f), + ) + val pace = if (m.phase is Phase.Work) org.openstrap.app.presentation.Pace.clock(Route.currentPace(route)) else null + if (pace != null) Text("$pace /km now", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + } + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.Bottom) { + val (value, unit) = when { + m.metersLeft != null && m.phase is Phase.Work -> "${m.metersLeft.toInt()}" to "m to go" + m.left != null -> dotClock(m.left) to if (resting) "rest left" else "left" + else -> null to "" + } + if (value != null) { + DotMatrixText(value, dot = DotSize.Detail) + Spacer(Modifier.width(8.dp)) + Text(unit, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body) + } else Text(if (m.phase == Phase.WarmUp) "Start the reps when you're ready" else "", color = Color.White, style = GlowType.body) + } + val done = r.rounds.filter { it.workEnd != null } + if (done.isNotEmpty()) { + Spacer(Modifier.height(12.dp)) + Text( + done.takeLast(6).joinToString(", ") { "${it.index + 1}: ${workoutClock(it.workEnd!! - it.workStart)}" }, + color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary, + ) + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveMap.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveMap.kt new file mode 100644 index 00000000..cb55aeee --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveMap.kt @@ -0,0 +1,161 @@ +package org.openstrap.app.ui.glow.live + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.ui.unit.Dp +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import org.openstrap.app.ui.theme.MetricColors +import androidx.compose.foundation.layout.Column +import org.openstrap.app.ui.workout.WorkoutCopy +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.device.WorkoutState +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.Route +import org.openstrap.app.ui.glow.* + +/** + * A distance workout's map page (board Map view): the map from the top of the screen, with minimize, the heart rate + * in its zone's [color] and the GPS over it, route only and follow me on the right; under it a black panel with the + * distance, the moving time and the pace (a ride's speed) and the latest kilometre, above the bar shared with the + * pace page ([LiveBottomBar]). A drag on the map pans it and stops following; follow me fits the route again. + */ +@Composable +internal fun LiveMapScreen( + r: WorkoutState.Recording, + route: List, + readings: List>, + bpm: Int?, + color: Color, + contentPadding: PaddingValues, + controls: LiveControls, + figures: List>, + /** The moving time, read by its own text so the page isn't composed again each second. */ + time: () -> Long, + /** The height of the bar shared with the other view ([LiveBottomBar]): the panel leaves this much room for it. */ + bottomRoom: Dp, + /** False when the heart rate is on the other half of a folded phone. */ + heartPill: Boolean = true, +) { + BackHandler(onBack = controls.onMinimize) + val density = LocalDensity.current + var panel by remember { mutableStateOf(344.dp) } + var routeOnly by rememberSaveable { mutableStateOf(false) } + var follow by rememberSaveable { mutableStateOf(true) } + // A reading comes every second: the zone colours follow every tenth, the line every fix. + val stretches = remember(route.size, readings.size / 10) { Route.byZone(route, readings, r.maxHeartRate) } + val here = route.lastOrNull { it.accuracy <= Route.MAX_ACCURACY_M } + val top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + contentPadding.calculateTopPadding() + 8.dp + Box(Modifier.fillMaxSize().background(Color.Black)) { + // The map runs under the panel's rounded top, so no black shows at its corners. + Box(Modifier.fillMaxWidth().fillMaxHeight().padding(bottom = (panel - PANEL_OVERLAP).coerceAtLeast(0.dp))) { + WorkoutMap( + stretches, routeOnly, Modifier.fillMaxSize(), here, + fitPadding = PaddingValues(start = 28.dp, top = top + 64.dp, end = 76.dp, bottom = PANEL_OVERLAP + 28.dp), + creditBottom = PANEL_OVERLAP + 14.dp, + hereColor = color.takeIf { bpm != null }, + follow = follow, onPanned = { follow = false }, + ) + Row( + Modifier.fillMaxWidth().padding(top = top, start = 16.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + RoundIcon(AppIcons.CaretDown, "Minimize", controls.onMinimize, surface = OverMap) + if (heartPill) LitHeartPill(bpm, null, color, r.paused, 44.dp) + Spacer(Modifier.weight(1f)) + val acc = route.lastOrNull()?.accuracy + Row( + Modifier.height(32.dp).clip(RoundedCornerShape(50)).background(OverMap).padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(6.dp).clip(CircleShape).background(if (acc != null && acc <= Route.MAX_ACCURACY_M) TrendGood else TrendBad)) + Spacer(Modifier.width(6.dp)) + Text(WorkoutCopy.gpsPill(acc), color = Color.White, style = GlowType.secondary, maxLines = 1) + } + } + Column( + Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = PANEL_OVERLAP + 44.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + MapButton(AppIcons.MapFold, if (routeOnly) "Show the map" else "Show route only, no map", lit = routeOnly) { routeOnly = !routeOnly } + MapButton(AppIcons.NavigationArrow, "Follow my position", lit = follow) { follow = true } + } + } + Column( + Modifier.align(Alignment.BottomCenter).fillMaxWidth() + .onSizeChanged { panel = with(density) { it.height.toDp() } } + .clip(org.openstrap.app.ui.glow.GlowLayout.SheetShape).background(Color.Black) + .padding(start = 16.dp, end = 16.dp, top = 18.dp, bottom = contentPadding.calculateBottomPadding()), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + // The moving time in the middle and larger: on this page it isn't above the views. + Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min), verticalAlignment = Alignment.Bottom) { + LiveStat(figures[0].second, Modifier.weight(1f)) { LiveValue(figures[0].first) } + StatRule() + LiveStat("Moving time", Modifier.weight(1.3f)) { ClockText(time, GlowType.mapClock, maxLines = 1) } + StatRule() + LiveStat(figures[1].second, Modifier.weight(1f)) { LiveValue(figures[1].first) } + } + LastSplit(route, readings, r.workout.pauseRanges) + Spacer(Modifier.height(bottomRoom)) + } + } +} + +/** How far the map runs under the panel's rounded top. */ +private val PANEL_OVERLAP = 28.dp + +/** Buttons and pills drawn over the map: near black, a little see-through. */ +private val OverMap = Color(0xD9141416) + +/** A round button over the map, white while what it does is on ([lit]). */ +@Composable +private fun MapButton(icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, lit: Boolean, onClick: () -> Unit) = + RoundIcon(icon, label, onClick, surface = if (lit) Color.White else OverMap, ink = if (lit) Color.Black else Color.White, iconSize = 20.dp) + +/** The latest whole kilometre on a dark row with how long ago it ended, until the next; nothing before the first. */ +@Composable +private fun LastSplit(route: List, readings: List>, pauses: List) { + val splits = remember(route.size, pauses) { Route.splits(route, pauses) } + val now = nowTicking() + val (line, ago) = WorkoutCopy.lastSplit(splits, readings, route.firstOrNull()?.t, now) ?: return + Row( + Modifier.fillMaxWidth().height(44.dp).clip(RoundedCornerShape(14.dp)).background(Raised).padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(8.dp).clip(CircleShape).background(MetricColors.steps)) + Spacer(Modifier.width(10.dp)) + Text(line, color = Color.White, style = GlowType.body.copy(fontFeatureSettings = "tnum"), maxLines = 1, modifier = Modifier.weight(1f)) + Text(ago, color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, maxLines = 1) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveMind.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveMind.kt new file mode 100644 index 00000000..8a03c6e3 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveMind.kt @@ -0,0 +1,188 @@ +package org.openstrap.app.ui.glow.live + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.openstrap.app.data.WorkoutTarget +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.glow.GlowType +import org.openstrap.app.ui.glow.activeSecondsState +import org.openstrap.app.ui.glow.workoutClock +import org.openstrap.app.ui.theme.MetricColors +import androidx.compose.foundation.gestures.detectTapGestures +import org.openstrap.app.ui.workout.WorkoutCopy + +/** The screen dims this long after the last touch. */ +private const val DIM_AFTER_MS = 10_000L + +/** + * Mind and body (workouts spec 2.6, board LiveMind): dark by design. The time against the length, a soft heart line + * that shows it settling, then Pause and the square to end. No zone colours, no kcal. The screen dims to near black + * after ten seconds and lights again on a touch, which does nothing else. One soft buzz at the end of the length; the + * workout keeps recording until you end it. With [split] the heart rate is on the other half of a folded phone, so this + * half leaves it out, and [compact] (a short half) drops the ring around the time. + */ +@Composable +internal fun LiveMindScreen( + r: WorkoutState.Recording, + liveHeartRate: Int?, + recent: List>, + connection: ConnectionState, + contentPadding: PaddingValues, + controls: LiveControls, + split: Boolean = false, + compact: Boolean = false, +) { + val connected = connection is ConnectionState.Connected + val bpm = liveHeartRate.takeIf { connected && !r.paused } + val active = activeSecondsState(r) + val length = (WorkoutTarget.parse(r.workout.target) as? WorkoutTarget.Time)?.seconds + var touched by remember { mutableLongStateOf(System.currentTimeMillis()) } + var dim by remember { mutableStateOf(false) } + LaunchedEffect(touched, r.paused) { + dim = false + if (r.paused) return@LaunchedEffect + delay(DIM_AFTER_MS) + dim = true + } + // Read only in the veil's draw, so dimming fades the screen without composing it again each frame. + val veil = animateFloatAsState(if (dim) 0.92f else 0f, tween(if (dim) 1500 else 200), label = "dim") + Box( + Modifier.fillMaxSize().pointerInput(Unit) { + // Seen before anything under it, and never taken from it: a touch lights the screen and still presses. + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + touched = System.currentTimeMillis() + } + }, + ) { + LiveFrame(r, MindColor.copy(alpha = 0.5f), connected, contentPadding, controls, time = null, quiet = true, fill = true, tapHint = !compact) { + Spacer(Modifier.weight(0.6f)) + MindRing({ active.value }, length, ring = !compact) + Spacer(Modifier.weight(0.5f)) + if (split) { + Spacer(Modifier.weight(0.6f)) + return@LiveFrame + } + val now = recent.lastOrNull()?.first ?: (System.currentTimeMillis() / 1000) + val values = remember(recent) { bucketRecent(recent, now) } + if (values.count { it != null } >= 2) MindHeartLine(values) + val first = values.firstOrNull { it != null }?.toInt() + Text( + bpm?.let { b -> + "Heart rate $b, settling" + (first?.minus(b)?.takeIf { it > 0 }?.let { ", down $it since you started" } ?: "") + } ?: WorkoutCopy.noHeartRate(r.paused, connected), + color = Color.White.copy(alpha = 0.8f), style = GlowType.body, + modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, + ) + Spacer(Modifier.weight(0.6f)) + } + // Dimmed, the first touch only lights the screen: the veil takes it. + Box( + Modifier.fillMaxSize().drawBehind { if (veil.value > 0f) drawRect(Color.Black.copy(alpha = veil.value)) } + .then(if (dim) Modifier.pointerInput(Unit) { detectTapGestures { touched = System.currentTimeMillis() } } else Modifier), + ) + } +} + +/** + * The time in a thin ring that fills in violet towards the length, "of 45 min" under it; open, the ring stays a + * faint track. Without the [ring], the time and its line alone. + */ +@Composable +private fun MindRing(time: () -> Long, length: Long?, ring: Boolean = true) { + // The time is read by the ring's draw and the clock's text, so the second composes only the text again. + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + if (ring) Canvas(Modifier.size(280.dp)) { + // The strength rest ring's line and track, so the app's rings read as one. + val stroke = 8.dp.toPx() + val inset = stroke / 2 + val arc = Size(size.width - stroke, size.height - stroke) + drawArc(Color.White.copy(alpha = 0.1f), 0f, 360f, false, Offset(inset, inset), arc, style = Stroke(stroke)) + if (length != null && length > 0) { + val sweep = 360f * (time().toFloat() / length).coerceIn(0f, 1f) + drawArc(MindColor, -90f, sweep, false, Offset(inset, inset), arc, style = Stroke(stroke, cap = StrokeCap.Round)) + } + } + MindClock(time, length) + } +} + +/** The mind ring's time and its line under it, its own scope so only it reads the second. */ +@Composable +private fun MindClock(time: () -> Long, length: Long?) { + val active = time() + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(workoutClock(active), color = Color.White.copy(alpha = 0.9f), style = GlowType.mindClock, maxLines = 1) + Text( + length?.let { "of ${it / 60} min" + if (active >= it) ", the length is up" else "" } ?: "Open, no length", + color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, textAlign = TextAlign.Center, + ) + } +} + +/** The heart rate as one thin violet line edge to edge, with a dot at now: no axis, no numbers. */ +@Composable +private fun MindHeartLine(values: List) { + val known = values.filterNotNull() + val lo = known.min() - 4 + val hi = known.max() + 4 + Spacer( + Modifier.fillMaxWidth().height(64.dp).drawWithCache { + // Built once per size and readings, not on every draw. + val step = size.width / (values.size - 1).coerceAtLeast(1) + val path = Path() + var last: Offset? = null + values.forEachIndexed { i, v -> + if (v == null) return@forEachIndexed + val p = Offset(i * step, size.height * (1f - (v - lo) / (hi - lo))) + if (last == null) path.moveTo(p.x, p.y) else path.lineTo(p.x, p.y) + last = p + } + val stroke = Stroke(2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round) + val end = last + onDrawBehind { + drawPath(path, MindColor.copy(alpha = 0.7f), style = stroke) + end?.let { drawCircle(MindColor, 4.dp.toPx(), it) } + } + }, + ) +} + +/** Mind and body's colour: the evening violet, never a zone's. */ +private val MindColor = MetricColors.evening diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LivePaused.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LivePaused.kt new file mode 100644 index 00000000..dbc7f46b --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LivePaused.kt @@ -0,0 +1,64 @@ +package org.openstrap.app.ui.glow.live + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.Workouts +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.glow.DotMatrixText +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.glow.GlowType +import org.openstrap.app.ui.glow.dotClock + +/** + * A paused workout (workouts spec 2.3, board Paused): the time stops and the pause counts up in dots, "Paused for", + * the heart rate now and how far it has dropped since you stopped ([recent], the live readings, hold the one at the + * pause), then the workout's [figures] so far (value, label). + */ +@Composable +internal fun ColumnScope.LivePaused(r: WorkoutState.Recording, liveHeartRate: Int?, recent: List>, figures: List>) { + val at = r.workout.pausedAt ?: return + val now = nowTicking() + Column( + Modifier.fillMaxWidth().padding(top = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Paused for", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + DotMatrixText(dotClock((now - at).coerceAtLeast(0)), dot = DotSize.Detail) + val before = Workouts.heartRateAt(recent, at) ?: recent.lastOrNull { it.first <= at }?.second + val drop = if (before != null && liveHeartRate != null) before - liveHeartRate else null + if (liveHeartRate != null) Text( + "Heart rate $liveHeartRate" + (drop?.takeIf { it > 0 }?.let { ", down $it since you stopped" } ?: ""), + color = Color.White, style = GlowType.sentence, textAlign = TextAlign.Center, + ) + Text( + "Pausing keeps this time out of your pace and averages", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), + style = GlowType.secondary, textAlign = TextAlign.Center, + ) + } + LiveFigures(figures, Modifier.padding(top = 16.dp)) +} + +/** A row of plain live figures (value over label) with thin rules between, a missing value as the waiting dash. */ +@Composable +internal fun LiveFigures(figures: List>, modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth().height(IntrinsicSize.Min), verticalAlignment = Alignment.Bottom) { + figures.forEachIndexed { i, (value, label) -> + if (i > 0) StatRule() + LiveStat(label, Modifier.weight(1f)) { LiveValue(value) } + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveSets.kt b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveSets.kt new file mode 100644 index 00000000..d2efaeeb --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/glow/live/LiveSets.kt @@ -0,0 +1,474 @@ +package org.openstrap.app.ui.glow.live + +import org.openstrap.app.presentation.WorkoutColors +import androidx.compose.foundation.Canvas +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.PaddingValues +import androidx.compose.foundation.layout.Row +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.geometry.Size +import androidx.compose.foundation.border +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.openstrap.app.data.Strength +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.db.AppWorkoutSet +import org.openstrap.app.data.db.ExerciseRow +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.components.HapticEvent +import org.openstrap.app.ui.components.pressClickable +import org.openstrap.app.ui.components.rememberHaptics +import org.openstrap.app.ui.glow.DotMatrixText +import org.openstrap.app.ui.glow.draw.DotSize +import org.openstrap.app.ui.glow.EdgeCard +import org.openstrap.app.ui.glow.GlowCard +import org.openstrap.app.ui.glow.GlowLayout +import org.openstrap.app.ui.glow.draw.GlowMotion +import org.openstrap.app.ui.glow.GlowType +import org.openstrap.app.ui.glow.draw.LightAngle +import org.openstrap.app.ui.glow.MediumBody +import org.openstrap.app.ui.glow.PageButton +import org.openstrap.app.ui.glow.PageRow +import org.openstrap.app.ui.glow.PageSheet +import org.openstrap.app.ui.glow.QuietPanel +import org.openstrap.app.ui.glow.Raised +import org.openstrap.app.ui.glow.MinusPlus +import org.openstrap.app.ui.glow.RoundIcon +import org.openstrap.app.ui.glow.WorkoutButton +import org.openstrap.app.ui.glow.activeSecondsState +import org.openstrap.app.ui.glow.draw.heroStyle +import org.openstrap.app.ui.glow.workoutClock +import org.openstrap.app.ui.glow.dotClock +import org.openstrap.app.ui.glow.workoutColor +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.workout.WorkoutCopy +import org.openstrap.app.ui.workout.ZONE_COLORS +import org.openstrap.app.ui.workout.strengthMoment + +/** Strength's rest colour, the green the floating bar and the hero rest in too. */ +private val RestGreen = Color(WorkoutColors.REST_GREEN) + +/** + * Strength (workouts spec 2.4, boards StrengthSet, StrengthLog and StrengthRest), with the total small on top. + * + * In a set: set N of M and its exercise (tap to name or change it), the set's clock in dots on a glow in the zone's + * colour, the sets as dots, the heart rate and zone, the sets done with their reps, weight, time and peak, and one big + * Set done. Resting: the rest left in dots on a card lit in rest green, the heart rate and how far it dropped, its line + * falling toward the level that ends rest sooner, what's next, +30 s and Start set N. The strap buzzes when rest ends. + * + * With logging on ([WorkoutState.Recording.logSets]), the log card follows each set while rest runs underneath: reps + * and kg filled from the last set of the exercise ([Strength.prefill]), Skip or Save, and Next exercise. + * + * [compact] (half a phone stood like a laptop, the heart rate on the other half): each card is one row with its clock, + * and the sets done and what's next are left out, so the buttons stay on screen. + */ +@Composable +internal fun LiveStrengthScreen( + r: WorkoutState.Recording, + liveHeartRate: Int?, + history: List>, + connection: ConnectionState, + contentPadding: PaddingValues, + controls: LiveControls, + exercises: List, + onStartSet: () -> Unit, + onSetDone: () -> Unit, + onExtendRest: () -> Unit, + onExercise: (String?) -> Unit, + onLog: (index: Int, exercise: String?, reps: Int, kg: Float) -> Unit, + compact: Boolean = false, +) { + val now = nowTickingState() + val connected = connection is ConnectionState.Connected + val bpm = liveHeartRate.takeIf { connected && !r.paused } + ZoneTick(bpm, r) + val color = if (r.resting && !r.paused) RestGreen else workoutColor(r, liveHeartRate, connected) + // Read where it shows (the cards' clocks, the paused figures, the log card), so the screen holds still between + // seconds. The rest of the screen doesn't move with the clock: [steady] is the moment without its seconds, which + // only the clocks read, so it needs no time at all. + val momentNow = { strengthMoment(r, now.value, bpm, history) } + val steady = remember(r, bpm, history) { strengthMoment(r, 0, bpm, history).copy(setSeconds = 0, restLeft = null) } + val haptics = rememberHaptics() + var naming by rememberSaveable { mutableStateOf(false) } + // Sets whose log card was skipped, so it doesn't come back. + val skipped = rememberSaveable(r.workout.start) { mutableStateListOf() } + val unlogged = r.sets.lastOrNull { it.end != null }?.takeIf { r.logSets && it.reps == null && it.index !in skipped && !r.inSet } + val active = activeSecondsState(r) + LiveFrame( + r, color, connected, contentPadding, controls, time = { active.value }, smallTime = true, pillControls = true, fill = true, + paused = { LivePaused(r, liveHeartRate.takeIf { connected }, history, listOf( + momentNow().done.let { done -> done.toString() to if (done == 1) "Set done" else "Sets done" }, + Strength.kgLifted(r.sets)?.let { Strength.kgText(it) } to "kg lifted", + r.workout.maxHeartRate?.toString() to "Peak bpm", + )) }, + ) { + val moment = steady + val tall = if (compact) 56.dp else 72.dp + if (compact) CompactCard(r, moment, momentNow, color, onName = { naming = true }) else when { + r.inSet -> SetCard(r, moment.setNumber, { momentNow().setSeconds }, bpm, color) { naming = true } + r.resting -> RestCard(r, { momentNow().restLeft ?: 0 }, moment.done, bpm, moment.drop, history) + else -> ReadyCard(r, moment.setNumber, color) { naming = true } + } + if (!compact && r.resting && !r.inSet) NextLine(moment.setNumber, r.exercise, moment.nextLoad) + if (!compact) DoneSets(r.sets, history) + Spacer(Modifier.weight(1f)) + when { + r.inSet -> WorkoutButton("Set done", Color.White, Color.Black, { haptics.perform(HapticEvent.SetDone); onSetDone() }, icon = AppIcons.Tick02, height = tall) + else -> Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + if (r.resting) Box(Modifier.width(108.dp)) { WorkoutButton("+30 s", Raised, Color.White, onExtendRest, height = tall) } + Box(Modifier.weight(1f)) { WorkoutButton("Start set ${moment.setNumber}", Color.White, Color.Black, onStartSet, icon = AppIcons.PlayFill, height = tall) } + } + } + val target = r.workout.setsTarget + if (!compact && target != null && moment.done >= target && !r.inSet) { + Text("All $target sets done. Hold the square to end, or keep going.", color = Color.White.copy(alpha = 0.6f), style = GlowType.secondary, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center) + } + } + if (unlogged != null) LogCard( + unlogged, r.sets, exercises, history, restLeft = { momentNow().restLeft }, + onSave = { exercise, reps, kg -> onLog(unlogged.index, exercise, reps, kg) }, + onSkip = { skipped += unlogged.index }, + onNextExercise = { skipped += unlogged.index; naming = true }, + ) + if (naming) ExerciseSheet(r.exercise, exercises.map { it.name }, onPick = { onExercise(it); naming = false }, onDismiss = { naming = false }) +} + +/** The set under way: set N of M and its exercise, the set's clock in dots, the sets as dots, the heart rate and zone. */ +@Composable +private fun SetCard(r: WorkoutState.Recording, number: Int, seconds: () -> Long, bpm: Int?, color: Color, onName: () -> Unit) { + val style = heroStyle(color, LightAngle.Below).let { s -> bpm?.let { s.copy(motion = GlowMotion.Heartbeat(it)) } ?: s } + GlowCard(style, Modifier.fillMaxWidth()) { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(WorkoutCopy.setOf(number, r.workout.setsTarget), color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + ExercisePill(r.exercise, onName) + } + Column(Modifier.fillMaxWidth().padding(top = 34.dp, bottom = 26.dp), horizontalAlignment = Alignment.CenterHorizontally) { + DotClock({ dotClock(seconds()) }, DotSize.Live) + Spacer(Modifier.height(14.dp)) + Text("in this set", color = Color.White.copy(alpha = 0.8f), style = GlowType.body) + } + SetDots(r.sets.count { it.end != null }, r.workout.setsTarget, inSet = true, Modifier.align(Alignment.CenterHorizontally)) + Spacer(Modifier.height(22.dp)) + HeartLine(bpm, r.maxHeartRate, r.paused) + } + } +} + +/** + * The set, the rest or the next set on one row (half a folded phone): what it is and its exercise, and the set's clock + * or the rest left in dots, on an edge card in the screen's colour. + */ +@Composable +private fun CompactCard( + r: WorkoutState.Recording, moment: org.openstrap.app.ui.workout.StrengthMoment, now: () -> org.openstrap.app.ui.workout.StrengthMoment, + color: Color, onName: () -> Unit, +) { + val (title, clock) = when { + r.inSet -> WorkoutCopy.setOf(moment.setNumber, r.workout.setsTarget) to { dotClock(now().setSeconds) } + r.resting -> "Rest after " + WorkoutCopy.setOf(moment.done, r.workout.setsTarget).lowercase() to + { dotClock((now().restLeft ?: 0).coerceAtLeast(0)) } + else -> (if (moment.setNumber == 1) "Ready for set 1" else "Rest over") to null + } + EdgeCard(color, Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, color = Color.White, style = GlowType.cardTitle, maxLines = 1) + ExercisePill(r.exercise, onName) + } + if (clock != null) DotClock(clock, DotSize.Detail) + } + } +} + +/** Before the first set, or once rest is over: which set is next and its exercise. */ +@Composable +private fun ReadyCard(r: WorkoutState.Recording, number: Int, color: Color, onName: () -> Unit) { + EdgeCard(color, Modifier.fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(if (number == 1) "Ready for set 1" else "Rest over", color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + ExercisePill(r.exercise, onName) + } + Spacer(Modifier.height(10.dp)) + Text(WorkoutCopy.setOf(number, r.workout.setsTarget) + " next. Tap Start when you pick up the weight.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + Spacer(Modifier.height(14.dp)) + SetDots(r.sets.count { it.end != null }, r.workout.setsTarget, inSet = false, Modifier.align(Alignment.CenterHorizontally)) + } +} + +/** + * Rest: what it follows, the time left in dots, the heart rate and how far it dropped, and the heart line since the + * set ended falling toward the level that ends rest sooner, drawn as a solid rule. + */ +@Composable +private fun RestCard(r: WorkoutState.Recording, left: () -> Long, done: Int, bpm: Int?, drop: Int?, history: List>) { + val rule = Strength.Rest.parse(r.workout.rest) ?: Strength.Rest.DEFAULT + // Outlined, not lit: the set's card glows, the rest's counts down in its ring. + Column( + Modifier.fillMaxWidth().clip(GlowLayout.CardShape).border(1.5.dp, Color.White.copy(alpha = 0.2f), GlowLayout.CardShape).padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row { + Text("Rest", color = Color.White, style = GlowType.cardTitle, maxLines = 1, modifier = Modifier.weight(1f)) + Text("after " + WorkoutCopy.setOf(done, r.workout.setsTarget).lowercase(), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + } + Spacer(Modifier.height(10.dp)) + val total = (r.restUntil ?: 0) - (r.sets.lastOrNull()?.end ?: r.restUntil ?: 0) + RestRing({ left().coerceAtLeast(0) }, total.coerceAtLeast(1)) + Spacer(Modifier.height(4.dp)) + Column(Modifier.fillMaxWidth()) { + val since = r.sets.lastOrNull()?.end + if (since != null) { + Spacer(Modifier.height(10.dp)) + FallingHeart(remember(history, since) { history.filter { it.first >= since - 10 } }, rule.belowBpm) + } + Spacer(Modifier.height(6.dp)) + if (bpm != null) Text("$bpm bpm" + (drop?.takeIf { it > 0 }?.let { ", down $it" } ?: ""), color = Color.White, style = GlowType.sentence) + Spacer(Modifier.height(4.dp)) + Text(WorkoutCopy.restRule(rule.belowBpm), color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + } + } +} + +/** The rest left in dots inside a ring in rest green that empties as it runs down. [left] is read in draw and its text. */ +@Composable +private fun RestRing(left: () -> Long, total: Long) { + Box(Modifier.size(220.dp), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize().padding(4.dp)) { + val w = 8.dp.toPx() + val inset = Offset(w / 2, w / 2) + val arc = Size(size.width - w, size.height - w) + drawArc(Color.White.copy(alpha = 0.1f), 0f, 360f, false, inset, arc, style = Stroke(w)) + val sweep = 360f * (left().toFloat() / total).coerceIn(0f, 1f) + drawArc(RestGreen, -90f, sweep, false, inset, arc, style = Stroke(w, cap = StrokeCap.Round)) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + DotClock({ dotClock(left()) }, DotSize.Detail) + Spacer(Modifier.height(10.dp)) + Text("left", color = Color.White.copy(alpha = 0.75f), style = GlowType.body) + } + } +} + +/** A clock in dots, its own scope: the second it reads composes only it again, not the card around it. */ +@Composable +private fun DotClock(text: () -> String, dot: DotSize) = DotMatrixText(text(), dot = dot) + +/** What follows the rest: "Set 4, bench press 10 × 60 kg". */ +@Composable +private fun NextLine(number: Int, exercise: String?, load: String?) { + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 14.dp)) { + Text("Next", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.body, modifier = Modifier.weight(1f)) + Text( + listOfNotNull("Set $number", exercise?.lowercase()?.let { e -> listOfNotNull(e, load).joinToString(" ") }).joinToString(", "), + color = Color.White, style = GlowType.body, + ) + } +} + +/** The heart rate since the set ended, falling, with the level that ends rest sooner as a thin solid rule. */ +@Composable +private fun FallingHeart(readings: List>, level: Int?) { + if (readings.size < 2) return + val lo = minOf(readings.minOf { it.second }, level ?: Int.MAX_VALUE) - 5 + val hi = readings.maxOf { it.second } + 5 + val from = readings.first().first + val to = maxOf(readings.last().first, from + 60) + Canvas(Modifier.fillMaxWidth().height(56.dp).semantics { contentDescription = "Heart rate since the set ended" }) { + fun x(t: Long) = (t - from).toFloat() / (to - from) * size.width + fun y(v: Int) = size.height - (v - lo).toFloat() / (hi - lo) * size.height + if (level != null) drawLine(Color.White.copy(alpha = 0.35f), Offset(0f, y(level)), Offset(size.width, y(level)), 1.dp.toPx()) + val path = Path().apply { + readings.forEachIndexed { i, (t, v) -> if (i == 0) moveTo(x(t), y(v)) else lineTo(x(t), y(v)) } + } + drawPath(path, EndRed, style = Stroke(2.5.dp.toPx(), cap = StrokeCap.Round)) + } +} + +/** The sets as dots: done ones white, the one under way white too, those to go dark. */ +@Composable +private fun SetDots(done: Int, target: Int?, inSet: Boolean, modifier: Modifier = Modifier) { + val total = maxOf(target ?: 0, done + if (inSet) 1 else 0) + if (total == 0) return + Row( + modifier.semantics { contentDescription = "$done sets done" + (target?.let { ", ${(it - done).coerceAtLeast(0)} to go" } ?: "") }, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + repeat(total) { i -> + val now = inSet && i == done + // The set under way has a soft ring round it. + Box(Modifier.size(22.dp), contentAlignment = Alignment.Center) { + if (now) Box(Modifier.size(22.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.3f))) + Box(Modifier.size(14.dp).clip(CircleShape).background(if (i < done || now) Color.White else Color.Black.copy(alpha = 0.35f))) + } + } + } +} + +/** The heart rate in plain figures with its zone named: the set's clock is the card's dotted number. */ +@Composable +private fun HeartLine(bpm: Int?, maxHr: Int, paused: Boolean) { + Row(verticalAlignment = Alignment.Bottom) { + Text(bpm?.toString() ?: WorkoutCopy.noHeartRate(paused), color = Color.White, style = GlowType.sentence) + if (bpm != null) { + Spacer(Modifier.width(6.dp)) + Text("bpm", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + Spacer(Modifier.weight(1f)) + WorkoutRecorder.liveZone(bpm, maxHr)?.let { z -> + Text(WorkoutCopy.zoneLine(z), color = Color.White, style = GlowType.secondary, maxLines = 1) + } + } + } +} + +/** The exercise as a dark pill to tap, or "Name it" while the sets are only numbered. */ +@Composable +private fun ExercisePill(name: String?, onClick: () -> Unit) { + val shape = RoundedCornerShape(50) + Text( + name ?: "Name it", color = Color.White, style = MediumBody, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.clip(shape).background(Color.Black.copy(alpha = 0.35f)).pressClickable(onClick, shape) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) +} + +/** The sets done, newest first: "Set 2" and "10 × 60 kg, 0:51, peak 148". */ +@Composable +private fun DoneSets(sets: List, history: List>) { + val done = sets.filter { it.end != null }.asReversed() + if (done.isEmpty()) return + // The peaks scan the readings, so they're worked out when a set or the readings change, not on every pass. + val lines = remember(done, history) { done.take(4).map { s -> WorkoutCopy.setLine(s, Strength.peak(history, s.start, s.end!!)) } } + Column(Modifier.fillMaxWidth()) { + done.take(4).forEachIndexed { i, s -> + SetRule() + Row(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 14.dp)) { + Text("Set ${s.index + 1}", color = Color.White, style = GlowType.body, modifier = Modifier.weight(1f)) + Text( + lines[i], color = Color.White.copy(alpha = 0.72f), style = GlowType.body, maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + SetRule() + } +} + +/** The line between the rows under the card, plain enough to read as a break between sets. */ +@Composable +private fun SetRule() = Box(Modifier.fillMaxWidth().height(1.dp).background(Color.White.copy(alpha = 0.18f))) + +/** + * The log card after a set (board StrengthLog): the set done with its time and peak, its exercise, reps and kg + * steppers filled from the last set of that exercise, Skip or Save, and Next exercise. Rest runs underneath. + */ +@Composable +private fun LogCard( + set: AppWorkoutSet, sets: List, exercises: List, history: List>, restLeft: () -> Long?, + onSave: (exercise: String?, reps: Int, kg: Float) -> Unit, onSkip: () -> Unit, onNextExercise: () -> Unit, +) { + val row = exercises.firstOrNull { it.name.equals(set.exercise, ignoreCase = true) } + val (fillReps, fillKg) = remember(set.index) { Strength.prefill(set.exercise, sets, row?.lastReps, row?.lastKg) } + var reps by rememberSaveable(set.index) { mutableStateOf(fillReps) } + var kg by rememberSaveable(set.index) { mutableStateOf(fillKg) } + val peak = Strength.peak(history, set.start, set.end ?: set.start) + PageSheet( + onSkip, "Set ${set.index + 1} done", + note = listOfNotNull(workoutClock((set.end ?: set.start) - set.start), peak?.let { "peak $it bpm" }).joinToString(", ") + + (restLeft()?.let { ". Rest is running, ${workoutClock(it.coerceAtLeast(0))} left." } ?: "."), + ) { + Text(set.exercise ?: "Numbered set", color = Color.White, style = GlowType.body) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + LogStepper("Reps", reps.toString(), "Fewer reps", "More reps", Modifier.weight(1f), { reps = Strength.stepReps(reps, -1) }, { reps = Strength.stepReps(reps, 1) }) + LogStepper("kg", Strength.kgText(kg), "Less weight", "More weight", Modifier.weight(1f), { kg = Strength.stepKg(kg, -1) }, { kg = Strength.stepKg(kg, 1) }) + } + Text("Filled from your last set. Change only what's different.", color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PageButton("Skip", onSkip, Modifier.weight(1f), icon = AppIcons.X) + PageButton("Save", { onSave(set.exercise, reps, kg) }, Modifier.weight(1f), primary = true, icon = AppIcons.Tick02) + } + PageButton("Next exercise", onNextExercise, Modifier.fillMaxWidth(), icon = AppIcons.Plus) + } +} + +/** A log card stepper: its label, the value large, and minus and plus. */ +@Composable +private fun LogStepper(label: String, value: String, less: String, more: String, modifier: Modifier, onLess: () -> Unit, onMore: () -> Unit) { + Column(modifier.clip(GlowLayout.CardShape).background(org.openstrap.app.ui.glow.ListSurface).padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, color = Color.White.copy(alpha = GlowType.SECONDARY_ALPHA), style = GlowType.secondary) + MinusPlus(less, more, onLess, onMore) { + Text(value, color = Color.White, style = GlowType.pageTitle, textAlign = TextAlign.Center) + } + } +} + +/** + * Naming the exercise: a field to type one, then yours (last used first) and the common lifts; or no name, so the sets + * are just numbered. + */ +@Composable +private fun ExerciseSheet(current: String?, used: List, onPick: (String?) -> Unit, onDismiss: () -> Unit) { + var typed by rememberSaveable { mutableStateOf("") } + PageSheet(onDismiss, "Exercise", note = "Picked once, it stays for the next sets.") { + BasicTextField( + typed, { typed = it }, Modifier.fillMaxWidth(), singleLine = true, + textStyle = GlowType.body.copy(color = Color.White), cursorBrush = SolidColor(Color.White), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (typed.isNotBlank()) onPick(typed) }), + decorationBox = { field -> + Row( + Modifier.fillMaxWidth().height(48.dp).clip(GlowLayout.TileShape).background(Raised).padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.weight(1f)) { + if (typed.isEmpty()) Text("Type a name", color = Color.White.copy(alpha = GlowType.CAPTION_ALPHA), style = GlowType.body) + field() + } + if (typed.isNotBlank()) Icon(AppIcons.Tick02, "Use this name", tint = Color.White, modifier = Modifier.size(28.dp).clip(CircleShape).pressClickable({ onPick(typed) }, CircleShape).padding(4.dp)) + } + }, + ) + val names = Strength.exerciseList(used).filter { typed.isBlank() || it.contains(typed.trim(), ignoreCase = true) } + Column { + names.forEachIndexed { i, name -> PageRow(name, value = if (name.equals(current, ignoreCase = true)) "Now" else null, divider = i > 0, onClick = { onPick(name) }) } + } + if (current != null) PageButton("No name, just number the sets", { onPick(null) }, Modifier.fillMaxWidth(), icon = AppIcons.X) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/AppNavState.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/AppNavState.kt new file mode 100644 index 00000000..b941bcbf --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/AppNavState.kt @@ -0,0 +1,408 @@ +package org.openstrap.app.ui.root + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.mapSaver +import androidx.compose.runtime.setValue +import java.time.LocalDate +import org.openstrap.app.NavRequest +import org.openstrap.app.ui.dashboard.Detail +import org.openstrap.app.ui.dashboard.Reselect +import org.openstrap.app.ui.dashboard.TabNav +import org.openstrap.app.ui.dashboard.openBeside + +/** The four tabs, in the bar's order. Their icons and colours are in [tabItems]. */ +enum class Tab(val label: String) { + Today("Today"), + Recovery("Recovery"), + Sleep("Sleep"), + Activity("Activity"), +} + +/** + * The pages that open over whichever tab is showing and close back to it (spec 3.3): the You page and what it opens, + * and the Strap page and what it opens. Shallowest first. + */ +enum class Page( + /** The row on the You or Strap page that opens this page, marked while it's open beside the list on a wide screen. */ + val row: String? = null, +) { + You, + Profile("Profile"), + HeartZones("Heart rate zones"), + HealthConnect("Health Connect"), + Backup("Backup"), + DeletedSleep("Deleted sleep"), + Updates("Updates"), + Diagnostics("Diagnostics"), + GoalsAndBests("Goals and bests"), + AllBadges, + WeeklyRecap, + Strap, + Alarms("Alarms"), + PhoneAlerts("Phone alerts"), + HealthAlerts("Health alerts"), + Firmware("Firmware"), + ZeppAccount("Zepp account"), +} + +/** Keeps the open pages across process death, as their names ("Strap,Alarms"). */ +val PagesSaver: Saver, String> = Saver( + save = { pages -> pages.joinToString(",") { it.name } }, + restore = { s -> s.split(',').mapNotNull { n -> Page.entries.firstOrNull { it.name == n } } }, +) + +/** The workout screens over the app before one starts: Choose, and Ready over it or on its own. */ +sealed interface WorkoutOverlay { + data object None : WorkoutOverlay + + /** Choose, the first screen of a workout (workouts spec 2.1). */ + data object Choose : WorkoutOverlay + + /** + * The ready screen for [sport], on its sport list when [picking] ("All sports"), with the rounds of a saved workout + * picked on Choose ([structure], null for last time's). [overChoose]: Choose is under it, so closing comes back + * there. [zones]: Your zones is open over it. + */ + data class Ready( + val sport: Int, + val picking: Boolean = false, + val structure: String? = null, + val overChoose: Boolean = false, + val zones: Boolean = false, + ) : WorkoutOverlay +} + +/** What [AppNavState.back] did. */ +sealed interface BackResult { + /** Something closed or the tab moved. */ + data object Handled : BackResult + + /** Today at its root: the caller returns to today's date, or lets the activity finish. */ + data object AtHome : BackResult +} + +/** + * Where the app is: the tab, the pages over it, each tab's details, the sheets and the workout screens before a + * workout. Plain state with no Android in it, so the rules for opening, closing and back are unit tested. Each field + * is its own state, so a change to one tab's stack recomposes only what reads that stack. + */ +@Stable +@Suppress("TooManyFunctions") // one function per way the app moves, so each screen's lambda names what it does +class AppNavState(initial: Snapshot = Snapshot()) { + /** Everything [AppNavState] holds, as plain values: for the saver and the tests. */ + data class Snapshot( + val tab: TabNav = TabNav(), + val pages: List = emptyList(), + val stacks: Map> = emptyMap(), + val allMetricsOpen: Boolean = false, + val editGoals: Boolean = false, + val addingOn: LocalDate? = null, + val savingStretch: LongRange? = null, + val workout: WorkoutOverlay = WorkoutOverlay.None, + ) + + /** Which tab shows and the one before it, for back. */ + var tab: TabNav by mutableStateOf(initial.tab) + private set + + /** The pages over the tab, deepest last. */ + var pages: List by mutableStateOf(initial.pages) + private set + + private val stackStates = Tab.entries.associateWith { mutableStateOf(initial.stacks[it].orEmpty()) } + + private val allMetricsState = mutableStateOf(initial.allMetricsOpen) + private val editGoalsState = mutableStateOf(initial.editGoals) + private val addingOnState = mutableStateOf(initial.addingOn) + private val savingState = mutableStateOf(initial.savingStretch) + + /** The All metrics sheet, from the grid button in the header: over the tab, never a place. */ + val allMetricsOpen: Boolean get() = allMetricsState.value + + /** The goals sheet, from the You page. */ + val editGoals: Boolean get() = editGoalsState.value + + /** The day a workout is being added to after the fact. */ + val addingOn: LocalDate? get() = addingOnState.value + + /** A stretch the strap noticed, by its start and end, being saved as a workout while its sport is chosen. */ + val savingStretch: LongRange? get() = savingState.value + + /** Choose and Ready, before a workout starts. */ + var workout: WorkoutOverlay by mutableStateOf(initial.workout) + private set + + /** The tab showing. */ + val current: Tab get() = Tab.entries[tab.current] + + /** Every tab's stack of details, deepest last. */ + val stacks: Map> get() = Tab.entries.associateWith(::stack) + + /** [tab]'s stack of details, deepest last. Back pops one. Opening a detail never moves you to another tab. */ + fun stack(tab: Tab): List = stackStates.getValue(tab).value + + /** Choose is open, on its own or under Ready. */ + /** Today at its root with nothing over it: where the Today hero shows the workout. */ + val atTodayRoot: Boolean get() = tab.current == Tab.Today.ordinal && stack(Tab.Today).isEmpty() && pages.isEmpty() + + val choosing: Boolean get() = workout.let { it == WorkoutOverlay.Choose || (it as? WorkoutOverlay.Ready)?.overChoose == true } + + /** Another tab: it becomes current, and back returns here. Every tab keeps its own stack. */ + fun select(tab: Tab) { + this.tab = this.tab.select(tab.ordinal) + } + + /** The pager came to rest on [page]: only a swipe the user made moves the tab ([TabNav.settled]). */ + fun settled(page: Int, swiped: Boolean) { + tab = tab.settled(page, swiped) + } + + /** A tap on the tab showing: the first scrolls to the top, the next pops to the root, which happens here. */ + fun reselect(tab: Tab): Reselect { + val (next, action) = this.tab.reselect(stack(tab).size) + this.tab = next + if (action == Reselect.PopToRoot) setStack(tab, emptyList()) + return action + } + + /** A screen opened or closed on the tab: the next tap on it scrolls before it pops. */ + fun moved() { + tab = tab.moved() + } + + /** Opens [page] over the tab, unless it's already on top. */ + fun openPage(page: Page) { + if (pages.lastOrNull() != page) pages = pages + page + } + + /** Closes the top page. */ + fun closePage() { + pages = pages.dropLast(1) + } + + /** Closes the pages over the tab, unless an update is being installed there. True if any closed. */ + fun closePages(firmwareInstalling: Boolean): Boolean { + if (pages.isEmpty() || firmwareInstalling) return false + pages = emptyList() + return true + } + + /** A row on a wide screen's list: its page beside the list, in place of the one there, unless an update installs. */ + fun openBeside(page: Page, firmwareInstalling: Boolean) { + if (!firmwareInstalling) pages = openBeside(pages, page) + } + + /** Opens [detail] on [tab]'s stack, unless it's already on top. */ + fun push(tab: Tab, detail: Detail) { + val stack = stack(tab) + if (stack.lastOrNull() != detail) setStack(tab, stack + detail) + } + + /** Closes [tab]'s top detail. */ + fun pop(tab: Tab) { + setStack(tab, stack(tab).dropLast(1)) + } + + /** A switcher pick: [detail] takes the top detail's place. */ + fun replaceTop(tab: Tab, detail: Detail) { + setStack(tab, stack(tab).dropLast(1) + detail) + } + + fun setStack(tab: Tab, stack: List) { + stackStates.getValue(tab).value = stack + } + + /** Ready for [sport], over Choose when it's open. Your zones stays as it was. */ + fun openReady(sport: Int, picking: Boolean = false, structure: String? = null) { + val zones = (workout as? WorkoutOverlay.Ready)?.zones == true + workout = WorkoutOverlay.Ready(sport, picking, structure, overChoose = choosing, zones = zones) + } + + /** Ready's own sport list picked another sport: only the sport changes. */ + fun readySport(sport: Int) { + workout = (workout as? WorkoutOverlay.Ready)?.copy(sport = sport) ?: WorkoutOverlay.Ready(sport, overChoose = choosing) + } + + /** Choose with every sport. Ready stays on top of it when it's open. */ + fun openChoose() { + workout = when (val w = workout) { + is WorkoutOverlay.Ready -> w.copy(overChoose = true) + else -> WorkoutOverlay.Choose + } + } + + /** Closes Ready, back to Choose when it was under it. */ + fun closeReady() { + workout = if (choosing) WorkoutOverlay.Choose else WorkoutOverlay.None + } + + /** Closes Choose. */ + fun closeChoose() { + workout = when (val w = workout) { + is WorkoutOverlay.Ready -> w.copy(overChoose = false) + else -> WorkoutOverlay.None + } + } + + /** A workout started: Ready and Choose close. */ + fun workoutStarted() { + workout = WorkoutOverlay.None + } + + /** Your zones, over Ready. */ + fun openZones() { + (workout as? WorkoutOverlay.Ready)?.let { workout = it.copy(zones = true) } + } + + fun closeZones() { + (workout as? WorkoutOverlay.Ready)?.let { workout = it.copy(zones = false) } + } + + fun setAllMetrics(open: Boolean) { + allMetricsState.value = open + } + + fun setEditGoals(open: Boolean) { + editGoalsState.value = open + } + + fun setAddingOn(date: LocalDate?) { + addingOnState.value = date + } + + fun setSavingStretch(stretch: LongRange?) { + savingState.value = stretch + } + + /** + * Where [request] takes the app. [recording]: a workout records, so Choose and Ready stay as they are. The day a + * request names, and the live screen [NavRequest.ShowWorkout] brings back, are the caller's. + */ + fun apply(request: NavRequest, recording: Boolean, firmwareInstalling: Boolean) { + when (request) { + is NavRequest.OpenReady -> if (!recording) openReady(request.sport, picking = false, structure = keptStructure()) + NavRequest.OpenChoose -> if (!recording) openChoose() + is NavRequest.OpenWorkout -> { + closePages(firmwareInstalling) + setStack(Tab.Activity, listOf(Detail.Workout(request.start))) + select(Tab.Activity) + } + is NavRequest.OpenDetail -> Detail.fromRoute(request.route)?.let { detail -> + closePages(firmwareInstalling) + setAllMetrics(false) + push(current, detail) + } + NavRequest.OpenToday -> { + closePages(firmwareInstalling) + setAllMetrics(false) + if (!recording) workout = WorkoutOverlay.None + setStack(Tab.Today, emptyList()) + select(Tab.Today) + } + NavRequest.OpenStrap -> over(Page.Strap, listOf(Page.Strap), firmwareInstalling) + NavRequest.OpenRecap -> over(Page.WeeklyRecap, listOf(Page.You, Page.GoalsAndBests, Page.WeeklyRecap), firmwareInstalling) + NavRequest.OpenUpdates -> over(Page.Updates, listOf(Page.You, Page.Updates), firmwareInstalling) + NavRequest.ShowWorkout -> Unit + } + } + + /** A request for Ready while it's open keeps the rounds it shows, as the sport's own tap would. */ + private fun keptStructure(): String? = (workout as? WorkoutOverlay.Ready)?.structure + + /** [top] over the tab, as [stack], in place of the pages there: unless it's on top already or an update installs. */ + private fun over(top: Page, stack: List, firmwareInstalling: Boolean) { + setAllMetrics(false) + if (pages.lastOrNull() != top && (pages.isEmpty() || closePages(firmwareInstalling))) pages = stack + } + + /** + * What back does, highest first, as the screens' own back handlers do it: Your zones, Ready, then Choose (not while + * a workout records, which hides them), the sport being picked for a stretch, the day a workout is being added to, + * the All metrics and goals sheets, the top page (Firmware while an update installs keeps it), the tab's top + * detail, then the tab before. Today at its root is [BackResult.AtHome]. + */ + fun back(firmwareInstalling: Boolean, recording: Boolean = false): BackResult { + val w = workout + when { + !recording && w is WorkoutOverlay.Ready -> if (w.zones) closeZones() else closeReady() + !recording && w == WorkoutOverlay.Choose -> closeChoose() + savingStretch != null -> setSavingStretch(null) + addingOn != null -> setAddingOn(null) + allMetricsOpen -> setAllMetrics(false) + editGoals -> setEditGoals(false) + pages.isNotEmpty() -> if (!(pages.last() == Page.Firmware && firmwareInstalling)) closePage() + stack(current).isNotEmpty() -> pop(current) + else -> { + tab = tab.back() ?: return BackResult.AtHome + } + } + return BackResult.Handled + } + + fun snapshot(): Snapshot = Snapshot(tab, pages, stacks, allMetricsOpen, editGoals, addingOn, savingStretch, workout) + + companion object { + private const val TAB = "tab" + private const val PAGES = "pages" + private const val STACK = "stack." + private const val ALL_METRICS = "allMetrics" + private const val EDIT_GOALS = "editGoals" + private const val ADDING_ON = "addingOn" + private const val SAVING = "saving" + private const val CHOOSE = "choose" + private const val READY_SPORT = "readySport" + private const val READY_PICKING = "readyPicking" + private const val ZONES = "zones" + private const val READY_STRUCTURE = "readyStructure" + + /** + * Keeps the place across process death, with the tab, page and stack encoders that were there before, so what + * survived then survives now. Anything unreadable restores as the default. + */ + val Saver: Saver = mapSaver( + save = { nav -> save(nav.snapshot()) }, + restore = { map -> AppNavState(restore(map)) }, + ) + + private fun SaverScope.save(s: Snapshot): Map = buildMap { + put(TAB, with(TabNav.Saver) { save(s.tab) }) + put(PAGES, with(PagesSaver) { save(s.pages) }) + s.stacks.forEach { (tab, stack) -> put(STACK + tab.name, with(Detail.StackSaver) { save(stack) }) } + put(ALL_METRICS, s.allMetricsOpen) + put(EDIT_GOALS, s.editGoals) + s.addingOn?.let { put(ADDING_ON, it.toEpochDay()) } + s.savingStretch?.let { put(SAVING, longArrayOf(it.first, it.last)) } + val w = s.workout + put(CHOOSE, w == WorkoutOverlay.Choose || (w as? WorkoutOverlay.Ready)?.overChoose == true) + if (w is WorkoutOverlay.Ready) { + put(READY_SPORT, w.sport) + put(READY_PICKING, w.picking) + put(ZONES, w.zones) + w.structure?.let { put(READY_STRUCTURE, it) } + } + } + + private fun restore(map: Map): Snapshot { + val choose = map[CHOOSE] as? Boolean == true + val workout = (map[READY_SPORT] as? Int)?.let { sport -> + WorkoutOverlay.Ready( + sport, map[READY_PICKING] as? Boolean == true, map[READY_STRUCTURE] as? String, choose, map[ZONES] as? Boolean == true, + ) + } ?: if (choose) WorkoutOverlay.Choose else WorkoutOverlay.None + return Snapshot( + tab = (map[TAB] as? IntArray)?.let(TabNav.Saver::restore) ?: TabNav(), + pages = (map[PAGES] as? String)?.let(PagesSaver::restore).orEmpty(), + stacks = Tab.entries.associateWith { t -> (map[STACK + t.name] as? String)?.let(Detail.StackSaver::restore).orEmpty() }, + allMetricsOpen = map[ALL_METRICS] as? Boolean == true, + editGoals = map[EDIT_GOALS] as? Boolean == true, + addingOn = (map[ADDING_ON] as? Long)?.let(LocalDate::ofEpochDay), + savingStretch = (map[SAVING] as? LongArray)?.takeIf { it.size == 2 }?.let { it[0]..it[1] }, + workout = workout, + ) + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/AppRootViewModel.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/AppRootViewModel.kt new file mode 100644 index 00000000..29a4bcce --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/AppRootViewModel.kt @@ -0,0 +1,269 @@ +package org.openstrap.app.ui.root + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.openstrap.app.data.HealthRepository +import org.openstrap.app.data.HeartZones +import org.openstrap.app.data.Rewards +import org.openstrap.app.data.RewardsKeeper +import org.openstrap.app.data.SportSettings +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.catching +import org.openstrap.app.data.db.HealthDao +import org.openstrap.app.data.db.StrapReadinessRow +import org.openstrap.app.data.settings.Goals +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.StrapController +import org.openstrap.app.device.SyncRecord +import org.openstrap.app.device.SyncState +import org.openstrap.app.device.WorkoutSession +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.health.HealthConnectExporter +import org.openstrap.app.presentation.RewardsView +import org.openstrap.app.presentation.SummaryRewards +import org.openstrap.app.presentation.summary +import org.openstrap.app.presentation.view +import org.openstrap.app.ui.dashboard.TodayEdits +import org.openstrap.app.ui.workout.LiveGlance +import org.openstrap.app.ui.workout.liveGlance +import org.openstrap.app.update.AppUpdater +import java.time.LocalDate +import java.time.ZoneId + +/** What the store and the strap say about the strap, for the header chip, Today and the pairing stage. */ +data class StrapFacts( + /** Paired until the store says otherwise, so a paired strap never flashes the pairing hero or "Not paired" on start. */ + val pairedKnown: Boolean, + val connection: ConnectionState, + val sync: SyncState, + val syncRecord: SyncRecord, +) + +/** The first real score's day (E4), once the store has answered. */ +sealed interface FirstScoreDay { + /** The store hasn't answered: nothing is decided yet. */ + data object Loading : FirstScoreDay + + data object None : FirstScoreDay + + data class Day(val date: LocalDate) : FirstScoreDay +} + +/** The day shown's own extras: the strap's score for it and the check-in. */ +data class DayExtras(val strapReadiness: StrapReadinessRow? = null, val checkIn: Int? = null) + +/** A badge past history earned, with the weekly goal it was earned at when it is a goal badge. */ +data class EarnedBadge(val badge: Rewards.Badge, val goal: Int?) + +/** The celebrations switches: the goal moment, the weekly recap, and the week the goal moment last showed. */ +data class CelebrationSettings(val goalMoment: Boolean = true, val weeklyRecap: Boolean = true, val momentWeek: Long? = null) + +/** What Health Connect needs before a copy or a restore. */ +sealed interface HcState { + data object Unavailable : HcState + + data object Granted : HcState + + data class Needs(val permissions: Set) : HcState +} + +/** + * Everything the app root reads from the data and writes to it (audit A1): the strap, the settings it shows and saves, + * the rewards, the first score and the Health Connect checks. Where the app is lives in [AppNavState], and the + * permission launchers, which need the activity, stay in the composables. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("TooManyFunctions", "LongParameterList") // one function per write the root makes, from the pieces it reads +class AppRootViewModel( + private val settings: SettingsStore, + private val health: HealthRepository, + dao: HealthDao, + strap: StrapController, + workout: WorkoutSession, + private val rewards: RewardsKeeper, + private val healthConnect: HealthConnectExporter, + updater: AppUpdater, + /** Every workout, the one merged list ([org.openstrap.app.ui.workout.WorkoutsViewModel.workouts]). */ + private val workouts: StateFlow>, + /** Where the rewards are worked out, off the main thread. */ + private val work: CoroutineDispatcher = Dispatchers.Default, +) : ViewModel() { + private fun Flow.state(initial: T): StateFlow = stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_MS), initial) + + val strap: StateFlow = combine( + settings.pairedDevice.map { it != null }.onStart { emit(true) }, + strap.connection, + strap.sync, + strap.syncRecord, + ::StrapFacts, + ).distinctUntilChanged().state(StrapFacts(true, strap.connection.value, strap.sync.value, strap.syncRecord.value)) + + val liveHeartRate: StateFlow = strap.liveHeartRate + val liveEnabled: StateFlow = strap.liveHeartRateEnabled + val goals: StateFlow = settings.goals.state(Goals()) + + /** Today's hidden and pinned cards, parsed once per change: a new object each time would recompose every tab. */ + val todayEdits: StateFlow = combine(settings.todayHiddenNames, settings.todayPinnedNames) { h, p -> TodayEdits.parse(h, p) } + .state(TodayEdits.parse(null, null)) + val habitAgeSeenAsOf: StateFlow = settings.habitAgeSeenAsOf.state(null) + val firstScoreDay: StateFlow = settings.firstScoreDay + .map { day -> day?.let { FirstScoreDay.Day(LocalDate.ofEpochDay(it)) } ?: FirstScoreDay.None } + .state(FirstScoreDay.Loading) + val updateWaiting: StateFlow = updater.waiting + val updateAuto: StateFlow = settings.updateAuto.state(true) + val workoutSides: StateFlow> = settings.workoutSides.state(emptyMap()) + val sportSettings: StateFlow> = settings.sportSettings.state(emptyMap()) + val workoutLiveView: StateFlow = settings.workoutLiveView.state(null) + val celebrations: StateFlow = + combine(settings.goalMoment, settings.weeklyRecap, settings.goalMomentWeek, ::CelebrationSettings).state(CelebrationSettings()) + + /** The workout at a glance, for the Today hero and the floating bar (workouts spec 2.10). */ + val liveGlance: StateFlow = liveGlance(workout, strap.liveHeartRate, strap.connection).state(null) + + /** The badges past history earned, shown once all together (requests spec 7.2). Null while none wait. */ + val historyBadges: StateFlow?> = settings.historyBadgesWaiting.flatMapLatest { waiting -> + if (!waiting) flowOf(null) + else combine(dao.badges(), dao.weekResults()) { earned, weeks -> + earned.mapNotNull { row -> + Rewards.badge(row.id)?.let { EarnedBadge(it, Rewards.goalAt(row, weeks, rewards.firstDay, ZoneId.systemDefault())) } + } + } + }.state(null) + + /** Goals and bests, All badges and the recap (workouts spec 3.5), once there are workouts. */ + val rewardsView: StateFlow = + combine(workouts, goals.map { it.workoutsPerWeek }, dao.personalBests(), dao.badges()) { list, goal, bests, earned -> + RewardsInputs(list, goal, bests, earned) + }.filter { it.workouts.isNotEmpty() } + .mapLatest { i -> catching { rewards.view(i.workouts, i.goal, i.bests, i.earned) }.getOrNull() } + .flowOn(work) + .state(null) + + private class RewardsInputs( + val workouts: List, + val goal: Int, + val bests: List, + val earned: List, + ) + + private val shownDay = MutableStateFlow?>(null) + + /** The day shown's strap score and check-in, from [dayShown]. */ + val dayExtras: StateFlow = shownDay.filterNotNull().flatMapLatest { (date, dayStart) -> + combine(dao.strapReadiness(dayStart), health.checkIn(date)) { r, c -> DayExtras(r, c?.feeling) } + }.state(DayExtras()) + + init { + // The weekly goal, bests and badges follow the workouts, worked out again on a new one, a trim or a delete + // (workouts spec 4), and on a new goal. The one recording isn't in it until it ends. Started at once, as the + // weekly recap notification reads what's stored. + val recording = workout.state.map { (it as? WorkoutState.Recording)?.workout?.start }.distinctUntilChanged() + viewModelScope.launch(work) { + combine(workouts, recording, settings.goals) { list, start, g -> + list.filter { it.workout.start != start } to g.workoutsPerWeek + } + .distinctUntilChangedBy { (list, goal) -> list.map { Triple(it.workout.start, it.shownStart, it.end) } to goal } + .collectLatest { (list, goal) -> catching { rewards.refresh(list, goal) }.onFailure { Log.w(TAG, "rewards failed", it) } } + } + } + + /** The day shown moved: [dayExtras] follows it. */ + fun dayShown(date: LocalDate, dayStart: Long) { + shownDay.value = date to dayStart + } + + private fun write(what: String, block: suspend () -> Unit) { + viewModelScope.launch { catching { block() }.onFailure { Log.w(TAG, "$what failed", it) } } + } + + /** Opening Habit Age marks the estimate seen, so Today stops offering it. */ + fun seeHabitAge(asOf: Long) = write("habit age") { settings.setHabitAgeSeen(asOf) } + + fun markFirstScore(day: LocalDate) = write("first score") { settings.setFirstScoreDay(day.toEpochDay()) } + + fun saveCheckIn(date: LocalDate, feeling: Int?) = write("check-in") { health.saveCheckIn(date, feeling) } + + fun saveTodayEdits(edits: TodayEdits) = + write("today edits") { settings.saveTodayEdits(edits.hidden.map { it.name }, edits.pinned.map { it.name }) } + + /** The goals. The widgets that show them follow the store. */ + fun saveGoals(goals: Goals) = write("goals") { settings.saveGoals(goals) } + + fun saveSportSettings(sport: Int, saved: SportSettings) = write("sport settings") { settings.setSportSettings(sport, saved) } + + /** The Outdoor or Indoor side Ready opens on. The Start widget follows the store. */ + fun setWorkoutSide(sport: Int, indoor: Boolean) = write("workout side") { settings.setWorkoutSide(sport, indoor) } + + fun setWorkoutLiveView(view: Int) = write("live view") { settings.setWorkoutLiveView(view) } + + fun setGoalMoment(on: Boolean) = write("goal moment") { settings.setGoalMoment(on) } + + fun setWeeklyRecap(on: Boolean) = write("weekly recap") { settings.setWeeklyRecap(on) } + + fun setGoalMomentWeek(week: Long) = write("goal moment week") { settings.setGoalMomentWeek(week) } + + /** Your max, or null to go back to the one the app works out. */ + fun setMaxHeartRate(bpm: Int?) = write("max heart rate") { settings.setMaxHeartRate(bpm) } + + /** In use before it's stored, so a quick second tap moves on from this one. */ + fun setHeartZones(zones: HeartZones) { + HeartZones.use(zones) + write("heart zones") { settings.setHeartZones(zones) } + } + + fun historyBadgesShown() = write("history badges") { settings.historyBadgesShown() } + + /** The calorie goal your last four weeks suggest (calories spec 3.4), null when it can't be worked out. */ + suspend fun suggestedCalorieGoal(): Int? = catching { health.suggestedCalorieGoal() }.getOrNull() + + /** What the workout starting at [start] earned, from every workout and the weekly goal (workouts spec 2.8). */ + suspend fun summaryRewards(start: Long): SummaryRewards? = + catching { rewards.summary(start, workouts.value, goals.value.workoutsPerWeek) }.getOrNull() + + /** The manual copy and auto sync write with these. The calorie ones are optional but asked for until granted. */ + suspend fun healthConnectWrite(): HcState = when { + !healthConnect.isAvailable() -> HcState.Unavailable + healthConnect.hasRequestedPermissions() -> HcState.Granted + else -> HcState.Needs(healthConnect.requestedPermissions) + } + + suspend fun healthConnectRead(): HcState = when { + !healthConnect.isAvailable() -> HcState.Unavailable + healthConnect.hasReadPermissions() -> HcState.Granted + else -> HcState.Needs(healthConnect.readPermissions) + } + + /** The needed ones may have been granted before, outside what the permission screen returns. */ + suspend fun healthConnectWriteGranted(result: Set): Boolean = + result.containsAll(healthConnect.permissions) || catching { healthConnect.hasPermissions() }.getOrDefault(false) + + fun healthConnectReadGranted(result: Set): Boolean = result.any { it in healthConnect.readPermissions } + + private companion object { + const val TAG = "OpenStrap" + const val STOP_MS = 5_000L + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/DebugPreviews.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/DebugPreviews.kt new file mode 100644 index 00000000..89bf17c8 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/DebugPreviews.kt @@ -0,0 +1,21 @@ +package org.openstrap.app.ui.root + +import kotlinx.coroutines.flow.MutableStateFlow +import org.openstrap.app.data.Moment +import org.openstrap.app.ui.dashboard.DataPreview +import org.openstrap.app.ui.dashboard.StrapPreview + +/** + * Debug: what the screens are shown in place of the real state, set by the activity's debug extras. Release builds + * never set them, so they stay null there. + */ +class DebugPreviews { + /** The moment forced by the `moment` extra, null to follow the clock. */ + val moment = MutableStateFlow(null) + + /** The strap state previewed by the `strap_state` extra, null for the real one. */ + val strap = MutableStateFlow(null) + + /** The data state previewed by the `data_state` extra, null for the real one. */ + val data = MutableStateFlow(null) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/DetailHost.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/DetailHost.kt new file mode 100644 index 00000000..02b78bf2 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/DetailHost.kt @@ -0,0 +1,157 @@ +// The workout detail takes the workouts view model for its summary and delete, as it did inside AppRoot. +@file:Suppress("ViewModelForwarding", "ComposeViewModelForwarding") + +package org.openstrap.app.ui.root + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import kotlinx.coroutines.flow.flowOf +import org.openstrap.app.AppContainer +import org.openstrap.app.data.DayRepository +import org.openstrap.app.data.EnergyExpenditure +import org.openstrap.app.data.RecoveryResult +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.settings.UserProfile +import org.openstrap.app.device.WorkoutSession +import org.openstrap.app.ui.dashboard.DayViewModel +import org.openstrap.app.ui.dashboard.Detail +import org.openstrap.app.ui.dashboard.Score +import org.openstrap.app.ui.glow.ActivityDetailContext +import org.openstrap.app.ui.glow.GlowHealthspanScreen +import org.openstrap.app.ui.glow.GlowMetricDetailScreen +import org.openstrap.app.ui.glow.GlowRecoveryScreen +import org.openstrap.app.ui.glow.GlowScoreDetailScreen +import org.openstrap.app.ui.glow.GlowSleepScreen +import org.openstrap.app.ui.glow.GlowWorkoutDetailScreen +import org.openstrap.app.ui.glow.GlowWorkoutsScreen +import org.openstrap.app.ui.glow.PageHeader +import org.openstrap.app.ui.glow.SleepEditing +import org.openstrap.app.ui.glow.WeekDetail +import org.openstrap.app.ui.glow.dayName +import org.openstrap.app.ui.workout.WorkoutsViewModel + +/** + * What a tab's stack shows on top: the same screens on every tab, each pushing onto the stack it's on. A switcher pick + * takes the detail's place, anything else it opens goes on top. + */ +@Composable +internal fun DetailHost( + tab: Tab, + detail: Detail, + nav: AppNavState, + shown: ShownDay, + histories: Histories, + container: AppContainer, + dayVm: DayViewModel, + workoutsVm: WorkoutsViewModel, + live: LiveHeart, + profile: UserProfile, + padding: PaddingValues, + onOpenProfile: () -> Unit, + seeHabitAge: () -> Unit, +) { + val day = shown.day + val pop: () -> Unit = { nav.pop(tab) } + val push: (Detail) -> Unit = { nav.push(tab, it) } + val onSwitch: (Detail) -> Unit = { nav.replaceTop(tab, it) } + val workouts by workoutsVm.workouts.collectAsStateWithLifecycle() + val activity by dayVm.activityHistory.collectAsStateWithLifecycle() + when (detail) { + is Detail.OfMetric -> GlowMetricDetailScreen( + detail.metric, detail.night, day, histories.daily, live.bpm, live.enabled, live.canUse, live.toggle, + push, onSwitch, dayVm::select, padding, pop, + ) + is Detail.OfScore -> GlowScoreDetailScreen( + detail.score, day, shown.recovery, shown.strapScore, histories.daily, + onOpen = push, onSwitch = onSwitch, onSelectDay = dayVm::select, + contentPadding = padding, onBack = pop, + workouts = workouts, activity = activity, onEditProfile = onOpenProfile, + sleepEditing = SleepEditing(dayVm::setSleepTimes, dayVm::deleteSleep, dayVm::restoreSleep), + ) + Detail.Week -> WeekDetail( + ActivityDetailContext( + day, shown.recovery?.takeIf { it.date == day.date }, workouts, activity, histories.daily, onOpen = push, onSwitch = {}, + onSelectDay = dayVm::select, onEditProfile = onOpenProfile, contentPadding = padding, onBack = pop, + ), + ) + is Detail.Workout -> { + val openWorkout by workoutsVm.open.collectAsStateWithLifecycle() + WorkoutDetail( + openWorkout?.takeIf { it.workout.start == detail.start }, padding, onBack = pop, + onDelete = { container.workout.discard(detail.start); pop() }, + workoutsVm = workoutsVm, session = container.workout, + bmrPerMinute = EnergyExpenditure.bmrPerMinute(profile, profile.age()), + recovery = shown.recovery, days = container.days, + // Its day's Calories: the day shown moves to it, as a strip pick does. + onOpenDay = { d -> dayVm.select(d); push(Detail.OfScore(Score.Energy)) }, + ) + } + Detail.AllWorkouts -> GlowWorkoutsScreen(workouts, padding, onOpen = { push(Detail.Workout(it)) }, onBack = pop) + Detail.Healthspan -> GlowHealthspanScreen(shown.healthspan, padding, onBack = pop) + // The Recovery and Sleep tabs' screens, opened over another tab from a card about them. + Detail.Recovery -> { + BackHandler(onBack = pop) + GlowRecoveryScreen( + day, shown.recovery, histories.daily, + header = { PageHeader("Recovery", pop, subtitle = dayName(day.date)) }, + contentPadding = padding, onOpenMetric = { push(Detail.OfMetric(it)) }, + onOpenScore = { push(Detail.OfScore(it)) }, strapScore = shown.strapScore, + healthspan = shown.healthspan, onOpenHealthspan = { seeHabitAge(); push(Detail.Healthspan) }, onOpenProfile = onOpenProfile, + recoveryFailure = shown.recoveryFailure, onRetryRecovery = dayVm::retryRecovery, + wear = shown.wear, firstScore = shown.firstScore, + ) + } + Detail.Sleep -> { + BackHandler(onBack = pop) + GlowSleepScreen( + day, header = { PageHeader("Sleep", pop, subtitle = dayName(day.date)) }, + contentPadding = padding, onOpen = push, recovery = shown.recovery, onRestore = dayVm::restoreSleep, + ) + } + } +} + +/** A workout's detail: one page for the strap's and the phone's ([GlowWorkoutDetailScreen]). */ +@Composable +private fun WorkoutDetail( + a: WorkoutAnalysis?, + contentPadding: PaddingValues, + onBack: () -> Unit, + onDelete: () -> Unit, + workoutsVm: WorkoutsViewModel, + session: WorkoutSession, + bmrPerMinute: Float?, + recovery: RecoveryResult?, + days: DayRepository, + onOpenDay: (LocalDate) -> Unit, +) { + val workouts by workoutsVm.workouts.collectAsStateWithLifecycle() + // OpenStrap's own recordings also get their summary (route, laps, sets, the recording for trim) and are deleted + // through the session. The strap's are deleted and remembered, so the next sync doesn't bring them back. + val start = a?.workout?.start + val fromPhone = a?.fromPhone == true + val summary by remember(start, fromPhone) { + if (start != null && fromPhone) workoutsVm.phoneSummary(start) else flowOf(null) + }.collectAsStateWithLifecycle(null) + val usualDrop by produceState(null, start, fromPhone) { value = start?.takeIf { !fromPhone }?.let { workoutsVm.usualDrop(it) } } + // The workout's day: its active kcal as just worked out when it's the day shown, else as stored. + val date = a?.let { Instant.ofEpochSecond(it.shownStart).atZone(ZoneId.systemDefault()).toLocalDate() } + val stored by remember(date, days) { + date?.let { d -> days.dailyEnergy(d, d) } ?: flowOf(emptyMap()) + }.collectAsStateWithLifecycle(emptyMap()) + val dayActive = recovery?.takeIf { it.date == date }?.energy?.activeKcal ?: date?.let { stored[it]?.activeKcal } + GlowWorkoutDetailScreen( + a, summary, workouts, usualDrop, contentPadding, onBack, + onDelete = if (fromPhone) onDelete else ({ start?.let(workoutsVm::deleteStrapWorkout); onBack() }), + onTrim = { from, to -> start?.let { session.trim(it, from, to) } }, onUndoTrim = { start?.let(session::undoTrim) }, + bmrPerMinute = bmrPerMinute, dayActiveKcal = dayActive, onOpenDay = date?.let { d -> { onOpenDay(d) } }, + ) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/HealthConnectActions.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/HealthConnectActions.kt new file mode 100644 index 00000000..42e1e180 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/HealthConnectActions.kt @@ -0,0 +1,136 @@ +// The message effect only shows a toast and clears the message, once per message, as it did in AppRoot. +@file:Suppress("UnnecessaryLaunchedEffect") + +package org.openstrap.app.ui.root + +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.health.connect.client.PermissionController +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.openstrap.app.ui.device.BackupCopy +import org.openstrap.app.ui.device.DeviceViewModel +import org.openstrap.app.ui.device.PickBackup + +/** The device view model's messages, each shown once as a toast. */ +@Composable +internal fun DeviceMessages(deviceVm: DeviceViewModel) { + val context = LocalContext.current + val message by deviceVm.message.collectAsStateWithLifecycle() + LaunchedEffect(message) { + message?.let { + Toast.makeText(context, it, Toast.LENGTH_LONG).show() + deviceVm.clearMessage() + } + } +} + +/** The Health Connect page's actions, each with the permissions it needs, asked for first when missing. */ +internal class HealthConnectActions( + /** Restore from Health Connect. */ + val import: () -> Unit, + /** The manual copy. */ + val export: () -> Unit, + /** Auto sync on or off. */ + val auto: (Boolean) -> Unit, +) + +internal const val HC_UNAVAILABLE = "Health Connect isn't available on this phone." +private const val HC_NOT_GRANTED = "Health Connect permissions were not granted." + +/** + * The Health Connect decisions are the root view model's, the launchers stay here because they need the activity. + * The write permissions are asked for the manual copy and for turning auto sync on, and which one asked survives the + * activity being recreated behind the permission screen. + */ +@Composable +internal fun rememberHealthConnectActions(rootVm: AppRootViewModel, deviceVm: DeviceViewModel): HealthConnectActions { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var hcForAuto by rememberSaveable { mutableStateOf(false) } + val hcLauncher = rememberLauncherForActivityResult(PermissionController.createRequestPermissionResultContract()) { granted -> + val auto = hcForAuto + hcForAuto = false + scope.launch { + // The needed ones may have been granted before, outside what this screen returns. The calorie ones may stay off. + when { + !rootVm.healthConnectWriteGranted(granted) -> Toast.makeText(context, HC_NOT_GRANTED, Toast.LENGTH_LONG).show() + auto -> deviceVm.setAutoHealthConnect(true) + else -> deviceVm.exportHealthConnect() + } + } + } + val hcReadLauncher = rememberLauncherForActivityResult(PermissionController.createRequestPermissionResultContract()) { granted -> + if (rootVm.healthConnectReadGranted(granted)) deviceVm.importHealthConnect() + else Toast.makeText(context, HC_NOT_GRANTED, Toast.LENGTH_LONG).show() + } + val import: () -> Unit = { + scope.launch { + when (val hc = rootVm.healthConnectRead()) { + HcState.Unavailable -> Toast.makeText(context, HC_UNAVAILABLE, Toast.LENGTH_LONG).show() + HcState.Granted -> deviceVm.importHealthConnect() + is HcState.Needs -> hcReadLauncher.launch(hc.permissions) + } + } + } + // The manual copy, or turning auto sync on ([forAuto]): with the write permissions, asked for first when missing. + val write: (Boolean) -> Unit = { forAuto -> + scope.launch { + // The calorie permissions are optional, but asked for again until granted: the launcher goes on with the + // ones it needs. + when (val hc = rootVm.healthConnectWrite()) { + HcState.Unavailable -> Toast.makeText(context, HC_UNAVAILABLE, Toast.LENGTH_LONG).show() + HcState.Granted -> if (forAuto) deviceVm.setAutoHealthConnect(true) else deviceVm.exportHealthConnect() + is HcState.Needs -> { + hcForAuto = forAuto + hcLauncher.launch(hc.permissions) + } + } + } + } + return HealthConnectActions( + import = import, + export = { write(false) }, + auto = { on -> if (on) write(true) else deviceVm.setAutoHealthConnect(false) }, + ) +} + +/** The Backup page's pickers: the zip saved by hand, automatic backup's folder, and the backup to restore. */ +internal class BackupActions( + val saveCopy: () -> Unit, + /** Opens the folder picker on [folder], or on Documents to turn backup on. A cancel leaves it as it was. */ + val chooseFolder: (Uri?) -> Unit, + val turnOn: () -> Unit, + /** Restore, opening on [folder] or Download. */ + val restore: (Uri?) -> Unit, +) + +/** The launchers stay with the app root, composed all the time, so an answer after a fold still arrives. */ +@Composable +internal fun rememberBackupActions(deviceVm: DeviceViewModel): BackupActions { + val csvLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/zip")) { uri -> + uri?.let(deviceVm::exportCsv) + } + val folderLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + uri?.let(deviceVm::chooseBackupFolder) + } + val restoreLauncher = rememberLauncherForActivityResult(PickBackup()) { uri -> + uri?.let(deviceVm::importCsv) + } + return BackupActions( + saveCopy = { csvLauncher.launch("openstrap-export.zip") }, + chooseFolder = folderLauncher::launch, + turnOn = { folderLauncher.launch(BackupCopy.DOCUMENTS) }, + restore = restoreLauncher::launch, + ) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/NavRequestsEffect.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/NavRequestsEffect.kt new file mode 100644 index 00000000..e108e69a --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/NavRequestsEffect.kt @@ -0,0 +1,35 @@ +package org.openstrap.app.ui.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import org.openstrap.app.NavRequest +import org.openstrap.app.NavigationRequests +import org.openstrap.app.ui.dashboard.Detail + +/** + * What a notification, a widget, the tile or a shortcut asked for, each once and in the order asked. A request sent + * before the first frame waits for it. + */ +@Composable +internal fun NavRequestsEffect(requests: NavigationRequests, nav: AppNavState, hooks: NavRequestHooks) { + val current by rememberUpdatedState(hooks) + LaunchedEffect(requests, nav) { + requests.requests.collect { r -> + val h = current + val firmware = Page.Firmware in nav.pages + val before = nav.pages + nav.apply(r, h.recording(), h.firmwareInstalling()) + if (firmware && Page.Firmware !in nav.pages) h.firmwareClosed() + when (r) { + // A health alert or body-signal notification: its metric's screen, for the alert's day. + is NavRequest.OpenDetail -> if (Detail.fromRoute(r.route) != null) r.day?.let(h.selectDay) + NavRequest.OpenToday -> h.today() + NavRequest.OpenStrap -> if (nav.pages != before && nav.pages == listOf(Page.Strap)) h.strapOpened() + NavRequest.ShowWorkout -> h.showWorkout() + else -> Unit + } + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/PagesOverTab.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/PagesOverTab.kt new file mode 100644 index 00000000..6bc3185b --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/PagesOverTab.kt @@ -0,0 +1,423 @@ +// The pages over the tab read the device, firmware, day and workouts view models they show, as they did inside AppRoot. +// Their keyed effects run once per key after the frame commits, as they did there. A SideEffect would run on every +// recomposition. +@file:Suppress("ViewModelForwarding", "ComposeViewModelForwarding", "UnnecessaryLaunchedEffect") + +package org.openstrap.app.ui.root + +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigationevent.NavigationEventDispatcherOwner +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import org.openstrap.app.AppContainer +import org.openstrap.app.PendingAction +import org.openstrap.app.data.HeartZones +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.SampleData +import org.openstrap.app.data.settings.UserProfile +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.alarms.AlarmsScreen +import org.openstrap.app.ui.alarms.AlarmsViewModel +import org.openstrap.app.ui.alarms.HealthAlertsScreen +import org.openstrap.app.ui.alarms.PhoneAlertsScreen +import org.openstrap.app.ui.components.GrowFromButton +import org.openstrap.app.ui.components.GrowProgress +import org.openstrap.app.ui.components.ListDetail +import org.openstrap.app.ui.components.NavRailWidth +import org.openstrap.app.ui.components.OledSurfaces +import org.openstrap.app.ui.components.PushContent +import org.openstrap.app.ui.components.SidePaneWidth +import org.openstrap.app.ui.dashboard.StrapView +import org.openstrap.app.ui.device.BackupPage +import org.openstrap.app.ui.device.DeletedSleepPage +import org.openstrap.app.ui.device.DeviceScreen +import org.openstrap.app.ui.device.DiagnosticsScreen +import org.openstrap.app.ui.device.HealthConnectPage +import org.openstrap.app.ui.device.ProfileScreen +import org.openstrap.app.ui.device.UpdatesPage +import org.openstrap.app.ui.device.YouPage +import org.openstrap.app.ui.device.openHealthConnect +import org.openstrap.app.ui.firmware.FirmwareScreen +import org.openstrap.app.ui.firmware.ZeppAccountScreen +import org.openstrap.app.ui.glow.GlowLayout +import org.openstrap.app.update.Releases + +/** + * The pages over the tab: the You page and the Strap page, and what they open. They grow out of the button that + * opened them and shrink back into it, drawn as they were while they go, and meanwhile back and taps belong to the tab + * again. + */ +@Composable +internal fun PagesOverTab( + nav: AppNavState, + hooks: PageHooks, + requestBluetooth: (() -> Unit) -> Unit, + strap: StrapHeader, + previewing: Boolean, + padding: PaddingValues, + rail: Boolean, + growFrom: Rect?, + pageGrow: GrowProgress, + navGlass: HazeState, + idleBack: NavigationEventDispatcherOwner, + openPage: (Page) -> Unit, + closePages: () -> Boolean, + firmwareInstalling: () -> Boolean, +) { + val keptPages = remember { arrayOf(nav.pages) } + if (nav.pages.isNotEmpty()) keptPages[0] = nav.pages + val tabBack = LocalNavigationEventDispatcherOwner.current + val profile by hooks.deviceVm.profile.collectAsStateWithLifecycle() + GrowFromButton( + nav.pages.isNotEmpty(), growFrom, + Modifier.fillMaxSize().padding(start = if (rail) NavRailWidth else 0.dp), + progress = pageGrow, + ) { closing -> + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).hazeSource(navGlass, zIndex = 1f)) { + CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides (if (closing || tabBack == null) idleBack else tabBack)) { + OledSurfaces { + OverPages( + nav.pages.ifEmpty { keptPages[0] }, hooks, requestBluetooth, profile, padding, strap.view.lastSeen, + strap.view.takeIf { previewing }, + open = openPage, close = nav::closePage, + // A row on a wide screen's list: its page beside the list, in place of the one there, unless + // an update is being installed there. + openBeside = { p -> nav.openBeside(p, firmwareInstalling()) }, + closeAll = { closePages() }, + onGoals = { nav.setEditGoals(true) }, + ) + } + } + } + } +} + +/** + * The top page over the tab, sliding in and out like a stack, each closing back to the one under it. On a wide screen + * (spec 2.4 Unfolded) the You and Strap pages are a list with the page a row opens beside it, as a tab with its + * detail: the list on the left with that row marked, the page in a pane on the right ([openBeside] puts it there, in + * place of the one open). Back closes the pane, or goes back within it, and the list's own back closes it all + * ([closeAll]). A page opened on its own (Profile from a card) is one centred column. + */ +// The stack of pages is a new list on each change and is read whole, so its stability changes nothing here. +@Suppress("UnstableCollections", "ComposeUnstableCollections") +@Composable +private fun OverPages( + pages: List, + hooks: PageHooks, + requestBluetooth: (() -> Unit) -> Unit, + profile: UserProfile, + padding: PaddingValues, + lastSeen: Long?, + /** Debug: a previewed strap state for the Strap page's card. */ + preview: StrapView?, + open: (Page) -> Unit, + close: () -> Unit, + openBeside: (Page) -> Unit, + closeAll: () -> Unit, + onGoals: () -> Unit, +) { + val container = hooks.container + // Read from the strap when first needed (the Strap page's rows show what it holds), and again on each page that edits it. + val needsAlarms = pages.any { it == Page.Strap || it == Page.Alarms || it == Page.HealthAlerts } + val alarmsVm: AlarmsViewModel? = if (needsAlarms) viewModel(key = "alarms", factory = ViewModelFactories.alarms(container)) else null + // One page. [list] is set for the You or Strap page as the list beside a pane: its rows open there, the row whose + // page is open is marked, and its back closes everything. + val page: @Composable (Page?, List?) -> Unit = page@{ shown, list -> + val rowOpens: (Page) -> Unit = if (list != null) openBeside else open + val selected = list?.getOrNull(1) + val back = if (list != null) closeAll else close + when (shown) { + null -> Unit + Page.You -> YouPageHost(hooks, profile, padding, rowOpens, selected, onGoals, back) + Page.Strap, Page.Alarms, Page.HealthAlerts, Page.PhoneAlerts, Page.Firmware, Page.ZeppAccount -> { + StrapPage(shown, hooks, alarmsVm, requestBluetooth, padding, lastSeen, preview, rowOpens, selected, back, open, close) + } + else -> DataPage(shown, hooks, profile, padding, open, close) + } + } + BoxWithConstraints(Modifier.fillMaxSize()) { + val root = pages.firstOrNull() + if (maxWidth >= SidePaneWidth && (root == Page.You || root == Page.Strap)) { + ListDetail( + pages.drop(1), isOpen = { it.isNotEmpty() }, keys = { s -> s.map { it.name } }, + minWidth = SidePaneWidth, + closedMaxWidth = GlowLayout.PageMaxWidth, + detail = { stack -> page(stack.lastOrNull(), null) }, + ) { page(root, pages) } + } else { + PushContent(pages, depth = { it.size }) { stack -> page(stack.lastOrNull(), null) } + } + } +} + +/** The Strap page and what it opens: alarms, health alerts, phone alerts, firmware and the Zepp account. */ +@Composable +private fun StrapPage( + shown: Page, + hooks: PageHooks, + alarmsVm: AlarmsViewModel?, + requestBluetooth: (() -> Unit) -> Unit, + padding: PaddingValues, + lastSeen: Long?, + preview: StrapView?, + rowOpens: (Page) -> Unit, + selected: Page?, + back: () -> Unit, + open: (Page) -> Unit, + close: () -> Unit, +) { + val container = hooks.container + when (shown) { + Page.Strap -> alarmsVm?.let { StrapPageHost(hooks, it, requestBluetooth, padding, lastSeen, preview, rowOpens, selected, back) } + Page.Alarms, Page.HealthAlerts -> alarmsVm?.let { AlarmsPage(it, health = shown == Page.HealthAlerts, padding, close) } + Page.PhoneAlerts -> PhoneAlertsScreen( + viewModel(key = "phoneAlerts", factory = ViewModelFactories.phoneAlerts(container)), padding, onBack = close, + ) + Page.Firmware -> FirmwareScreen(hooks.firmwareVm, padding, onBack = close, onZeppAccount = { open(Page.ZeppAccount) }) + Page.ZeppAccount -> ZeppAccountScreen(hooks.firmwareVm, padding, onBack = close) + else -> Unit + } +} + +/** The You page's other pages: your data, the app's updates, goals and badges, profile, zones and diagnostics. */ +@Composable +private fun DataPage( + shown: Page, + hooks: PageHooks, + profile: UserProfile, + padding: PaddingValues, + open: (Page) -> Unit, + close: () -> Unit, +) { + val container = hooks.container + when (shown) { + Page.HealthConnect -> HealthConnectPageHost(hooks, padding, close) + Page.Backup -> BackupPageHost(hooks, padding, close) + Page.Updates -> UpdatesPageHost(container, hooks.rootVm, padding, close) + Page.DeletedSleep -> { + val deleted by hooks.dayVm.deletedSleep.collectAsStateWithLifecycle() + DeletedSleepPage(deleted, padding, onRestore = hooks.dayVm::restoreSleep, onBack = close) + } + Page.GoalsAndBests, Page.AllBadges, Page.WeeklyRecap -> + RewardsPage(shown, hooks.rootVm, container.rewards.firstDay, padding, open, close) + Page.Profile -> ProfileScreen( + profile, padding, onSave = { hooks.deviceVm.saveProfile(it); close() }, onBack = close, + onHeartZones = { open(Page.HeartZones) }, + ) + Page.HeartZones -> HeartZonesPage(container, padding, hooks.rootVm::setMaxHeartRate, hooks.rootVm::setHeartZones, close) + Page.Diagnostics -> { + val log by container.strap.log.collectAsStateWithLifecycle() + DiagnosticsScreen(log, padding, onBack = close) + } + else -> Unit + } +} + +/** The alarms, or the health alerts, read from the strap afresh as the page opens. */ +@Composable +private fun AlarmsPage(alarms: AlarmsViewModel, health: Boolean, padding: PaddingValues, close: () -> Unit) { + LaunchedEffect(Unit) { alarms.refresh() } + val alarmsState by alarms.state.collectAsStateWithLifecycle() + if (health) { + HealthAlertsScreen(alarms, alarmsState, padding, onBack = close) + } else { + AlarmsScreen(alarms, alarmsState, padding, onBack = close) + } +} + +/** The You page: who you are, your goals, your data and the app. */ +@Composable +private fun YouPageHost( + hooks: PageHooks, + profile: UserProfile, + padding: PaddingValues, + open: (Page) -> Unit, + selected: Page?, + onGoals: () -> Unit, + onBack: () -> Unit, +) { + val container = hooks.container + val deviceVm = hooks.deviceVm + val goals by hooks.rootVm.goals.collectAsStateWithLifecycle() + val log by container.strap.log.collectAsStateWithLifecycle() + val hcAuto by deviceVm.healthConnectAuto.collectAsStateWithLifecycle() + val backup by deviceVm.backup.collectAsStateWithLifecycle() + val updates by container.updater.state.collectAsStateWithLifecycle() + val updateWaiting by hooks.rootVm.updateWaiting.collectAsStateWithLifecycle() + val deletedSleep by hooks.dayVm.deletedSleep.collectAsStateWithLifecycle() + val youMaxHr by hooks.workoutsVm.maxHeartRate.collectAsStateWithLifecycle() + // Permissions can be taken back in Health Connect while away: the row says so on return. + LifecycleResumeEffect(Unit) { + deviceVm.checkAutoHealthConnect() + onPauseOrDispose { } + } + YouPage( + profile, goals, Recovery.sleepNeed(profile.age(), profile.sleepNeedMinutes), log.size, padding, + onProfile = { open(Page.Profile) }, + maxHeartRate = youMaxHr, + onHeartZones = { open(Page.HeartZones) }, + onGoals = onGoals, + onGoalsAndBests = { open(Page.GoalsAndBests) }, + healthConnectAuto = hcAuto, + onHealthConnect = { open(Page.HealthConnect) }, + backup = backup, + onBackup = { open(Page.Backup) }, + deletedSleep = deletedSleep.size, + onDeletedSleep = { open(Page.DeletedSleep) }, + updates = updates, + installedVersion = container.updater.installed, + updateWaiting = updateWaiting, + onUpdates = { open(Page.Updates) }, + onDiagnostics = { open(Page.Diagnostics) }, + onSampleData = if (SampleData.AVAILABLE) ({ deviceVm.loadSampleData() }) else null, + onBack = onBack, + selected = selected?.row, + ) +} + +/** The Strap page, with what the strap holds read once it's connected. */ +@Composable +private fun StrapPageHost( + hooks: PageHooks, + alarms: AlarmsViewModel, + requestBluetooth: (() -> Unit) -> Unit, + padding: PaddingValues, + lastSeen: Long?, + preview: StrapView?, + open: (Page) -> Unit, + selected: Page?, + onBack: () -> Unit, +) { + val container = hooks.container + val alarmsState by alarms.state.collectAsStateWithLifecycle() + val account by hooks.firmwareVm.account.collectAsStateWithLifecycle() + val connection by container.strap.connection.collectAsStateWithLifecycle() + // Nothing read yet (the strap wasn't there when it was asked): ask again once it's connected. + LaunchedEffect(connection is ConnectionState.Connected) { + if (connection is ConnectionState.Connected && alarmsState.caps == null && !alarmsState.busy) alarms.refresh() + } + DeviceScreen( + hooks.deviceVm, padding, requestBluetooth, alarmsState, account != null, lastSeen, + onAlarms = { open(Page.Alarms) }, + onPhoneAlerts = { open(Page.PhoneAlerts) }, + onHealthAlerts = { open(Page.HealthAlerts) }, + onFirmware = { open(Page.Firmware) }, + onZeppAccount = { open(Page.ZeppAccount) }, + onBack = onBack, + preview = preview, + selected = selected?.row, + ) +} + +@Composable +private fun HealthConnectPageHost(hooks: PageHooks, padding: PaddingValues, onBack: () -> Unit) { + val context = LocalContext.current + val deviceVm = hooks.deviceVm + val hcAuto by deviceVm.healthConnectAuto.collectAsStateWithLifecycle() + // As on the You page: the switch says so when permissions were taken back meanwhile. + LifecycleResumeEffect(Unit) { + deviceVm.checkAutoHealthConnect() + onPauseOrDispose { } + } + HealthConnectPage( + hcAuto, padding, + onAuto = hooks.healthConnect.auto, + onCopyNow = hooks.healthConnect.export, + onRestore = hooks.healthConnect.import, + onOpen = { + if (!openHealthConnect(context)) Toast.makeText(context, HC_UNAVAILABLE, Toast.LENGTH_LONG).show() + }, + onBack = onBack, + ) +} + +@Composable +private fun BackupPageHost(hooks: PageHooks, padding: PaddingValues, onBack: () -> Unit) { + val deviceVm = hooks.deviceVm + val backup by deviceVm.backup.collectAsStateWithLifecycle() + val backingUp by deviceVm.backingUp.collectAsStateWithLifecycle() + val folder = backup.folder?.let(Uri::parse) + BackupPage( + backup, backingUp, padding, + onTurnOn = hooks.backup.turnOn, + onTurnOff = { deviceVm.turnOffBackup() }, + onFolder = { hooks.backup.chooseFolder(folder) }, + onBackUpNow = { deviceVm.backUpNow() }, + onRestore = { hooks.backup.restore(folder) }, + onSaveCopy = hooks.backup.saveCopy, + onBack = onBack, + ) +} + +/** The Updates page, with Android's "Install unknown apps" switch asked for when an install needs it. */ +@Composable +private fun UpdatesPageHost(container: AppContainer, rootVm: AppRootViewModel, padding: PaddingValues, onBack: () -> Unit) { + val updater = container.updater + val updates by updater.state.collectAsStateWithLifecycle() + val auto by rootVm.updateAuto.collectAsStateWithLifecycle() + val workoutNow by container.workout.state.collectAsStateWithLifecycle() + // Seen: the dots on the avatar and the Updates row go, and again for a release found while it's open. + LaunchedEffect(Releases.newer(updates.releases, updater.installed).firstOrNull()?.tag) { updater.markSeen() } + // Back from Android's "Install unknown apps" switch: on, the install goes ahead. Kept by its tag, so it still + // installs after the activity was recreated in Settings. + var waiting by rememberSaveable(stateSaver = PendingAction.Saver) { mutableStateOf(null) } + val allowLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + (waiting as? PendingAction.Install)?.let { install -> waiting = install.copy(answered = true) } + } + LaunchedEffect(waiting, updates.releases) { + val install = (waiting as? PendingAction.Install)?.takeIf { it.answered } ?: return@LaunchedEffect + val release = updates.releases.firstOrNull { it.tag == install.tag } ?: return@LaunchedEffect + waiting = null + if (updater.canInstall()) updater.install(release) + } + val uri = LocalUriHandler.current + UpdatesPage( + updates, updater.installed, auto, workoutNow is WorkoutState.Recording, padding, + onInstall = { r -> + when { + r.apk == null -> uri.openUri(r.page) + updater.canInstall() -> updater.install(r) + else -> { + waiting = PendingAction.Install(r.tag) + allowLauncher.launch(updater.allowInstalls) + } + } + }, + onCheck = updater::checkNow, + onAuto = updater::setAuto, + onGitHub = { uri.openUri(Releases.PAGE) }, + onBack = { + updater.dismissFailure() + onBack() + }, + ) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/PermissionGates.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/PermissionGates.kt new file mode 100644 index 00000000..47cad5b2 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/PermissionGates.kt @@ -0,0 +1,136 @@ +package org.openstrap.app.ui.root + +import android.Manifest +import android.widget.Toast +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import org.openstrap.app.PendingAction +import org.openstrap.app.PendingStrapAction +import org.openstrap.app.data.SportSettings +import org.openstrap.app.data.WorkoutPlan +import org.openstrap.app.device.BlePermissions +import org.openstrap.app.ui.components.GlassAlertDialog + +/** The Bluetooth and location gates in front of a workout and the Strap page's actions. */ +internal class PermissionGates( + /** Runs the action once Bluetooth may be used. */ + val requestBluetooth: (() -> Unit) -> Unit, + /** Starts a plan once Bluetooth (and location, for GPS) may be used. [SportSettings] is what Ready keeps. */ + val startPlan: (WorkoutPlan, SportSettings?) -> Unit, + /** Asks for location, then runs the action whatever the answer. */ + val requestLocation: (() -> Unit) -> Unit, + private val blocked: MutableState, +) { + /** Bluetooth was turned down for good: Android shows no dialog any more, so the app offers its settings page. */ + val bluetoothBlocked: Boolean get() = blocked.value + + fun dismissBlocked() { + blocked.value = false + } +} + +/** + * The permission gates. A workout waits as saveable data, so the answer still starts it after the activity was + * recreated behind the dialog. A Strap page action waits in [pendingStrap], which outlives a fold. Location for a GPS + * workout is asked only when GPS is on, and the workout starts either way (steps without it). Ready's "Turn on + * location" hands over a closure that only refreshes Ready, so that one isn't saved. + */ +@Composable +internal fun rememberPermissionGates( + pendingStrap: PendingStrapAction, + locationGranted: () -> Boolean, + start: (PendingAction.StartPlan) -> Unit, +): PermissionGates { + val context = LocalContext.current + val activity = LocalActivity.current + var pending by rememberSaveable(stateSaver = PendingAction.Saver) { mutableStateOf(null) } + val blocked = rememberSaveable { mutableStateOf(false) } + val btLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + val waiting = pending as? PendingAction.StartPlan + val strapAction = pendingStrap.action + pending = null + pendingStrap.action = null + when { + BlePermissions.granted(result) -> waiting?.let(start) ?: strapAction?.invoke() + activity != null && BlePermissions.turnedDownForGood(activity, BlePermissions.bluetooth) -> blocked.value = true + else -> Toast.makeText(context, "Bluetooth permission is required.", Toast.LENGTH_LONG).show() + } + } + val requestBluetooth: (() -> Unit) -> Unit = { then -> + pendingStrap.action = then + btLauncher.launch(BlePermissions.required()) + } + val withBluetooth: (PendingAction.StartPlan) -> Unit = { action -> + if (BlePermissions.granted(context)) { + start(action) + } else { + pending = action + btLauncher.launch(BlePermissions.required()) + } + } + var afterLocation by remember { mutableStateOf<(() -> Unit)?>(null) } + val locationLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + if (result.values.none { it }) { + Toast.makeText(context, "Without location, distance comes from the strap's steps.", Toast.LENGTH_LONG).show() + } + val waiting = pending as? PendingAction.StartPlan + if (waiting != null) { + pending = null + withBluetooth(waiting) + } + afterLocation?.invoke() + afterLocation = null + } + val locationPermissions = arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION) + val requestLocation: (() -> Unit) -> Unit = { then -> + afterLocation = then + locationLauncher.launch(locationPermissions) + } + val startPlan: (WorkoutPlan, SportSettings?) -> Unit = { plan, saved -> + val action = PendingAction.StartPlan(plan, saved) + if (plan.useGps && !locationGranted()) { + pending = action + locationLauncher.launch(locationPermissions) + } else { + withBluetooth(action) + } + } + return PermissionGates(requestBluetooth, startPlan, requestLocation, blocked) +} + +/** Nearby devices turned down for good: the way to OpenStrap's settings page, where it can be turned on. */ +@Composable +internal fun BluetoothBlockedDialog(gates: PermissionGates) { + if (!gates.bluetoothBlocked) return + val context = LocalContext.current + GlassAlertDialog( + onDismissRequest = gates::dismissBlocked, + title = { Text("Allow Nearby devices") }, + text = { + Text( + "OpenStrap needs Nearby devices to find and connect to your strap. Android won't ask again, " + + "so turn it on in OpenStrap's settings, under Permissions.", + ) + }, + confirmButton = { + TextButton(onClick = { + gates.dismissBlocked() + context.startActivity(BlePermissions.settingsIntent(context)) + }) { Text("Open settings") } + }, + dismissButton = { + TextButton(onClick = gates::dismissBlocked) { Text("Not now") } + }, + ) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/RewardsPages.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/RewardsPages.kt new file mode 100644 index 00000000..2e3d00c8 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/RewardsPages.kt @@ -0,0 +1,125 @@ +package org.openstrap.app.ui.root + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import java.time.DayOfWeek +import java.time.LocalDate +import org.openstrap.app.AppContainer +import org.openstrap.app.data.HeartZones +import org.openstrap.app.data.Rewards +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.presentation.RewardsCopy +import org.openstrap.app.ui.device.GoalsSheet +import org.openstrap.app.ui.glow.AllBadgesPage +import org.openstrap.app.ui.glow.CelebrationsSheet +import org.openstrap.app.ui.glow.GoalsAndBestsPage +import org.openstrap.app.ui.glow.HeartZonesScreen +import org.openstrap.app.ui.glow.HistoryBadgesScreen +import org.openstrap.app.ui.glow.WeeklyGoalSheet +import org.openstrap.app.ui.glow.WeeklyRecapPage + +/** + * The Goals and bests page, All badges and the weekly recap (workouts spec 3.5), from what's stored and every workout, + * with the weekly goal and celebrations sheets they open. + */ +@Composable +internal fun RewardsPage( + shown: Page, + rootVm: AppRootViewModel, + firstDay: DayOfWeek, + padding: PaddingValues, + open: (Page) -> Unit, + close: () -> Unit, +) { + val view by rootVm.rewardsView.collectAsStateWithLifecycle() + val goals by rootVm.goals.collectAsStateWithLifecycle() + var editGoal by rememberSaveable { mutableStateOf(false) } + var celebrations by rememberSaveable { mutableStateOf(false) } + when (shown) { + Page.GoalsAndBests -> GoalsAndBestsPage( + view, padding, onGoal = { editGoal = true }, onAllBadges = { open(Page.AllBadges) }, onRecap = { open(Page.WeeklyRecap) }, + onCelebrations = { celebrations = true }, onBack = close, + ) + Page.AllBadges -> AllBadgesPage(view, padding, onBack = close) + else -> { + // Only before the rewards first load: last week by the clock, as the page opens. + @Suppress("ForbiddenMethodCall/uiClock") + val first = view?.thisWeek?.minusWeeks(1) ?: Rewards.weekStart(LocalDate.now(), firstDay).minusWeeks(1) + WeeklyRecapPage(view, first, padding, onGoal = { editGoal = true }, onBack = close) + } + } + if (editGoal) { + WeeklyGoalSheet(goals.workoutsPerWeek, onSave = { n -> + rootVm.saveGoals(goals.copy(workoutsPerWeek = n)) + editGoal = false + }, onDismiss = { editGoal = false }) + } + if (celebrations) { + val settings by rootVm.celebrations.collectAsStateWithLifecycle() + CelebrationsSheet( + settings.goalMoment, settings.weeklyRecap, + onGoalMoment = rootVm::setGoalMoment, + onWeeklyRecap = rootVm::setWeeklyRecap, + onDismiss = { celebrations = false }, + ) + } +} + +/** + * The Heart rate zones page, from Ready's Your zones and the You page: your max as the app works it out, and your + * zones. Both are stored in the settings, which every zone and max HR reader goes by, and moved zones are in use at once. + */ +@Composable +internal fun HeartZonesPage( + container: AppContainer, + padding: PaddingValues, + onMax: (Int?) -> Unit, + onZones: (HeartZones) -> Unit, + onBack: () -> Unit, +) { + val zones by HeartZones.current.collectAsStateWithLifecycle() + val max by container.maxHeartRates.current.collectAsStateWithLifecycle() + // Afresh as the page opens, as it always was. A profile change works it out again on its own. + LaunchedEffect(Unit) { container.maxHeartRates.now() } + HeartZonesScreen(max, zones, padding, onMax = onMax, onZones = onZones, onBack = onBack) +} + +/** The Goals editor, over everything, with the calorie goal your last four weeks suggest read as it opens (calories spec 3.4). */ +@Composable +internal fun GoalsSheetHost(nav: AppNavState, rootVm: AppRootViewModel) { + if (!nav.editGoals) return + val goals by rootVm.goals.collectAsStateWithLifecycle() + val suggestedCalories by produceState(null) { value = rootVm.suggestedCalorieGoal() } + GoalsSheet(goals, suggestedCalories = suggestedCalories, onSave = { g -> + rootVm.saveGoals(g) + nav.setEditGoals(false) + }, onDismiss = { nav.setEditGoals(false) }) +} + +/** The badges past history earned, once, all on one screen (requests spec 7.2), when no workout is on screen. */ +@Composable +internal fun HistoryBadgesHost(nav: AppNavState, rootVm: AppRootViewModel, workoutState: WorkoutState) { + val historyBadges by rootVm.historyBadges.collectAsStateWithLifecycle() + val earned = historyBadges?.takeIf { workoutState is WorkoutState.Idle && nav.workout == WorkoutOverlay.None } + if (earned.isNullOrEmpty()) return + Box(Modifier.fillMaxSize()) { + HistoryBadgesScreen( + earned.map { it.badge }, WindowInsets.navigationBars.asPaddingValues(), + onContinue = rootVm::historyBadgesShown, + face = { b -> RewardsCopy.face(b, earned.firstOrNull { it.badge.id == b.id }?.goal) }, + ) + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/RootHooks.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/RootHooks.kt new file mode 100644 index 00000000..5b62c64c --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/RootHooks.kt @@ -0,0 +1,37 @@ +package org.openstrap.app.ui.root + +import org.openstrap.app.AppContainer +import org.openstrap.app.ui.dashboard.DayViewModel +import org.openstrap.app.ui.device.DeviceViewModel +import org.openstrap.app.ui.firmware.FirmwareViewModel +import org.openstrap.app.ui.workout.WorkoutsViewModel +import java.time.LocalDate + +/** What applying a request asks of the app beyond where it is. */ +internal class NavRequestHooks( + /** A workout records now: requests for Choose and Ready wait. */ + val recording: () -> Boolean, + val firmwareInstalling: () -> Boolean, + /** Firmware left the pages, so it starts again from the top next time. */ + val firmwareClosed: () -> Unit, + /** A notification's metric is for this day. */ + val selectDay: (LocalDate) -> Unit, + /** The Moment widget: Today on today. */ + val today: () -> Unit, + /** The Strap page opened over the tab: it grows as the header's strap chip opens it. */ + val strapOpened: () -> Unit, + /** The live workout screen, even when it was tucked away. */ + val showWorkout: () -> Unit, +) + +/** Everything the pages over the tab act with: the view models they show and the actions that need the activity. */ +internal class PageHooks( + val container: AppContainer, + val dayVm: DayViewModel, + val deviceVm: DeviceViewModel, + val firmwareVm: FirmwareViewModel, + val workoutsVm: WorkoutsViewModel, + val rootVm: AppRootViewModel, + val healthConnect: HealthConnectActions, + val backup: BackupActions, +) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/ShownDayState.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/ShownDayState.kt new file mode 100644 index 00000000..b4c2d11a --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/ShownDayState.kt @@ -0,0 +1,90 @@ +// Keyed effects run once per key after the frame commits, as they did in AppRoot. A SideEffect would run on every +// recomposition. +@file:Suppress("UnnecessaryLaunchedEffect") + +package org.openstrap.app.ui.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import java.time.LocalDate +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.RecoveryResult +import org.openstrap.app.ui.dashboard.DataPreview +import org.openstrap.app.ui.dashboard.DayUiState +import org.openstrap.app.ui.dashboard.DayViewModel +import org.openstrap.app.ui.dashboard.FirstScore +import org.openstrap.app.ui.dashboard.RecoveryFailure +import org.openstrap.app.ui.dashboard.StrapPreview +import org.openstrap.app.ui.dashboard.earliestScore +import org.openstrap.app.ui.dashboard.firstScore +import org.openstrap.app.ui.dashboard.previewed +import org.openstrap.app.ui.dashboard.wear + +private const val S_PER_MIN = 60 + +/** The day, its Recovery and a failed Recovery as the screens are shown them, the debug previews applied. */ +class PreviewedDay(val day: DayUiState, val recovery: RecoveryResult?, val recoveryFailure: RecoveryFailure?) + +/** + * The day shown with the debug previews applied: a strap [preview] of a failed Recovery shows the day as failed, with + * yesterday's score from the real result, and a [data] preview shows first nights or no sleep in the day and its + * Recovery. Nothing else changes. + */ +@Composable +internal fun rememberPreviewedDay(dayVm: DayViewModel, preview: StrapPreview?, data: DataPreview?): PreviewedDay { + val realRecovery by dayVm.recovery.collectAsStateWithLifecycle() + val realRecoveryFailure by dayVm.recoveryFailure.collectAsStateWithLifecycle() + val realDay by dayVm.state.collectAsStateWithLifecycle() + val day = remember(realDay, data) { realDay.previewed(data) } + val previewFailure = if (preview == StrapPreview.RecoveryFail) { + realRecovery?.let { r -> RecoveryFailure(r.date, r.readinessTrend.firstOrNull { it.first == r.date.minusDays(1) }?.second) } + } else { + null + } + val recovery = if (previewFailure != null) null else realRecovery?.previewed(data) + return PreviewedDay(day, recovery, previewFailure ?: realRecoveryFailure) +} + +/** + * Everything a tab and detail is shown of the day: its wear for the Partial day tag (spec 4.3), the strap's score and + * check-in, and the first real score (E4), marked once and never written while a preview shows. + */ +@Composable +internal fun rememberShownDay( + shown: PreviewedDay, + dayVm: DayViewModel, + rootVm: AppRootViewModel, + clockDate: LocalDate, + now: Long, + preview: StrapPreview?, + data: DataPreview?, +): ShownDay { + val day = shown.day + val recovery = shown.recovery + val healthspan by dayVm.healthspan.collectAsStateWithLifecycle() + LaunchedEffect(day.date, day.dayStart) { rootVm.dayShown(day.date, day.dayStart) } + val dayExtras by rootVm.dayExtras.collectAsStateWithLifecycle() + val dayIsToday = day.date == clockDate + val wear = day.wear(recovery, dayIsToday) + .previewed(data, (((if (dayIsToday) now else day.dayEnd + 1) - day.dayStart) / S_PER_MIN).toInt()) + val firstScoreDay by rootVm.firstScoreDay.collectAsStateWithLifecycle() + val dayRecovery = recovery?.takeIf { it.date == day.date } + val first = if (firstScoreDay == FirstScoreDay.Loading) { + FirstScore(false) + } else { + firstScore( + dayRecovery?.readiness?.score, day.date, dayIsToday, dayRecovery?.readiness?.phase == Recovery.Phase.Learning, + dayRecovery?.let { earliestScore(it.readinessTrend, day.date) }, (firstScoreDay as? FirstScoreDay.Day)?.date, + ) + } + LaunchedEffect(first.remember, preview, data) { + if (preview == null && data == null) first.remember?.let(rootVm::markFirstScore) + } + return ShownDay( + day, recovery, shown.recoveryFailure, healthspan, dayExtras.strapReadiness, dayExtras.checkIn, wear, + firstScore = first.show || data == DataPreview.FirstScoreShown, + ) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/StrapHeader.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/StrapHeader.kt new file mode 100644 index 00000000..4634f5eb --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/StrapHeader.kt @@ -0,0 +1,100 @@ +// Keyed effects run once per key after the frame commits, as they did in AppRoot. A SideEffect would run on every +// recomposition. +@file:Suppress("UnnecessaryLaunchedEffect") + +package org.openstrap.app.ui.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import java.time.LocalDate +import kotlinx.coroutines.delay +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.SyncState +import org.openstrap.app.ui.dashboard.DataPreview +import org.openstrap.app.ui.dashboard.DayUiState +import org.openstrap.app.ui.dashboard.PAIRED_HOLD_MS +import org.openstrap.app.ui.dashboard.PairStep +import org.openstrap.app.ui.dashboard.StrapChip +import org.openstrap.app.ui.dashboard.StrapPreview +import org.openstrap.app.ui.dashboard.StrapView +import org.openstrap.app.ui.dashboard.pairStep +import org.openstrap.app.ui.dashboard.previewed +import org.openstrap.app.ui.dashboard.strapChip + +/** What the screens are shown of the strap, the header chip, and the time the chip counts from. */ +class StrapHeader(val view: StrapView, val chip: StrapChip, val now: Long) + +private const val CHIP_TICK_MS = 30_000L +private const val MS_PER_S = 1000 + +// The chip's ticker and the last heart rate's day read the clock as they tick, as the header did before the split. +@Suppress("ForbiddenMethodCall/uiClock") +private fun epochNow() = System.currentTimeMillis() / MS_PER_S + +/** + * The strap as the header and Today show it: the real state, or a debug [preview]'s (nothing else changes). When the + * strap was last heard from is kept fresh while connected, then left where it stopped, and survives process death. + * Without that in this run, the last synced reading or the last sync stand in. + */ +@Suppress("ForbiddenMethodCall/uiClock") // see epochNow +@Composable +internal fun rememberStrapHeader(facts: StrapFacts, day: DayUiState, preview: StrapPreview?): StrapHeader { + var seenConnected by rememberSaveable { mutableStateOf(null) } + val connected = facts.connection is ConnectionState.Connected + LaunchedEffect(connected) { + if (connected) while (true) { + seenConnected = epochNow() + delay(CHIP_TICK_MS) + } + } + val realLastSeen = listOfNotNull( + seenConnected, + (facts.sync as? SyncState.Done)?.atEpochSeconds, + day.heartRate.lastOrNull()?.x?.takeIf { day.date == LocalDate.now() }, + ).maxOrNull() + // The chip's "18 min ago" moves on by itself. + val now by produceState(epochNow()) { + while (true) { + delay(CHIP_TICK_MS) + value = epochNow() + } + } + val view = StrapView(facts.pairedKnown, facts.connection, facts.sync, facts.syncRecord, realLastSeen).previewed(preview, now) + // Right after the process starts the service is still reconnecting, so the chip waits before saying "18 min ago". + val sinceStart = if (preview != null) { + Long.MAX_VALUE + } else { + (android.os.SystemClock.uptimeMillis() - android.os.Process.getStartUptimeMillis()) / MS_PER_S + } + return StrapHeader(view, strapChip(view.paired, view.connection, view.sync, view.lastSeen, now, sinceStart), now) +} + +/** + * The pairing stage (E3) from the real pairing: the Strap page's scan before a strap is paired, then the connection. + * Once paired it stays on Today until the strap first connects, and holds Connected a moment. A debug data [preview] + * shows its own step. + */ +@Composable +internal fun rememberPairingStage( + pairedKnown: Boolean, + scanning: Boolean, + found: Boolean, + connection: ConnectionState, + preview: DataPreview?, +): PairStep? { + var firstConnect by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(pairedKnown) { if (!pairedKnown) firstConnect = true } + val realStep = if (!pairedKnown || firstConnect) pairStep(pairedKnown, scanning, found, connection) else null + LaunchedEffect(realStep == PairStep.Connected) { + if (realStep == PairStep.Connected) { + delay(PAIRED_HOLD_MS) + firstConnect = false + } + } + return preview?.pair ?: realStep +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/TabsPager.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/TabsPager.kt new file mode 100644 index 00000000..01ce515e --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/TabsPager.kt @@ -0,0 +1,399 @@ +// The tabs read the day, workouts and root view models they show, as they did inside AppRoot. The keyed effect runs once +// per key after the frame commits, as it did there. A SideEffect would run on every recomposition. +@file:Suppress("ViewModelForwarding", "ComposeViewModelForwarding", "UnnecessaryLaunchedEffect") + +package org.openstrap.app.ui.root + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigationevent.NavigationEventDispatcherOwner +import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import kotlinx.coroutines.launch +import org.openstrap.app.AppContainer +import org.openstrap.app.data.Healthspan +import org.openstrap.app.data.Moment +import org.openstrap.app.data.RecoveryResult +import org.openstrap.app.data.db.StrapReadinessRow +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.components.GrowProgress +import org.openstrap.app.ui.components.ListDetail +import org.openstrap.app.ui.components.Motion +import org.openstrap.app.ui.components.NavItem +import org.openstrap.app.ui.components.NavRailWidth +import org.openstrap.app.ui.components.PullToSync +import org.openstrap.app.ui.components.SideBySideWidth +import org.openstrap.app.ui.components.sinkBehind +import org.openstrap.app.ui.dashboard.DailyHistory +import org.openstrap.app.ui.dashboard.DayPicker +import org.openstrap.app.ui.dashboard.DayUiState +import org.openstrap.app.ui.dashboard.DayViewModel +import org.openstrap.app.ui.dashboard.Detail +import org.openstrap.app.ui.dashboard.LocalScrollToTop +import org.openstrap.app.ui.dashboard.MomentHeader +import org.openstrap.app.ui.dashboard.RecoveryFailure +import org.openstrap.app.ui.dashboard.StrapChip +import org.openstrap.app.ui.dashboard.Wear +import org.openstrap.app.ui.dashboard.dayTitle +import org.openstrap.app.ui.dashboard.heroLightAngle +import org.openstrap.app.ui.dashboard.momentMood +import org.openstrap.app.ui.dashboard.todayTitle +import org.openstrap.app.ui.glow.AllMetricsSheet +import org.openstrap.app.ui.glow.GlowActivityScreen +import org.openstrap.app.ui.glow.GlowRecoveryScreen +import org.openstrap.app.ui.glow.GlowSleepScreen +import org.openstrap.app.ui.glow.GlowTodayScreen +import org.openstrap.app.ui.glow.HomeTab +import org.openstrap.app.ui.glow.IndexTarget +import org.openstrap.app.ui.glow.LiveHeroHooks +import org.openstrap.app.ui.glow.LocalGlowMood +import org.openstrap.app.ui.glow.LocalGlowPagePaused +import org.openstrap.app.ui.glow.TodayStrap +import org.openstrap.app.ui.glow.draw.GlowMood +import org.openstrap.app.ui.glow.metricIndex +import org.openstrap.app.ui.theme.AppIcons +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.workout.WorkoutsViewModel + +/** Each tab's place in the bar: its label, icons and the capsule's colour while it shows (spec 3.2). */ +fun tabItems(): List = Tab.entries.map { tab -> + when (tab) { + Tab.Today -> NavItem(tab.label, AppIcons.HouseSimple, AppIcons.HouseSimpleFill, Color.White) + Tab.Recovery -> NavItem(tab.label, AppIcons.Plant, AppIcons.PlantFill, MetricColors.readiness) + Tab.Sleep -> NavItem(tab.label, AppIcons.Moon, AppIcons.MoonFill, MetricColors.sleepDeep) + Tab.Activity -> NavItem(tab.label, AppIcons.SneakerMove, AppIcons.SneakerMoveFill, MetricColors.steps) + } +} + +/** The day shown as every tab and detail sees it: the debug previews applied, its Recovery, strap score and check-in. */ +class ShownDay( + val day: DayUiState, + val recovery: RecoveryResult?, + val recoveryFailure: RecoveryFailure?, + val healthspan: Healthspan.Outcome?, + val strapReadiness: StrapReadinessRow?, + val checkIn: Int?, + /** How much of the day the strap was worn, for the Partial day tag on the cards it lowers (spec 4.3). */ + val wear: Wear?, + /** The first real score shows its moment (E4). */ + val firstScore: Boolean, +) { + /** The strap's own score for the day shown. */ + val strapScore: Int? get() = strapReadiness?.takeIf { it.day == day.dayStart }?.score +} + +/** The stored histories the charts read. */ +class Histories(val daily: DailyHistory) + +/** Live heart rate, whether it's on, whether it can be (the strap connected), and the switch. */ +class LiveHeart(val bpm: Int?, val enabled: Boolean, val canUse: Boolean, val toggle: (Boolean) -> Unit) + +/** Each tab's count of taps asking its lists to scroll to the top. */ +@Stable +class ScrollRequests { + private val counts = mutableStateListOf(*Array(Tab.entries.size) { 0 }) + + fun of(tab: Int): Int = counts[tab] + + fun bump(tab: Int) { + counts[tab] = counts[tab] + 1 + } +} + +/** What Today shows of the strap and a workout under way, and the moment it's laid out for. */ +class TodayHooks( + val strap: TodayStrap, + val liveHero: LiveHeroHooks, + val moment: Moment?, + val usualBedtime: java.time.LocalTime?, + val running: WorkoutState.Recording?, + val onOpenRunning: () -> Unit, +) + +/** + * The four tabs, every one kept 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. Swiping moves between tabs: [nav] owns the tab and the pager follows it. A tap animates the pager there, and + * only a swipe the user made, once it settles, is another way to pick the tab. Back handlers on tabs that aren't + * showing answer to [idleBack], a dispatcher nobody presses, so back only ever closes what's on screen. + */ +@Composable +internal fun TabsPager( + nav: AppNavState, + shown: ShownDay, + histories: Histories, + today: TodayHooks, + container: AppContainer, + dayVm: DayViewModel, + workoutsVm: WorkoutsViewModel, + rootVm: AppRootViewModel, + live: LiveHeart, + padding: PaddingValues, + rail: Boolean, + pageGrow: GrowProgress, + navGlass: HazeState, + idleBack: NavigationEventDispatcherOwner, + scroll: ScrollRequests, + showCharge: Boolean, + onChargeConsume: () -> Unit, + header: HeaderHooks, + detail: @Composable (Tab, Detail) -> Unit, + onOpenPage: (Page) -> Unit, + seeHabitAge: () -> Unit, +) { + val tab = nav.tab.current + val pager = rememberPagerState(initialPage = tab) { Tab.entries.size } + val swiped = remember { java.util.concurrent.atomic.AtomicBoolean(false) } + LaunchedEffect(tab) { + if (pager.currentPage != tab) { + swiped.set(false) + pager.animateScrollToPage(tab, animationSpec = tween(Motion.SWIFT, easing = Motion.easing)) + } + } + LaunchedEffect(pager) { + launch { pager.interactionSource.interactions.collect { if (it is DragInteraction.Start) swiped.set(true) } } + snapshotFlow { pager.settledPage }.collect { nav.settled(it, swiped.get()) } + } + val currentDetail = nav.stack(nav.current).lastOrNull() + LaunchedEffect(currentDetail) { + workoutsVm.open((currentDetail as? Detail.Workout)?.start) + // A screen opened or closed: the next tap on the tab scrolls before it pops. + nav.moved() + } + val strap by rootVm.strap.collectAsStateWithLifecycle() + val connected = strap.connection is ConnectionState.Connected + // A detail screen open on this tab keeps the swipe for itself (and for its back gesture). + val subScreenOpen = currentDetail != null || nav.pages.isNotEmpty() + HorizontalPager( + pager, Modifier.padding(start = if (rail) NavRailWidth else 0.dp).sinkBehind(pageGrow).hazeSource(navGlass), + userScrollEnabled = !subScreenOpen, key = { it }, beyondViewportPageCount = Tab.entries.size - 1, + ) { page -> + val back = if (page == tab) emptyArray() else arrayOf(LocalNavigationEventDispatcherOwner provides idleBack) + @Suppress("SpreadOperator") // at most one local, once per page + CompositionLocalProvider( + // Remembered: a new lambda each time would be a new static local, recomposing the whole tab under it. + LocalGlowPagePaused provides remember(pager, page) { { pager.isScrollInProgress || pager.settledPage != page } }, + LocalScrollToTop provides scroll.of(page), + *back, + ) { + val t = Tab.entries[page] + // A tab's root with its stack opening over it (or beside it, when wide). + ListDetail( + nav.stack(t), isOpen = { it.isNotEmpty() }, keys = { s -> s.map(Detail::keyOf) }, minWidth = SideBySideWidth, + detail = { stack -> stack.lastOrNull()?.let { detail(t, it) } }, + ) { + PullToSync(strap.sync, connected, container.strap::syncNow) { + TabRoot( + t, nav, shown, histories, today, container, dayVm, workoutsVm, rootVm, live, padding, showCharge, + onChargeConsume, header, onOpenPage, seeHabitAge, + ) + } + } + } + } +} + +/** A tab's own screen, at the root of its stack. Cards about other tabs open those screens here, and none switches tab. */ +@Composable +private fun TabRoot( + t: Tab, + nav: AppNavState, + shown: ShownDay, + histories: Histories, + today: TodayHooks, + container: AppContainer, + dayVm: DayViewModel, + workoutsVm: WorkoutsViewModel, + rootVm: AppRootViewModel, + live: LiveHeart, + padding: PaddingValues, + showCharge: Boolean, + onChargeConsume: () -> Unit, + header: HeaderHooks, + onOpenPage: (Page) -> Unit, + seeHabitAge: () -> Unit, +) { + val day = shown.day + val workouts by workoutsVm.workouts.collectAsStateWithLifecycle() + val usualSteps by dayVm.usualSteps.collectAsStateWithLifecycle() + val activity by dayVm.activityHistory.collectAsStateWithLifecycle() + val suggestions by workoutsVm.suggestions.collectAsStateWithLifecycle() + // Hidden while a workout records: the strap's history may show the one under way. + val suggestion = suggestions.firstOrNull().takeIf { today.running == null } + val saveSuggestion: () -> Unit = { nav.setSavingStretch(suggestion?.let { it.start..it.end }) } + val dismissSuggestion: () -> Unit = { suggestion?.let { container.workout.dismissSuggestion(it.start, it.end) } } + val openStack: (Tab, Detail) -> Unit = { tab, d -> nav.setStack(tab, listOf(d)) } + when (t) { + // The moment's mood lights Today only (spec 2.1 Moods), the Sleep tab sets its own. + Tab.Today -> CompositionLocalProvider(LocalGlowMood provides (today.moment?.let(::momentMood) ?: GlowMood.Day)) { + val offer by container.workout.offer.collectAsStateWithLifecycle() + val edits by rootVm.todayEdits.collectAsStateWithLifecycle() + val habitAgeSeen by rootVm.habitAgeSeenAsOf.collectAsStateWithLifecycle() + GlowTodayScreen( + day, live.bpm, header = { TabHeader(Tab.Today, header) }, contentPadding = padding, moment = today.moment, + onOpenMetric = { openStack(Tab.Today, Detail.OfMetric(it)) }, + onOpen = { openStack(Tab.Today, it) }, usualSteps = usualSteps, + recovery = shown.recovery, + strapReadiness = shown.strapReadiness, + checkIn = shown.checkIn, + onCheckIn = { v -> rootVm.saveCheckIn(day.date, v) }, + onOpenRecovery = { openStack(Tab.Today, Detail.Recovery) }, + workouts = workouts, + onOpenWorkout = { openStack(Tab.Today, Detail.Workout(it)) }, + onOpenHealthspan = { seeHabitAge(); openStack(Tab.Today, Detail.Healthspan) }, + healthspan = shown.healthspan, + habitAgeSeenAsOf = habitAgeSeen, + onOpenProfile = { onOpenPage(Page.Profile) }, + history = histories.daily, + edits = edits, + onEditsChange = rootVm::saveTodayEdits, + suggestion = suggestion, + onSaveSuggestion = saveSuggestion, + onDismissSuggestion = dismissSuggestion, + // The same way in as Activity's start card: the ready screen for the sport, or Choose with every sport. + onStartSport = { nav.openReady(it) }, + onOtherSport = nav::openChoose, + offer = offer, + onRecordOffer = { container.workout.recordOffer() }, + onDeclineOffer = { container.workout.declineOffer() }, + running = today.running, + onOpenRunning = today.onOpenRunning, + liveHero = today.liveHero, + heroLightAngle = today.moment?.let(::heroLightAngle), + strap = today.strap, + recoveryFailure = shown.recoveryFailure, + onRetryRecovery = dayVm::retryRecovery, + onPair = { onOpenPage(Page.Strap) }, + onSyncNow = container.strap::syncNow, + usualBedtime = today.usualBedtime, + wear = shown.wear, firstScore = shown.firstScore, + ) + } + Tab.Recovery -> GlowRecoveryScreen( + day, shown.recovery, histories.daily, header = { TabHeader(Tab.Recovery, header) }, contentPadding = padding, + onOpenMetric = { openStack(Tab.Recovery, Detail.OfMetric(it)) }, + onOpenScore = { openStack(Tab.Recovery, Detail.OfScore(it)) }, + strapScore = shown.strapScore, + healthspan = shown.healthspan, + onOpenHealthspan = { seeHabitAge(); openStack(Tab.Recovery, Detail.Healthspan) }, + onOpenProfile = { onOpenPage(Page.Profile) }, + showCharge = showCharge, onChargeShown = onChargeConsume, + recoveryFailure = shown.recoveryFailure, onRetryRecovery = dayVm::retryRecovery, + wear = shown.wear, firstScore = shown.firstScore, + ) + Tab.Sleep -> GlowSleepScreen( + day, header = { TabHeader(Tab.Sleep, header) }, contentPadding = padding, + onOpen = { openStack(Tab.Sleep, it) }, onRestore = dayVm::restoreSleep, + recovery = shown.recovery, + ) + Tab.Activity -> GlowActivityScreen( + day, shown.recovery, workouts, activity, + header = { TabHeader(Tab.Activity, header) }, contentPadding = padding, + onOpenMetric = { openStack(Tab.Activity, Detail.OfMetric(it)) }, + onOpen = { openStack(Tab.Activity, it) }, usualSteps = usualSteps, + onOpenWorkout = { openStack(Tab.Activity, Detail.Workout(it)) }, + onAllWorkouts = { openStack(Tab.Activity, Detail.AllWorkouts) }, + onEditProfile = { onOpenPage(Page.Profile) }, + onStartWorkout = { nav.openReady(it) }, + suggestion = suggestion, + onSaveSuggestion = saveSuggestion, + onDismissSuggestion = dismissSuggestion, + onAllSports = nav::openChoose, + onAddWorkout = { nav.setAddingOn(day.date) }, + liveHeartRate = live.bpm, history = histories.daily, wear = shown.wear, + ) + } +} + +/** + * All metrics: a map, not a card. Rows whose home is a tab go there, at its root and top (Charge to its card), the + * rest open their detail on the tab showing. The day is shared, so the tab shows the same one. The sheet closes first. + */ +@Composable +internal fun AllMetricsHost( + nav: AppNavState, + shown: ShownDay, + workoutsVm: WorkoutsViewModel, + scroll: ScrollRequests, + closePages: () -> Boolean, + onShowCharge: () -> Unit, +) { + if (!nav.allMetricsOpen) return + val workouts by workoutsVm.workouts.collectAsStateWithLifecycle() + AllMetricsSheet( + metricIndex(shown.day, shown.recovery, shown.healthspan, workouts, strapScore = shown.strapScore), shown.day.date, + onDismiss = { nav.setAllMetrics(false) }, + onOpen = { target -> + nav.setAllMetrics(false) + closePages() + when (target) { + is IndexTarget.Open -> nav.push(nav.current, target.detail) + is IndexTarget.Home -> { + val home = when (target.tab) { + HomeTab.Recovery -> Tab.Recovery + HomeTab.Sleep -> Tab.Sleep + } + nav.setStack(home, emptyList()) + nav.select(home) + if (target.charge) onShowCharge() else scroll.bump(home.ordinal) + } + } + }, + ) +} + +/** What the header on every tab shows and opens. */ +internal class HeaderHooks( + val day: DayUiState, + val today: TodayMoment, + val running: WorkoutState.Recording?, + val chip: StrapChip, + val dayVm: DayViewModel, + val rootVm: AppRootViewModel, + val onOpenPage: (Page) -> Unit, +) + +/** The header on every tab: the moment on Today (a past day's name for a past day), the tab's name elsewhere. */ +@Composable +private fun TabHeader(t: Tab, h: HeaderHooks) { + val dayVm = h.dayVm + val daysWithData by dayVm.daysWithData.collectAsStateWithLifecycle() + val picker = remember(daysWithData, dayVm) { DayPicker(daysWithData, dayVm::select) } + val updateWaiting by h.rootVm.updateWaiting.collectAsStateWithLifecycle() + val day = h.day + val title = if (t != Tab.Today) { + t.label + } else { + h.today.layout?.let { m -> + // During a workout the header is the sport under way, after one the sport just done. + val sport = if (m == Moment.During) h.running?.workout?.sportType else h.today.lastWorkout?.workout?.sportType + todayTitle(m, day.date, sport) + } ?: dayTitle(day.date) + } + MomentHeader( + title, day.date, h.chip, dayVm::previousDay, dayVm::nextDay, picker, + onOpenYou = { h.onOpenPage(Page.You) }, onOpenStrap = { h.onOpenPage(Page.Strap) }, + // All metrics is hidden for now (spec 3.1). Passing { nav.setAllMetrics(true) } brings the button back: the + // sheet opens over whatever shows, and no tab hears of it (spec 3.2). + onOpenAllMetrics = null, + updateWaiting = updateWaiting, + ) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/TodayMoment.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/TodayMoment.kt new file mode 100644 index 00000000..bb55d498 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/TodayMoment.kt @@ -0,0 +1,113 @@ +// Keyed effects run once per key after the frame commits, as they did in AppRoot. A SideEffect would run on every +// recomposition. +@file:Suppress("UnnecessaryLaunchedEffect") + +package org.openstrap.app.ui.root + +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import kotlinx.coroutines.delay +import org.openstrap.app.data.Moment +import org.openstrap.app.data.MomentResolver +import org.openstrap.app.data.RecoveryResult +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.ui.dashboard.DayUiState +import org.openstrap.app.ui.dashboard.RecoveryFailure +import org.openstrap.app.ui.dashboard.layoutMoment +import org.openstrap.app.ui.workout.WorkoutsViewModel + +/** What the clock says about Today (spec 4.1). */ +class TodayMoment( + /** Today by the clock. */ + val clockDate: LocalDate, + /** The moment, forced or the clock's: whichever day is shown, the mood follows it. */ + val moment: Moment?, + /** What Today is laid out for: the moment today, the waking layout for a past day. */ + val layout: Moment?, + /** Your usual bedtime, kept from when today was last loaded. */ + val usualBedtime: LocalTime?, + /** The workout that ended last, for the header's sport after one. */ + val lastWorkout: WorkoutAnalysis?, +) + +private const val MINUTE_MS = 60_000L + +/** + * The moment on a clock that ticks each minute only while the app is in view. It stays in composition: it reads the + * day and Recovery from one view model and the workouts from another, and moving it would change when the kept facts + * update (wave 4 Decisions 8). Open across midnight on today, the day shown moves on with the clock ([onNewDay]). + */ +// The ticker reads the clock as each minute starts, not once in composition. +@Suppress("ForbiddenMethodCall/uiClock") +@Composable +internal fun rememberTodayMoment( + day: DayUiState, + recovery: RecoveryResult?, + recoveryFailure: RecoveryFailure?, + recording: Boolean, + forced: Moment?, + workoutsVm: WorkoutsViewModel, + onNewDay: (LocalDate) -> Unit, +): TodayMoment { + val workouts by workoutsVm.workouts.collectAsStateWithLifecycle() + val lifecycle = LocalLifecycleOwner.current.lifecycle + val clock by produceState(Instant.now(), lifecycle) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (true) { + value = Instant.now() + delay(MINUTE_MS - System.currentTimeMillis() % MINUTE_MS) + } + } + } + val zone = ZoneId.systemDefault() + val clockDate = clock.atZone(zone).toLocalDate() + // Open across midnight on today, the day moves on with the clock, so night carries on past 00:00. + var shownToday by remember { mutableStateOf(clockDate) } + val newDay by rememberUpdatedState(onNewDay) + LaunchedEffect(clockDate) { + if (clockDate != shownToday) { + if (day.date == shownToday) newDay(clockDate) + shownToday = clockDate + } + } + // Your usual workout time comes from workouts you started, not walks the strap noticed by itself. + val workoutStarts = remember(workouts) { workouts.filter { it.startedByYou }.map { it.workout.start } } + val lastWorkout = remember(workouts) { workouts.maxByOrNull { it.end } } + // The moment is the clock's, whichever day is shown: browsing yesterday at night is still night (the mood follows + // it), and only Today's layout goes back to waking for a past day. Today's wake and usual bedtime are kept from + // when today was last loaded, so a past day doesn't lose them. Until today's recovery has loaded once, the + // bedtime isn't known and the fallback would show Evening for a second before the real moment, so there's no + // moment until then: Today's plain title, the day's mood. + // A Recovery that failed counts as loaded: the moment shouldn't wait on it. + val todayLoaded = day.date == clockDate && (recovery?.date == clockDate || recoveryFailure?.date == clockDate) + val freshClockFacts = if (todayLoaded) day.sleep?.end to recovery?.usualBedtime else null + var keptClockFacts by remember(clockDate) { mutableStateOf?>(null) } + SideEffect { if (freshClockFacts != null && freshClockFacts != keptClockFacts) keptClockFacts = freshClockFacts } + val clockFacts = freshClockFacts ?: keptClockFacts + val clockMoment = remember(clock.epochSecond / 60, clockDate, recording, lastWorkout?.end, clockFacts, workoutStarts) { + clockFacts?.let { (sleepEnd, bedtime) -> + MomentResolver.resolve(clock, zone, clockDate, recording, lastWorkout?.end, sleepEnd, bedtime, workoutStarts) + } + } + val moment = forced ?: clockMoment + LaunchedEffect(forced, clockMoment) { + forced?.let { Log.i("OpenStrap", "moment: forced $it, the clock says $clockMoment") } + } + return TodayMoment(clockDate, moment, layoutMoment(moment, day.date == clockDate), clockFacts?.second, lastWorkout) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/ViewModelFactories.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/ViewModelFactories.kt new file mode 100644 index 00000000..24018d8f --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/ViewModelFactories.kt @@ -0,0 +1,64 @@ +package org.openstrap.app.ui.root + +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.flow.StateFlow +import org.openstrap.app.AppContainer +import org.openstrap.app.data.SampleData +import org.openstrap.app.data.Today +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.export.AutoBackup +import org.openstrap.app.ui.alarms.AlarmsViewModel +import org.openstrap.app.ui.alarms.PhoneAlertsViewModel +import org.openstrap.app.ui.dashboard.DayViewModel +import org.openstrap.app.ui.device.DeviceViewModel +import org.openstrap.app.ui.firmware.FirmwareViewModel +import org.openstrap.app.ui.workout.WorkoutsViewModel + +/** + * How each of the app's view models is made from the container, in one place, so the app root and the pages that + * share a view model with it can't build it two ways. + */ +internal object ViewModelFactories { + /** The day shown, which moves on to the new day as soon as the phone's date, time or time zone changes. */ + fun day(c: AppContainer): ViewModelProvider.Factory = viewModelFactory { + initializer { + DayViewModel(c.days, c.settings.profile, c.autoHealthConnect::sleepEdited, dateChanges = Today.changes(c.app)) + } + } + + fun workouts(c: AppContainer): ViewModelProvider.Factory = viewModelFactory { + initializer { WorkoutsViewModel(c.workouts, dateChanges = Today.changes(c.app)) } + } + + /** [workouts] is the workouts view model's list: both live as long as the activity, so the list is worked out once. */ + fun root(c: AppContainer, workouts: StateFlow>): ViewModelProvider.Factory = viewModelFactory { + initializer { + AppRootViewModel( + c.settings, c.health, c.database.healthDao(), c.strap, c.workout, c.rewards, c.healthConnect, c.updater, workouts, + ) + } + } + + fun device(c: AppContainer): ViewModelProvider.Factory = viewModelFactory { + initializer { + DeviceViewModel( + c.settings, c.strap, c.companion, c.scanner::scan, c.backups, + AutoBackup(c.app, c.settings, c.workout.restored, c.workout.state, c.backups), + c.healthConnect, c.autoHealthConnect, + { SampleData.load(c.database.healthDao()) }, + ) + } + } + + fun firmware(c: AppContainer): ViewModelProvider.Factory = viewModelFactory { + initializer { FirmwareViewModel(c.strap, c.settings, c.app.cacheDir) { c.app.contentResolver.openInputStream(it) } } + } + + fun phoneAlerts(c: AppContainer): ViewModelProvider.Factory = viewModelFactory { + initializer { PhoneAlertsViewModel(c.phoneAlerts, c.settings) } + } + + fun alarms(c: AppContainer): ViewModelProvider.Factory = viewModelFactory { initializer { AlarmsViewModel(c.strap) } } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/root/WorkoutLayer.kt b/app/src/main/kotlin/org/openstrap/app/ui/root/WorkoutLayer.kt new file mode 100644 index 00000000..1e5c663e --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/root/WorkoutLayer.kt @@ -0,0 +1,437 @@ +// The workout screens read the workouts and root view models for their lists, settings and rewards, as inside AppRoot. +@file:Suppress("ViewModelForwarding", "ComposeViewModelForwarding") + +package org.openstrap.app.ui.root + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource +import java.time.LocalDate +import java.time.ZoneId +import kotlinx.coroutines.flow.emptyFlow +import org.openstrap.app.AppContainer +import org.openstrap.app.data.SportSettings +import org.openstrap.app.data.WorkoutPlan +import org.openstrap.app.data.WorkoutSetup +import org.openstrap.app.data.Workouts +import org.openstrap.app.data.db.SavedWorkout +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutSession +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.device.locationAvailable +import org.openstrap.app.presentation.SummaryRewards +import org.openstrap.app.ui.dashboard.DayUiState +import org.openstrap.app.ui.glow.Celebrations +import org.openstrap.app.ui.glow.ChooseSport +import org.openstrap.app.ui.glow.GlowAddWorkoutScreen +import org.openstrap.app.ui.glow.GlowChooseScreen +import org.openstrap.app.ui.glow.GlowSportPickerScreen +import org.openstrap.app.ui.glow.GlowTabletopHeart +import org.openstrap.app.ui.glow.GlowWorkoutReadyScreen +import org.openstrap.app.ui.glow.GlowWorkoutSummaryScreen +import org.openstrap.app.ui.glow.PushOverlay +import org.openstrap.app.ui.glow.WorkoutCapsule +import org.openstrap.app.ui.glow.WorkoutMorph +import org.openstrap.app.ui.glow.live.GlowWorkoutGlance +import org.openstrap.app.ui.glow.live.GlowWorkoutLiveScreen +import org.openstrap.app.ui.workout.GlanceAction +import org.openstrap.app.ui.workout.LiveGlance +import org.openstrap.app.ui.workout.StartCopy +import org.openstrap.app.ui.workout.WorkoutsViewModel + +/** The highest heart rate the add workout chart allows for when yours isn't known. */ +private const val FALLBACK_MAX_HR = 190 + +/** Where Choose, Ready and the zones page sit in what popups blur, each over the one before. */ +private const val CHOOSE_Z = 2f +private const val READY_Z = 2.5f +private const val ZONES_Z = 3f + +private const val MS_PER_S = 1000 + +/** The live workout, the Choose and Ready screens, and the folds that split them. */ +internal class WorkoutLayerHooks( + val live: Int?, + val connection: ConnectionState, + /** Starts a plan once Bluetooth (and location, for GPS) may be used, then closes Ready and Choose. */ + val startPlan: (WorkoutPlan, SportSettings?) -> Unit, + val requestLocation: (() -> Unit) -> Unit, + val capsuleBottom: Dp, + val tabletop: Boolean, + val bookHinge: FoldingFeature?, +) + +/** What a tap on the workout at a glance does, on the floating bar and on the Today hero. */ +internal fun glanceAction(session: WorkoutSession): (GlanceAction) -> Unit = { a -> + when (a) { + GlanceAction.Pause -> session.pause() + GlanceAction.Resume -> session.resume() + GlanceAction.KeepGoing -> session.keepGoing() + GlanceAction.SetDone -> session.setDone() + GlanceAction.StartSet -> session.startSet() + GlanceAction.AddRest -> { + val strength = (session.state.value as? WorkoutState.Recording)?.strength == true + if (strength) session.extendRest() else session.addRest() + } + GlanceAction.Skip -> session.skipPhase() + // Ending asks on the live screen, as it does there. + GlanceAction.End, GlanceAction.Open -> session.minimized.value = false + } +} + +/** + * Half open on a table, the fold level: heart rate fills the standing top half and the app carries on below the fold. + * During a workout the top half is the workout's heart card instead, and the controls stay below. + */ +@Composable +internal fun TabletopTop( + hinge: FoldingFeature, + container: AppContainer, + workoutOnTop: WorkoutState.Recording?, + day: DayUiState, + live: Int?, + liveEnabled: Boolean, + connection: ConnectionState, +) { + val density = LocalDensity.current + val top = Modifier.height(with(density) { hinge.bounds.top.toDp() }) + if (workoutOnTop != null) { + val recent by container.workout.recent.collectAsStateWithLifecycle() + val zoneSeconds by container.workout.zoneSeconds.collectAsStateWithLifecycle() + val kcal by container.workout.kcal.collectAsStateWithLifecycle() + GlowWorkoutGlance(workoutOnTop, live, recent, zoneSeconds, connection, top, kcal) + } else { + GlowTabletopHeart( + day, live, liveEnabled.takeIf { connection is ConnectionState.Connected }, { container.strap.setLiveHeartRate(it) }, top, + ) + } + Spacer(Modifier.height(with(density) { hinge.bounds.height().toDp() })) +} + +/** + * What the sport picker needs (spec 6.3): your last four sports for its Recent row, newest first and only those you + * started, and the Outdoor or Indoor side last chosen per sport on Ready. + */ +@Composable +internal fun pickerSports(workoutsVm: WorkoutsViewModel, rootVm: AppRootViewModel): Pair, Map> { + val workouts by workoutsVm.workouts.collectAsStateWithLifecycle() + val sides by rootVm.workoutSides.collectAsStateWithLifecycle() + val recent = remember(workouts) { + Workouts.recentSports(workouts.filter { it.startedByYou }.sortedByDescending { it.workout.start }.map { it.workout.sportType }) + } + return recent to sides +} + +/** Adding a workout from a day's heart rate, over everything. */ +@Composable +internal fun AddWorkoutHost(nav: AppNavState, container: AppContainer, workoutsVm: WorkoutsViewModel, rootVm: AppRootViewModel) { + val date = nav.addingOn ?: return + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + val series by produceState>?>(null, date) { value = workoutsVm.dayMinuteHeartRate(date) } + val maxHr by workoutsVm.maxHeartRate.collectAsStateWithLifecycle() + val (recent, sides) = pickerSports(workoutsVm, rootVm) + GlowAddWorkoutScreen( + date, series, maxHr ?: FALLBACK_MAX_HR, WindowInsets.navigationBars.asPaddingValues(), + onSave = { from, to, sport -> + container.workout.saveFromHistory(from, to, sport) + nav.setAddingOn(null) + }, + onBack = { nav.setAddingOn(null) }, + recentSports = recent, sides = sides, + ) + } +} + +/** The sport for a stretch the strap noticed, saved as a workout once picked. */ +@Composable +internal fun SaveStretchHost(nav: AppNavState, container: AppContainer, workoutsVm: WorkoutsViewModel, rootVm: AppRootViewModel) { + val s = nav.savingStretch ?: return + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { + val (recent, sides) = pickerSports(workoutsVm, rootVm) + GlowSportPickerScreen( + WindowInsets.navigationBars.asPaddingValues(), + onPick = { sport -> + container.workout.saveFromHistory(s.first, s.last, sport) + nav.setSavingStretch(null) + }, + onBack = { nav.setSavingStretch(null) }, + recentSports = recent, sides = sides, + ) + } +} + +/** + * The workout screens over the whole app: the ready screen, the live screen (or, tucked away, a capsule above the tab + * bar) and the summary once it ends. + */ +@Composable +internal fun BoxScope.WorkoutLayer( + container: AppContainer, + nav: AppNavState, + state: WorkoutState, + minimized: Boolean, + workoutsVm: WorkoutsViewModel, + rootVm: AppRootViewModel, + hooks: WorkoutLayerHooks, + /** What popups blur: Choose, Ready and the zones page join it over the pages, so a sheet blurs the screen it opened from. */ + glass: HazeState?, + /** The workout at a glance, for the floating bar, read only there. */ + glance: () -> LiveGlance?, + onGlanceAction: (GlanceAction) -> Unit, + /** The Today hero is showing the workout, so the bar stays away. */ + capsuleHidden: Boolean, +) { + // Read here, ahead of a workout, so the live screen usually opens on the right view at once. + val liveView by rootVm.workoutLiveView.collectAsStateWithLifecycle() + // Also kept subscribed all along, so Ready never opens on the defaults before your settings arrive. + val sportSettings by rootVm.sportSettings.collectAsStateWithLifecycle() + val padding = WindowInsets.navigationBars.asPaddingValues() + val full = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background) + val over: (Float) -> Modifier = { z -> if (glass != null) full.hazeSource(glass, zIndex = z) else full } + when (state) { + // Minimizing morphs the live screen into the capsule above the tab bar, and tapping the capsule back. + is WorkoutState.Recording -> LiveWorkout( + container.workout, state, minimized, liveView, rootVm::setWorkoutLiveView, hooks, padding, + capsule = { place -> + glance()?.let { g -> + WorkoutCapsule(g, onClick = { container.workout.minimized.value = false }, onAction = onGlanceAction, modifier = place) + } + }, + capsuleHidden = capsuleHidden, + ) + is WorkoutState.Ended -> Box(full) { WorkoutSummary(container.workout, state, workoutsVm, rootVm, padding) } + else -> Unit + } + // The ready screen comes and goes like any other screen, and a workout starting replaces it at once. + val recording = state is WorkoutState.Recording || state is WorkoutState.Ended + // Read once for both screens, and handed down to be read only there. + val saved by remember { container.workout.savedWorkouts() }.collectAsStateWithLifecycle(emptyList()) + // Choose sits under Ready, so closing Ready comes back to it. + PushOverlay(nav.choosing.takeIf { it && !recording }, cut = recording) { + Box(over(CHOOSE_Z)) { ChooseOverlay(nav, workoutsVm, rootVm, { sportSettings }, { saved }, hooks, padding) } + } + val ready = nav.workout as? WorkoutOverlay.Ready + PushOverlay(ready?.sport.takeIf { !recording }, cut = recording) { sport -> + Box(over(READY_Z)) { + ReadyOverlay(nav, sport, ready, container.workout, workoutsVm, rootVm, { sportSettings }, { saved }, hooks, padding) + // Your zones, over Ready. Back comes back to it. + PushOverlay(Unit.takeIf { ready?.zones == true }, cut = false) { + Box(over(ZONES_Z)) { + HeartZonesPage(container, padding, rootVm::setMaxHeartRate, rootVm::setHeartZones, onBack = nav::closeZones) + } + } + } + } +} + +@Composable +private fun LiveWorkout( + session: WorkoutSession, + state: WorkoutState.Recording, + minimized: Boolean, + liveView: Int?, + onLiveView: (Int) -> Unit, + hooks: WorkoutLayerHooks, + padding: PaddingValues, + capsule: @Composable (Modifier) -> Unit, + capsuleHidden: Boolean, +) { + val kcal by session.kcal.collectAsStateWithLifecycle() + WorkoutMorph( + minimized, hooks.capsuleBottom + padding.calculateBottomPadding(), + full = { + val recent by session.recent.collectAsStateWithLifecycle() + val stats by session.stats.collectAsStateWithLifecycle() + val zoneSeconds by session.zoneSeconds.collectAsStateWithLifecycle() + val route by session.route.collectAsStateWithLifecycle() + val history by session.history.collectAsStateWithLifecycle() + val exercises by remember { session.exercises() }.collectAsStateWithLifecycle(emptyList()) + // Folded, the heart card has the standing half: above the fold (drawn with the tabletop heart) or, stood + // up like a book, the left half here. + val liveScreen: @Composable (Modifier) -> Unit = { modifier -> + Box(modifier) { + GlowWorkoutLiveScreen( + state, hooks.live, recent, stats, zoneSeconds, hooks.connection, padding, + onPause = session::pause, onResume = session::resume, onEnd = session::end, + onMinimize = { session.minimized.value = true }, + onEndAtPrompt = session::endAtPrompt, onKeepGoing = session::keepGoing, + route = route, history = history, + onStartSet = session::startSet, onSetDone = session::setDone, onExtendRest = session::extendRest, + onLap = session::lap, + exercises = exercises, onExercise = session::setExercise, onLogSet = session::logSet, + onSkip = session::skipPhase, onAddRest = session::addRest, + split = hooks.tabletop || hooks.bookHinge != null, + liveView = liveView, onLiveView = onLiveView, + kcal = kcal, + ) + } + } + val book = hooks.bookHinge + if (book != null) { + val density = LocalDensity.current + Row(Modifier.fillMaxSize()) { + GlowWorkoutGlance( + state, hooks.live, recent, zoneSeconds, hooks.connection, + Modifier.width(with(density) { book.bounds.left.toDp() }).fillMaxHeight(), kcal, + ) + Spacer(Modifier.width(with(density) { book.bounds.width().toDp() })) + liveScreen(Modifier.weight(1f).fillMaxHeight()) + } + } else { + liveScreen(Modifier.fillMaxSize()) + } + }, + capsule = capsule, + capsuleHidden = capsuleHidden, + ) +} + +@Composable +private fun WorkoutSummary( + session: WorkoutSession, + state: WorkoutState.Ended, + workoutsVm: WorkoutsViewModel, + rootVm: AppRootViewModel, + padding: PaddingValues, +) { + // A debug preview of a stored workout writes nothing just by opening, so no effect is worked out for it. + val summary by remember(state) { workoutsVm.phoneSummary(state.start, backfill = !state.preview) }.collectAsStateWithLifecycle(null) + val calmDown by session.calmDown.collectAsStateWithLifecycle() + val route by session.route.collectAsStateWithLifecycle() + val workouts by workoutsVm.workouts.collectAsStateWithLifecycle() + val usualDrop by produceState(null, state.start) { value = workoutsVm.usualDrop(state.start) } + // What it earned, from every workout and day (workouts spec 2.8), once the list holds it. + val goals by rootVm.goals.collectAsStateWithLifecycle() + val ended = workouts.firstOrNull { it.workout.start == state.start } + val rewards by produceState(null, ended?.end, ended?.shownStart, goals.workoutsPerWeek) { + if (ended == null) return@produceState + value = rootVm.summaryRewards(state.start) + } + val celebrations by rootVm.celebrations.collectAsStateWithLifecycle() + GlowWorkoutSummaryScreen( + summary, calmDown.takeIf { !state.preview }, if (state.preview) emptyList() else route, workouts, usualDrop, padding, + onDone = session::dismissSummary, onDelete = { session.discard(state.start) }, + onTrim = { from, to -> session.trim(state.start, from, to) }, onUndoTrim = { session.undoTrim(state.start) }, + rewards = rewards.takeIf { !state.preview }, + celebrations = Celebrations( + celebrations.goalMoment, celebrations.weeklyRecap, celebrations.momentWeek, + setGoalMoment = rootVm::setGoalMoment, + setWeeklyRecap = rootVm::setWeeklyRecap, + markMoment = rootVm::setGoalMomentWeek, + ), + ) +} + +// Choose works out your usual sport and its "last time" as it opens, from the clock then, as it did inside AppRoot. +@Suppress("ForbiddenMethodCall/uiClock") +@Composable +private fun ChooseOverlay( + nav: AppNavState, + workoutsVm: WorkoutsViewModel, + rootVm: AppRootViewModel, + sportSettings: () -> Map, + savedWorkouts: () -> List, + hooks: WorkoutLayerHooks, + padding: PaddingValues, +) { + val context = LocalContext.current + val workouts by workoutsVm.workouts.collectAsStateWithLifecycle() + val (_, sides) = pickerSports(workoutsVm, rootVm) + val zone = ZoneId.systemDefault() + val mine = remember(workouts) { workouts.filter { it.startedByYou }.sortedByDescending { it.workout.start } } + val sports = remember(mine) { + WorkoutSetup.choose(mine.map { it.workout.sportType to it.workout.start }, System.currentTimeMillis() / MS_PER_S) + } + val today = LocalDate.now(zone) + val usual = sports.usual?.let { type -> + val last = mine.firstOrNull { Workouts.tileType(it.workout.sportType) == type } + ChooseSport(type, last?.let { StartCopy.lastTime(it, today, zone) }.orEmpty()) + } + val saved = savedWorkouts() + val recent = remember(sports, saved) { WorkoutSetup.recentChips(sports.recent, saved) } + GlowChooseScreen( + usual, recent, hooks.connection, hooks.live, padding, + onStartUsual = { type -> + val settings = sportSettings()[type] ?: SportSettings() + hooks.startPlan(WorkoutSetup.plan(type, settings, sides, locationAvailable(context)), null) + }, + onReady = { nav.openReady(it, picking = false, structure = null) }, + onSaved = { w -> nav.openReady(w.sport, picking = false, structure = w.structure) }, + onBack = nav::closeChoose, + ) +} + +@Composable +private fun ReadyOverlay( + nav: AppNavState, + sport: Int, + ready: WorkoutOverlay.Ready?, + session: WorkoutSession, + workoutsVm: WorkoutsViewModel, + rootVm: AppRootViewModel, + sportSettings: () -> Map, + savedWorkouts: () -> List, + hooks: WorkoutLayerHooks, + padding: PaddingValues, +) { + val saved = savedWorkouts() + val (recentSports, sides) = pickerSports(workoutsVm, rootVm) + val key = Workouts.tileType(sport) + val maxHr by workoutsVm.maxHeartRate.collectAsStateWithLifecycle() + GlowWorkoutReadyScreen( + sport, ready?.picking == true, hooks.connection, hooks.live, padding, + settings = sportSettings()[key] ?: SportSettings(), + saved = saved, onSaveIntervals = { name, code -> session.saveWorkout(name, sport, code) }, structure = ready?.structure, + recentSports = recentSports, sides = sides, + maxHeartRate = maxHr, onYourZones = nav::openZones, + onSide = rootVm::setWorkoutSide, + onTurnOnLocation = hooks.requestLocation, + onSport = nav::readySport, + onStart = hooks.startPlan, + onBack = nav::closeReady, + ) +} + +/** The folds half open: [table] lying level (the top half stands), [book] stood up like a book (the fold upright). */ +internal class Folds(val table: FoldingFeature?, val book: FoldingFeature?) + +/** Where the phone is folded half open, as the window reports it. */ +@Composable +internal fun rememberFolds(): Folds { + val activity = LocalActivity.current + val windowLayout by remember(activity) { + activity?.let { WindowInfoTracker.getOrCreate(it).windowLayoutInfo(it) } ?: emptyFlow() + }.collectAsStateWithLifecycle(null) + val halfOpen = windowLayout?.displayFeatures?.filterIsInstance() + ?.filter { it.state == FoldingFeature.State.HALF_OPENED }.orEmpty() + return Folds( + halfOpen.firstOrNull { it.orientation == FoldingFeature.Orientation.HORIZONTAL }, + halfOpen.firstOrNull { it.orientation == FoldingFeature.Orientation.VERTICAL }, + ) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/theme/AppIcons.kt b/app/src/main/kotlin/org/openstrap/app/ui/theme/AppIcons.kt index af843d73..417028b1 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/theme/AppIcons.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/theme/AppIcons.kt @@ -81,6 +81,8 @@ object AppIcons { // The You page's rows. val Target: ImageVector by lazy { line("Target", "M17 12C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12C7 9.23858 9.23858 7 12 7", "M14 2.20004C13.3538 2.06886 12.6849 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12C22 11.3151 21.9311 10.6462 21.8 10", "M12.0303 11.9625L16.5832 7.4096M19.7404 4.34462L19.1872 2.35748C19.0853 2.03011 18.6914 1.89965 18.4259 2.11662C16.9898 3.29018 15.4254 4.87091 16.703 7.36419C19.2771 8.56455 20.7466 6.94584 21.8733 5.5853C22.0975 5.3146 21.9623 4.90767 21.6247 4.81005L19.7404 4.34462Z") } + /** A picture in a frame, for adding a photo to a share card (boards ShareRun to ShareGame), drawn to the boards' own icon. */ + val Image: ImageVector by lazy { line("Image", "M3 8C3 6.34315 4.34315 5 6 5H18C19.6569 5 21 6.34315 21 8V16C21 17.6569 19.6569 19 18 19H6C4.34315 19 3 17.6569 3 16V8Z", "M7.2 10A1.8 1.8 0 1 0 10.8 10A1.8 1.8 0 1 0 7.2 10Z", "M21 16L16 11L8 19") } val UploadSimple: ImageVector by lazy { line("UploadSimple", "M2.99994 17C2.99994 17.93 2.99994 18.395 3.10216 18.7765C3.37956 19.8117 4.18821 20.6204 5.22348 20.8978C5.60498 21 6.06997 21 6.99994 21L16.9999 21C17.9299 21 18.3949 21 18.7764 20.8978C19.8117 20.6204 20.6203 19.8117 20.8977 18.7765C20.9999 18.395 20.9999 17.93 20.9999 17", "M16.5 7.49993C16.5 7.49993 13.1858 2.99997 12 2.99996C10.8141 2.99995 7.50002 7.49996 7.50002 7.49996M12 3.99996V16") } val ArrowCounterClockwise: ImageVector by lazy { line("ArrowCounterClockwise", "M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C8.66873 3 5.76018 4.80989 4.20404 7.5", "M3 3V4.27816C3 6.47004 3 7.56599 3.70725 8.16512C4.4145 8.76425 5.49553 8.58408 7.6576 8.22373L9 8") } val Bug: ImageVector by lazy { line("Bug", "M3.01309 4.99084C2.89323 6.05084 3.55249 8.42285 6.48923 8.42285", "M17.5951 8.38081C18.8357 8.57881 21.1132 7.49881 20.9957 5.00281", "M20.9928 20.9989C21.0528 19.9429 20.1777 17.5549 17.599 17.4229", "M6.45163 17.4708C5.65013 17.2308 3.01306 18.3348 3.01306 20.9988", "M9.3299 6.11884C9.35388 5.09884 9.84533 2.99884 12.0029 2.99884C13.9208 2.99884 14.5861 4.61884 14.676 6.11884M6.26131 9.41884C6.38118 8.63884 7.29216 6.81484 9.36586 6.63484C11.4635 6.55564 14.3403 6.58684 14.8797 6.67084C15.5869 6.73377 17.2951 7.43884 17.7506 9.41884C17.9124 10.4388 17.8285 11.8788 17.8524 12.7188C17.8165 13.5588 17.9207 15.2623 17.7565 16.1388C17.6367 17.0988 16.9894 18.4668 16.1024 19.3068C14.7838 20.7228 11.1639 22.2108 8.03534 19.4508C6.41713 17.8908 6.30925 16.3788 6.18939 15.7788C6.15725 15.4571 6.15875 13.8763 6.16541 12.3588C6.14144 11.046 6.17235 9.78063 6.26131 9.41884Z", "M3.01306 12.8988H5.9498", "M20.9929 12.8988L18.1161 12.8988", "M12.0033 16.4988L12.0033 20.2788") } @@ -135,6 +137,8 @@ object AppIcons { val Walking: ImageVector by lazy { line("Walking", "M6 12.5L7.73811 9.89287C7.91034 9.63452 8.14035 9.41983 8.40993 9.26578L10.599 8.01487C11.1619 7.69323 11.8483 7.67417 12.4282 7.9641C13.0851 8.29255 13.4658 8.98636 13.7461 9.66522C14.2069 10.7814 15.3984 12 18 12", "M13 9L11.7772 14.5951M10.5 8.5L9.77457 11.7645C9.6069 12.519 9.88897 13.3025 10.4991 13.777L14 16.5L15.5 21", "M9.5 16L9 17.5L6.5 20.5", "M15 4.5C15 5.32843 14.3284 6 13.5 6C12.6716 6 12 5.32843 12 4.5C12 3.67157 12.6716 3 13.5 3C14.3284 3 15 3.67157 15 4.5Z") } /** Running, the sport tile and every run. */ val WorkoutRun: ImageVector by lazy { line("WorkoutRun", "M17 4.5C17 5.32843 16.3284 6 15.5 6C14.6716 6 14 5.32843 14 4.5C14 3.67157 14.6716 3 15.5 3C16.3284 3 17 3.67157 17 4.5Z", "M15 21.0008L14.3359 18.3848C14.1161 17.5191 13.6615 16.7284 13.0207 16.0974L11.5 14.5998M20 8.19913C17.9627 10.4921 16.1547 10.1433 15 9.27743C14.8878 9.19326 14.7664 9.06672 14.6482 8.92548C14.2356 8.43256 14.0293 8.18609 13.8282 8.09214C13.6271 7.99818 13.3747 7.99813 12.8698 7.99805C12.5444 7.99799 12.2186 7.99877 12 8.00136C8.53767 8.0423 7 9.18366 6 11.1534M12 8.00136L10.7309 9.95956C10.0332 11.0362 9.68429 11.5745 9.67069 12.1397C9.66463 12.3914 9.70617 12.642 9.79313 12.8784C9.98834 13.409 10.4922 13.8059 11.5 14.5998M15 9.27743L11.5 14.5998", "M4 17.7303L4.67822 17.8916C6.40663 18.3028 8.20324 17.5164 9 16") } + /** Not in the free set: a runner on a belt, drawn on the same grid, for Ready's treadmill side. */ + val Treadmill: ImageVector by lazy { line("Treadmill", "M10.86 3.51A1.56 1.56 0 1 0 13.98 3.51A1.56 1.56 0 1 0 10.86 3.51Z", "M7.74 16.38L10.08 11.7L12.42 13.26V16.38", "M6.18 9.36L8.52 6.24H12.42L13.98 9.36L16.32 10.14", "M2 20.5H18", "M18 20.5L21 11.5H18.5") } /** Strength training, the sport tile and every strength workout. */ val Dumbbell01: ImageVector by lazy { line("Dumbbell01", "M7.73438 13.7323C7.73438 13.7323 8.63984 12.5102 9.23438 12.2319C11.0292 11.3915 11.3943 11.0263 12.2344 9.23091C12.5127 8.63618 13.7344 7.73044 13.7344 7.73044M10.2344 16.2331C10.2344 16.2331 11.4561 15.3273 11.7344 14.7326C12.5745 12.9373 12.9396 12.5721 14.7344 11.7317C15.3289 11.4533 16.2344 10.2312 16.2344 10.2312", "M14.4311 2.89207C14.938 2.38343 15.7611 2.38212 16.2696 2.88916L21.0814 7.68726C21.5899 8.1943 21.5912 9.01767 21.0843 9.52632L19.5557 11.0603C19.0488 11.5689 18.2257 11.5702 17.7172 11.0632L12.9054 6.26507C12.397 5.75803 12.3957 4.93466 12.9025 4.42601L14.4311 2.89207Z", "M4.41377 12.9022C4.92065 12.3936 5.74376 12.3923 6.25225 12.8993L11.0641 17.6974C11.5725 18.2045 11.5738 19.0278 11.067 19.5365L9.53836 21.0704C9.03148 21.5791 8.20837 21.5804 7.69988 21.0733L2.88808 16.2752C2.37959 15.7682 2.37829 14.9448 2.88517 14.4362L4.41377 12.9022Z", "M17.9377 3.45254C19.8201 0.985467 23.14 3.8401 20.5431 6.02872M3.37811 17.9773C0.998473 19.9687 3.99782 23.1586 6.06742 20.4657") } /** A pennant, for marking a lap. */ @@ -145,6 +149,14 @@ object AppIcons { val Refresh04: ImageVector by lazy { line("Refresh04", "M3.05493 13C3.01863 12.6717 3 12.338 3 12C3 7.02944 7.02944 3 12 3C14.8273 3 17.35 4.30367 19 6.34267M20.9451 11C20.9814 11.3283 21 11.662 21 12C21 16.9706 16.9706 21 12 21C9.17273 21 6.64996 19.6963 5 17.6573", "M16 7H17C18.4142 7 19.1213 7 19.5607 6.56066C20 6.12132 20 5.41421 20 4V3", "M8 17H7C5.58579 17 4.87868 17 4.43934 17.4393C4 17.8787 4 18.5858 4 20V21") } /** Find strap on the Strap page. */ val SearchArea: ImageVector by lazy { line("SearchArea", "M18.5016 18.5L21 21M20 14.5C20 11.4624 17.5376 9 14.5 9C11.4624 9 9 11.4624 9 14.5C9 17.5376 11.4624 20 14.5 20C17.5376 20 20 17.5376 20 14.5Z", "M10 3H14M3 10V14M6.5 21C4.567 21 3 19.433 3 17.5M17.5 3C19.433 3 21 4.567 21 6.5M3 6.5C3 4.567 4.567 3 6.5 3") } + + /** The live map's route-only switch: a folded map. */ + val MapFold: ImageVector by lazy { line("MapFold", "M3 6L9 4L15 6L21 4V18L15 20L9 18L3 20Z", "M9 4V18", "M15 6V20") } + + /** The live map's follow-me button: an arrow pointing the way. */ + val NavigationArrow: ImageVector by lazy { solid("NavigationArrow", "M12 2L19 21L12 17L5 21Z") } + /** Skip: a play triangle against a bar, for moving on to the next phase. */ + val SkipFill: ImageVector by lazy { solid("SkipFill", "M5 6.2C5 4.7 6.6 3.8 7.9 4.6L15.6 10.3C16.8 11.1 16.8 12.9 15.6 13.7L7.9 19.4C6.6 20.2 5 19.3 5 17.8Z", "M17.5 5.5C17.5 4.7 18.1 4 19 4C19.9 4 20.5 4.7 20.5 5.5V18.5C20.5 19.3 19.9 20 19 20C18.1 20 17.5 19.3 17.5 18.5Z") } /** All sports: three dots in a circle, "more" at the end of the sport tiles, apart from All metrics' grid. */ val MoreHorizontalCircle01: ImageVector by lazy { line("MoreHorizontalCircle01", "M21 12C21 11.1716 20.3284 10.5 19.5 10.5C18.6716 10.5 18 11.1716 18 12C18 12.8284 18.6716 13.5 19.5 13.5C20.3284 13.5 21 12.8284 21 12Z", "M13.5 12C13.5 11.1716 12.8284 10.5 12 10.5C11.1716 10.5 10.5 11.1716 10.5 12C10.5 12.8284 11.1716 13.5 12 13.5C12.8284 13.5 13.5 12.8284 13.5 12Z", "M6 12C6 11.1716 5.32843 10.5 4.5 10.5C3.67157 10.5 3 11.1716 3 12C3 12.8284 3.67157 13.5 4.5 13.5C5.32843 13.5 6 12.8284 6 12Z") } } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/theme/Surfaces.kt b/app/src/main/kotlin/org/openstrap/app/ui/theme/Surfaces.kt new file mode 100644 index 00000000..f8428a02 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/theme/Surfaces.kt @@ -0,0 +1,6 @@ +package org.openstrap.app.ui.theme + +import androidx.compose.ui.graphics.Color + +/** The Control surface (spec 2.2): buttons, chips, pills and text fields on a page. */ +internal val ControlSurface = Color(0xFF1F1F22) diff --git a/app/src/main/kotlin/org/openstrap/app/ui/theme/Theme.kt b/app/src/main/kotlin/org/openstrap/app/ui/theme/Theme.kt index 10c429f7..93a6c941 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/theme/Theme.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/theme/Theme.kt @@ -113,9 +113,6 @@ val FunnelSans = FontFamily( variable(R.font.funnel_sans, 700), ) -/** MuseoModerno for the OpenStrap wordmark only. SIL OFL 1.1 (see licenses/). */ -val MuseoModerno = FontFamily(variable(R.font.museo_moderno, 600)) - private val Base = Typography() /** Big numerals use tabular figures so values don't jitter as they update (e.g. live HR). */ diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/FamilySummary.kt b/app/src/main/kotlin/org/openstrap/app/ui/workout/FamilySummary.kt new file mode 100644 index 00000000..7238ec61 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/workout/FamilySummary.kt @@ -0,0 +1,337 @@ +package org.openstrap.app.ui.workout + +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import org.openstrap.app.data.Games +import org.openstrap.app.data.Strength +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.WorkoutSummaryData +import org.openstrap.app.data.Workouts +import org.openstrap.app.data.db.AppWorkoutRound +import org.openstrap.app.data.db.AppWorkoutSet +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.presentation.Units + +/* + * What each kind of workout's summary says under its hero (workouts spec 2.8): the fastest split, sets per exercise + * and how fast rest brought the heart rate down, each round's peak and drop, how the heart settled, a game's periods + * and whether you faded. Pure, so it's tested without a workout ending. + */ + +/** One logged or numbered set on the summary: "10 × 60 kg" (or "Set 3" unlogged) and its peak. */ +data class SetLine(val number: Int, val text: String, val peak: Int?) + +/** An exercise and its sets, in the order first done; [name] is null for sets without one. */ +data class ExerciseSets(val name: String?, val sets: List) + +/** One round of an interval workout: its peak, and how far the heart rate came down in the rest after it. */ +data class RoundLine(val number: Int, val peak: Int?, val drop: Int?) + +/** One period of a game: its name, average heart rate and hard efforts. */ +data class PeriodLine(val name: String, val averageBpm: Int?, val hardEfforts: Int, val seconds: Long) + +object FamilySummary { + /** + * "This morning, 07:12", "Today, 18:40" (the afternoon), "This evening, 19:05", "Yesterday, 07:12"; another day by + * [DayWords.relative]: "Saturday 10:00", "Tue 21 Oct 10:00". The time follows the phone's 12 or 24 hour setting. + */ + fun whenLine( + start: Long, now: Long, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault(), + use24Hour: Boolean = org.openstrap.app.presentation.ClockSetting.use24Hour, + ): String { + val t = Instant.ofEpochSecond(start).atZone(zone) + val clock = TimeOfDay.format(start, zone, use24Hour, locale) + val today = Instant.ofEpochSecond(now).atZone(zone).toLocalDate() + return when (t.toLocalDate()) { + today -> when (t.hour) { + in 4..11 -> "This morning, $clock" + in 17..23 -> "This evening, $clock" + else -> "Today, $clock" + } + today.minusDays(1) -> "Yesterday, $clock" + else -> DayWords.relative(t.toLocalDate(), today, locale) + " " + clock + } + } + + /** The fastest whole kilometre among [rows] (by its time), when there are two or more to choose from. */ + fun fastestSplit(rows: List): Int? { + val whole = rows.withIndex().filter { it.value.kind == SplitRow.Kind.Whole } + if (whole.size < 2) return null + return whole.minByOrNull { it.value.seconds }?.index + } + + /** + * Finished [sets] by exercise, in the order each was first done, with the peak of [readings] (time, bpm) in each set. + * A logged set reads "10 × 60 kg"; one without a log reads by its time. + */ + fun exerciseSets(sets: List, readings: List>): List { + val done = sets.filter { it.end != null } + val order = done.map { it.exercise?.trim()?.takeIf(String::isNotEmpty) }.distinctBy { it?.lowercase() } + return order.map { name -> + val mine = done.filter { it.exercise?.trim()?.takeIf(String::isNotEmpty)?.lowercase() == name?.lowercase() } + ExerciseSets(name, mine.mapIndexed { i, s -> + val text = if (s.reps != null && s.weightKg != null) "${s.reps} × ${Strength.kgText(s.weightKg)} kg" + else if (s.reps != null) "${s.reps} reps" else Units.workoutClock(s.end!! - s.start) + SetLine(if (name == null) s.index + 1 else i + 1, text, Strength.peak(readings, s.start, s.end!!)) + }) + } + } + + /** Sets on the hero: every finished one. */ + fun setsDone(sets: List): Int = sets.count { it.end != null } + + /** The average rest between [sets], in seconds; null without a finished rest. */ + fun averageRest(sets: List): Long? { + val rests = sets.mapNotNull { s -> s.end?.let { e -> s.restEnd?.minus(e) } }.filter { it > 0 } + return if (rests.isEmpty()) null else rests.sum() / rests.size + } + + /** How long the heart rate took to fall under [below] after each set, as [WorkoutSummaryData.restRecovery] works it out. */ + fun restRecovery(sets: List, readings: List>, below: Int?): Long? = + WorkoutSummaryData.restRecovery(sets, readings, below) + + /** + * "Your heart rate dropped under 110 in 58 s on average", and against the last strength session's [before]: "9 s + * faster than last time" or slower. Within 3 s it says "about the same as last time". + */ + fun restLine(seconds: Long, below: Int, before: Long?): String { + val first = "Your heart rate dropped under $below in ${clockOrSeconds(seconds)} on average" + val diff = before?.minus(seconds) + return when { + diff == null -> "$first." + diff > 3 -> "$first, ${clockOrSeconds(diff)} faster than last time." + diff < -3 -> "$first, ${clockOrSeconds(-diff)} slower than last time." + else -> "$first, about the same as last time." + } + } + + private fun clockOrSeconds(s: Long) = Units.secondsOrClock(s) + + /** + * Each finished round's peak, and the drop in the rest after it: the peak less the lowest reading of [readings] + * in that rest. No drop for a round without a rest after it. + */ + fun rounds(rounds: List, readings: List>): List = + rounds.filter { it.workEnd != null }.map { r -> + val workEnd = r.workEnd!! + val peak = r.peakBpm ?: Strength.peak(readings, r.workStart, workEnd) + val low = r.restEnd?.takeIf { it > workEnd }?.let { end -> readings.filter { it.first in workEnd..end }.minOfOrNull { it.second } } + RoundLine(r.index + 1, peak, if (peak != null && low != null) (peak - low).coerceAtLeast(0) else null) + } + + /** + * The line under the rounds: "You recovered 19 bpm in 10 s on average. Round 6 was your hardest." The rest is + * [restSeconds] when the rounds had a set rest; the hardest round is the highest peak, named only with 3 or more. + */ + fun roundsLine(lines: List, restSeconds: Long?): String? { + val drops = lines.mapNotNull { it.drop } + val hardest = hardestRound(lines)?.let { lines[it] } + val parts = listOfNotNull( + drops.takeIf { it.isNotEmpty() }?.let { + val avg = Math.round(it.average()).toInt() + if (restSeconds != null) "You recovered $avg bpm in ${clockOrSeconds(restSeconds)} on average." else "You recovered $avg bpm in each rest on average." + }, + hardest?.let { "Round ${it.number} was your hardest." }, + ) + return parts.joinToString(" ").takeIf { it.isNotEmpty() } + } + + /** The hardest of [lines], the highest peak (the first of equals), as its index; named only with 3 or more peaks. */ + fun hardestRound(lines: List): Int? { + if (lines.count { it.peak != null } < 3) return null + return lines.indices.filter { lines[it].peak != null }.maxByOrNull { lines[it].peak!! } + } + + /** A peak bar starts from this share of the highest peak, so the gaps between sets and rounds show. */ + const val PEAK_FLOOR = 0.7 + + /** The shortest a bar gets, so a low one still shows. */ + const val PEAK_MIN_SHARE = 0.08f + + /** + * How long each of [peaks] draws against the highest (boards SummaryStrength, SummaryHiit, SummaryGame): the + * highest is full, the rest by how far they sit above [PEAK_FLOOR] of it, at least [PEAK_MIN_SHARE]. Null for a + * missing peak. + */ + fun peakShares(peaks: List): List { + val top = peaks.filterNotNull().maxOrNull() ?: return peaks.map { null } + val floor = top * PEAK_FLOOR + return peaks.map { p -> p?.let { (if (top <= floor) 1.0 else (it - floor) / (top - floor)).toFloat().coerceIn(PEAK_MIN_SHARE, 1f) } } + } + + /** Work time over the finished rounds. */ + fun workSeconds(rounds: List): Long = rounds.sumOf { r -> r.workEnd?.let { it - r.workStart } ?: 0L } + + /** + * [summary]'s heart rate over its shown range, as (epoch seconds, bpm): what its hero, its sections and its share + * card all read. + */ + fun readings(summary: PhoneSummary): List> { + val a = summary.analysis + return summary.recorded.filter { it.timestamp in a.shownStart..a.end }.map { it.timestamp to it.bpm } + } + + /** How long to average over at each end, for the heart settling. */ + const val SETTLE_S = 120L + + /** How far the heart has to come down over a calm session before it reads as settled rather than held. */ + const val SETTLED_BPM = 3 + + /** + * The heart settling over a calm session: the average of [readings] over its first [SETTLE_S] and over its last. + * Null when the session is too short for both, or either has no readings. + */ + fun settled(readings: List>, start: Long, end: Long): Pair? { + if (end - start < SETTLE_S * 3) return null + val first = WorkoutCopy.averageOver(readings, start, start + SETTLE_S) ?: return null + val last = WorkoutCopy.averageOver(readings, end - SETTLE_S, end) ?: return null + return first to last + } + + /** How far the heart came down from [from] to [to], null under [SETTLED_BPM]: the summary's and the card's rule. */ + fun settledDrop(from: Int, to: Int): Int? = (from - to).takeIf { it >= SETTLED_BPM } + + /** "Your heart settled from 96 to 66.", or when it didn't come down, "Your heart held near 72." */ + fun settledLine(from: Int, to: Int): String = if (settledDrop(from, to) != null) "Your heart settled from $from to $to." else "Your heart held near $to." + + /** [settledLine] as the share card says it: "Heart settled 96 to 66", or "Heart held near 72". */ + fun settledShort(from: Int, to: Int): String = if (settledDrop(from, to) != null) "Heart settled $from to $to" else "Heart held near $to" + + /** + * A game's [periods] from its rounds, named as the live screen names them out of the [of] it was set up with + * ([StartCopy.periodName]: "1st half", "3rd quarter", "Period 2"), so a game ended early keeps its periods' names. + * With the average of [readings] and the hard efforts ([Games.hardEfforts]) in each. + */ + fun periods(rounds: List, readings: List>, maxHr: Int, of: Int): List { + val done = rounds.filter { it.workEnd != null } + return done.map { r -> + val name = StartCopy.periodName(r.index, of) + val workEnd = r.workEnd!! + val inside = readings.filter { it.first in r.workStart..workEnd } + PeriodLine(name, WorkoutCopy.averageOver(readings, r.workStart, workEnd), Games.hardEfforts(inside, maxHr), workEnd - r.workStart) + } + } + + /** A game is read in blocks this long. */ + const val READ_BLOCK_S = 10 * 60L + + /** A block this far under the first half's average is a fade. */ + const val FADE_PCT = 6 + + /** + * One line on how a game went (board SummaryGame), from [readings] over [start] to [end] in [READ_BLOCK_S] blocks: + * "You faded a little after 70 min" when every block from one on sits [FADE_PCT] or more under the first half's + * average, "You finished stronger than you started" when the last block is that far above it, else "You kept your + * effort up to the end". Null for games under half an hour. + */ + fun gameRead(readings: List>, start: Long, end: Long): String? { + if (end - start < 3 * READ_BLOCK_S) return null + val firstHalf = WorkoutCopy.averageOver(readings, start, start + (end - start) / 2) ?: return null + val blocks = generateSequence(start) { it + READ_BLOCK_S }.takeWhile { it + READ_BLOCK_S / 2 < end } + .map { from -> from to WorkoutCopy.averageOver(readings, from, minOf(from + READ_BLOCK_S, end)) }.toList() + val low = firstHalf * (100 - FADE_PCT) / 100 + val fadeFrom = blocks.indices.drop(1).firstOrNull { i -> blocks.drop(i).all { (_, avg) -> avg != null && avg <= low } } + val last = blocks.lastOrNull()?.second + return when { + fadeFrom != null -> "You faded a little after ${(blocks[fadeFrom].first - start) / 60} min." + last != null && last >= firstHalf * (100 + FADE_PCT) / 100 -> "You finished stronger than you started." + else -> "You kept your effort up to the end." + } + } + + /** Each zone's time on the zone strip, to the nearest minute, and a minute for any time under one. */ + fun zoneMinutes(zones: IntArray): List = zones.map { s -> if (s <= 0) 0 else Math.round(s / 60.0).toInt().coerceAtLeast(1) } + + /** + * The heart rate as [points] evenly spaced values from [readings] (time, bpm), each the average of its stretch and + * then of the [window] values round it, so the line draws smooth. Null where a stretch has no reading. + */ + fun heartLine(readings: List>, points: Int, window: Int = 1): List { + if (readings.isEmpty() || points < 2) return List(points.coerceAtLeast(0)) { null } + val first = readings.first().first + val span = (readings.last().first - first).coerceAtLeast(1) + val raw = org.openstrap.app.data.Summaries.bucketMeans(readings, points, { ((it.first - first) * (points - 1) / span).toInt().coerceIn(0, points - 1) }, { it.second.toFloat() }) + val half = window / 2 + return raw.indices.map { i -> + if (raw[i] == null) null + else raw.subList((i - half).coerceAtLeast(0), (i + half + 1).coerceAtMost(points)).filterNotNull().average().toFloat() + } + } + + /** + * The line under the week's days with the weekly goal (board SummaryMind): "3 workouts this week, 1 more for your + * goal." or, once it's met, "4 workouts this week, goal met." + */ + fun weekGoalLine(count: Int, goal: Int): String { + val first = "$count ${if (count == 1) "workout" else "workouts"} this week" + return if (count >= goal) "$first, goal met." else "$first, ${goal - count} more for your goal." + } + + /** Whether a kcal [target] was reached: at [reachedSeconds], or by the [activeKcal] at the end. */ + fun targetReached(target: String?, reachedSeconds: Long?, activeKcal: Int?): Boolean { + val k = org.openstrap.app.data.WorkoutTarget.parse(target) as? org.openstrap.app.data.WorkoutTarget.Kcal ?: return false + return reachedSeconds != null || (activeKcal != null && activeKcal >= k.kcal) + } + + /** "Mostly zone 3": the zone with the most time, or null with none. */ + fun mostlyZone(zones: IntArray): String? = zones.withIndex().filter { it.value > 0 }.maxByOrNull { it.value }?.let { "Mostly zone ${it.index + 1}" } + + /** Another workout of the same sport counts as like this one within this share of its active time. */ + const val SIMILAR_SHARE = 0.25 + + /** + * "4 bpm lower than Monday, over a similar time": [a]'s average heart rate against the last workout of the same + * sport in [others] whose active time is within [SIMILAR_SHARE] of it. Within 2 bpm, "about the same as Monday". + * Null without one. + */ + fun versusLast(a: WorkoutAnalysis, others: List, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault()): String? { + val mine = a.workout.averageHeartRate ?: return null + val active = a.activeSeconds.takeIf { it > 0 } ?: return null + val last = others.filter { + it.workout.start < a.workout.start && it.workout.sportType == a.workout.sportType && it.workout.averageHeartRate != null && + kotlin.math.abs(it.activeSeconds - active) <= active * SIMILAR_SHARE + }.maxByOrNull { it.workout.start } ?: return null + val day = Instant.ofEpochSecond(last.workout.start).atZone(zone).format(DateTimeFormatter.ofPattern("EEEE", locale)) + val diff = mine - last.workout.averageHeartRate!! + return when { + diff <= -3 -> "${-diff} bpm lower than $day, over a similar time." + diff >= 3 -> "$diff bpm higher than $day, over a similar time." + else -> "About the same heart rate as $day, over a similar time." + } + } + + /** The days of the week of [today] (from [firstDay]) and whether each had one of [starts], in order. */ + fun weekDays(starts: List, today: LocalDate, firstDay: DayOfWeek, zone: ZoneId = ZoneId.systemDefault()): List> { + val first = today.minusDays(((today.dayOfWeek.value - firstDay.value + 7) % 7).toLong()) + val days = starts.map { Instant.ofEpochSecond(it).atZone(zone).toLocalDate() }.toSet() + return (0L until 7L).map { first.plusDays(it).let { d -> d to (d in days) } } + } + + /** "Load, hard" style label for the game hero's figure: Load and how it compares with your usual. */ + fun loadLabel(load: Int, usual: Float?): String = "Load" + loadWord(load, usual) + + /** The game's share card line, the number in: "Load 120, hard", "Load 80". */ + fun loadLine(load: Int, usual: Float?): String = "Load $load" + loadWord(load, usual) + + /** How [load] compares with your [usual], as the Load labels end: ", hard", ", light", or nothing. */ + private fun loadWord(load: Int, usual: Float?): String = when (org.openstrap.app.ui.dashboard.TodayCopy.loadStatus(load.toFloat(), usual, endOfDay = true)) { + org.openstrap.app.ui.dashboard.TodayCopy.LoadStatus.Above -> ", hard" + org.openstrap.app.ui.dashboard.TodayCopy.LoadStatus.Below -> ", light" + else -> "" + } + + /** Whether [a] is a run whose distance came from GPS, the only one whose pace means something. */ + fun gpsDistance(meters: Float?, fromSteps: Boolean): Float? = meters?.takeIf { !fromSteps && it > 0f } + + /** The family a summary takes for [sportType] with its [structure]: rounds make any sport's summary an intervals one. */ + fun familyOf(sportType: Int, structure: org.openstrap.app.data.Structure?): Workouts.Family = when (structure) { + is org.openstrap.app.data.Structure.Periods -> Workouts.Family.Games + null -> Workouts.family(sportType) + else -> Workouts.Family.Intervals + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/HeartZonesCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/workout/HeartZonesCopy.kt new file mode 100644 index 00000000..aaa2d7a9 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/workout/HeartZonesCopy.kt @@ -0,0 +1,62 @@ +package org.openstrap.app.ui.workout + +import org.openstrap.app.data.HeartZones +import org.openstrap.app.data.MaxHeartRate +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** What the Heart rate zones page says (board SettingsHeartZones). Pure, so it's tested without the page. */ +object HeartZonesCopy { + const val TITLE = "Heart rate zones" + const val MAX_TITLE = "Your max heart rate" + const val SET_IT = "Set it myself" + const val LEARN_IT = "Learn it again" + + /** Under the list: one set, what reads them, and how to change them. */ + const val NOTE = "The same for every sport. Zone goals and time in zones use them, and Load uses your max. Tap a zone to move its edges." + + /** The You page's row: "Max 192 bpm, the same zones for every sport". */ + fun youLine(maxHr: Int?): String = if (maxHr != null) "Max $maxHr bpm, the same zones for every sport" else "The same zones for every sport" + + /** The Profile page's row for your max, which opens the Heart rate zones page. */ + const val MAX_ROW = "Max heart rate" + fun profileLine(set: Int?): String = if (set != null) "$set bpm, set by you" else "Worked out from your age and workouts" + + /** Where your max comes from, under it: "Learned from your hardest efforts, 3 Oct". */ + fun source(m: MaxHeartRate, locale: Locale = Locale.getDefault()): String = when (m.source) { + MaxHeartRate.Source.Set -> "Set by you" + MaxHeartRate.Source.Learned -> "Learned from your hardest efforts" + (m.learnedOn?.let { ", " + it.format(DateTimeFormatter.ofPattern("d MMM", locale)) } ?: "") + MaxHeartRate.Source.Age -> "From your age, until a hard effort shows more" + MaxHeartRate.Source.Strap -> "From your Zepp profile, until a hard effort shows more" + MaxHeartRate.Source.Guess -> "A first guess. Add your age, or set it yourself." + } + + /** A zone's name on the list: "Zone 2". */ + fun zoneName(zone: Int): String = "Zone ${zone + 1}" + + /** A zone's heart rates on the list: "115 to 133", and "172 and up" for zone 5. */ + fun zoneBpm(zones: HeartZones, zone: Int, maxHr: Int): String { + val from = zones.startBpm(zone, maxHr) + return zones.endBpm(zone, maxHr)?.let { "$from to $it" } ?: "$from and up" + } + + /** The sheet for moving a zone's edges: its steppers' names and what they do. */ + const val STARTS_AT = "Starts at" + const val ENDS_AT = "Ends at" + fun edgesNote(zone: Int): String = when (zone) { + 0 -> "Moving its end moves where zone 2 starts." + HeartZones.STANDARD.starts.lastIndex -> "Zone 5 runs on up to your max. Moving its start moves where zone 4 ends." + else -> "Moving an edge moves the zone beside it too." + } + + const val RESET = "Back to the usual zones" + + /** The sheet for setting your max yourself. */ + const val SET_TITLE = "Your max heart rate" + const val SET_NOTE = "From a test or a race you ran flat out. Your zones follow it." + const val SET_FIELD = "Max heart rate" + const val SAVE = "Save" + + /** A typed max: 120 to 230 bpm, as the profile has always taken it. */ + fun parseMax(text: String): Int? = text.trim().toIntOrNull()?.takeIf { it in org.openstrap.app.data.Recovery.TESTED_MAX_RANGE } +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/LiveGlance.kt b/app/src/main/kotlin/org/openstrap/app/ui/workout/LiveGlance.kt new file mode 100644 index 00000000..3ea12f8a --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/workout/LiveGlance.kt @@ -0,0 +1,522 @@ +package org.openstrap.app.ui.workout + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import org.openstrap.app.data.Route +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutSession +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.data.WorkoutTarget +import org.openstrap.app.data.Workouts +import org.openstrap.app.presentation.Pace +import org.openstrap.app.presentation.Units +import org.openstrap.app.presentation.WorkoutWords +import java.util.Locale +import kotlin.math.ceil +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf + +/** What a [LiveGlance] is lit in: the zone's colour, work red, rest green, yoga purple, paused grey, asking amber. */ +enum class GlanceTint { Zone, Work, Rest, Mind, Paused, Asking } + +/** The next thing to do from the Today hero or the floating bar, or the second one beside it. */ +enum class GlanceAction { Pause, Resume, KeepGoing, End, Open, SetDone, StartSet, AddRest, Skip } + +/** + * Where an interval workout is ([GlanceInput.interval]): its [phase], round [round] of [rounds] (from 1), the seconds + * [left] in a timed phase or the metres [metersLeft] in a run rep, the rest after this work ([nextRest]), and the + * heart rate's [drop] since the work ended. [everyMinute] work ends on Done. + */ +data class IntervalMoment( + val phase: org.openstrap.app.data.Phase, + val round: Int, + val rounds: Int, + val left: Long? = null, + val metersLeft: Float? = null, + val nextRest: Long? = null, + val drop: Int? = null, + val everyMinute: Boolean = false, + val reps: Boolean = false, + /** A game's periods: each one's length, for the time in it. */ + val periodLength: Long? = null, +) + +/** + * Where a strength workout is ([GlanceInput.strength]): in set [setNumber] (or before it), its [setSeconds], the rest + * [restLeft] of [restTotal] and the level that ends it sooner ([restBelow]), the [drop] in heart rate since the set + * ended, and what's next ([nextLoad], "10 × 60 kg"). + */ +data class StrengthMoment( + val inSet: Boolean, + val setNumber: Int, + val setsTarget: Int?, + val done: Int, + val setSeconds: Long = 0, + val restLeft: Long? = null, + val restTotal: Long? = null, + val restBelow: Int? = null, + val drop: Int? = null, + val exercise: String? = null, + val nextLoad: String? = null, +) + +/** The bar under the figure: how far toward the goal ([drain] false, it fills), or rest running out (it empties). */ +data class GlanceProgress(val fraction: Float, val drain: Boolean = false) + +/** + * A workout at a glance while its live screen is tucked away (workouts spec 2.10): the one source for the Today hero + * and the floating bar, so the two never disagree. + * + * [figure] is the one number that matters now (km, minutes, bpm) with its [unit]; [line1] and [line2] the bar's two + * lines; [progress] the goal's bar; [tint] and [zone] its colour; [action] the next thing you'd do and [second] the one + * beside it. The hero also shows [goal] and [toGo] under its bar and three [figures] (value, label). + */ +data class LiveGlance( + val figure: String?, + val unit: String?, + val line1: String, + val line2: String?, + val progress: GlanceProgress?, + val tint: GlanceTint, + val zone: Int?, + val action: GlanceAction, + val actionLabel: String, + val second: GlanceAction? = null, + val secondLabel: String? = null, + /** The action's name where the bar has less room ("Set 4" for "Start set 4"). */ + val shortLabel: String? = null, + /** The hero's Next box ("Set 4, bench press 10 × 60 kg"). */ + val next: String? = null, + val goal: String? = null, + val toGo: String? = null, + val figures: List> = emptyList(), + /** The total time so far, "24:51". */ + val clock: String = "", + /** A GPS workout's last whole kilometre for the hero's strip: "Km 4 in 5:55" and "Avg 146 bpm. Fastest so far was km 3." */ + val split: Pair? = null, + /** The bar shows the live bpm in dots beside its action (only where the action is Pause). */ + val bpm: Int? = null, +) + +/** + * The workout as it is now, for [LiveGlance.of]: time and pauses, the strap, the distance and pace for a GPS workout, + * the goal, and the stop question while it's asked ([stoppedSince], when the wearer last moved, epoch seconds). + */ +data class GlanceInput( + val sportType: Int, + val now: Long, + val activeSeconds: Long, + val pausedAt: Long?, + val bpm: Int?, + val connected: Boolean, + val maxHr: Int, + val gps: Boolean, + val distanceMeters: Float?, + val distanceFromSteps: Boolean = false, + /** Seconds per kilometre now and over the whole workout; null with no pace yet. */ + val pace: Double? = null, + val averagePace: Double? = null, + val target: WorkoutTarget? = null, + val activeKcal: Float? = null, + val stoppedSince: Long? = null, + val inCar: Boolean = false, + /** The last whole kilometre, its average heart rate, and the fastest so far by index. */ + val lastSplit: Route.Split? = null, + val lastSplitBpm: Int? = null, + val fastestSplit: Int? = null, + val strength: StrengthMoment? = null, + val interval: IntervalMoment? = null, + /** A game's effort so far, 1 to 5 ([org.openstrap.app.data.Games.effort]). */ + val effort: Int? = null, + /** Average heart rate so far, for an indoor workout's figures. */ + val averageBpm: Int? = null, +) + +/** + * The glance of the workout [session] records, once a second and whenever what it shows changes; null while nothing + * records. Worked out off the screen, so the Today hero and the floating bar each read it without the app around them + * recomposing every second, and on [work], since each second's figures walk the whole route (audit C7). + */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +fun liveGlance( + session: WorkoutSession, + liveHeartRate: Flow, + connection: Flow, + work: CoroutineDispatcher = Dispatchers.Default, +): Flow { + val ticks = flow { + while (true) { + emit(System.currentTimeMillis() / 1000) + delay(1000 - System.currentTimeMillis() % 1000) + } + } + val strap = combine(liveHeartRate, connection) { bpm, c -> bpm to (c is ConnectionState.Connected) } + val figures = combine(session.route, session.kcal, session.stats) { route, kcal, stats -> Triple(route, kcal, stats) } + // Nothing ticks while nothing records. + val recording = session.state.map { it is WorkoutState.Recording }.distinctUntilChanged() + return recording.flatMapLatest { on -> if (!on) flowOf(null) else glanceTicks(session, strap, figures, ticks) }.flowOn(work) +} + +/** [liveGlance] while a workout records. */ +private fun glanceTicks( + session: WorkoutSession, + strap: Flow>, + figures: Flow, WorkoutRecorder.Kcal?, WorkoutRecorder.Stats?>>, + ticks: Flow, +): Flow { + // The splits are worked out again only when the route grows, not every second. + var splitsOf = -1 to emptyList() + return combine(session.state, strap, figures, ticks) { st, (bpm, connected), (route, kcal, stats), now -> + val r = st as? WorkoutState.Recording ?: return@combine null + val w = r.workout + val active = WorkoutRecorder.activeSeconds(w, now) + val distance = w.distanceMeters + if (w.useGps && splitsOf.first != route.size) splitsOf = route.size to Route.splits(route, w.pauseRanges) + val whole = splitsOf.second.filter { it.meters >= 1000 } + val last = whole.lastOrNull() + val lastBpm = last?.let { s -> + val from = whole.getOrNull(whole.size - 2)?.endT ?: route.firstOrNull()?.t ?: s.endT + WorkoutCopy.averageOver(session.history.value, from, s.endT) + } + Glance.of( + GlanceInput( + w.sportType, now, active, w.pausedAt, bpm, connected, r.maxHeartRate, w.useGps, distance, w.distanceFromSteps, + pace = if (w.useGps) Route.currentPace(route) else null, + averagePace = Pace.of(distance, active), + target = WorkoutTarget.parse(w.target), activeKcal = kcal?.active, + stoppedSince = r.endPrompt, inCar = r.inCar, averageBpm = stats?.average, + lastSplit = last, lastSplitBpm = lastBpm, fastestSplit = whole.minByOrNull { it.pace }?.index, + strength = if (r.strength) strengthMoment(r, now, bpm, session.history.value) else null, + interval = intervalMoment(r, now, bpm, WorkoutCopy.readings(session.history.value, session.recent.value), route), + effort = org.openstrap.app.data.Games.effort(session.zoneSeconds.value), + ), + ) + }.distinctUntilChanged() +} + + +/** Where strength workout [r] is at [now], for its glance; [history] is its live readings, for the drop since the set. */ +internal fun strengthMoment(r: WorkoutState.Recording, now: Long, bpm: Int?, history: List>): StrengthMoment { + val last = r.sets.lastOrNull() + val done = r.sets.count { it.end != null } + val ended = last?.end + val atEnd = ended?.let { Workouts.heartRateAt(history, it) ?: org.openstrap.app.data.Strength.peak(history, it - 5, it) } + val load = r.exercise?.let { e -> + r.sets.lastOrNull { it.reps != null && it.exercise.equals(e, ignoreCase = true) } + ?.let { s -> "${s.reps} × ${org.openstrap.app.data.Strength.kgText(s.weightKg ?: 0f)} kg" } + } + return StrengthMoment( + inSet = r.inSet, setNumber = if (r.inSet) r.sets.size else r.sets.size + 1, setsTarget = r.workout.setsTarget, done = done, + setSeconds = if (r.inSet && last != null) (now - last.start).coerceAtLeast(0) else 0, + restLeft = r.restUntil?.let { it - now }, restTotal = r.restUntil?.let { u -> ended?.let { u - it } }, + restBelow = org.openstrap.app.data.Strength.Rest.parse(r.workout.rest)?.belowBpm, + drop = if (atEnd != null && bpm != null && r.resting) atEnd - bpm else null, + exercise = r.exercise, nextLoad = load, + ) +} + +/** + * Where interval workout [r] is at [now], for its glance and live screen; null without rounds. [readings] are its + * live readings (time, bpm), for the drop since the work ended; [route] its GPS fixes, for a run rep's metres to go. + */ +internal fun intervalMoment(r: WorkoutState.Recording, now: Long, bpm: Int?, readings: List>, route: List): IntervalMoment? { + val s = r.structure ?: return null + val p = r.phase(now) ?: return null + val active = WorkoutRecorder.activeSeconds(r.workout, now) + val rest = when (s) { + is org.openstrap.app.data.Structure.Timed -> s.rest + is org.openstrap.app.data.Structure.Reps -> s.rest + is org.openstrap.app.data.Structure.EveryMinute -> null + is org.openstrap.app.data.Structure.Periods -> s.rest + } + return when (p) { + is org.openstrap.app.data.Phase.Work -> { + val meters = (s as? org.openstrap.app.data.Structure.Reps)?.meters?.let { goal -> + val ran = route.filter { it.t >= p.start }.takeIf { it.size > 1 }?.let { Route.distance(it, r.workout.pauseRanges).toFloat() } ?: 0f + (goal - ran).coerceAtLeast(0f) + } + val minuteLeft = (s as? org.openstrap.app.data.Structure.EveryMinute)?.let { + WorkoutRecorder.activeSeconds(r.workout, p.start) + it.period - active + } + IntervalMoment( + p, p.round + 1, s.rounds, left = (p.endsAt?.let { it - active } ?: minuteLeft)?.coerceAtLeast(0), metersLeft = meters, + nextRest = rest.takeIf { p.round + 1 < s.rounds }, everyMinute = s is org.openstrap.app.data.Structure.EveryMinute, + periodLength = (s as? org.openstrap.app.data.Structure.Periods)?.length, + reps = s is org.openstrap.app.data.Structure.Reps, + ) + } + is org.openstrap.app.data.Phase.Rest -> { + val atEnd = Workouts.heartRateAt(readings, p.start) + IntervalMoment( + p, p.round + 2, s.rounds, left = (p.endsAt - active).coerceAtLeast(0), + drop = if (atEnd != null && bpm != null) atEnd - bpm else null, reps = s is org.openstrap.app.data.Structure.Reps, + ) + } + org.openstrap.app.data.Phase.WarmUp -> IntervalMoment(p, 1, s.rounds) + org.openstrap.app.data.Phase.Done -> IntervalMoment(p, s.rounds, s.rounds) + } +} + +object Glance { + /** [i] worked out (workouts spec 2.10's table): asking first, then paused, then the sport's own moment. */ + fun of(i: GlanceInput): LiveGlance { + val sport = Workouts.sportName(i.sportType) + val bpm = i.bpm.takeIf { i.connected && i.pausedAt == null } + val zone = WorkoutRecorder.liveZone(bpm, i.maxHr) + val distance = Workouts.family(i.sportType) == Workouts.Family.Distance && i.gps + val clock = Units.workoutClock(i.activeSeconds) + val figure = if (distance) i.distanceMeters?.let { kmFigure(it) } ?: "0.00" else bpm?.toString() + val unit = if (distance) "km" else if (figure != null) "bpm" else null + val progress = progress(i) + val (goal, toGo) = goalLine(i) + val figures = if (distance) listOf( + Pace.clock(i.pace) to "Pace /km", + bpm?.toString() to (zone?.let { "bpm, zone ${it + 1}" } ?: "bpm"), + i.activeKcal?.toInt()?.toString() to "Active kcal", + ) else listOfNotNull( + i.distanceMeters?.takeIf { it >= Units.SHOWN_FROM_METERS }?.let { + Units.km(it, Units.Precision.Workout) to if (i.distanceFromSteps) "km from steps" else "km" + }, + i.averageBpm?.toString() to "Avg bpm", + i.activeKcal?.toInt()?.toString() to "Active kcal", + ) + val base = LiveGlance( + figure, unit, "", null, progress, GlanceTint.Zone, zone, GlanceAction.Pause, "Pause", + goal = goal, toGo = toGo, figures = figures, split = splitLine(i), clock = clock, + ) + i.stoppedSince?.let { since -> + val minutes = ((i.now - since) / 60).coerceAtLeast(1) + return base.copy( + line1 = "Still going?", line2 = if (i.inCar) "Looks like you're in a car" else "No movement for $minutes min", + tint = GlanceTint.Asking, action = GlanceAction.KeepGoing, actionLabel = "Keep going", second = GlanceAction.End, secondLabel = "End", + ) + } + i.pausedAt?.let { at -> + val done = if (distance && i.distanceMeters != null) "$sport, ${kmWithUnit(i.distanceMeters)} so far" else "$sport, $clock so far" + return base.copy( + line1 = "Paused for ${Units.workoutClock((i.now - at).coerceAtLeast(0))}", line2 = done, + tint = GlanceTint.Paused, zone = null, action = GlanceAction.Resume, actionLabel = "Resume", + ) + } + val state = when { + !i.connected -> WorkoutWords.STRAP_GONE + bpm == null -> "Waiting for heart rate" + else -> null + } + i.strength?.let { s -> return strength(base, s, bpm, i) } + i.interval?.let { m -> return interval(base, m, bpm, i) } + if (Workouts.family(i.sportType) == Workouts.Family.MindBody) return mind(base, i) + if (Workouts.family(i.sportType) == Workouts.Family.Games) return game(base, null, bpm, i) + if (distance) { + val first = listOfNotNull(kmWithUnit(i.distanceMeters ?: 0f), Pace.text(i.pace)).joinToString(", ") + val second = listOfNotNull(clock, toGoShort(i) ?: state ?: zone?.let { "zone ${it + 1}" }).joinToString(", ") + return base.copy(line1 = first, line2 = second, bpm = bpm, actionLabel = "Pause") + } + val zoneLine = zone?.let { WorkoutCopy.zoneLine(it) } + return base.copy( + line1 = "$sport, $clock", line2 = listOfNotNull(state ?: zoneLine, toGoShort(i)).joinToString(", ").ifEmpty { null }, + bpm = bpm, + ) + } + + /** + * Strength's moments (spec 2.10's table): in a set, the set's clock with Set done; resting, the rest left draining + * in green with Start set N and +30 s, the drop in heart rate and what's next; between sets, Start set N. + */ + private fun strength(base: LiveGlance, s: StrengthMoment, bpm: Int?, i: GlanceInput): LiveGlance { + val of = s.setsTarget?.let { " of $it" } ?: "" + val figures = listOf( + bpm?.toString() to (base.zone?.let { "bpm, zone ${it + 1}" } ?: "bpm"), + s.done.toString() to if (s.done == 1) "Set done" else "Sets done", + i.activeKcal?.toInt()?.toString() to "Active kcal", + ) + val next = listOfNotNull("Set ${s.setNumber}", s.exercise?.lowercase()?.let { e -> listOfNotNull(e, s.nextLoad).joinToString(" ") }) + .joinToString(", ") + val start = base.copy( + action = GlanceAction.StartSet, actionLabel = "Start set ${s.setNumber}", shortLabel = "Set ${s.setNumber}", + figures = figures, progress = null, goal = null, toGo = null, + ) + return when { + s.inSet -> base.copy( + figure = Units.workoutClock(s.setSeconds, Units.DOT_LOCALE), unit = "in this set", + line1 = "Set ${s.setNumber}$of, ${Units.workoutClock(s.setSeconds)}", + line2 = listOfNotNull(s.exercise, bpm?.let { "$it bpm" }).joinToString(", ").ifEmpty { null }, + action = GlanceAction.SetDone, actionLabel = "Set done", figures = figures, progress = null, goal = null, toGo = null, + ) + s.restLeft != null -> { + val left = Units.workoutClock(s.restLeft.coerceAtLeast(0)) + val heart = bpm?.let { b -> "$b bpm" + (s.drop?.takeIf { it > 0 }?.let { ", down $it" } ?: "") } + start.copy( + figure = Units.workoutClock(s.restLeft.coerceAtLeast(0), Units.DOT_LOCALE), unit = "left", tint = GlanceTint.Rest, + line1 = "$left rest left", line2 = listOfNotNull(heart, "set ${s.setNumber} next").joinToString(", "), + progress = s.restTotal?.takeIf { it > 0 }?.let { GlanceProgress((s.restLeft.toFloat() / it).coerceIn(0f, 1f), drain = true) }, + goal = listOfNotNull(heart?.let { "$it." }, s.restBelow?.let { "Ends sooner once you stay under $it." }).joinToString(" ").ifEmpty { null }, + second = GlanceAction.AddRest, secondLabel = "+30 s", next = next, + ) + } + else -> start.copy( + line1 = if (s.done == 0) "Ready for set 1" else "Rest over, set ${s.setNumber}$of next", + line2 = listOfNotNull(s.exercise, bpm?.let { "$it bpm" }).joinToString(", ").ifEmpty { null }, + next = next, + ) + } + } + + /** + * Mind and body (spec 2.10's table): the minutes of the length in violet, one line ("Yoga, 23:40 of 45 min"), a + * quieter Pause and no heart rate in dots. + */ + private fun mind(base: LiveGlance, i: GlanceInput): LiveGlance { + val of = (i.target as? WorkoutTarget.Time)?.let { " of ${it.seconds / 60} min" } ?: "" + return base.copy( + figure = "${i.activeSeconds / 60}", unit = "min$of", line1 = "${Workouts.sportName(i.sportType)}, ${base.clock}$of", line2 = null, + tint = GlanceTint.Mind, zone = null, bpm = null, goal = null, toGo = null, figures = emptyList(), + ) + } + + /** + * Games (spec 2.10's table): the period and the time in it, the zone and the effort, and Half time; in the break, its + * time left and Start the next period. Free play has no periods: the sport and its time. + */ + private fun game(base: LiveGlance, m: IntervalMoment?, bpm: Int?, i: GlanceInput): LiveGlance { + val zone = base.zone?.let { "Zone ${it + 1}" } + val effort = i.effort?.let { "effort $it" } + val figures = listOf( + bpm?.toString() to (base.zone?.let { "bpm, zone ${it + 1}" } ?: "bpm"), + i.effort?.let { "$it of 5" } to "Effort", + i.activeKcal?.toInt()?.toString() to "Active kcal", + ) + val heart = listOfNotNull(zone, effort).joinToString(", ").ifEmpty { null } + val game = base.copy(figures = figures, goal = null, toGo = null, progress = null, bpm = bpm, line2 = heart) + if (m == null || m.periodLength == null) return game.copy(line1 = "${Workouts.sportName(i.sportType)}, ${base.clock}") + return when (m.phase) { + is org.openstrap.app.data.Phase.Work -> { + val inPeriodS = (m.periodLength - (m.left ?: 0)).coerceAtLeast(0) + val inPeriod = Units.workoutClock(inPeriodS) + game.copy( + figure = Units.workoutClock(inPeriodS, Units.DOT_LOCALE), + unit = StartCopy.periodName(m.round - 1, m.rounds).lowercase(), + line1 = "${StartCopy.periodName(m.round - 1, m.rounds)}, $inPeriod", + action = GlanceAction.Skip, actionLabel = StartCopy.breakName(m.rounds), + ) + } + is org.openstrap.app.data.Phase.Rest -> { + val left = Units.workoutClock(m.left ?: 0) + game.copy( + figure = Units.workoutClock(m.left ?: 0, Units.DOT_LOCALE), unit = "left", tint = GlanceTint.Rest, + line1 = "${StartCopy.breakName(m.rounds)}, $left left", + action = GlanceAction.Skip, actionLabel = "Start ${StartCopy.periodName(m.round - 1, m.rounds).lowercase()}", + shortLabel = "Start", + ) + } + else -> game.copy(line1 = "Full time, ${base.clock}") + } + } + + /** + * Intervals' moments (spec 2.10's table): work in work red with the seconds left (or the metres of a run rep) and + * Pause; rest in rest green with Skip rest and +10 s; warming up with Start rounds; all rounds done. + */ + private fun interval(base: LiveGlance, m: IntervalMoment, bpm: Int?, i: GlanceInput): LiveGlance { + if (m.periodLength != null) return game(base, m, bpm, i) + val word = if (m.reps) "Rep" else "Round" + val start = base.copy(goal = null, toGo = null, progress = null) + return when (m.phase) { + is org.openstrap.app.data.Phase.Work -> { + val figure = m.metersLeft?.let { "${it.toInt()}" } ?: m.left?.toString() + start.copy( + figure = figure, unit = if (m.metersLeft != null) "m to go" else if (m.everyMinute) "s left in the minute" else "s left", + tint = GlanceTint.Work, line1 = "Work, ${word.lowercase()} ${m.round} of ${m.rounds}", + line2 = listOfNotNull(m.nextRest?.let { "Then ${StartCopy.restText(it)} rest" } ?: "Last one".takeIf { m.round == m.rounds }, bpm?.let { "$it bpm" }) + .joinToString(", ").ifEmpty { null }, + bpm = bpm, + action = if (m.everyMinute) GlanceAction.Skip else GlanceAction.Pause, actionLabel = if (m.everyMinute) "Done" else "Pause", + ) + } + is org.openstrap.app.data.Phase.Rest -> start.copy( + figure = m.left?.toString(), unit = "s rest", tint = GlanceTint.Rest, + line1 = "Rest, ${word.lowercase()} ${m.round} next", + line2 = bpm?.let { b -> "$b bpm" + (m.drop?.takeIf { it > 0 }?.let { ", down $it" } ?: "") }, + action = GlanceAction.Skip, actionLabel = "Skip rest", second = GlanceAction.AddRest, secondLabel = "+10 s", + ) + org.openstrap.app.data.Phase.WarmUp -> start.copy( + line1 = "Warming up, ${base.clock}", line2 = bpm?.let { "$it bpm" }, action = GlanceAction.Skip, actionLabel = "Start rounds", bpm = null, + ) + org.openstrap.app.data.Phase.Done -> start.copy( + line1 = "All ${m.rounds} ${word.lowercase()}s done", line2 = listOfNotNull(base.clock, bpm?.let { "$it bpm" }).joinToString(", "), bpm = bpm, + ) + } + } + + /** The hero's split strip: "Km 4 in 5:55", then its average and which was fastest; null before a whole kilometre. */ + fun splitLine(i: GlanceInput): Pair? { + val s = i.lastSplit ?: return null + val fastest = i.fastestSplit?.takeIf { it != s.index }?.let { "Fastest so far was km ${it + 1}." } + ?: "Your fastest so far.".takeIf { s.index > 0 } + val avg = i.lastSplitBpm?.let { "Avg $it bpm." } + return WorkoutWords.split(s.index, s.seconds) to listOfNotNull(avg, fastest).joinToString(" ").ifEmpty { null } + } + + /** The goal's bar: how far toward a distance, time or kcal goal. A zone goal has none. */ + fun progress(i: GlanceInput): GlanceProgress? = when (val t = i.target) { + is WorkoutTarget.Distance -> GlanceProgress(((i.distanceMeters ?: 0f) / t.meters).coerceIn(0f, 1f)) + is WorkoutTarget.Time -> GlanceProgress((i.activeSeconds.toFloat() / t.seconds).coerceIn(0f, 1f)) + is WorkoutTarget.Kcal -> GlanceProgress(((i.activeKcal ?: 0f) / t.kcal).coerceIn(0f, 1f)) + is WorkoutTarget.Zones, null -> null + } + + /** The hero's line under its bar: "Goal 5 km" and "About 4 min to go", or the goal met. */ + fun goalLine(i: GlanceInput): Pair { + val t = i.target ?: return null to null + val label = when (t) { + is WorkoutTarget.Distance -> t.label + is WorkoutTarget.Time -> t.label + is WorkoutTarget.Kcal -> t.label + is WorkoutTarget.Zones -> return "Target ${t.label.lowercase().replaceFirstChar { it.uppercase() }}" to null + } + if ((progress(i)?.fraction ?: 0f) >= 1f) return "Goal $label" to "Reached" + val left = when (t) { + is WorkoutTarget.Distance -> { + val pace = i.pace ?: i.averagePace + val meters = t.meters - (i.distanceMeters ?: 0f) + pace?.let { minutesToGo(meters / 1000.0 * it) }?.let { "About $it" } + } + is WorkoutTarget.Time -> minutesToGo((t.seconds - i.activeSeconds).toDouble()) + is WorkoutTarget.Kcal -> "${(t.kcal - (i.activeKcal ?: 0f)).toInt().coerceAtLeast(0)} kcal to go" + } + return "Goal $label" to left + } + + /** The bar's short "to go": "0.68 km to go", "5 min to go", "120 kcal to go"; null without a goal or once met. */ + private fun toGoShort(i: GlanceInput): String? { + if ((progress(i)?.fraction ?: 0f) >= 1f) return null + return when (val t = i.target) { + is WorkoutTarget.Distance -> "${kmWithUnit(t.meters - (i.distanceMeters ?: 0f))} to go" + is WorkoutTarget.Time -> minutesToGo((t.seconds - i.activeSeconds).toDouble()) + is WorkoutTarget.Kcal -> "${(t.kcal - (i.activeKcal ?: 0f)).toInt().coerceAtLeast(0)} kcal to go" + else -> null + } + } + + /** "4 min to go", rounded up; "Under a minute to go". */ + private fun minutesToGo(seconds: Double): String = + if (seconds < 60) "Under a minute to go" else "${ceil(seconds / 60).toInt()} min to go" + + /** "4.32" kilometres, as the figure in the dot font, so always with a dot and ASCII digits. */ + fun kmFigure(meters: Float): String = Units.km(meters, Units.Precision.Workout, Units.DOT_LOCALE) + + /** "4.32 km", or "680 m" under one, down to "0 m" where a line needs a figure. */ + fun kmWithUnit(meters: Float, locale: Locale = Locale.getDefault()): String = + Units.distance(meters, Units.Precision.Workout, locale = locale) ?: String.format(locale, "%d m", meters.coerceAtLeast(0f).toInt()) + + /** A pace as a clock in the dot font, "5:42" ([Pace.clock]); null with none, or one too slow to be moving. */ + fun paceClock(secondsPerKm: Double?): String? = Pace.clock(secondsPerKm, Units.DOT_LOCALE) +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/StartCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/workout/StartCopy.kt new file mode 100644 index 00000000..c7c43d09 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/ui/workout/StartCopy.kt @@ -0,0 +1,464 @@ +package org.openstrap.app.ui.workout + +import androidx.compose.ui.graphics.vector.ImageVector +import org.openstrap.app.data.Route +import org.openstrap.app.data.Strength +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.WorkoutTarget +import org.openstrap.app.data.SportTypes +import org.openstrap.app.data.Workouts +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.presentation.Units +import org.openstrap.app.ui.theme.AppIcons +import java.time.LocalDate +import java.util.Locale +import kotlin.math.roundToInt + +/* + * What Choose and Ready say (workouts spec 2.1, 2.2, 7): the sport, the goal and what the strap will do, never the + * family. Pure, so it's tested without the screens. + */ +object StartCopy { + /** The strap's one line on Choose: "Strap ready, 72 bpm", or what's wrong. */ + fun strapLine(connection: ConnectionState, bpm: Int?): String = when (connection) { + is ConnectionState.Connected -> if (bpm != null) "Strap ready, $bpm bpm" else "Strap ready" + ConnectionState.Connecting, ConnectionState.Authenticating -> "Connecting to the strap" + ConnectionState.NotPaired -> "No strap paired. You can start anyway." + ConnectionState.Disconnected, is ConnectionState.Failed -> "Strap not connected. You can start anyway." + } + + /** The goal chip for no goal, named for the sport: "Just run", "Just walk", "Just ride", else "Just go". */ + fun noGoal(type: Int): String = when { + type in SportTypes.RUNS -> "Just run" + type in SportTypes.WALKS -> "Just walk" + Workouts.tileType(type) == 4 -> "Just ride" + else -> "Just go" + } + + /** No plan: strength's open sets, or mind and body with no length. */ + const val OPEN = "Open" + + /** Above Ready's plan card, by family: "Goal", "Plan", "Length", "Match". */ + fun planTitle(family: Workouts.Family): String = when (family) { + Workouts.Family.Distance, Workouts.Family.IndoorCardio -> "Goal" + Workouts.Family.Strength, Workouts.Family.Intervals -> "Plan" + Workouts.Family.MindBody -> "Length" + Workouts.Family.Games -> "Match" + } + + /** The plan pill's sheet title, and what the pill says it does to a screen reader. */ + fun planSheet(family: Workouts.Family): String = if (family == Workouts.Family.Games) "Free play or periods" else "Choose a ${planTitle(family).lowercase()}" + fun planHint(family: Workouts.Family): String = "Change the ${planTitle(family).lowercase()}" + + /** A typed number in [range]; null for anything else. */ + fun parseNumber(text: String, range: LongRange): Long? = text.trim().toLongOrNull()?.takeIf { it in range } + + /* -------------------------------------------- Strength's plan ------------------------------------------- */ + + const val PLAN = "Plan" + const val STRENGTH_STATUS = "The strap buzzes when rest is over, so the phone can stay on the bench." + const val OPEN_SETS_KIND_NOTE = "As many sets as you like, rest after each" + const val PLAN_KIND_NOTE = "A number of sets with rest between" + const val OPEN_SETS_NOTE = "As many sets as you like. The strap buzzes when rest is over." + const val STRENGTH_TYPING = "How many sets, then the rest in seconds" + + /** The sets presets under the plan. */ + val SET_PRESETS: List> = (3..6).map { it to "$it sets" } + + /** What the plan opens on when there's none from last time. */ + const val DEFAULT_SETS = 5 + + /** Typed sets, within [Strength.SETS]. */ + fun parseSets(text: String): Int? = parseNumber(text, Strength.SETS.first.toLong()..Strength.SETS.last.toLong())?.toInt() + + /** Typed rest in seconds, within [Strength.Rest.RANGE_S]. */ + fun parseSetRest(text: String): Long? = parseNumber(text, Strength.Rest.RANGE_S) + + const val END_REST_EARLY = "End rest early" + + /** End rest early's note, with the level it ends at: "Once you're under 110 bpm". */ + fun endRestNote(bpm: Int): String = "Once you're under $bpm bpm" + + /** The level End rest early turns on at when none was chosen before. */ + const val DEFAULT_BELOW = 110 + + const val LOG_SETS = "Log reps and weight" + const val LOG_SETS_NOTE = "A quick card after each set" + + const val GAMES_STATUS = "Phone in your bag? That's fine." + + /** The GPS line on Ready for an outdoor sport: finding a fix with its accuracy, then ready with it ("GPS ready, 6 m"). */ + fun gpsStatus(accuracy: Float?): String = when { + accuracy == null || accuracy > 1000 -> "Finding GPS" + accuracy > Route.MAX_ACCURACY_M -> "Finding GPS, ±${accuracy.toInt()} m" + else -> "GPS ready, ${accuracy.roundToInt().coerceAtLeast(1)} m" + } + + /** Under two minutes in seconds ("90 s"), then minutes: "2 min", "2 min 30 s". */ + fun restText(seconds: Long): String = when { + seconds < 120 -> "$seconds s" + seconds % 60 == 0L -> "${seconds / 60} min" + else -> "${seconds / 60} min ${seconds % 60} s" + } + + /** + * One of HIIT's presets on Ready: its rounds (null for Free), its name and what it is in the plan pill's sheet, and + * its icon there, null for the sport's own. Each carries its icon, so a new preset can't miss one. + */ + class HiitPreset(val rounds: org.openstrap.app.data.Structure?, val name: String, val note: String, val icon: ImageVector?) + + /** HIIT's presets on Ready, Free last. */ + val PRESETS: List = listOf( + HiitPreset(org.openstrap.app.data.Structure.TABATA, "Tabata", "20 s on, 10 off, 8 rounds", AppIcons.Timer), + HiitPreset(org.openstrap.app.data.Structure.FORTY_TWENTY, "40 / 20", "10 rounds", AppIcons.Hourglass), + HiitPreset(org.openstrap.app.data.Structure.EVERY_MINUTE, "Every minute", "12 rounds, rest is what's left", AppIcons.Alarm), + HiitPreset(null, "Free", "No timer, laps mark the rounds", null), + ) + + const val INTERVALS_STATUS = "The strap buzzes at every switch, so the phone can stay on the floor." + const val HIIT_FREE_NOTE = "No timer. Laps mark the rounds." + const val HIIT_TYPING = "Work, then rest, then how many rounds" + const val EVERY_TYPING = "Each round's time, then how many rounds" + const val WARM_UP = "Warm up first" + const val WARM_UP_NOTE = "No timer until you start the rounds." + + /** HIIT's plan in its pill: a preset's name, else what it is ("30 / 15", "Every 90 s"). */ + fun hiitName(s: org.openstrap.app.data.Structure): String { + PRESETS.firstOrNull { it.rounds?.code == s.warm(false).code }?.let { return it.name } + return when (s) { + is org.openstrap.app.data.Structure.Timed -> "${s.work} / ${s.rest}" + is org.openstrap.app.data.Structure.EveryMinute -> if (s.period == 60L) "Every minute" else "Every ${restText(s.period)}" + else -> structureName(s) + } + } + + /** All of a timed structure's time, in a line beside its rounds: "4:00 in all"; null where it has none. */ + fun totalLine(s: org.openstrap.app.data.Structure): String? = totalTime(s)?.let { "${Units.workoutClock(it)} in all" } + + /** Typed work, rest and rounds over [s]: work 5 s to 10 min, rest up to 10 min, 1 to [MAX_ROUNDS]; what doesn't read stays. */ + fun typedTimed(s: org.openstrap.app.data.Structure.Timed, typed: List): org.openstrap.app.data.Structure.Timed = s.copy( + work = parseNumber(typed[0], 5L..600L) ?: s.work, + rest = parseNumber(typed[1], 0L..600L) ?: s.rest, + rounds = parseNumber(typed[2], 1L..MAX_ROUNDS.toLong())?.toInt() ?: s.rounds, + ) + + /** Typed round time and rounds over [s]: 10 s to 10 min a round, 1 to [MAX_ROUNDS]; what doesn't read stays. */ + fun typedEveryMinute(s: org.openstrap.app.data.Structure.EveryMinute, typed: List): org.openstrap.app.data.Structure.EveryMinute = s.copy( + period = parseNumber(typed[0], 10L..600L) ?: s.period, + rounds = parseNumber(typed[1], 1L..MAX_ROUNDS.toLong())?.toInt() ?: s.rounds, + ) + + const val MAX_ROUNDS = 50 + + /** A structure's name: a preset's ("Tabata"), else what it is ("6 × 400 m", "8 × 2 min", "30 / 15, 6 rounds"). */ + fun structureName(s: org.openstrap.app.data.Structure): String { + PRESETS.firstOrNull { it.rounds?.code == s.warm(false).code }?.let { return it.name } + return when (s) { + is org.openstrap.app.data.Structure.Timed -> "${s.work} / ${s.rest}, ${s.rounds} rounds" + is org.openstrap.app.data.Structure.EveryMinute -> "Every ${restText(s.period)}, ${s.rounds} rounds" + // As Ready's rep field and note say it ([repParts]): "6 × 1.5 km", "8 × 1 min". + is org.openstrap.app.data.Structure.Reps -> repParts(s).let { (n, unit) -> "${s.rounds} × $n $unit" } + is org.openstrap.app.data.Structure.Periods -> periodsName(s) + } + } + + /** "Halves", "Quarters" or "3 periods". */ + fun periodsName(s: org.openstrap.app.data.Structure.Periods): String = when (s.rounds) { + 2 -> "Halves" + 4 -> "Quarters" + else -> "${s.rounds} periods" + } + + /** A period by its place: "1st half", "3rd quarter", "Period 2". */ + fun periodName(index: Int, of: Int): String { + val n = index + 1 + val nth = when { + n % 100 in 11..13 -> "${n}th" + n % 10 == 1 -> "${n}st" + n % 10 == 2 -> "${n}nd" + n % 10 == 3 -> "${n}rd" + else -> "${n}th" + } + return when (of) { + 2 -> "$nth half" + 4 -> "$nth quarter" + else -> "Period $n" + } + } + + /** What the break between periods is called. */ + fun breakName(of: Int): String = if (of == 2) "Half time" else "Break" + + /** Mind and body's line on Ready (workouts spec 2.2), with a length and without one. */ + const val MIND_STATUS = "Quiet strap and a dim screen. One soft buzz at the end." + const val MIND_OPEN_NOTE = "Quiet strap and a dim screen. End it when you're ready." + const val MIND_OPEN_KIND_NOTE = "No length, end it yourself" + const val MIND_TIME_KIND_NOTE = "One soft buzz when the time is up" + + /** Mind and body's length presets: 15, 30 and 45 min and 1 h. */ + val MIND_PRESETS: List> = org.openstrap.app.data.WorkoutSetup.LENGTHS.map { it.seconds to timeValue(it.seconds) } + + /** A game's choice on Ready: free play or its periods. */ + const val FREE_PLAY = "Free play" + const val FREE_PLAY_KIND_NOTE = "No periods, the clock just runs" + const val HALVES_KIND_NOTE = "Two halves with half time between" + const val QUARTERS_KIND_NOTE = "Four quarters with a break between each" + const val GAMES_TYPING = "How many, how long, then the break" + + /** What a game's strap does: "...The strap buzzes at the end of each half and break.", or nothing in free play. */ + fun gamesNote(p: org.openstrap.app.data.Structure.Periods?): String { + p ?: return GAMES_STATUS + val each = when (p.rounds) { 2 -> "half"; 4 -> "quarter"; else -> "period" } + return "$GAMES_STATUS The strap buzzes at the end of each $each" + if (p.rest > 0 && p.rounds > 1) " and break." else "." + } + + /** Typed periods, minutes long and break minutes over [p]: 2 to 8 periods of 1 to 90 min, a break up to 30 min. */ + fun typedPeriods(p: org.openstrap.app.data.Structure.Periods, typed: List): org.openstrap.app.data.Structure.Periods = p.copy( + rounds = parseNumber(typed[0], 2L..8L)?.toInt() ?: p.rounds, + length = parseNumber(typed[1], 1L..90L)?.let { it * 60 } ?: p.length, + rest = parseNumber(typed[2], 0L..30L)?.let { it * 60 } ?: p.rest, + ) + + /** All of a timed structure's time, rounds of work and the rests between, for Ready: "4:00". */ + fun totalTime(s: org.openstrap.app.data.Structure): Long? = when (s) { + is org.openstrap.app.data.Structure.Timed -> s.rounds * s.work + (s.rounds - 1) * s.rest + is org.openstrap.app.data.Structure.EveryMinute -> s.rounds * s.period + is org.openstrap.app.data.Structure.Reps -> null + is org.openstrap.app.data.Structure.Periods -> s.rounds * s.length + (s.rounds - 1) * s.rest + } + + /** + * Your usual's line: what you did last time and when ("Last time 5.2 km, Tuesday"): the distance for a distance + * sport that has one, the sets for strength, else the minutes. + */ + fun lastTime(a: WorkoutAnalysis, today: LocalDate, zone: java.time.ZoneId, locale: Locale = Locale.getDefault()): String { + val what = when { + a.distanceMeters != null && a.distanceMeters >= 100 && Workouts.family(a.workout.sportType) == Workouts.Family.Distance -> + Units.distance(a.distanceMeters, Units.Precision.Workout, locale = locale).orEmpty() + a.sets != null && a.sets > 0 -> if (a.sets == 1) "1 set" else "${a.sets} sets" + else -> "${(a.activeSeconds / 60).coerceAtLeast(1)} min" + } + val day = java.time.Instant.ofEpochSecond(a.workout.start).atZone(zone).toLocalDate() + return "Last time $what, ${DayWords.inSentence(day, today, locale)}" + } + + /* -------------------------------------------- Ready's where --------------------------------------------- */ + + /** Start on Ready, naming the sport: "Start run", and "Start treadmill run" on the treadmill. */ + fun startLabel(type: Int): String = if (type == TREADMILL) "Start treadmill run" else WorkoutCopy.startLabel(type) + + /** Where row's title for the side you're on: "Outside", "On a treadmill", "Indoors", "Open water", "In a pool". */ + fun place(sides: Workouts.Sides, indoor: Boolean): String = when { + !indoor -> if (sides.outdoor == OPEN_WATER) "Open water" else "Outside" + sides.indoor == TREADMILL -> "On a treadmill" + sides.indoor == POOL -> "In a pool" + else -> "Indoors" + } + + /** The where row's pill, naming the other side: "Treadmill", "Outside", "Indoors", "Pool", "Open water". */ + fun otherPlace(sides: Workouts.Sides, indoor: Boolean): String = when { + indoor -> if (sides.outdoor == OPEN_WATER) "Open water" else "Outside" + sides.indoor == TREADMILL -> "Treadmill" + sides.indoor == POOL -> "Pool" + else -> "Indoors" + } + + /** Under an indoor side's title: what you get without GPS. */ + fun indoorNote(type: Int): String = when { + type in SportTypes.RUNS -> "Pace from your steps" + type in SportTypes.ON_FOOT -> "Distance from your steps" + else -> "Time and your heart, no distance" + } + + private const val TREADMILL = 2 + private const val POOL = 6 + private const val OPEN_WATER = 7 + + /* -------------------------------------------- Ready's goal ---------------------------------------------- */ + + /** The goal kinds' names after the sport's [noGoal], in the goal pill and its sheet. */ + const val DISTANCE = "Distance" + const val TIME = "Time" + const val ZONE = "Zone" + const val INTERVALS = "Intervals" + + const val TAP_TO_TYPE = "Tap to type" + + /** Under each goal kind in the pill's sheet, what the strap does with it. */ + const val DISTANCE_KIND_NOTE = "A buzz at halfway and at the goal" + const val TIME_KIND_NOTE = "A buzz when the time is up" + const val ZONE_KIND_NOTE = "A buzz when you leave your zone" + const val INTERVALS_KIND_NOTE = "Repeats with rest between, a buzz at each switch" + + /** No goal's note: what's recorded, the distance only where there is one. */ + fun noGoalNote(hasDistance: Boolean): String = if (hasDistance) "No goal. Time, distance and your heart." else "No goal. Time and your heart." + + /** A distance goal's value in km, as typed: "5", "7.5", "12.45", and a half marathon "21.1". */ + fun distanceValue(meters: Int): String = + if (meters % 1000 == 0) "${meters / 1000}" else String.format(Locale.ROOT, "%.2f", meters / 1000.0).trimEnd('0').trimEnd('.') + + /** What a distance goal does: "The strap buzzes at halfway and at 7.5 km." */ + fun distanceNote(meters: Int): String = "The strap buzzes at halfway and at ${distanceValue(meters)} km." + + /** Typed km ("12.4", "12,4") as metres, 0.1 to [MAX_KM] km; null for anything else. */ + fun parseDistance(text: String): Int? { + val km = text.trim().replace(',', '.').toDoubleOrNull() ?: return null + return (km * 1000).roundToInt().takeIf { it in 100..MAX_KM * 1000 } + } + + /** Minus or plus ([up]) on a distance: to the next half km, from half a km to [MAX_KM]. */ + fun stepDistance(meters: Int, up: Boolean): Int { + val step = 500 + val next = if (up) (meters / step + 1) * step else (meters - 1) / step * step + return next.coerceIn(step, MAX_KM * 1000) + } + + const val MAX_KM = 300 + + /** The distance presets under the value: 3, 5 and 10 km and a half marathon, with their labels. */ + val DISTANCE_PRESETS: List> = listOf(3000 to "3", 5000 to "5", 10_000 to "10", HALF_MARATHON to "Half") + + const val HALF_MARATHON = 21_097 + + /** A time goal's value as numbers and units: "1" "h" "15" "min", "1" "h", or "45" "min" under an hour. */ + fun timeParts(seconds: Long): List> { + val h = seconds / 3600 + val m = seconds / 60 % 60 + return listOfNotNull(("$h" to "h").takeIf { h > 0 }, ("$m" to "min").takeIf { m > 0 || h == 0L }) + } + + /** A time goal in one line, as every duration reads ([Units.duration]): "1 h 15", "1 h", "45 min". */ + fun timeValue(seconds: Long): String = Units.duration(seconds) + + /** Minus or plus ([up]) on a time: to the next 5 minutes, from 5 min to [MAX_HOURS] h. */ + fun stepTime(seconds: Long, up: Boolean): Long { + val step = 300L + val next = if (up) (seconds / step + 1) * step else (seconds - 1) / step * step + return next.coerceIn(step, MAX_HOURS * 3600L) + } + + const val MAX_HOURS = 10 + + /** Typed hours and minutes as seconds, either may be blank: 1 min to [MAX_HOURS] h; null for anything else. */ + fun parseTime(hours: String, minutes: String): Long? { + val h = hours.trim().ifEmpty { "0" }.toLongOrNull() ?: return null + val m = minutes.trim().ifEmpty { "0" }.toLongOrNull() ?: return null + return (h * 3600 + m * 60).takeIf { it in 60..MAX_HOURS * 3600L } + } + + /** The time presets under the value: 30 and 45 min, 1 h and 1 h 30, with their labels. */ + val TIME_PRESETS: List> = listOf(1800L to "30 min", 2700L to "45 min", 3600L to "1 h", 5400L to "1 h 30") + + /** A time goal's note: when it would end if you started now, "Ends near 19:15". */ + fun endsNear(now: java.time.LocalTime, seconds: Long, is24Hour: Boolean): String = + "Ends near " + TimeOfDay.format(now.plusSeconds(seconds), is24Hour) + + /** While a time is typed: "Type hours, then minutes. Ends near 19:15". */ + fun timeTypingNote(endsNear: String): String = "Type hours, then minutes. $endsNear" + + /** How the zone buttons work, and what the strap does. */ + const val ZONE_NOTE = "Tap a zone, or a second one for a range. The strap buzzes when you leave it." + + const val YOUR_ZONES = "Your zones" + + /** + * A zone goal's heart rates in [zones] at [maxHr], with the zones' names: "115 to 152 bpm, aerobic to tempo", + * "115 to 133 bpm, aerobic", and up from zone 5: "172 bpm and up, max". + */ + fun zoneRangeLine(goal: WorkoutTarget.Zones, zones: org.openstrap.app.data.HeartZones, maxHr: Int): String { + val from = zones.startBpm(goal.low, maxHr) + val bpm = zones.endBpm(goal.high, maxHr)?.let { "$from to $it bpm" } ?: "$from bpm and up" + val names = if (goal.low == goal.high) ZONE_NAMES[goal.low].lowercase() else "${ZONE_NAMES[goal.low].lowercase()} to ${ZONE_NAMES[goal.high].lowercase()}" + return "$bpm, $names" + } + + /** Run intervals' presets on Ready: 8 × 200 m, 6 × 400 m, 5 × 1 km and 4 × 4 min, each with its rest. */ + val INTERVAL_PRESETS: List = listOf( + org.openstrap.app.data.Structure.Reps(200, null, 60, 8), + org.openstrap.app.data.Structure.Reps(400, null, 90, 6), + org.openstrap.app.data.Structure.Reps(1000, null, 120, 5), + org.openstrap.app.data.Structure.Reps(null, 240, 180, 4), + ) + + /** What run intervals open on before you've chosen any: 6 × 400 m with 90 s rest. */ + val DEFAULT_INTERVALS: org.openstrap.app.data.Structure.Reps = INTERVAL_PRESETS[1] + + /** A rep's length as a number and its unit: "400" "m", "1.5" "km", "4" "min", "90" "s". */ + fun repParts(r: org.openstrap.app.data.Structure.Reps): Pair = when { + r.meters != null -> if (r.meters >= 1000) distanceValue(r.meters) to "km" else "${r.meters}" to "m" + (r.seconds ?: 0) % 60 == 0L -> "${(r.seconds ?: 0) / 60}" to "min" + else -> "${r.seconds}" to "s" + } + + /** The rest between reps as a number and its unit: "90" "s", "2" "min", "150" "s". */ + fun restParts(seconds: Long): Pair = if (seconds >= 120 && seconds % 60 == 0L) "${seconds / 60}" to "min" else "$seconds" to "s" + + /** + * What run intervals do: "The strap buzzes at the end of each 400 m and when rest is up. 2.4 km in all.", or for + * reps of a time "at the end of each 4 min rep", with "16 min of reps in all". + */ + fun intervalsNote(r: org.openstrap.app.data.Structure.Reps): String { + val (n, unit) = repParts(r) + val each = if (r.meters != null) "each $n $unit" else "each $n $unit rep" + val total = r.meters?.let { m -> + val all = m * r.rounds + if (all >= 1000) "${distanceValue(all)} km in all" else "$all m in all" + } ?: "${timeValue((r.seconds ?: 0) * r.rounds)} of reps in all" + return "The strap buzzes at the end of $each and when rest is up. $total." + } + + const val INTERVALS_TYPING = "How many, then each rep, then the rest" + const val TAP_A_NUMBER = "Tap a number to type it" + const val EACH_REP_IN = "Each rep in" + + /** The units a rep's length is typed in. */ + enum class RepUnit(val label: String) { Meters("m"), Kilometers("km"), Minutes("min") } + + /** The unit [r] is shown in, to type it in the same one. */ + fun repUnit(r: org.openstrap.app.data.Structure.Reps): RepUnit = when { + r.meters != null -> if (r.meters >= 1000) RepUnit.Kilometers else RepUnit.Meters + else -> RepUnit.Minutes + } + + /** How many reps: 1 to [MAX_REPS]. */ + fun parseCount(text: String): Int? = text.trim().toIntOrNull()?.takeIf { it in 1..MAX_REPS } + + const val MAX_REPS = 30 + + /** A typed rep length in [unit] as [r] with it: 50 m to 50 km, or 10 s to 60 min; null for anything else. */ + fun parseRep(text: String, unit: RepUnit, r: org.openstrap.app.data.Structure.Reps): org.openstrap.app.data.Structure.Reps? { + val v = text.trim().replace(',', '.').toDoubleOrNull() ?: return null + return when (unit) { + RepUnit.Meters -> v.roundToInt().takeIf { it in 50..50_000 }?.let { r.copy(meters = it, seconds = null) } + RepUnit.Kilometers -> (v * 1000).roundToInt().takeIf { it in 50..50_000 }?.let { r.copy(meters = it, seconds = null) } + RepUnit.Minutes -> (v * 60).roundToInt().toLong().takeIf { it in 10..3600 }?.let { r.copy(meters = null, seconds = it) } + } + } + + /** Typed rest in seconds, none to 15 min. */ + fun parseRest(text: String): Long? = text.trim().toLongOrNull()?.takeIf { it in 0..900 } + + /** Saving the reps by name, to find them on Choose and here. */ + const val SAVE_INTERVALS = "Save" + const val SAVED_INTERVALS = "Saved" + const val SAVE_NOTE = "To find on Choose and here. Up to ten are kept." + const val NAME_HINT = "Name, like Tuesday intervals" + + /** Ready's switch rows, under the where row. */ + const val ASK_TO_END = "Ask to end when I stop" + const val ASK_TO_END_NOTE = "The strap buzzes after 8 min still" + /** The route switch, named for what it gives you: "Save a map of my run", "...of my walk", "...of my ride". */ + fun keepRoute(type: Int): String { + val name = Workouts.shortName(type).lowercase() + val noun = when { + "cycl" in name || "bik" in name -> "ride" + name == "skiing" -> "ski" + name == "rucking" -> "ruck" + else -> name + } + return "Save a map of my $noun" + } + const val TURN_ON_LOCATION = "Turn on location" +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/SummaryCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/workout/SummaryCopy.kt index cdd51f3e..ab04125a 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/workout/SummaryCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/workout/SummaryCopy.kt @@ -1,16 +1,12 @@ package org.openstrap.app.ui.workout -import java.time.Instant -import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.Locale import org.openstrap.app.data.Route import org.openstrap.app.data.WorkoutTarget import org.openstrap.app.data.Workouts +import org.openstrap.app.presentation.Units import org.openstrap.app.ui.dashboard.TodayCopy import org.openstrap.app.ui.dashboard.sportNoun -import org.openstrap.app.ui.glow.km -import org.openstrap.app.ui.glow.workoutClock /* * What the summary right after a workout says (spec 6.3, "Right after"): the heart rate drop as it happens and how it @@ -21,11 +17,14 @@ import org.openstrap.app.ui.glow.workoutClock /** The hero right after a workout: still [watching] the first minute, or the [drop] once it's done, and its line. */ data class CalmDownLine(val watching: Boolean, val drop: Int?, val text: String) -/** A kilometre on the Splits list: "Km 3" in "5:42", with its average heart rate when there were readings. */ -data class SplitRow(val name: String, val time: String, val averageBpm: Int?) - -/** A workout as the personal best check sees it: its sport, active [seconds], and [meters] from GPS only. */ -data class BestCandidate(val start: Long, val sportType: Int, val seconds: Long, val meters: Float?) +/** + * A kilometre on the Splits list: "Km 3" in "5:42", with its average heart rate when there were readings. [seconds] and + * [kind] are what the list's marks read, so a change to the words can't move them. + */ +data class SplitRow(val name: String, val time: String, val averageBpm: Int?, val seconds: Long, val kind: Kind) { + /** A whole kilometre, or the part one at the end. */ + enum class Kind { Whole, Last } +} object SummaryCopy { /** The drop is read over the first minute after the end. */ @@ -44,9 +43,6 @@ object SummaryCopy { /** A drop only says something about fitness after a real effort: ending at this share of max or more. */ private const val MIN_EFFORT_PCT = 65 - /** The page title right after a workout, by the sport's short name: "Run saved" (spec 6.3). */ - fun savedTitle(sportType: Int?): String = sportType?.let { "${Workouts.shortName(it)} saved" } ?: "Workout saved" - /** The (i) on the calm down hero. */ const val DROP_MEANING = "How far your heart rate falls in the first minute after you stop. A bigger drop usually " + "means a fitter heart, and under 12 bpm is on the low side. It only says something after a hard effort." @@ -136,22 +132,17 @@ object SummaryCopy { * The workout card's line: distance (≈ from steps), the average, and paused time past a minute: "5.21 km, average * 142 bpm. Paused 4 min." Null when there's none of them. */ - fun workoutLine(meters: Float?, fromSteps: Boolean, averageBpm: Int?, pausedSeconds: Long): String? { + fun workoutLine( + meters: Float?, fromSteps: Boolean, averageBpm: Int?, pausedSeconds: Long, locale: Locale = Locale.getDefault(), + ): String? { val parts = listOfNotNull( - meters?.takeIf { it >= 10f }?.let { (if (fromSteps) "≈ " else "") + km(it) + if (it >= 1000) " km" else "" }, + meters?.let { Units.distance(it, Units.Precision.Workout, fromSteps, locale) }, averageBpm?.takeIf { it > 0 }?.let { "average $it bpm" }, ).joinToString(", ").replaceFirstChar { it.uppercase() } val paused = (pausedSeconds / 60).takeIf { it >= 1 }?.let { "Paused $it min." } return listOfNotNull(parts.takeIf { it.isNotEmpty() }?.let { "$it." }, paused).joinToString(" ").ifEmpty { null } } - /** The workout card's calories, active first (calories spec 4.2): "486 active kcal, total 612"; null without them. */ - fun kcalLine(active: Int?, total: Int?, locale: java.util.Locale = java.util.Locale.getDefault()): String? = when { - total == null || total <= 0 -> null - active == null || active == total -> "%,d kcal".format(locale, total) - else -> "%,d active kcal, total %,d".format(locale, active, total) - } - /** * The workout card's kcal target ([target], a [WorkoutTarget.code]): "Target 400 kcal, reached at 38:10" from * [reachedSeconds], or "Target not reached, 212 of 400 kcal" from [activeKcal]. A workout that got there without a @@ -161,7 +152,7 @@ object SummaryCopy { val k = WorkoutTarget.parse(target) as? WorkoutTarget.Kcal ?: return null val goal = "%,d".format(locale, k.kcal) return when { - reachedSeconds != null -> "Target $goal kcal, reached at ${workoutClock(reachedSeconds)}" + reachedSeconds != null -> "Target $goal kcal, reached at ${Units.workoutClock(reachedSeconds, locale)}" activeKcal != null && activeKcal >= k.kcal -> "Target $goal kcal, reached" activeKcal != null -> "Target not reached, %,d of $goal kcal".format(locale, activeKcal) else -> "Target $goal kcal, not reached" @@ -169,13 +160,17 @@ object SummaryCopy { } /** A lap's line on the list: "1.02 km, average 150 bpm"; null with neither. */ - fun lapLine(meters: Double?, averageBpm: Int?): String? = listOfNotNull( - meters?.let { km(it.toFloat()) + if (it >= 1000) " km" else "" }, + fun lapLine(meters: Double?, averageBpm: Int?, locale: Locale = Locale.getDefault()): String? = listOfNotNull( + meters?.let { Units.distance(it.toFloat(), Units.Precision.Workout, locale = locale) }, averageBpm?.let { "average $it bpm" }, ).joinToString(", ").replaceFirstChar { it.uppercase() }.ifEmpty { null } /** The trim panel's header: "18:02–18:40, 38:12". */ - fun trimHeader(from: String, to: String, seconds: Long): String = "$from–$to, ${workoutClock(seconds)}" + fun trimHeader(from: String, to: String, seconds: Long): String = "$from–$to, ${Units.workoutClock(seconds)}" + + /** The last part's distance, down to "0 m" where the smoothed total leaves next to nothing. */ + private fun lastPart(meters: Float, locale: Locale): String = + Units.distance(meters, Units.Precision.Workout, locale = locale) ?: String.format(locale, "%d m", meters.toInt()) /** A last stretch shorter than this isn't worth a row. */ private const val PARTIAL_MIN_M = 100.0 @@ -186,56 +181,19 @@ object SummaryCopy { * With the workout's [totalMeters], the last part is what's left of it after the whole kilometres, so the list adds * up to the distance shown above it (the raw route runs a little longer than the smoothed distance). */ - fun splitRows(splits: List, readings: List>, firstT: Long?, totalMeters: Float? = null): List = + fun splitRows( + splits: List, readings: List>, firstT: Long?, totalMeters: Float? = null, + locale: Locale = Locale.getDefault(), + ): List = splits.mapIndexedNotNull { i, sp -> val whole = sp.meters >= 1000.0 if (!whole && (i != splits.lastIndex || sp.meters < PARTIAL_MIN_M)) return@mapIndexedNotNull null val from = splits.getOrNull(i - 1)?.endT ?: firstT ?: sp.endT val last = totalMeters?.let { it - 1000f * i }?.takeIf { it > 0f } ?: sp.meters.toFloat() - SplitRow(if (whole) "Km ${i + 1}" else "Last ${km(last)}", workoutClock(sp.seconds), WorkoutCopy.averageOver(readings, from, sp.endT)) - } - - /** A best counts once the last better one is at least this long ago. */ - const val BEST_GAP_S = 30 * 86_400L - - /** "Yet" only once there are this many of the sport before. */ - const val BEST_MIN_BEFORE = 5 - - /** "Since March" only when it beats at least this many of the sport since then. */ - const val BEST_MIN_BEATEN = 3 - - /** Shorter than these and a best means little. */ - private const val BEST_MIN_M = 1000f - private const val BEST_MIN_S = 10 * 60L - - /** - * A personal best as one line, from what's stored: the farthest of the sport by GPS ("Farthest run since March."), - * else the longest by active time ("Longest walk since March."). It counts when the last better one of [others] is - * [BEST_GAP_S] or more before with [BEST_MIN_BEATEN] or more of the sport since, or there is none among - * [BEST_MIN_BEFORE] or more ("Your farthest run yet."). Null otherwise. TODO: the spec's "Fastest 5 km since March" needs the fastest stretch of every stored route of the - * sport, which means reading every route back; nothing caches split times yet. - */ - fun personalBest(w: BestCandidate, others: List, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault()): String? { - val noun = sportNoun(w.sportType) ?: Workouts.sportName(w.sportType).lowercase(locale) - val before = others.filter { it.sportType == w.sportType && it.start < w.start } - fun best(word: String, value: (BestCandidate) -> Double?, min: Double): String? { - val mine = value(w)?.takeIf { it >= min } ?: return null - val measured = before.filter { value(it) != null } - val better = measured.filter { value(it)!! >= mine }.maxByOrNull { it.start } - return when { - better == null -> "Your $word $noun yet.".takeIf { measured.size >= BEST_MIN_BEFORE } - w.start - better.start >= BEST_GAP_S && measured.count { it.start > better.start } >= BEST_MIN_BEATEN -> "${word.replaceFirstChar { it.uppercase() }} $noun since ${month(better.start, w.start, zone, locale)}." - else -> null - } + SplitRow( + if (whole) "Km ${i + 1}" else "Last " + lastPart(last, locale), + Units.workoutClock(sp.seconds, locale), WorkoutCopy.averageOver(readings, from, sp.endT), sp.seconds, + if (whole) SplitRow.Kind.Whole else SplitRow.Kind.Last, + ) } - return best("farthest", { it.meters?.toDouble() }, BEST_MIN_M.toDouble()) - ?: best("longest", { it.seconds.toDouble().takeIf { s -> s > 0 } }, BEST_MIN_S.toDouble()) - } - - /** "March", or "March 2025" when [at] is in another year than [now]. */ - private fun month(at: Long, now: Long, zone: ZoneId, locale: Locale): String { - val d = Instant.ofEpochSecond(at).atZone(zone) - val pattern = if (d.year == Instant.ofEpochSecond(now).atZone(zone).year) "MMMM" else "MMMM yyyy" - return d.format(DateTimeFormatter.ofPattern(pattern, locale)) - } } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutCopy.kt index 9dfd6ff9..41a5a1fe 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutCopy.kt @@ -4,7 +4,10 @@ import org.openstrap.app.data.Route import org.openstrap.app.data.WorkoutTarget import org.openstrap.app.data.Workouts import org.openstrap.app.device.ConnectionState -import org.openstrap.app.ui.glow.workoutClock +import org.openstrap.app.presentation.StrapBands +import org.openstrap.app.presentation.Units +import org.openstrap.app.presentation.WorkoutWords +import java.util.Locale /* * What the Ready and live workout screens say (spec 6.3), and the small rules behind what they show: the split line's @@ -21,12 +24,6 @@ enum class LiveView { } } -/** The figures the live screen shows around the moving time, in the order they are laid out. */ -enum class SideFigure { Avg, Max, Kcal, Distance, Pace } - -/** The live figures: [sides] either side of the moving time, [below] in the row under it (none when empty). */ -data class LiveFigures(val sides: List, val below: List) - /** A finished kilometre on the live screen: which one ([index] from 0), when it ended, and its line. */ data class SplitLine(val index: Int, val endT: Long, val text: String) @@ -34,8 +31,11 @@ object WorkoutCopy { /** How long a split line stays under the stats. */ const val SPLIT_LINE_S = 10L - /** At or under this, the Ready line mentions the strap's battery. */ - const val LOW_BATTERY = 20 + /** + * At or under this, Ready's line mentions the strap's battery. Earlier on purpose than the low battery everywhere + * else ([StrapBands.LOW_BATTERY], 15): before a workout, a strap at 20% may not last a long one. + */ + const val READY_LOW_BATTERY = 20 private const val KM = 1000.0 @@ -51,7 +51,7 @@ object WorkoutCopy { "Strap connected", listOfNotNull( if (bpm != null) "Live from your strap." else "Waiting for heart rate.", - connection.batteryPercent?.takeIf { it <= LOW_BATTERY && !connection.charging }?.let { "Battery $it%." }, + connection.batteryPercent?.takeIf { it <= READY_LOW_BATTERY && !connection.charging }?.let { "Battery $it%." }, ).joinToString(" "), live = true, ) @@ -62,18 +62,11 @@ object WorkoutCopy { private const val ANYWAY = "You can start anyway." - /** Under GPS: what it gives, or what's missing without it. */ - fun gpsNote(on: Boolean, sportType: Int): String = when { - on -> "On. Distance, pace and the map." - sportType in Workouts.ON_FOOT -> "Off. Distance from the strap's steps, roughly." - else -> "Off. No distance without GPS." - } - /** The GPS row while the phone's location is off or not allowed. Start still works, without GPS. */ const val LOCATION_OFF = "Location is off" const val LOCATION_OFF_NOTE = "Turn it on for distance and the map, or start without it." - /** Under Keep the route. */ + /** Under Save a map of my run. */ fun routeNote(on: Boolean): String = if (on) "On. Saved on this phone for the map." else "Off. Only the totals are kept." /** Start, naming the sport: "Start run", "Start HIIT", and "Start workout" for Other. */ @@ -82,27 +75,37 @@ object WorkoutCopy { else -> "Start " + if (name.any { it.isLowerCase() }) name.lowercase() else name } - /** The Ready info sheet's meaning. */ - const val READY_MEANING = "Heart rate comes from the strap every second. GPS is on for outdoor workouts, and you can turn it off." + /** "Set 3 of 5", or "Set 3" with no plan. */ + fun setOf(number: Int, target: Int?): String = "Set $number" + (target?.let { " of $it" } ?: "") + + /** What ends strength's rest, under its countdown. */ + fun restRule(belowBpm: Int?): String = + if (belowBpm == null) "Rest ends when the time is up." else "Rest ends when the time is up, or sooner once you stay under $belowBpm." + + /** A set done, as the live screen lists it: "10 × 60 kg, 0:51, peak 148", or without the log "0:51, peak 148". */ + fun setLine(s: org.openstrap.app.data.db.AppWorkoutSet, peak: Int?): String = listOfNotNull( + s.reps?.let { r -> s.weightKg?.let { "$r × ${org.openstrap.app.data.Strength.kgText(it)} kg" } ?: "$r reps" }, + s.end?.let { Units.workoutClock(it - s.start) }, + peak?.let { "peak $it" }, + ).joinToString(", ") - /** The Ready info sheet's rows: the strap always, GPS and the route for an [outdoor] sport. */ - fun readyRows(outdoor: Boolean): List> = listOfNotNull( - "Heart rate" to "The strap sends heart rate every second, with the phone's screen off too. If it drops out or " + - "isn't connected at the start, recording carries on, and the missing minutes are filled from the strap's own " + - "history after the next sync.", - ("GPS" to "Outdoors, GPS uses the phone's location during this workout only, for distance, pace and the map. " + - "Its switch turns it off for this workout. Map tiles come from OpenFreeMap, which sees roughly where the map " + - "is. Route only on the map loads no tiles. Without GPS, walks and runs get a distance from the strap's " + - "steps, marked ≈.").takeIf { outdoor }, - ("Keep the route" to "With Keep the route on, the route is saved on this phone for the map. Off, only the " + - "totals are kept and the route is discarded when the workout ends.").takeIf { outdoor }, - ) + /** The Today hero's way back to the live screen, naming the sport: "Open run", "Open workout" for Other. */ + fun openLabel(sportType: Int): String = "Open" + startLabel(sportType).removePrefix("Start") - /** "Zone 3, aerobic" as its two parts, drawn as two text elements. */ - fun zoneTitle(zone: Int): Pair = "Zone ${zone + 1}," to ZONE_NAMES[zone].lowercase() + /** "Zone 3, aerobic" as its two parts ([WorkoutWords.zoneTitle]). */ + fun zoneTitle(zone: Int): Pair = WorkoutWords.zoneTitle(zone) - /** "9 min in this zone". */ - fun zoneTime(seconds: Int): String = if (seconds < 60) "Under a minute in this zone" else "${seconds / 60} min in this zone" + /** [zoneTitle] as one line: "Zone 3, aerobic" ([WorkoutWords.zoneLine]). */ + fun zoneLine(zone: Int): String = WorkoutWords.zoneLine(zone) + + /** What a live screen says where the heart rate goes and there's none, the same words on every screen and the notification. */ + fun noHeartRate(paused: Boolean, connected: Boolean = true): String = WorkoutWords.noHeartRate(paused, connected) + + /** The live screen's note while the strap is away ([WorkoutWords.STRAP_GONE_NOTE]). */ + const val STRAP_GONE_NOTE = WorkoutWords.STRAP_GONE_NOTE + + /** "9 min in this zone" ([WorkoutWords.zoneTime]). */ + fun zoneTime(seconds: Int): String = WorkoutWords.zoneTime(seconds) /** * The line for the latest whole kilometre in [splits], for [SPLIT_LINE_S] after it ended, else null: "Km 3 in 5:42, @@ -117,7 +120,21 @@ object WorkoutCopy { if (now - sp.endT >= SPLIT_LINE_S || sp.endT - now > SPLIT_LINE_S) return null val from = splits.getOrNull(i - 1)?.endT ?: firstT ?: sp.endT val avg = averageOver(history, from, sp.endT) - return SplitLine(i, sp.endT, "Km ${i + 1} in ${workoutClock(sp.seconds)}" + (avg?.let { ", avg $it" } ?: "")) + return SplitLine(i, sp.endT, WorkoutWords.split(i, sp.seconds) + (avg?.let { ", avg $it" } ?: "")) + } + + /** + * The map page's line for the latest whole kilometre, kept until the next: "Km 4 in 5:55, avg 146" and how long + * ago it ended, "just now" or "3 min ago". Null before the first. + */ + fun lastSplit(splits: List, history: List>, firstT: Long?, now: Long): Pair? { + val i = splits.indexOfLast { it.meters >= KM } + if (i < 0) return null + val sp = splits[i] + val from = splits.getOrNull(i - 1)?.endT ?: firstT ?: sp.endT + val avg = averageOver(history, from, sp.endT) + val ago = (now - sp.endT).coerceAtLeast(0) / 60 + return WorkoutWords.split(i, sp.seconds) + (avg?.let { ", avg $it" } ?: "") to if (ago < 1) "just now" else "$ago min ago" } /** The average of the [readings] (time, bpm) from [from] to [to], whole bpm; null without any. */ @@ -142,41 +159,21 @@ object WorkoutCopy { /** Whether going from zone [previous] to [current] ticks: a change between two readings, not the first one. */ fun zoneTick(previous: Int?, current: Int?): Boolean = previous != null && current != null && previous != current - /* ------------------------------------------ Side figures ------------------------------------------ */ + /** The live heart card's progress toward a kcal target: "212 of 300 kcal". */ + fun kcalProgress(activeKcal: Float?, target: WorkoutTarget.Kcal): String = "${activeKcal?.toInt() ?: 0} of ${target.label}" - /** - * What this workout has to show around the moving time, in layout order: the average and peak always, kcal - * with a profile ([kcal]), the distance once there is one, and the pace only with GPS ([gps], not from steps). - */ - fun sideFigures(kcal: Boolean, distance: Boolean, gps: Boolean): List = SideFigure.entries.filter { - when (it) { - SideFigure.Avg, SideFigure.Max -> true - SideFigure.Kcal -> kcal - SideFigure.Distance -> distance - SideFigure.Pace -> distance && gps - } - } + /** GPS on the live screen's top line: "GPS ±4 m", or "Finding GPS" before a fix worth naming. */ + fun gpsPill(accuracy: Float?): String = if (accuracy == null || accuracy > 1000) "Finding GPS" else "GPS ±${accuracy.toInt()} m" - /** - * Every figure this workout has, all at once: the average and peak either side of the moving time, and kcal, the - * distance and the pace in a row under it, so nothing waits behind a tap. - */ - fun liveFigures(available: List): LiveFigures = LiveFigures( - available.filter { it == SideFigure.Avg || it == SideFigure.Max }, - available.filter { it != SideFigure.Avg && it != SideFigure.Max }, - ) - - /** The label over Ready's targets: what the strap does for the one chosen. */ - fun targetHint(target: WorkoutTarget?): String = when (target) { - is WorkoutTarget.Kcal -> "Target. The strap buzzes at ${target.kcal} active kcal." - else -> "Target. The strap buzzes if you drift out." + /** The same toward a distance goal, in the goal's unit: "2.15 of 5 km", "640 of 800 m". */ + fun distanceProgress(meters: Float?, target: WorkoutTarget.Distance, locale: Locale = Locale.getDefault()): String { + val m = meters ?: 0f + return if (target.meters < 1000) String.format(locale, "%d of %s", m.toInt(), target.label) + else "${Units.km(m, Units.Precision.Workout, locale)} of ${target.label}" } - /** The live heart card's progress toward a kcal target: "212 of 300 kcal". */ - fun kcalProgress(activeKcal: Float?, target: WorkoutTarget.Kcal): String = "${activeKcal?.toInt() ?: 0} of ${target.label}" - - /** "312 kcal" for the notification and the capsule; null before there's a figure. */ - fun kcalText(activeKcal: Float?): String? = activeKcal?.let { "${it.toInt()} kcal" } + /** "312 kcal" for the notification and the capsule ([WorkoutWords.kcalText]); null before there's a figure. */ + fun kcalText(activeKcal: Float?): String? = WorkoutWords.kcalText(activeKcal) /** * How high the live heart card's light reaches for [zone] (spec 2.5): about a third of the card in zone 1, all of it diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutDetailCopy.kt b/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutDetailCopy.kt index 188873fb..46b9f920 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutDetailCopy.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutDetailCopy.kt @@ -3,15 +3,14 @@ package org.openstrap.app.ui.workout import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter import java.util.Locale import org.openstrap.app.data.WorkoutTarget import org.openstrap.app.data.Workouts +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.Units import org.openstrap.app.ui.dashboard.Info import org.openstrap.app.ui.dashboard.TodayCopy import org.openstrap.app.ui.dashboard.sportNoun -import org.openstrap.app.ui.glow.km -import org.openstrap.app.ui.glow.workoutClock /* * What the page of a past workout says (spec 6.3, "Workout detail"): the sentence first ("Hard run, mostly in zone @@ -19,8 +18,13 @@ import org.openstrap.app.ui.glow.workoutClock * without a workout. */ -/** A small fact on the workout's list: [name], [value] in dot-matrix digits, and the [status] under the name. */ -data class WorkoutFact(val name: String, val value: String, val status: String?) +/** + * A small fact on the workout's list: [name], [value] in dot-matrix digits, and the [status] under the name. [kind] is + * what its colour and icon go by, so a change to the words can't change them. + */ +data class WorkoutFact(val name: String, val value: String, val status: String?, val kind: Kind) { + enum class Kind { Distance, Paused, Steps, Calories, Target } +} /** What a workout's other figures look like, for the strap's own record of a phone workout; [from] its start, "22:53". */ data class EffectFigures(val aerobic: Float?, val anaerobic: Float?, val load: Int?, val recoveryMinutes: Int?, val from: String? = null) @@ -85,16 +89,9 @@ object WorkoutDetailCopy { return head + (mostlyZone(zoneSeconds)?.let { ", mostly in zone ${it + 1}" } ?: "") + "." } - /** The hero's day: "Today", "Yesterday", "Tue 21 Oct", with the year when it isn't [today]'s. */ - fun day(start: Long, today: LocalDate, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault()): String { - val d = Instant.ofEpochSecond(start).atZone(zone).toLocalDate() - return when { - d == today -> "Today" - d == today.minusDays(1) -> "Yesterday" - d.year == today.year -> d.format(DateTimeFormatter.ofPattern("EEE d MMM", locale)) - else -> d.format(DateTimeFormatter.ofPattern("EEE d MMM yyyy", locale)) - } - } + /** The hero's day ([DayWords.relative]): "Today", "Yesterday", "Tuesday", "Tue 21 Oct", "Tue 21 Oct 2025". */ + fun day(start: Long, today: LocalDate, zone: ZoneId = ZoneId.systemDefault(), locale: Locale = Locale.getDefault()): String = + DayWords.relative(Instant.ofEpochSecond(start).atZone(zone).toLocalDate(), today, locale) /** * The workout's small facts, each left out when there's nothing to say: distance (from GPS, or estimated from @@ -106,15 +103,22 @@ object WorkoutDetailCopy { locale: Locale = Locale.getDefault(), ): List = listOfNotNull( - meters?.takeIf { it >= 10f }?.let { m -> - val unit = if (m >= 1000) "Kilometres" else "Metres" - WorkoutFact("Distance", if (m >= 1000) km(m) else m.toInt().toString(), if (fromSteps) "$unit, estimated from your steps" else unit) + meters?.takeIf { it >= Units.SHOWN_FROM_METERS }?.let { m -> + val unit = if (m >= Units.METERS_PER_KM) "Kilometres" else "Metres" + // The value is drawn in the dot font, so it keeps the dot and ASCII digits. + val value = if (m >= Units.METERS_PER_KM) Units.km(m, Units.Precision.Workout, Units.DOT_LOCALE) else m.toInt().toString() + WorkoutFact("Distance", value, if (fromSteps) "$unit, estimated from your steps" else unit, WorkoutFact.Kind.Distance) + }, + pausedSeconds.takeIf { it >= MIN_PAUSED_S }?.let { + WorkoutFact("Paused", Units.workoutClock(it, Units.DOT_LOCALE), "Not counted in the time", WorkoutFact.Kind.Paused) }, - pausedSeconds.takeIf { it >= MIN_PAUSED_S }?.let { WorkoutFact("Paused", workoutClock(it), "Not counted in the time") }, - steps?.takeIf { it > 0 }?.let { WorkoutFact("Steps", "%,d".format(locale, it), null) }, + steps?.takeIf { it > 0 }?.let { WorkoutFact("Steps", "%,d".format(locale, it), null, WorkoutFact.Kind.Steps) }, calories?.takeIf { it > 0 }?.let { total -> - if (activeKcal != null && activeKcal != total) WorkoutFact("Calories", "%,d".format(locale, activeKcal), "Total %,d".format(locale, total)) - else WorkoutFact("Calories", "%,d".format(locale, total), "kcal") + if (activeKcal != null && activeKcal != total) { + WorkoutFact("Calories", "%,d".format(locale, activeKcal), "Total %,d".format(locale, total), WorkoutFact.Kind.Calories) + } else { + WorkoutFact("Calories", "%,d".format(locale, total), "kcal", WorkoutFact.Kind.Calories) + } }, ) @@ -125,12 +129,12 @@ object WorkoutDetailCopy { fun targetFact(target: String?, reachedSeconds: Long?, activeKcal: Int?, locale: Locale = Locale.getDefault()): WorkoutFact? { val k = WorkoutTarget.parse(target) as? WorkoutTarget.Kcal ?: return null val status = when { - reachedSeconds != null -> "Reached at ${workoutClock(reachedSeconds)}" + reachedSeconds != null -> "Reached at ${Units.workoutClock(reachedSeconds, locale)}" activeKcal != null && activeKcal >= k.kcal -> "Reached" activeKcal != null -> "Not reached, %,d of %,d".format(locale, activeKcal, k.kcal) else -> "Not reached" } - return WorkoutFact("Target", "%,d kcal".format(locale, k.kcal), status) + return WorkoutFact("Target", "%,d kcal".format(locale, k.kcal), status, WorkoutFact.Kind.Target) } /** diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutStyle.kt b/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutStyle.kt index c53c35e7..5cc8bd65 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutStyle.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutStyle.kt @@ -1,7 +1,22 @@ package org.openstrap.app.ui.workout import androidx.compose.ui.graphics.Color +import org.openstrap.app.presentation.WorkoutColors +import org.openstrap.app.presentation.WorkoutWords -/** Zone 1 (easy) to 5 (max), cool to hot; during a workout the app takes on the current one. */ -internal val ZONE_COLORS = listOf(Color(0xFF8FA8FF), Color(0xFF3DDC97), Color(0xFFFFD166), Color(0xFFFF9F43), Color(0xFFFF5A5F)) -internal val ZONE_NAMES = listOf("Easy", "Aerobic", "Tempo", "Threshold", "Max") +/** Zone 1 (easy) to 5 (max), cool to hot ([WorkoutColors.ZONES]); during a workout the app takes on the current one. */ +internal val ZONE_COLORS: List = WorkoutColors.ZONES.map(::Color) +internal val ZONE_NAMES: List = WorkoutWords.ZONE_NAMES + +/** + * Each kind of workout's colour on Choose and Ready (workouts spec 2.1): lime for distance, strength's orange, heart red + * for intervals, a calm blue for indoor cardio, the evening's violet for mind and body, and a clear blue for games. + */ +internal fun familyColor(family: org.openstrap.app.data.Workouts.Family): Color = when (family) { + org.openstrap.app.data.Workouts.Family.Distance -> org.openstrap.app.ui.theme.MetricColors.steps + org.openstrap.app.data.Workouts.Family.IndoorCardio -> ZONE_COLORS[0] + org.openstrap.app.data.Workouts.Family.Strength -> ZONE_COLORS[3] + org.openstrap.app.data.Workouts.Family.Intervals -> org.openstrap.app.ui.theme.MetricColors.heart + org.openstrap.app.data.Workouts.Family.MindBody -> org.openstrap.app.ui.theme.MetricColors.evening + org.openstrap.app.data.Workouts.Family.Games -> org.openstrap.app.ui.theme.MetricColors.spo2 +} diff --git a/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutsViewModel.kt b/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutsViewModel.kt index 53576917..95786a82 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutsViewModel.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/workout/WorkoutsViewModel.kt @@ -2,233 +2,117 @@ package org.openstrap.app.ui.workout import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import android.util.Log +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import org.openstrap.app.data.RecoveryEngine +import kotlinx.coroutines.withContext +import org.openstrap.app.data.DayWindow +import org.openstrap.app.data.Today import org.openstrap.app.data.WorkoutAnalysis -import org.openstrap.app.data.WorkoutRecorder -import org.openstrap.app.data.Workouts +import org.openstrap.app.data.WorkoutRepository +import org.openstrap.app.data.WorkoutSuggestions +import org.openstrap.app.data.catching import org.openstrap.app.data.db.HealthDao -import org.openstrap.app.data.settings.UserProfile -import kotlin.math.roundToInt - -/** - * What a phone workout's summary shows: [analysis] over its shown range, [recorded] every sample from - * [recordedStart] to [recordedEnd] (for the trim chart). - */ -data class PhoneSummary( - val analysis: WorkoutAnalysis, - val recorded: List, - val recordedStart: Long, - val recordedEnd: Long, - val endReason: Int?, - /** The route, when it was kept; empty otherwise. */ - val route: List = emptyList(), - /** Every live reading in the shown range, for colouring the route. */ - val history: List> = emptyList(), - val distanceMeters: Float? = null, - val distanceFromSteps: Boolean = false, - /** Strength: each set and the rest after it. */ - val sets: List = emptyList(), - /** - * The strap's own records of the same workout, by start, when it kept any (a strap session, or its - * auto-detection); more than one when a long workout held several. - */ - val strapWorkouts: List = emptyList(), - /** Laps, when any were marked. */ - val laps: List = emptyList(), - /** Finished pauses, which no split counts. */ - val pauses: List = emptyList(), - /** Its effect figures are the strap's own ([org.openstrap.app.data.db.AppWorkout.effectFromStrap]). */ - val effectFromStrap: Boolean = false, -) +import org.openstrap.app.data.db.SportEffectRow +import java.time.Clock +import java.time.LocalDate + +/** The data layer's summary, kept under its old name for the screens. */ +typealias PhoneSummary = org.openstrap.app.data.WorkoutSummaryData /** Workouts list (newest first) and the one open in detail. */ @OptIn(ExperimentalCoroutinesApi::class) -class WorkoutsViewModel(private val dao: HealthDao, private val profile: Flow) : ViewModel() { - /** - * Zones fall back to the app's one max-HR rule (tested value, Tanaka, strap, raised by observed peaks) when a - * workout has no sport effect of its own. Shared, so the 90-day read behind it runs once per profile change. - */ - private val fallbackMaxHr: Flow = profile - .mapLatest { p -> RecoveryEngine(dao).maxHeartRate(p).roundToInt() } - .flowOn(Dispatchers.Default) - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - .filterNotNull() - - private val zoneCache = ZoneCache>() +class WorkoutsViewModel( + private val repo: WorkoutRepository, + /** The clock today and now are read from, asked afresh each time. Tests pass one they move by hand. */ + private val clock: () -> Clock = { Clock.systemDefaultZone() }, + /** The phone's clock, time zone or date changed ([Today.changes]), so today is read again at once. */ + dateChanges: Flow = emptyFlow(), + /** Where rows are worked into what the screens show, off the main thread (audit C7). */ + private val work: CoroutineDispatcher = Dispatchers.Default, +) : ViewModel() { + /** Today as it moves, so the suggestions' window moves on at midnight (audit C10). */ + private val today: Flow = Today.dates(clock, dateChanges) + + private fun nowSeconds(): Long = clock().instant().epochSecond /** - * Each listed workout's zone seconds, by (from the phone, start), worked out from its whole HR stream as its page - * does, so the list and the page agree. Off the main thread, and only for workouts whose row, effect, max HR or - * stream changed ([ZoneCache]); empty at first, so the list shows before its zones fill in. + * The strap's workouts and the phone's, newest first, worked out off the main thread. Zones fill in after the + * list shows ([WorkoutRepository.workouts]). */ - private val listZones: Flow, List>> = - combine( - dao.workouts(), dao.sportEffects(), dao.workoutHeartRateMarks(), - combine(dao.appWorkouts(), dao.appWorkoutHeartRateMarks()) { phone, marks -> phone to marks }, fallbackMaxHr, - ) { ws, effects, marks, (phone, phoneMarks), maxHr -> - val strapMark = marks.associateBy { it.workoutStart } - val phoneMark = phoneMarks.associateBy { it.workoutStart } - val strapByStart = ws.associateBy { it.start } - val phoneByStart = phone.associateBy { it.start } - val stamps = ws.associate { w -> (false to w.start) to listOf(w, Workouts.effectOf(w, effects), maxHr, strapMark[w.start]) } + - phone.associate { w -> (true to w.start) to listOf(w, maxHr, phoneMark[w.start]) } - val work: suspend (Pair) -> List = { (fromPhone, start) -> - if (fromPhone) WorkoutRecorder.analyse(phoneByStart.getValue(start), dao.appWorkoutHeartRateOnce(start), maxHr).zoneSeconds - else Workouts.analyse(strapByStart.getValue(start), dao.workoutHeartRateOnce(start), effects, maxHr).zoneSeconds - } - stamps to work - } - .mapLatest { (stamps, work) -> zoneCache.zones(stamps, work) } - .flowOn(Dispatchers.Default) - .onStart { emit(emptyMap()) } - - /** - * The strap's workouts and the phone's, newest first. List rows use the stored end; the detail screen works out - * the real one from the HR stream. Zones come from [listZones]. A phone strength workout carries its count of - * finished sets. - */ - val workouts: StateFlow> = - combine( - combine( - dao.workouts(), dao.sportEffects(), dao.lastWorkoutHeartRates(), - combine(dao.appWorkouts(), dao.setCounts()) { phone, sets -> phone to sets.associate { it.workoutStart to it.sets } }, fallbackMaxHr, - ) { ws, effects, last, (phone, sets), maxHr -> Workouts.listed(ws, effects, last, phone, sets, maxHr) }, - listZones, - ) { list, zones -> list.map { a -> zones[a.fromPhone to a.workout.start]?.let { a.copy(zoneSeconds = it) } ?: a } } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val workouts: StateFlow> = repo.workouts() + .flowOn(work) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) /** The strap's sport-effect records, newest last: recovery time, VO2 max and weekly load after each workout. */ - val effects: StateFlow> = - dao.sportEffects().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + val effects: StateFlow> = + repo.effects().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) /** The max heart rate zones are shares of, once worked out. */ - val maxHeartRate: StateFlow = fallbackMaxHr.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) + val maxHeartRate: StateFlow = repo.maxHeartRate().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) /** A day's heart rate a minute at a time, for adding a workout after the fact. */ - suspend fun dayMinuteHeartRate(date: java.time.LocalDate): List> = kotlinx.coroutines.withContext(Dispatchers.Default) { - val w = org.openstrap.app.data.DayWindow.of(date) - org.openstrap.app.data.Summaries.bucketAverage(org.openstrap.app.data.Summaries.heartRateSeries(dao.heartRateOnce(w.from, w.to), dao.activityOnce(w.from, w.to)), 60) - } - - /** From the start of yesterday: the stretches the strap's history may hold that nobody started. */ - private val suggestFrom = org.openstrap.app.data.DayWindow.of(java.time.LocalDate.now().minusDays(1)).from + suspend fun dayMinuteHeartRate(date: LocalDate): List> = withContext(work) { repo.dayMinuteHeartRate(date) } /** - * Workouts nobody started, newest first ([org.openstrap.app.data.WorkoutSuggestions]), from the strap's heart rate - * since yesterday. Sleep, saved workouts and stretches answered "Not a workout" never count. + * Workouts nobody started, newest first ([WorkoutSuggestions]), from the strap's heart rate since yesterday. Sleep, + * saved workouts and stretches answered "Not a workout" never count. */ - val suggestions: StateFlow> = combine( - combine(dao.heartRate(suggestFrom, Long.MAX_VALUE), dao.activity(suggestFrom, Long.MAX_VALUE)) { hr, minutes -> - org.openstrap.app.data.Summaries.bucketAverage(org.openstrap.app.data.Summaries.heartRateSeries(hr, minutes), 60) - }, - combine(dao.workouts(), dao.appWorkouts(), dao.dismissedWorkouts(suggestFrom), dao.sleepSessions(suggestFrom, Long.MAX_VALUE)) { strap, phone, dismissed, sleep -> - strap.map { it.start..it.end } + phone.map { it.start..(it.recordedEnd ?: it.end ?: it.start) } + - dismissed.map { it.start..it.end } + sleep.map { it.start..it.end } - }, - fallbackMaxHr, - ) { series, busy, maxHr -> Triple(series, busy, maxHr) } - .mapLatest { (series, busy, maxHr) -> - val now = System.currentTimeMillis() / 1000 - val resting = dao.restingHrOnce(now - 14 * 86_400L, now).lastOrNull()?.bpm ?: 60 - org.openstrap.app.data.WorkoutSuggestions.find(series, resting, maxHr, busy) - } - .flowOn(Dispatchers.Default) + val suggestions: StateFlow> = today + .flatMapLatest { d -> repo.suggestions(DayWindow.of(d.minusDays(1)).from, ::nowSeconds) } + .flowOn(work) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) private val openStart = MutableStateFlow(null) - val open: StateFlow = openStart.flatMapLatest { start -> - if (start == null) flowOf(null) - else dao.appWorkout(start).flatMapLatest { phone -> - if (phone != null) phoneAnalysis(start) - else combine(dao.workout(start), dao.workoutHeartRate(start), dao.sportEffects(), fallbackMaxHr) { w, hr, effects, maxHr -> - w?.let { Workouts.analyse(it, hr, effects, maxHr) } - } - } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) - - /** A phone workout with every second of it, updating as gap filling adds minutes. */ - fun phoneAnalysis(start: Long): Flow = - combine(dao.appWorkout(start), dao.appWorkoutHeartRate(start), fallbackMaxHr) { w, hr, maxHr -> - w?.let { WorkoutRecorder.analyse(it, hr, maxHr) } - } + val open: StateFlow = openStart.flatMapLatest { start -> if (start == null) flowOf(null) else repo.open(start) } + .flowOn(work).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) /** * A phone workout's summary: its analysis, everything recorded (for trimming), and the strap's own record of it - * when there is one. [backfill] works out the effect of a workout from before there was one and saves it; off, - * opening the summary writes nothing. + * when there is one. [backfill] works out the effect of a workout from before there was one and saves it + * ([backfillEffect]) as the summary opens. Off, opening the summary writes nothing. */ fun phoneSummary(start: Long, backfill: Boolean = true): Flow = - combine(dao.appWorkout(start), dao.appWorkoutHeartRate(start), fallbackMaxHr, workouts, combine(dao.route(start), dao.sets(start)) { r, s -> r to s }) { w, hr, maxHr, all, extra -> - Quad(w, hr, maxHr, all) to extra + repo.phoneSummary(start).flowOn(work) + .onStart { if (backfill) backfillEffect(start) } + + /** + * Works out and saves the effect of phone workout [start] when it ended before effects were worked out + * ([WorkoutRepository.backfillEffect]). On a coroutine of its own, not in the summary's read, so the summary shows + * without waiting for it and comes round again once the effect is saved. + */ + private fun backfillEffect(start: Long) { + viewModelScope.launch(work) { + catching { repo.backfillEffect(start) }.onFailure { Log.w("OpenStrap", "effect backfill failed for $start", it) } } - .mapLatest { (q, extra) -> - val (routeRows, sets) = extra - val (w, hr, maxHr, all) = q - w ?: return@mapLatest null - // A workout from before its effect was worked out gets it now; saving it brings this summary round again. - if (backfill && w.end != null && w.epoc == null && hr.isNotEmpty()) { - runCatching { org.openstrap.app.data.WorkoutEffectEngine(dao).backfill(w, profile.first()) } - } - val a = WorkoutRecorder.analyse(w, hr, maxHr) - // The strap's own records of this workout ([Workouts.sameWorkout]), which the lists leave out. - val effects = dao.sportEffects().first() - val strap = Workouts.strapTwins(dao.workoutsOnce(w.from, w.end ?: a.end), w.from..(w.end ?: a.end)) - .map { s -> Workouts.analyse(s, dao.workoutHeartRate(s.start).first(), effects, maxHr) } - val route = routeRows.map { it.point }.filter { it.t in w.from..(w.end ?: Long.MAX_VALUE) } - PhoneSummary( - a, WorkoutRecorder.expand(hr), w.start, w.recordedEnd ?: w.end ?: a.end, w.endReason, - route, hr.filter { !it.fromHistory }.map { it.timestamp to it.bpm }, w.distanceMeters, w.distanceFromSteps, sets, strap, - w.lapMarks.takeIf { it.isNotEmpty() }?.let { marks -> - WorkoutRecorder.laps(w.from, w.end ?: a.end, marks, hr.filter { !it.fromHistory }.map { it.timestamp to it.bpm }, route, w.pauseRanges) - }.orEmpty(), - w.pauseRanges, w.effectFromStrap, - ) - }.flowOn(Dispatchers.Default) + } /** * Your usual heart rate drop in the first minute after a workout, before [before] ([SummaryCopy.usualDrop]): from * the strap's own workouts, whose recording runs on after the end, and only those ended from a real effort * ([WorkoutAnalysis.recoveryIsMeaningful]). Phone workouts keep no drop yet. Null with too few. */ - suspend fun usualDrop(before: Long): Int? = kotlinx.coroutines.withContext(Dispatchers.Default) { - val maxHr = fallbackMaxHr.first() - val effects = dao.sportEffects().first() - val drops = dao.workouts().first() - .filter { it.start < before && it.start >= before - USUAL_DROP_LOOKBACK_S } - .sortedByDescending { it.start } - .take(SummaryCopy.USUAL_DROP_OF * 2) - .mapNotNull { w -> Workouts.analyse(w, dao.workoutHeartRateOnce(w.start), effects, maxHr).takeIf { it.recoveryIsMeaningful }?.recovery60 } - SummaryCopy.usualDrop(drops) + suspend fun usualDrop(before: Long): Int? = withContext(work) { + SummaryCopy.usualDrop(repo.recoveryDrops(before, SummaryCopy.USUAL_DROP_OF * 2)) } - private companion object { - /** Drops older than this say little about your fitness now. */ - const val USUAL_DROP_LOOKBACK_S = 180 * 86_400L - } - - private data class Quad(val a: A, val b: B, val c: C, val d: D) - fun open(start: Long?) { openStart.value = start } /** Deletes a strap workout and keeps it from coming back with a later sync ([HealthDao.deleteStrapWorkout]). */ fun deleteStrapWorkout(start: Long) { - viewModelScope.launch { dao.deleteStrapWorkout(start, System.currentTimeMillis() / 1000) } + viewModelScope.launch { repo.deleteStrapWorkout(start, nowSeconds()) } } } diff --git a/app/src/main/kotlin/org/openstrap/app/ui/zepp/ZeppSignIn.kt b/app/src/main/kotlin/org/openstrap/app/ui/zepp/ZeppSignIn.kt index 3149379d..5bd979ee 100644 --- a/app/src/main/kotlin/org/openstrap/app/ui/zepp/ZeppSignIn.kt +++ b/app/src/main/kotlin/org/openstrap/app/ui/zepp/ZeppSignIn.kt @@ -1,6 +1,7 @@ package org.openstrap.app.ui.zepp import android.annotation.SuppressLint +import android.util.Log import android.webkit.CookieManager import android.webkit.WebStorage import android.webkit.WebView @@ -43,8 +44,9 @@ import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import kotlinx.coroutines.delay +import org.openstrap.app.data.catching import org.openstrap.app.ui.components.pressClickable -import org.openstrap.app.ui.glow.ControlSurface +import org.openstrap.app.ui.theme.ControlSurface import org.openstrap.app.ui.glow.GlowLayout import org.openstrap.app.ui.glow.GlowType import org.openstrap.app.ui.glow.PageButton @@ -112,8 +114,11 @@ fun ZeppSignInDialog( val session = ZeppCloud.sessionFromCookies(currentCookies()) if (session != null) { step = SignInStep.Fetching - step = runCatching { SignInStep.Found(ZeppCloud.listDevices(session, preferredApiBase)) } - .getOrElse { SignInStep.Failed(deviceListError(it)) } + step = catching { SignInStep.Found(ZeppCloud.listDevices(session, preferredApiBase)) } + .getOrElse { + Log.w("OpenStrap", "zepp: device list failed: $it") + SignInStep.Failed(deviceListError(it)) + } break } delay(1000) diff --git a/app/src/main/kotlin/org/openstrap/app/update/AppUpdater.kt b/app/src/main/kotlin/org/openstrap/app/update/AppUpdater.kt index 548024f0..39bfea5f 100644 --- a/app/src/main/kotlin/org/openstrap/app/update/AppUpdater.kt +++ b/app/src/main/kotlin/org/openstrap/app/update/AppUpdater.kt @@ -8,7 +8,6 @@ import android.content.pm.PackageInfo import android.content.pm.PackageInstaller import android.content.pm.PackageManager import android.net.Uri -import android.os.Build import android.provider.Settings import android.util.Log import androidx.work.Constraints @@ -18,10 +17,8 @@ import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -35,10 +32,12 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.openstrap.app.BuildConfig import org.openstrap.app.OpenStrapApp +import org.openstrap.app.data.catching import org.openstrap.app.data.settings.SettingsStore import java.io.File import java.io.IOException import java.net.HttpURLConnection +import java.net.URI import java.net.URL import java.security.MessageDigest import java.util.concurrent.TimeUnit @@ -73,16 +72,18 @@ data class UpdateState( * downloads that release's APK, checks it against GitHub's SHA-256 and that it's OpenStrap signed with the same key, * then hands it to Android's package installer. Never while a workout records, since installing closes the app. */ -class AppUpdater(private val app: Application, private val settings: SettingsStore, private val recording: () -> Boolean) { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) +class AppUpdater( + private val app: Application, + private val settings: SettingsStore, + private val recording: () -> Boolean, + /** The app's scope ([org.openstrap.app.AppContainer.appScope]): a download carries on when the page closes. */ + private val scope: CoroutineScope, +) { private val cache = File(app.filesDir, "releases.json") private val checkLock = Mutex() private val _state = MutableStateFlow(UpdateState()) val state: StateFlow = _state - /** An update notification was tapped: the Updates page opens over the tab showing. */ - val openRequest = MutableStateFlow(false) - /** The version running, "2.1.0". */ val installed: String = BuildConfig.VERSION_NAME @@ -125,24 +126,26 @@ class AppUpdater(private val app: Application, private val settings: SettingsSto suspend fun check(): Boolean = checkLock.withLock { _state.update { it.copy(checking = true) } try { - val json = get(Releases.API, github = true) - val releases = Releases.parse(json) - withContext(Dispatchers.IO) { - val tmp = File(app.filesDir, "releases.json.tmp") - tmp.writeText(json) - tmp.renameTo(cache) + catching { + val json = get(Releases.API, github = true) + val releases = Releases.parse(json) + withContext(Dispatchers.IO) { + val tmp = File(app.filesDir, "releases.json.tmp") + tmp.writeText(json) + tmp.renameTo(cache) + } + val now = System.currentTimeMillis() / 1000 + settings.setUpdateChecked(now) + _state.update { it.copy(releases = releases, checkedAt = now, checkFailed = false) } + true + }.getOrElse { e -> + Log.i(TAG, "Update check failed: $e") + _state.update { it.copy(checkFailed = true) } + false } - val now = System.currentTimeMillis() / 1000 - settings.setUpdateChecked(now) - _state.update { it.copy(releases = releases, checkedAt = now, checking = false, checkFailed = false) } - true - } catch (e: CancellationException) { + } finally { + // Cancelled too: the page doesn't stay on Checking. _state.update { it.copy(checking = false) } - throw e - } catch (e: Exception) { - Log.i(TAG, "Update check failed: $e") - _state.update { it.copy(checking = false, checkFailed = true) } - false } } @@ -169,7 +172,7 @@ class AppUpdater(private val app: Application, private val settings: SettingsSto _state.update { it.copy(install = Install.Downloading(release.tag, 0, apk.size)) } scope.launch { val tag = release.tag - try { + catching { if (recording()) throw Stop(InstallFailure.Recording) val file = download(release, apk) checkArchive(file) @@ -178,16 +181,10 @@ class AppUpdater(private val app: Application, private val settings: SettingsSto _state.update { it.copy(install = Install.Installing(tag)) } settings.setUpdateInstalling(tag) commit(file) - } catch (e: CancellationException) { - throw e - } catch (e: Stop) { - fail(tag, e.failure) - } catch (e: IOException) { - Log.i(TAG, "Update download failed: $e") - fail(tag, InstallFailure.Network) - } catch (e: Exception) { - Log.i(TAG, "Update install failed: $e") - fail(tag, InstallFailure.Android, e.message) + }.onFailure { e -> + val failure = failureFor(e) + if (e !is Stop) Log.i(TAG, "Update install failed ($failure): $e") + fail(tag, failure, e.message.takeIf { failure == InstallFailure.Android }) } } } @@ -231,17 +228,24 @@ class AppUpdater(private val app: Application, private val settings: SettingsSto private class Stop(val failure: InstallFailure) : Exception(failure.name) + /** Why [e] stopped an install: one of the checks below, the network, or anything else Android's to blame for. */ + private fun failureFor(e: Throwable): InstallFailure = when (e) { + is Stop -> e.failure + is IOException -> InstallFailure.Network + else -> InstallFailure.Android + } + /** The APK in the cache, downloaded unless an earlier, matching copy is there, and its SHA-256 checked. */ private suspend fun download(release: Release, apk: ReleaseAsset): File = withContext(Dispatchers.IO) { - if (!apk.url.startsWith("https://")) throw Stop(InstallFailure.NotOpenStrap) + urlFailure(apk.url)?.let { throw Stop(it) } val expected = apk.sha256 - ?: release.sums?.takeIf { it.url.startsWith("https://") }?.let { Releases.sumFor(get(it.url), apk.name) } + ?: release.sums?.takeIf { urlFailure(it.url) == null }?.let { Releases.sumFor(get(it.url), apk.name) } ?: throw Stop(InstallFailure.NoChecksum) val dir = File(app.cacheDir, "updates").apply { mkdirs() } // Only the one being installed is kept. dir.listFiles()?.filter { it.name != apk.name }?.forEach { it.delete() } val file = File(dir, apk.name) - if (file.exists() && sha256(file) == expected) return@withContext file + if (file.exists() && checksumFailure(expected, sha256(file)) == null) return@withContext file val tmp = File(dir, "${apk.name}.part") val conn = URL(apk.url).openConnection() as HttpURLConnection @@ -272,9 +276,9 @@ class AppUpdater(private val app: Application, private val settings: SettingsSto } } } - if (hex(digest.digest()) != expected) { + checksumFailure(expected, hex(digest.digest()))?.let { tmp.delete() - throw Stop(InstallFailure.Checksum) + throw Stop(it) } if (!tmp.renameTo(file)) throw IOException("Couldn't keep the download") file @@ -287,35 +291,44 @@ class AppUpdater(private val app: Application, private val settings: SettingsSto private fun checkArchive(file: File) { val pm = app.packageManager val archive = pm.getPackageArchiveInfo(file.path, PackageManager.GET_SIGNING_CERTIFICATES) - if (archive == null || archive.packageName != app.packageName) throw Stop(InstallFailure.NotOpenStrap) val mine = pm.getPackageInfo(app.packageName, PackageManager.GET_SIGNING_CERTIFICATES) - if (signers(archive).isEmpty() || signers(archive) != signers(mine)) throw Stop(InstallFailure.Signature) + val failure = archiveFailure(archive?.packageName, app.packageName, archive?.let(::signers).orEmpty(), signers(mine)) + if (failure != null) throw Stop(failure) } private fun signers(info: PackageInfo): Set = info.signingInfo?.apkContentsSigners.orEmpty().mapTo(HashSet()) { hex(MessageDigest.getInstance("SHA-256").digest(it.toByteArray())) } - /** One package installer session for [file]; Android reports to [UpdateReceiver]. */ - private suspend fun commit(file: File) = withContext(Dispatchers.IO) { + /** + * One package installer session for [file], which Android reports on to [UpdateReceiver]. A session that fails before its + * commit is abandoned, or it would stay open, holding its copy of the APK, until Android cleans up (audit E12). + */ + internal suspend fun commit(file: File) = withContext(Dispatchers.IO) { val installer = app.packageManager.packageInstaller val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { setAppPackageName(app.packageName) setSize(file.length()) // Without a prompt when Android allows it (OpenStrap installed the copy it replaces); it asks otherwise. - if (Build.VERSION.SDK_INT >= 31) setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) } val id = installer.createSession(params) - installer.openSession(id).use { session -> - file.inputStream().use { input -> - session.openWrite("base.apk", 0, file.length()).use { out -> - input.copyTo(out) - session.fsync(out) + var committed = false + try { + installer.openSession(id).use { session -> + file.inputStream().use { input -> + session.openWrite("base.apk", 0, file.length()).use { out -> + input.copyTo(out) + session.fsync(out) + } } + val intent = Intent(app, UpdateReceiver::class.java).setAction(UpdateReceiver.ACTION_INSTALL_STATUS) + // Mutable, so Android can put the status in it. + val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + session.commit(PendingIntent.getBroadcast(app, id, intent, flags).intentSender) + committed = true } - val intent = Intent(app, UpdateReceiver::class.java).setAction(UpdateReceiver.ACTION_INSTALL_STATUS) - // Mutable, so Android can put the status in it. - val flags = PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0) - session.commit(PendingIntent.getBroadcast(app, id, intent, flags).intentSender) + } finally { + if (!committed) runCatching { installer.abandonSession(id) }.onFailure { Log.w(TAG, "Update session $id not abandoned: $it") } } } @@ -368,6 +381,33 @@ class AppUpdater(private val app: Application, private val settings: SettingsSto } private fun hex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } + + /** A download address OpenStrap fetches from: HTTPS with a host, nothing else. Null when [url] is one. */ + internal fun urlFailure(url: String): InstallFailure? { + val uri = runCatching { URI(url) }.getOrNull() + val ok = uri != null && uri.scheme.equals("https", ignoreCase = true) && !uri.host.isNullOrEmpty() + return if (ok) null else InstallFailure.NotOpenStrap + } + + /** The download's SHA-256, [actual], against GitHub's, [expected]. Null when they match. */ + internal fun checksumFailure(expected: String, actual: String): InstallFailure? = + if (expected.isNotBlank() && actual.equals(expected.trim(), ignoreCase = true)) null else InstallFailure.Checksum + + /** + * The downloaded APK is OpenStrap, [archivePackage] the [installedPackage], and signed with the very keys this + * copy was ([installedSigners]). Null when it is. Android would refuse it otherwise, after the download. + */ + internal fun archiveFailure( + archivePackage: String?, + installedPackage: String, + archiveSigners: Set, + installedSigners: Set, + ): InstallFailure? = + when { + archivePackage == null || archivePackage != installedPackage -> InstallFailure.NotOpenStrap + archiveSigners.isEmpty() || archiveSigners != installedSigners -> InstallFailure.Signature + else -> null + } } } diff --git a/app/src/main/kotlin/org/openstrap/app/update/Releases.kt b/app/src/main/kotlin/org/openstrap/app/update/Releases.kt index 2b65357e..64532595 100644 --- a/app/src/main/kotlin/org/openstrap/app/update/Releases.kt +++ b/app/src/main/kotlin/org/openstrap/app/update/Releases.kt @@ -1,5 +1,6 @@ package org.openstrap.app.update +import android.os.Build import org.json.JSONArray import org.json.JSONObject import java.time.Instant @@ -21,6 +22,8 @@ data class Release( val apk: ReleaseAsset?, /** SHA256SUMS.txt, for an APK GitHub gives no digest for. */ val sums: ReleaseAsset?, + /** The oldest Android API level the release installs on, when its notes say ([Releases.minSdk]). */ + val minSdk: Int? = null, ) { /** "2.1.0". */ val versionName: String get() = version.joinToString(".") @@ -51,11 +54,21 @@ object Releases { return 0 } + /** + * A release's notes can say the oldest Android it installs on with a line ``. GitHub and the + * Updates page both hide the comment. The plain form `minSdk: 36` reads too, but shows in the notes. + */ + private val minSdkMarker = Regex("""(?im)^\s*(?:)?\s*$""") + + /** The API level the [notes] declare as the oldest the release installs on ([minSdkMarker]), or null for none. */ + fun minSdk(notes: String): Int? = minSdkMarker.find(notes)?.groupValues?.get(1)?.toIntOrNull() + /** * The releases in GitHub's `GET /repos/{repo}/releases` reply, newest first. Drafts, pre-releases and tags that - * aren't versions are left out. + * aren't versions are left out, and so is a release whose notes ask for a newer Android than [sdk], since the + * package installer would turn it down after the download. */ - fun parse(json: String): List { + fun parse(json: String, sdk: Int = Build.VERSION.SDK_INT): List { val array = JSONArray(json) return (0 until array.length()).mapNotNull { i -> val r = array.getJSONObject(i) @@ -63,15 +76,19 @@ object Releases { val tag = r.optString("tag_name") val version = version(tag) ?: return@mapNotNull null val assets = r.optJSONArray("assets")?.let { a -> (0 until a.length()).map { asset(a.getJSONObject(it)) } }.orEmpty() + val notes = r.optString("body").takeUnless { r.isNull("body") }.orEmpty() + val minSdk = minSdk(notes) + if (minSdk != null && minSdk > sdk) return@mapNotNull null Release( tag = tag, version = version, - notes = r.optString("body").takeUnless { r.isNull("body") }.orEmpty(), + notes = notes, publishedAt = r.optString("published_at").takeIf { it.isNotEmpty() && !r.isNull("published_at") } ?.let { runCatching { Instant.parse(it) }.getOrNull() }, page = r.optString("html_url").ifEmpty { "$PAGE/tag/$tag" }, apk = assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) }, sums = assets.firstOrNull { it.name.equals("SHA256SUMS.txt", ignoreCase = true) }, + minSdk = minSdk, ) }.sortedWith { a, b -> compare(b.version, a.version) } } diff --git a/app/src/main/kotlin/org/openstrap/app/update/UpdateNotifier.kt b/app/src/main/kotlin/org/openstrap/app/update/UpdateNotifier.kt index 919ab278..17d71d34 100644 --- a/app/src/main/kotlin/org/openstrap/app/update/UpdateNotifier.kt +++ b/app/src/main/kotlin/org/openstrap/app/update/UpdateNotifier.kt @@ -8,7 +8,7 @@ import android.content.Intent import androidx.core.app.NotificationCompat import org.openstrap.app.MainActivity import org.openstrap.app.R -import org.openstrap.app.ui.device.UpdateCopy +import org.openstrap.app.presentation.UpdateCopy /** Says a new OpenStrap is out, or that it was just installed. Either opens the Updates page. */ class UpdateNotifier(private val context: Context) { diff --git a/app/src/main/kotlin/org/openstrap/app/update/UpdateReceiver.kt b/app/src/main/kotlin/org/openstrap/app/update/UpdateReceiver.kt index c22add33..b1e7cceb 100644 --- a/app/src/main/kotlin/org/openstrap/app/update/UpdateReceiver.kt +++ b/app/src/main/kotlin/org/openstrap/app/update/UpdateReceiver.kt @@ -5,10 +5,8 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller import android.os.Build -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import org.openstrap.app.OpenStrapApp +import org.openstrap.app.device.launchForBroadcast /** * Android's package installer reporting on an install ([AppUpdater.install]), and Android saying the app was replaced. @@ -31,16 +29,12 @@ class UpdateReceiver : BroadcastReceiver() { } Intent.ACTION_MY_PACKAGE_REPLACED -> { val pending = goAsync() - CoroutineScope(Dispatchers.Default).launch { - try { - val tag = container.settings.updateInstalling() ?: return@launch - container.settings.setUpdateInstalling(null) - val version = Releases.version(tag) - if (version != null && version == Releases.version(container.updater.installed)) { - UpdateNotifier(context).installed(version.joinToString(".")) - } - } finally { - pending.finish() + launchForBroadcast(container.appScope, "update: package replaced", pending::finish) { + val tag = container.settings.updateInstalling() ?: return@launchForBroadcast + container.settings.setUpdateInstalling(null) + val version = Releases.version(tag) + if (version != null && version == Releases.version(container.updater.installed)) { + UpdateNotifier(context).installed(version.joinToString(".")) } } } diff --git a/app/src/main/kotlin/org/openstrap/app/widget/MomentWidget.kt b/app/src/main/kotlin/org/openstrap/app/widget/MomentWidget.kt index 14f1b46b..7e2f2aab 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/MomentWidget.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/MomentWidget.kt @@ -2,7 +2,6 @@ package org.openstrap.app.widget import android.content.Context import android.content.Intent -import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -29,31 +28,14 @@ import androidx.glance.layout.width import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.unit.ColorProvider -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.first import org.openstrap.app.MainActivity -import org.openstrap.app.OpenStrapApp -import org.openstrap.app.data.DayWindow -import org.openstrap.app.data.MomentResolver -import org.openstrap.app.data.SleepHistory -import org.openstrap.app.data.Summaries -import org.openstrap.app.data.LiveSteps -import org.openstrap.app.data.Workouts -import org.openstrap.app.device.WidgetUpdater -import org.openstrap.app.device.WorkoutState -import org.openstrap.app.ui.components.ChartPoint -import org.openstrap.app.ui.dashboard.SLEEP_NIGHTS -import org.openstrap.app.ui.dashboard.TabCopy -import org.openstrap.app.ui.dashboard.TodayCopy -import org.openstrap.app.ui.dashboard.usualSport +import org.openstrap.app.data.WidgetKind import org.openstrap.app.ui.glow.draw.drawGhostLine import org.openstrap.app.ui.glow.draw.drawStageStrip import org.openstrap.app.ui.glow.draw.ghostLineGeometry import org.openstrap.app.ui.glow.draw.renderToBitmap import org.openstrap.app.ui.theme.MetricColors import org.openstrap.app.ui.workout.SportIcons -import java.time.Instant -import java.time.ZoneId /** * The Moment widget (widgets spec 3.1): what matters now, following the day as Today's hero does, from the same @@ -63,7 +45,7 @@ import java.time.ZoneId * five ([WidgetUpdater.scheduleMoment]). */ class MomentWidget : LoadingWidget(WidgetKind.Moment) { - override suspend fun loadShown(context: Context): MomentWidgetModel = load(context) + override fun shown(data: WidgetData, context: Context): MomentWidgetModel = load(data, context.widgets) override fun sample(): MomentWidgetModel = java.time.ZoneId.systemDefault().let { WidgetSamples.moment(java.time.LocalDate.now(it), it) } @@ -72,88 +54,18 @@ class MomentWidget : LoadingWidget(WidgetKind.Moment) { companion object { /** - * Everything Today's hero reads, from the database and the app's own sums: the moment's inputs - * ([widgetMomentInputs]), today's Recovery and Energy ([org.openstrap.app.data.HealthRepository.recovery]), - * last night as the Sleep tab reads it, the workouts, the day's heart rate and steps. Then the alarm for the - * next change, from the same inputs. + * Everything Today's hero reads, as one refresh read it ([WidgetSnapshot.gather]): the moment's inputs, today's + * Recovery and Energy, last night as the Sleep tab reads it, the workouts, the day's heart rate and steps. Then + * the alarm for the next change, from the same inputs. */ - internal suspend fun load(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): MomentWidgetModel { - val container = (context.applicationContext as OpenStrapApp).container - val today = now.atZone(zone).toLocalDate() - val t = now.epochSecond - val dao = container.database.healthDao() - suspend fun read(what: String, default: T, block: suspend () -> T): T = try { - block() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(WIDGET_TAG, "widgets: $what for Moment not read: $e") - default - } - val profile = container.settings.profile.first() - val rec = read("Recovery", null) { container.health.recovery(today, profile) } - val bedtime = rec?.usualBedtime ?: read("bedtime", null) { container.health.usualBedtime(today) } - val inputs = widgetMomentInputs(container, bedtime, now, zone) - val dayStart = DayWindow.of(today, zone).from - val strapScore = read("the strap's score", null) { dao.strapReadinessOnce(dayStart, dayStart).firstOrNull()?.score } - val night = read("last night", null) { - val span = DayWindow(DayWindow.nightEndingOn(today.minusDays(SLEEP_NIGHTS - 1L), zone).from, DayWindow.nightEndingOn(today, zone).to) - val sessions = dao.sleepSessionsOnce(span.from, span.to) - SleepHistory.mainSleep(sessions, DayWindow.nightEndingOn(today, zone))?.let { main -> - SleepWidgetNight.of(today, main, dao.sleepStagesOnce(main.start), SleepHistory.mainSleeps(sessions, zone)) - } - } - // Workouts over the usual workout time's look-back, as Today's usual sport reads them. - val from = t - MomentResolver.USUAL_WORKOUT_DAYS * 86_400L - val phone = read("phone workouts", emptyList()) { dao.appWorkoutsOnce(from, t) } - // The strap's record of a phone workout is that workout, as in the lists ([Workouts.sameWorkout]). - val strap = read("workouts", emptyList()) { dao.workoutsOnce(from, t) }.filter { Workouts.phoneTwin(it, phone, t) == null } - val yours = strap.filter { it.subtype != Workouts.SUBTYPE_AUTO_DETECTED && it.sportType != Workouts.GENERIC_ACTIVITY }.map { it.sportType to it.start } + - phone.filter { it.sportType != Workouts.GENERIC_ACTIVITY }.map { it.sportType to it.start } - val all = strap.map { it.sportType to it.start } + phone.map { it.sportType to it.start } - val usual = usualSport(yours) ?: usualSport(all.filter { it.first != Workouts.GENERIC_ACTIVITY }) ?: Workouts.DEFAULT_SPORTS.first() - val ended = strap.map { MomentWorkout(it.sportType, it.end) } + phone.mapNotNull { w -> w.end?.let { MomentWorkout(w.sportType, it) } } - val last = ended.filter { it.end <= t }.maxByOrNull { it.end } - val day = DayWindow.of(today, zone) - val dayWorkouts = (strap.map { it.start to it.sportType } + phone.map { it.start to it.sportType }) - .filter { (start, sport) -> start in day.from..day.to && sport != Workouts.GENERIC_ACTIVITY }.sortedBy { it.first }.map { it.second } - val recording = (container.workout.state.value as? WorkoutState.Recording)?.let { MomentRecordingInput(it.workout.sportType, it.workout.start) } - val minutes = read("steps", emptyList()) { dao.activityOnce(day.from, day.to) } - val hr = read("heart rate", emptyList()) { dao.heartRateOnce(day.from, day.to) } - val series = Summaries.heartRateSeries(hr, minutes) - val heart = Summaries.bucketAverage(series).map { ChartPoint(it.first, it.second.toFloat()) } - val resting = read("resting heart rate", null) { dao.restingHrOnce(day.from, day.to).firstOrNull()?.bpm } - ?: if (series.size >= RESTING_ESTIMATE_MINUTES) Summaries.estimateRestingHr(series) else null - val input = MomentWidgetInput( - inputs = inputs, - placed = WidgetRefresh.placedOverride ?: WidgetUpdater.placed(context), - recovery = rec?.let { TodayCopy.recoveryShown(it.readiness, strapScore, TabCopy.RECOVERY_WAITING) }, - score = rec?.readiness?.score, - alert = rec?.readiness?.strainAlert == true, - noScore = TodayCopy.noScore(rec?.readiness), - firstNights = rec?.charge?.anchored != true, - energy = rec?.charge?.let(MomentEnergy::of), - load = rec?.load, - loadStatus = rec?.let { TodayCopy.loadStatus(it.load, it.chronicLoad, endOfDay = false) }, - night = night, - usualSport = usual, - lastWorkout = last, - recording = recording, - dayWorkouts = dayWorkouts, - heartRate = heart, - restingHr = resting, - steps = LiveSteps().today(Summaries.totalSteps(minutes), container.strap.todaySteps.value), - stepGoal = container.settings.goals.first().steps, - ) + internal fun load(data: WidgetData, widgets: WidgetUpdater): MomentWidgetModel { + val input = checkNotNull(data.momentWidget) { "not read for Moment" }.getOrThrow() val forced = WidgetRefresh.momentOverride - val model = MomentWidgetModel.from(input, now, zone, forced) + val model = MomentWidgetModel.from(input, data.now, data.zone, forced) // The debug gallery's forced moment has no end of its own. - if (forced == null) WidgetUpdater.scheduleMoment(context, inputs, model.tick, now, zone) + if (forced == null) widgets.scheduleMoment(input.inputs, model.tick, data.now, data.zone) return WidgetRefresh.moodOverride?.let { model.copy(mood = it) } ?: model } - - /** Our own resting estimate needs most of a day's minutes, as Today's ([org.openstrap.app.ui.dashboard.DayViewModel]). */ - private const val RESTING_ESTIMATE_MINUTES = 6 * 60 } } diff --git a/app/src/main/kotlin/org/openstrap/app/widget/MomentWidgetModel.kt b/app/src/main/kotlin/org/openstrap/app/widget/MomentWidgetModel.kt index da72dcfc..21d95889 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/MomentWidgetModel.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/MomentWidgetModel.kt @@ -5,6 +5,7 @@ import org.openstrap.app.data.Moment import org.openstrap.app.data.MomentInputs import org.openstrap.app.data.MomentLead import org.openstrap.app.data.Recovery +import org.openstrap.app.data.WidgetKind import org.openstrap.app.data.momentLead import org.openstrap.app.ui.components.ChartPoint import org.openstrap.app.ui.components.StageSpan @@ -12,20 +13,20 @@ import org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT import org.openstrap.app.ui.dashboard.TabCopy import org.openstrap.app.ui.dashboard.TodayCopy import org.openstrap.app.ui.dashboard.WakingLead -import org.openstrap.app.ui.dashboard.clockText import org.openstrap.app.ui.dashboard.momentMood import org.openstrap.app.ui.dashboard.sportNoun import org.openstrap.app.ui.dashboard.todayTitle import org.openstrap.app.ui.dashboard.wakingLead -import org.openstrap.app.ui.glow.AFTER_RIM -import org.openstrap.app.ui.glow.EVENING_BODY -import org.openstrap.app.ui.glow.GlowMood -import org.openstrap.app.ui.glow.GlowStyle -import org.openstrap.app.ui.glow.LightAngle -import org.openstrap.app.ui.glow.WINDOW_RIM -import org.openstrap.app.ui.glow.chargeShown -import org.openstrap.app.ui.glow.heroStyle +import org.openstrap.app.ui.glow.draw.AFTER_RIM +import org.openstrap.app.ui.glow.draw.EVENING_BODY +import org.openstrap.app.ui.glow.draw.GlowMood +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.LightAngle +import org.openstrap.app.ui.glow.draw.WINDOW_RIM +import org.openstrap.app.presentation.chargeShown +import org.openstrap.app.ui.glow.draw.heroStyle import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.presentation.TimeOfDay import java.time.Instant import java.time.LocalTime import java.time.ZoneId @@ -103,11 +104,13 @@ data class MomentWidgetInput( val restingHr: Int?, val steps: Int, val stepGoal: Int, + /** No [night] because the user deleted it: the sentences say so rather than ask whether the strap was on. */ + val nightDeleted: Boolean = false, ) /** How the Large's chart is drawn. */ enum class MomentChartKind { - /** The night's stages laid flat, as the nap strip ([org.openstrap.app.ui.glow.StageStrip]). */ + /** The night's stages laid flat, as the app's nap strip. */ Stages, /** A solid white line ending in its dot, as the Steps widget's ([org.openstrap.app.ui.glow.draw.ghostLineGeometry]). */ @@ -267,7 +270,7 @@ data class MomentWidgetModel( val t = now.epochSecond val energy = input.energy - fun clock(epoch: Long) = clockText(epoch, zone) + fun clock(epoch: Long) = TimeOfDay.format(epoch, zone) /** Waking: Recovery's score and word, the waking sentence, Energy at waking, the night's stages. */ fun recovery(lead: MomentLead, name: String, mood: GlowMood): MomentWidgetModel { @@ -279,7 +282,7 @@ data class MomentWidgetModel( words = listOfNotNull(word), shortWords = listOfNotNull(word), figures = listOfNotNull(energy?.atWake?.let { "Energy at waking $it" }), - sentences = shorter(TodayCopy.waking(input.night?.asleepSeconds, input.score, input.alert, input.noScore, input.firstNights, isToday = true)), + sentences = shorter(TodayCopy.waking(input.night?.asleepSeconds, input.score, input.alert, input.noScore, input.firstNights, isToday = true, deleted = input.nightDeleted)), waiting = null, start = null, startLabel = null, recording = null, chart = stagesChart(), light = MomentLight(MetricColors.readiness, LightAngle.LowerLeft), @@ -323,7 +326,7 @@ data class MomentWidgetModel( val e = energy val left = e?.now val drained = e?.let { TabCopy.drainedAmount(it.drained) } - val sentence = if (noSleep) TodayCopy.NO_SLEEP + val sentence = if (noSleep) (if (input.nightDeleted) TodayCopy.deletedNight(true) else TodayCopy.NO_SLEEP) else TodayCopy.charge(left, e?.let { TabCopy.drainedAmount(it.drained).toInt() }, e?.anchored == true, now.atZone(zone).hour) return energyModel( lead, name, mood, @@ -420,7 +423,7 @@ data class MomentWidgetModel( fun night(lead: MomentLead, name: String, mood: GlowMood): MomentWidgetModel { val bed = input.inputs.usualBedtime val light = MomentLight(MetricColors.sleepDeep, LightAngle.Below) - val bedtime = bed?.let { "Bedtime %02d:%02d".format(it.hour, it.minute) } + val bedtime = bed?.let { "Bedtime ${TimeOfDay.format(it)}" } val clock = now.atZone(zone).toLocalTime() val left = bed?.let { afterNoon(it) - afterNoon(clock) }?.takeIf { it > 0 } if (left == null) { diff --git a/app/src/main/kotlin/org/openstrap/app/widget/RecoveryWidget.kt b/app/src/main/kotlin/org/openstrap/app/widget/RecoveryWidget.kt index 8687b968..8ca44411 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/RecoveryWidget.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/RecoveryWidget.kt @@ -1,7 +1,7 @@ package org.openstrap.app.widget +import org.openstrap.app.data.DetailRoutes import android.content.Context -import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -18,22 +18,17 @@ import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height import androidx.glance.layout.width import androidx.glance.text.Text -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.first -import org.openstrap.app.OpenStrapApp -import org.openstrap.app.data.DayWindow -import org.openstrap.app.ui.glow.EdgeKind -import org.openstrap.app.ui.glow.EdgeStatus -import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.data.WidgetKind +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.EdgeStatus +import org.openstrap.app.ui.glow.draw.GlowMood import org.openstrap.app.ui.glow.draw.WeekBarsLook import org.openstrap.app.ui.glow.draw.drawWeekBars import org.openstrap.app.ui.glow.draw.renderToBitmap import org.openstrap.app.ui.glow.draw.weekBars -import org.openstrap.app.ui.glow.edgeLight +import org.openstrap.app.ui.glow.draw.edgeLight import org.openstrap.app.ui.theme.MetricColors -import java.time.Instant import java.time.LocalDate -import java.time.ZoneId /** * The Recovery widget (widgets spec 3.4): this morning's score and word in the same spot every day, with what's @@ -42,7 +37,7 @@ import java.time.ZoneId * the Recovery screen. */ class RecoveryWidget : LoadingWidget(WidgetKind.Recovery) { - override suspend fun loadShown(context: Context): Shown = load(context) + override fun shown(data: WidgetData, context: Context): Shown = load(data) override fun sample(): Shown = Shown(WidgetSamples.recovery(LocalDate.now()), GlowMood.Day) @@ -55,27 +50,12 @@ class RecoveryWidget : LoadingWidget(WidgetKind.Recovery) companion object { /** * Today's Recovery, worked out as the app does ([org.openstrap.app.data.HealthRepository.recovery]), and - * yesterday's while this morning's isn't in; the mood from the moment now ([widgetMood]). + * yesterday's while this morning's isn't in. The mood from the moment then ([widgetMood]). */ - internal suspend fun load(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): Shown { - val container = (context.applicationContext as OpenStrapApp).container - val profile = container.settings.profile.first() - val dao = container.database.healthDao() - suspend fun day(date: LocalDate) = try { - val r = container.health.recovery(date, profile) - val dayStart = DayWindow.of(date, zone).from - r to RecoveryDay.of(r, dao.strapReadinessOnce(dayStart, dayStart).firstOrNull()?.score) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(WIDGET_TAG, "widgets: Recovery for $date not worked out: $e") - null - } - val today = now.atZone(zone).toLocalDate() - val result = day(today) - val yesterday = if (result?.second?.shown?.value == null) day(today.minusDays(1))?.second else null - val model = RecoveryWidgetModel.from(result?.second, yesterday) - return Shown(model, widgetMood(container, result?.first?.usualBedtime, now, zone)) + internal fun load(data: WidgetData): Shown { + val read = checkNotNull(data.recovery) { "not read for Recovery" } + val model = RecoveryWidgetModel.from(read.today, read.yesterday) + return Shown(model, widgetMood(data, read.today?.let { read.todayResult?.usualBedtime })) } } } @@ -99,7 +79,7 @@ private val Ink = MetricColors.readiness @Composable private fun RecoveryContent(data: RecoveryWidget.Shown) { val model = data.model - val open = openMetric(LocalContext.current, RECOVERY_ROUTE, model.date) + val open = openMetric(LocalContext.current, DetailRoutes.RECOVERY, model.date) EdgeWidget(edgeLight(EdgeStatus.Usual, EdgeKind.Earlier), Ink, data.mood, open, BITMAP_EXTRA) { frame -> when { model.score == null -> WaitingContent(model.title, model.waiting, frame) @@ -210,8 +190,5 @@ private fun Week(week: List, column: Float, density: Float) { /* Bitmaps */ /* ------------------------------------------------------------------------------------------------ */ -/** The route `open_metric` takes for the Recovery screen ([org.openstrap.app.ui.dashboard.Detail.fromRoute]). */ -internal const val RECOVERY_ROUTE = "Recovery" - /** Room for the dots and the week beside the backgrounds, in the bitmap budget. */ private const val BITMAP_EXTRA = 1.25 diff --git a/app/src/main/kotlin/org/openstrap/app/widget/SleepWidget.kt b/app/src/main/kotlin/org/openstrap/app/widget/SleepWidget.kt index 24715833..0e8acd38 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/SleepWidget.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/SleepWidget.kt @@ -3,7 +3,6 @@ package org.openstrap.app.widget import android.content.Context import android.graphics.Bitmap import android.graphics.Paint -import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -29,23 +28,17 @@ import androidx.glance.layout.width import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.unit.ColorProvider -import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.first -import org.openstrap.app.OpenStrapApp -import org.openstrap.app.data.DayWindow -import org.openstrap.app.data.SleepHistory -import org.openstrap.app.ui.dashboard.Detail -import org.openstrap.app.ui.dashboard.SLEEP_NIGHTS -import org.openstrap.app.ui.glow.EdgeKind -import org.openstrap.app.ui.glow.EdgeStatus -import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.data.DetailRoutes +import org.openstrap.app.data.WidgetKind +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.EdgeStatus +import org.openstrap.app.ui.glow.draw.GlowMood import org.openstrap.app.ui.glow.draw.drawStageBar import org.openstrap.app.ui.glow.draw.renderToBitmap -import org.openstrap.app.ui.glow.edgeLight -import org.openstrap.app.ui.glow.stageColor +import org.openstrap.app.ui.glow.draw.edgeLight +import org.openstrap.app.ui.glow.draw.stageColor import org.openstrap.app.ui.theme.MetricColors /** @@ -55,7 +48,7 @@ import org.openstrap.app.ui.theme.MetricColors * as the Sleep tab. The whole widget opens the Sleep score's detail for that night. */ class SleepWidget : LoadingWidget(WidgetKind.Sleep) { - override suspend fun loadShown(context: Context): SleepWidgetModel = load(context) + override fun shown(data: WidgetData, context: Context): SleepWidgetModel = load(data) override fun sample(): SleepWidgetModel = ZoneId.systemDefault().let { WidgetSamples.sleep(LocalDate.now(it), it) } @@ -64,33 +57,13 @@ class SleepWidget : LoadingWidget(WidgetKind.Sleep) { companion object { /** - * This morning's night as the Sleep tab reads it ([SleepHistory.mainSleep] over the night ending today, its - * stages, the week's other nights from [SleepHistory.mainSleeps] over the tab's [SLEEP_NIGHTS]), and - * yesterday's while this morning's isn't synced. "After your first night" until the strap has stored a sleep. + * This morning's night as the Sleep tab reads it ([WidgetSnapshot.gather]), and yesterday's while this + * morning's isn't synced. "After your first night" until the strap has stored a sleep. */ - internal suspend fun load(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): SleepWidgetModel { - val container = (context.applicationContext as OpenStrapApp).container - val dao = container.database.healthDao() - val today = now.atZone(zone).toLocalDate() - return try { - val span = DayWindow(DayWindow.nightEndingOn(today.minusDays(SLEEP_NIGHTS - 1L), zone).from, DayWindow.nightEndingOn(today, zone).to) - val sessions = dao.sleepSessionsOnce(span.from, span.to) - val nights = SleepHistory.mainSleeps(sessions, zone) - suspend fun night(date: LocalDate): SleepWidgetNight? = SleepHistory.mainSleep(sessions, DayWindow.nightEndingOn(date, zone))?.let { main -> - SleepWidgetNight.of(date, main, dao.sleepStagesOnce(main.start), nights) - } - val tonight = night(today) - val yesterday = if (tonight == null) night(today.minusDays(1)) else null - val first = tonight == null && yesterday == null && nights.isEmpty() && dao.latestSleepEnd().first() == null - val day = DayWindow.of(today, zone) - val naps = sessions.any { it.nap && it.start in day.from..day.to } - SleepWidgetModel.from(tonight, yesterday, first, naps, zone) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(WIDGET_TAG, "widgets: sleep for $today not read: $e") - SleepWidgetModel.from(null, null, firstNight = false, napsToday = false, zone = zone) - } + internal fun load(data: WidgetData): SleepWidgetModel { + val read = checkNotNull(data.sleep) { "not read for Sleep" }.getOrNull() + ?: return SleepWidgetModel.from(null, null, firstNight = false, napsToday = false, zone = data.zone) + return SleepWidgetModel.from(read.tonight, read.yesterday, read.firstNight, read.napsToday, data.zone, read.deleted) } } } @@ -117,7 +90,7 @@ private val LegendStyle = WidgetType.caption.copy(color = ColorProvider(Color.Wh @Composable private fun SleepContent(m: SleepWidgetModel) { val context = LocalContext.current - val open = if (m.date != null) openMetric(context, Detail.SLEEP_SCORE_ROUTE, m.date) else openMetric(context, SLEEP_ROUTE, null) + val open = if (m.date != null) openMetric(context, DetailRoutes.SLEEP_SCORE, m.date) else openMetric(context, DetailRoutes.SLEEP, null) EdgeWidget(edgeLight(EdgeStatus.Usual, EdgeKind.Sleep), Ink, GlowMood.Night, open, BITMAP_EXTRA) { frame -> when { m.waiting != null -> WaitingContent(m.title, m.waiting, frame) @@ -334,8 +307,5 @@ private fun stageRowBar(r: SleepStageRow, widthDp: Float, density: Float): Bitma /* Bitmaps */ /* ------------------------------------------------------------------------------------------------ */ -/** The route `open_metric` takes for the Sleep screen, while there's no night to open the score of. */ -internal const val SLEEP_ROUTE = "Sleep" - /** Room for the dots, the stage bar and the stage rows' bars beside the backgrounds, in the bitmap budget. */ private const val BITMAP_EXTRA = 1.35 diff --git a/app/src/main/kotlin/org/openstrap/app/widget/SleepWidgetModel.kt b/app/src/main/kotlin/org/openstrap/app/widget/SleepWidgetModel.kt index 0a1b76e3..44723cc8 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/SleepWidgetModel.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/SleepWidgetModel.kt @@ -9,13 +9,14 @@ import org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT import org.openstrap.app.ui.dashboard.SLEEP_NAMES import org.openstrap.app.ui.dashboard.TabCopy import org.openstrap.app.ui.dashboard.TodayCopy -import org.openstrap.app.ui.glow.DotSize +import org.openstrap.app.ui.glow.draw.DotSize import org.openstrap.app.ui.glow.draw.stageTotals import java.time.LocalDate import java.time.ZoneId +import org.openstrap.app.presentation.Units /** - * One night as the Sleep tab's hero reads it ([org.openstrap.app.ui.dashboard.DayUiState]): the night's main sleep, its + * One night as the Sleep tab's hero reads it: the night's main sleep, its * stages and their totals, and the week's other nights for the longest or shortest rule. */ data class SleepWidgetNight( @@ -100,17 +101,19 @@ data class SleepWidgetModel( * isn't synced. Before any: the title and why, "After your first night" before the strap has seen one * ([firstNight]), else what the Sleep tab says for a day with no night ([napsToday] as it reads them). */ - fun from(today: SleepWidgetNight?, yesterday: SleepWidgetNight?, firstNight: Boolean, napsToday: Boolean, zone: ZoneId = ZoneId.systemDefault()): SleepWidgetModel { + fun from(today: SleepWidgetNight?, yesterday: SleepWidgetNight?, firstNight: Boolean, napsToday: Boolean, zone: ZoneId = ZoneId.systemDefault(), deleted: Boolean = false): SleepWidgetModel { today?.let { return night(it, stale = false, zone) } yesterday?.let { return night(it, stale = true, zone) } - val line = if (firstNight) AFTER_FIRST_NIGHT else TabCopy.noNight(napsToday, isToday = true) + val line = if (firstNight) AFTER_FIRST_NIGHT else TabCopy.noNight(napsToday, isToday = true, deleted = deleted) return SleepWidgetModel(null, TITLE, null, false, null, null, null, null, null, null, emptyList(), emptyList(), emptyList(), line) } /** Each stage's name, time and share of the night's stage seconds, as the Sleep score detail lists them. */ fun stageRows(totals: List>): List { val all = totals.sumOf { it.second }.coerceAtLeast(1L) - return totals.map { (stage, seconds) -> SleepStageRow(stage, SLEEP_NAMES[stage].orEmpty(), TodayCopy.duration(seconds), seconds.toFloat() / all) } + return totals.map { (stage, seconds) -> + SleepStageRow(stage, SLEEP_NAMES[stage].orEmpty(), Units.duration(seconds), seconds.toFloat() / all) + } } private fun night(n: SleepWidgetNight, stale: Boolean, zone: ZoneId): SleepWidgetModel { @@ -329,10 +332,6 @@ object SleepLayout { val top: TopRow = TopRow(null, note, 0f), val spread: Spread = Spread(0f, 0), ) - /** Whether the title row holds [title] and [note] on one line in [width]. */ - fun noteFits(title: String, note: String?, width: Float, fontScale: Float): Boolean = note != null && - WidgetText.width(title, WidgetText.TITLE_SP, fontScale) + SIDE_GAP_DP + WidgetText.width(note, WidgetText.SECONDARY_SP, fontScale) <= width - /** * The Wide in [width] by [height]: the title row, the score row ([scoreRow] with time asleep) and the stage bar * always, the legend on one row or two while it fits, else none. diff --git a/app/src/main/kotlin/org/openstrap/app/widget/StartWidget.kt b/app/src/main/kotlin/org/openstrap/app/widget/StartWidget.kt index 867cea9e..f3d5f331 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/StartWidget.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/StartWidget.kt @@ -5,7 +5,6 @@ import android.content.Intent import android.graphics.Bitmap import android.graphics.Paint import android.os.SystemClock -import android.util.Log import android.util.TypedValue import android.widget.RemoteViews import androidx.compose.runtime.Composable @@ -40,22 +39,20 @@ import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.unit.ColorProvider -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.first import org.openstrap.app.MainActivity -import org.openstrap.app.OpenStrapApp import org.openstrap.app.R +import org.openstrap.app.data.WidgetKind import org.openstrap.app.data.WorkoutRecorder import org.openstrap.app.data.Workouts import org.openstrap.app.device.WorkoutState -import org.openstrap.app.ui.glow.ControlSurface -import org.openstrap.app.ui.glow.EdgeKind -import org.openstrap.app.ui.glow.EdgeStatus -import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.ui.theme.ControlSurface +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.EdgeStatus +import org.openstrap.app.ui.glow.draw.GlowMood import org.openstrap.app.ui.glow.draw.drawIcon import org.openstrap.app.ui.glow.draw.renderToBitmap -import org.openstrap.app.ui.glow.edgeLight -import org.openstrap.app.ui.glow.locationAvailable +import org.openstrap.app.ui.glow.draw.edgeLight import org.openstrap.app.ui.theme.AppIcons import org.openstrap.app.ui.theme.MetricColors import org.openstrap.app.ui.workout.SportIcons @@ -69,7 +66,7 @@ import java.time.ZoneId * and the moment's mood. Play, a tile or the widget opens Ready with that sport, All sports the sport picker. */ class StartWidget : LoadingWidget(WidgetKind.Start) { - override suspend fun loadShown(context: Context): Shown = load(context) + override fun shown(data: WidgetData, context: Context): Shown = load(data) override fun sample(): Shown = Shown(WidgetSamples.start(Instant.now(), ZoneId.systemDefault()), GlowMood.Day) @@ -82,43 +79,18 @@ class StartWidget : LoadingWidget(WidgetKind.Start) { companion object { /** * Every workout as the workout lists read them ([Workouts.listed]), the Outdoor or Indoor side last chosen per - * sport and whether location is on, as Ready reads them, and the workout recording now. The mood from the - * moment now ([widgetMood]). + * sport and whether location is on, as Ready reads them, and the workout recording then. The mood from the + * moment then ([widgetMood]). */ - internal suspend fun load(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): Shown { - val container = (context.applicationContext as OpenStrapApp).container - val recording = (container.workout.state.value as? WorkoutState.Recording)?.let { r -> + internal fun load(data: WidgetData): Shown { + val read = checkNotNull(data.start) { "not read for Start" } + val recording = (read.state as? WorkoutState.Recording)?.let { r -> val w = r.workout - StartRecordingInput(w.sportType, w.start, WorkoutRecorder.activeSeconds(w, now.epochSecond), r.paused) + StartRecordingInput(w.sportType, w.start, WorkoutRecorder.activeSeconds(w, data.now.epochSecond), r.paused) } - val model = try { - val dao = container.database.healthDao() - val workouts = Workouts.listed( - dao.workouts().first(), dao.sportEffects().first(), dao.lastWorkoutHeartRates().first(), dao.appWorkouts().first(), - dao.setCounts().first().associate { it.workoutStart to it.sets }, LIST_MAX_HR, - ) - StartWidgetModel.from(workouts, container.settings.workoutSides.first(), locationAvailable(context), recording, now, zone) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(WIDGET_TAG, "widgets: workouts for Start not read: $e") - StartWidgetModel.from(emptyList(), emptyMap(), false, recording, now, zone) - } - val bedtime = try { - container.health.usualBedtime(now.atZone(zone).toLocalDate()) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - null - } - return Shown(model, widgetMood(container, bedtime, now, zone)) + val model = StartWidgetModel.from(read.workouts.orEmpty(), read.sides, read.location, recording, data.now, data.zone) + return Shown(model, widgetMood(data, data.usualBedtime)) } - - /** - * The max heart rate the lists' zones are shares of. The widget shows no zones, so it skips the app's 90-day - * read for it ([org.openstrap.app.data.RecoveryEngine.maxHeartRate]); nothing it shows depends on it. - */ - private const val LIST_MAX_HR = 190 } } diff --git a/app/src/main/kotlin/org/openstrap/app/widget/StartWidgetModel.kt b/app/src/main/kotlin/org/openstrap/app/widget/StartWidgetModel.kt index 4cda0643..5b3df00a 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/StartWidgetModel.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/StartWidgetModel.kt @@ -2,16 +2,14 @@ package org.openstrap.app.widget import org.openstrap.app.data.WorkoutAnalysis import org.openstrap.app.data.Workouts -import org.openstrap.app.ui.dashboard.TabCopy -import org.openstrap.app.ui.dashboard.TodayCopy -import org.openstrap.app.ui.dashboard.clockText -import org.openstrap.app.ui.glow.topSports +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.data.Weeks +import org.openstrap.app.presentation.topSports import org.openstrap.app.ui.workout.WorkoutCopy -import java.time.DayOfWeek import java.time.Instant import java.time.ZoneId -import java.time.temporal.TemporalAdjusters import java.util.Locale +import org.openstrap.app.presentation.Units /** * A sport on the Start widget. [sportType] is what a tap hands Ready (`EXTRA_READY_SPORT`), which then opens on the @@ -141,9 +139,11 @@ data class StartWidgetModel( val next = sports.drop(1).map { sp -> if (said.add(sp.shortName)) sp else sp.copy(caption = sp.name).also { said += sp.name } } val gpsOffered = Workouts.gpsOffered(usual.sportType, Workouts.indoorSide(usual.sportType, sides)) val today = now.atZone(zone).toLocalDate() - val monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay(zone).toEpochSecond() + val weekStart = Weeks.start(today, Weeks.firstDay(locale)).atStartOfDay(zone).toEpochSecond() // As Today and Your week count them: the strap's own "Activity" records aren't workouts. - val week = workouts.filter { it.workout.sportType != Workouts.GENERIC_ACTIVITY && it.workout.start in monday..now.epochSecond } + val week = workouts.filter { + it.workout.sportType != Workouts.GENERIC_ACTIVITY && it.workout.start in weekStart..now.epochSecond + } val last = workouts.filter { Workouts.tileType(it.workout.sportType) == Workouts.tileType(usual.sportType) && it.workout.start <= now.epochSecond } .maxByOrNull { it.workout.start } return StartWidgetModel( @@ -159,11 +159,11 @@ data class StartWidgetModel( lastLabel = lastLabel(usual.sportType), lastLine = last?.let { a -> val day = recentDay(Instant.ofEpochSecond(a.workout.start).atZone(zone).toLocalDate(), today, locale) - "$day, ${fact(a)}" + "$day, ${fact(a, locale)}" } ?: NONE_YET, recording = recording?.let { r -> StartRecording( - StartSport.recorded(r.sportType), "Since ${clockText(r.start, zone)}", + StartSport.recorded(r.sportType), "Since ${TimeOfDay.format(r.start, zone, locale = locale)}", if (r.paused) StartRecording.PAUSED else StartRecording.RECORDING, r.activeSeconds * 1000L, now.toEpochMilli(), r.paused, ) @@ -174,8 +174,8 @@ data class StartWidgetModel( /** "2 workouts, 1 h 35", "1 workout, 40 min", "No workouts yet". */ fun weekLine(count: Int, seconds: Long): String = when (count) { 0 -> NO_WORKOUTS - 1 -> "1 workout, ${TodayCopy.duration(seconds)}" - else -> "$count workouts, ${TodayCopy.duration(seconds)}" + 1 -> "1 workout, ${Units.duration(seconds)}" + else -> "$count workouts, ${Units.duration(seconds)}" } /** "Last run" for a run outdoors or on a treadmill, "Last HIIT", and "Last workout" for Other. */ @@ -185,10 +185,10 @@ data class StartWidgetModel( } /** A workout's one figure here: its distance ("5.2 km"), else its sets ("4 sets"), else how long it was. */ - fun fact(a: WorkoutAnalysis): String { - a.distanceMeters?.takeIf { it >= 10f }?.let { return TabCopy.distanceText(it, a.distanceFromSteps) } + fun fact(a: WorkoutAnalysis, locale: Locale = Locale.getDefault()): String { + a.distanceMeters?.let { Units.distance(it, Units.Precision.Day, a.distanceFromSteps, locale) }?.let { return it } a.sets?.takeIf { it > 0 }?.let { return if (it == 1) "1 set" else "$it sets" } - return TodayCopy.duration(a.activeSeconds) + return Units.duration(a.activeSeconds) } } } diff --git a/app/src/main/kotlin/org/openstrap/app/widget/StepsWidget.kt b/app/src/main/kotlin/org/openstrap/app/widget/StepsWidget.kt index 20c6f2c5..f54d8040 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/StepsWidget.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/StepsWidget.kt @@ -3,7 +3,6 @@ package org.openstrap.app.widget import android.content.Context import android.graphics.Bitmap import android.graphics.Paint -import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -23,16 +22,13 @@ import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height import androidx.glance.layout.width import androidx.glance.text.Text -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.first -import org.openstrap.app.OpenStrapApp -import org.openstrap.app.data.DailyMetric import org.openstrap.app.data.DayWindow -import org.openstrap.app.data.LiveSteps +import org.openstrap.app.data.DetailRoutes import org.openstrap.app.data.Summaries -import org.openstrap.app.ui.glow.EdgeKind -import org.openstrap.app.ui.glow.EdgeStatus -import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.data.WidgetKind +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.EdgeStatus +import org.openstrap.app.ui.glow.draw.GlowMood import org.openstrap.app.ui.glow.draw.WeekBarsLook import org.openstrap.app.ui.glow.draw.drawGhostLine import org.openstrap.app.ui.glow.draw.drawPillBar @@ -40,13 +36,8 @@ import org.openstrap.app.ui.glow.draw.ghostLineGeometry import org.openstrap.app.ui.glow.draw.renderToBitmap import org.openstrap.app.ui.glow.draw.weekBars import org.openstrap.app.ui.glow.draw.weekStub -import org.openstrap.app.ui.glow.edgeLight +import org.openstrap.app.ui.glow.draw.edgeLight import org.openstrap.app.ui.theme.MetricColors -import java.time.DayOfWeek -import java.time.Instant -import java.time.ZoneId -import java.time.temporal.TemporalAdjusters -import kotlin.math.roundToInt /** * The Steps widget (widgets spec 3.3): today's steps against the goal, glanceable all day. The Wide adds what's to go @@ -54,7 +45,7 @@ import kotlin.math.roundToInt * value, in Steps' lime and the moment's mood, as Today's Steps card. The whole widget opens the Steps detail. */ class StepsWidget : LoadingWidget(WidgetKind.Steps) { - override suspend fun loadShown(context: Context): Shown = load(context) + override fun shown(data: WidgetData, context: Context): Shown = load(data) override fun sample(): Shown = Shown(WidgetSamples.steps(java.time.LocalDate.now()), GlowMood.Day) @@ -67,38 +58,20 @@ class StepsWidget : LoadingWidget(WidgetKind.Steps) { companion object { /** * Today's steps as Today's Steps card counts them: the synced minutes with the strap's live total on top - * ([LiveSteps]), the day's line from the minutes hour by hour, the goal from settings, and this week's earlier - * days from the daily totals the app's week shows. The mood from the moment now ([widgetMood]). + * ([org.openstrap.app.data.LiveSteps]), the day's line from the minutes hour by hour, the goal from settings, + * and this week's earlier days from the daily totals the app's week shows. The mood from the moment now + * ([widgetMood]). */ - internal suspend fun load(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): Shown { - val container = (context.applicationContext as OpenStrapApp).container - val at = now.atZone(zone) + internal fun load(data: WidgetData): Shown { + val read = checkNotNull(data.steps) { "not read for Steps" } + val at = data.now.atZone(data.zone) val today = at.toLocalDate() - val goal = container.settings.goals.first().steps - val model = try { - val day = DayWindow.of(today, zone) - val minutes = container.database.healthDao().activityOnce(day.from, day.to) - val steps = LiveSteps().today(Summaries.totalSteps(minutes), container.strap.todaySteps.value) - val monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) - val past = if (monday < today) { - container.health.daily(DailyMetric.Steps, monday, today.minusDays(1), zone).first() - .filter { it.date < today }.associate { it.date to it.value.roundToInt() } - } else emptyMap() - StepsWidgetModel.from(today, steps, Summaries.hourlySteps(minutes, day.from), at.hour, goal, past) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Log.w(WIDGET_TAG, "widgets: steps for $today not read: $e") - StepsWidgetModel.from(today, 0, List(24) { 0f }, at.hour, goal, emptyMap()) - } - val bedtime = try { - container.health.usualBedtime(today) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - null - } - return Shown(model, widgetMood(container, bedtime, now, zone)) + val goal = read.goal.getOrThrow() + val model = read.day?.let { d -> + val hourly = Summaries.hourlySteps(d.minutes, DayWindow.of(today, data.zone).from) + StepsWidgetModel.from(today, d.steps, hourly, at.hour, goal, d.past) + } ?: StepsWidgetModel.from(today, 0, List(24) { 0f }, at.hour, goal, emptyMap()) + return Shown(model, widgetMood(data, data.usualBedtime)) } } } @@ -122,7 +95,7 @@ private const val TRACK_ALPHA = 0.10f @Composable private fun StepsContent(data: StepsWidget.Shown) { val m = data.model - val open = openMetric(LocalContext.current, STEPS_ROUTE, m.date) + val open = openMetric(LocalContext.current, DetailRoutes.STEPS, m.date) EdgeWidget(edgeLight(EdgeStatus.Usual, EdgeKind.Progress), Ink, data.mood, open, BITMAP_EXTRA) { frame -> when (frame.bucket) { WidgetSizes.Small -> SmallContent(m, frame) @@ -243,7 +216,7 @@ private fun GoalBar(progress: Float, f: WidgetFrame) { ) } -/** "This week" and how many days met the goal on one line, then a bar a day Monday to Sunday under it. */ +/** "This week" and how many days met the goal on one line, then a bar a day under it, from the week's first day. */ @Composable private fun WeekRow(m: StepsWidgetModel, week: StepsLayout.Week, density: Float) { // One child of the Large's column: Glance drops a column's children past ten. @@ -264,9 +237,6 @@ private fun WeekRow(m: StepsWidgetModel, week: StepsLayout.Week, density: Float) /* Bitmaps */ /* ------------------------------------------------------------------------------------------------ */ -/** The route `open_metric` takes for the Steps detail ([org.openstrap.app.ui.dashboard.Detail.fromRoute]). */ -internal const val STEPS_ROUTE = "Steps" - /** Room for the dots, the line, the bar and the week beside the backgrounds, in the bitmap budget. */ private const val BITMAP_EXTRA = 1.35 diff --git a/app/src/main/kotlin/org/openstrap/app/widget/StepsWidgetModel.kt b/app/src/main/kotlin/org/openstrap/app/widget/StepsWidgetModel.kt index f992c363..f9620332 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/StepsWidgetModel.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/StepsWidgetModel.kt @@ -2,15 +2,14 @@ package org.openstrap.app.widget import org.openstrap.app.data.Summaries import org.openstrap.app.ui.dashboard.ActivityCopy -import org.openstrap.app.ui.dashboard.Metric -import org.openstrap.app.ui.glow.DotSize +import org.openstrap.app.ui.glow.draw.DotSize import org.openstrap.app.ui.glow.draw.WeekBarsLook -import java.time.DayOfWeek import java.time.LocalDate import java.time.format.TextStyle -import java.time.temporal.TemporalAdjusters import java.util.Locale import kotlin.math.ceil +import org.openstrap.app.data.Weeks +import org.openstrap.app.presentation.Units /** How a day of this week stands against the goal, which colours its bar on the Large. */ enum class StepsDay { @@ -47,7 +46,7 @@ data class StepsWidgetModel( /** "Goal 10,000". */ val goalNote: String, val steps: Int, - /** The number in dots, "8,432", and the word under or beside it. */ + /** The number in dots, "8,432" with the dot font's ASCII digits and marks ([Units.DOT_LOCALE]), and its word. */ val stepsText: String, val word: String, /** "1,568 to go", or "Goal met" past the goal. */ @@ -60,7 +59,7 @@ data class StepsWidgetModel( val line: List, /** The line's value axis tops out here, as the card's: the goal, or the day when it's past it. */ val lineMax: Float, - /** Monday to Sunday. */ + /** The week's days, from the locale's first day. */ val week: List, /** "Goal met 2 of 6 days". */ val weekLine: String, @@ -70,8 +69,8 @@ data class StepsWidgetModel( /** The line shows once there's a step to draw, as on Today's card. */ val showLine: Boolean get() = steps > 0 && line.size >= 2 - /** What TalkBack reads for the dotted number: "8,432 steps today, 84% of your goal". */ - val description: String get() = "$stepsText steps today, ${share.replaceFirstChar { it.lowercase() }}" + /** What TalkBack reads for the dotted number, in the phone's language: "8,432 steps today, 84% of your goal". */ + val description: String get() = "${Units.count(steps.toLong())} steps today, ${share.replaceFirstChar { it.lowercase() }}" companion object { const val TITLE = "Steps" @@ -96,11 +95,11 @@ data class StepsWidgetModel( date = today, title = TITLE, goal = goal, - goalNote = "Goal ${format(goal)}", + goalNote = "Goal ${Units.count(goal.toLong(), locale)}", steps = steps, - stepsText = format(steps), + stepsText = Units.count(steps.toLong(), Units.DOT_LOCALE), word = TODAY, - toGo = toGo(steps, goal), + toGo = toGo(steps, goal, locale), share = ActivityCopy.goalShare(steps, goal), progress = if (goal <= 0) 1f else (steps / goal.toFloat()).coerceIn(0f, 1f), line = line, @@ -112,15 +111,15 @@ data class StepsWidgetModel( } /** "1,568 to go", or "Goal met" from the goal on. */ - fun toGo(steps: Int, goal: Int): String = if (steps >= goal) GOAL_MET else "${format(goal - steps)} to go" + fun toGo(steps: Int, goal: Int, locale: Locale = Locale.getDefault()): String = + if (steps >= goal) GOAL_MET else "${Units.count((goal - steps).toLong(), locale)} to go" /** "Goal met 2 of 6 days", "Goal met 0 of 1 day". */ fun weekLine(met: Int, of: Int): String = "Goal met $met of $of ${if (of == 1) "day" else "days"}" - /** Monday to Sunday of [today]'s week, each day as it stands against [goal]. */ - fun week(today: LocalDate, steps: Int, goal: Int, past: Map, locale: Locale): List { - val monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) - return (0L until 7L).map { monday.plusDays(it) }.map { d -> + /** The days of [today]'s week, from [locale]'s first day ([Weeks]), each as it stands against [goal]. */ + fun week(today: LocalDate, steps: Int, goal: Int, past: Map, locale: Locale): List = + Weeks.days(today, Weeks.firstDay(locale)).map { d -> val letter = d.dayOfWeek.getDisplayName(TextStyle.NARROW, locale) when { d > today -> StepsWeekDay(d, letter, null, StepsDay.ToCome) @@ -129,7 +128,6 @@ data class StepsWidgetModel( ?: StepsWeekDay(d, letter, null, StepsDay.NoRecord) } } - } /** * Each day's bar as a share of the week's top, the goal or the most steps in a day if that's more, so a met day @@ -139,8 +137,6 @@ data class StepsWidgetModel( val top = maxOf(goal, week.maxOf { it.steps ?: 0 }).coerceAtLeast(1).toFloat() return week.map { d -> d.steps?.takeIf { d.state != StepsDay.ToCome }?.let { it / top } } } - - private fun format(n: Int) = Metric.Steps.format(n.toFloat()) } } diff --git a/app/src/main/kotlin/org/openstrap/app/widget/StrapStatus.kt b/app/src/main/kotlin/org/openstrap/app/widget/StrapStatus.kt index 51a66954..0be3d709 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/StrapStatus.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/StrapStatus.kt @@ -1,20 +1,13 @@ package org.openstrap.app.widget -import org.openstrap.app.ui.dashboard.clockText +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.StrapBands +import org.openstrap.app.presentation.TimeOfDay import java.time.Instant import java.time.LocalDate import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.time.format.TextStyle -import java.time.temporal.ChronoUnit import java.util.Locale -/** At or under this battery level, while not charging, every widget says so (widgets spec 5). */ -const val STRAP_LOW_PERCENT = 15 - -/** A last sync older than this takes the status line, and a tap syncs. */ -const val SYNC_STALE_SECONDS = 3 * 3600L - /** The strap status line a widget shows in its top row (widgets spec 5); none while all is well. */ sealed interface StrapStatus { /** Opens the Strap page. */ @@ -31,30 +24,30 @@ sealed interface StrapStatus { } /** - * The status line, first match wins: not [paired], then [battery] at [STRAP_LOW_PERCENT] or less and not [charging], - * then [lastSync] (epoch seconds) more than [SYNC_STALE_SECONDS] before [now]. While [syncing] the stale line reads + * The status line, first match wins: not [paired], then [battery] at [StrapBands.LOW_BATTERY] or less and not [charging], + * then [lastSync] (epoch seconds) more than [StrapBands.STALE_AFTER_S] before [now]. While [syncing] the stale line reads * Syncing instead; a sync while all is well shows nothing, so the background syncs don't flash a line. Before the * first sync is known there's no time to give, so no line. */ fun strapStatus(paired: Boolean, battery: Int?, charging: Boolean, lastSync: Long?, now: Long, syncing: Boolean = false): StrapStatus? = when { !paired -> StrapStatus.NotPaired - battery != null && battery <= STRAP_LOW_PERCENT && !charging -> StrapStatus.LowBattery(battery) + battery != null && StrapBands.lowBattery(battery, charging) -> StrapStatus.LowBattery(battery) syncStale(lastSync, now) -> if (syncing) StrapStatus.Syncing else StrapStatus.StaleSync(lastSync!!) else -> null } -/** True when the last sync ended more than [SYNC_STALE_SECONDS] before [now]. */ -fun syncStale(lastSync: Long?, now: Long): Boolean = lastSync != null && now - lastSync > SYNC_STALE_SECONDS +/** True when the last sync ended more than [StrapBands.STALE_AFTER_S] before [now] ([StrapBands.stale]). */ +fun syncStale(lastSync: Long?, now: Long): Boolean = StrapBands.stale(lastSync, now) /** True when the line for the strap's state could read otherwise: pairing, the low battery edge or charging changed. */ fun strapStateChanged(paired: Boolean, battery: Int?, charging: Boolean, wasPaired: Boolean, wasBattery: Int?, wasCharging: Boolean): Boolean { - fun low(b: Int?) = b != null && b <= STRAP_LOW_PERCENT + fun low(b: Int?) = b != null && b <= StrapBands.LOW_BATTERY return paired != wasPaired || charging != wasCharging || low(battery) != low(wasBattery) } /** - * The line's words: "Pair your strap", "Strap at 12%", "Syncing", and "Synced 09:10" today, "Synced yesterday", the - * weekday within the week ("Synced Tuesday") and the date before that ("Synced 21 Oct"). + * The line's words: "Pair your strap", "Strap at 12%", "Syncing", and "Synced 09:10" today, then by [DayWords] + * "Synced yesterday", the weekday within the week ("Synced Tuesday") and the date before that ("Synced Tue 21 Oct"). */ fun strapStatusLine(status: StrapStatus, now: Long, zone: ZoneId, locale: Locale = Locale.getDefault()): String = when (status) { StrapStatus.NotPaired -> "Pair your strap" @@ -62,25 +55,14 @@ fun strapStatusLine(status: StrapStatus, now: Long, zone: ZoneId, locale: Locale StrapStatus.Syncing -> "Syncing" is StrapStatus.StaleSync -> { val then = Instant.ofEpochSecond(status.at).atZone(zone).toLocalDate() - val days = ChronoUnit.DAYS.between(then, Instant.ofEpochSecond(now).atZone(zone).toLocalDate()) - "Synced " + when { - days <= 0 -> clockText(status.at, zone) - days == 1L -> recentDay(then, then.plusDays(1), locale).lowercase(locale) - else -> recentDay(then, then.plusDays(days), locale) - } + val today = Instant.ofEpochSecond(now).atZone(zone).toLocalDate() + "Synced " + if (then >= today) TimeOfDay.format(status.at, zone, locale = locale) else DayWords.inSentence(then, today, locale) } } /** - * [date] as a widget says when something was, seen from [today]: "Today", "Yesterday", the weekday within the week - * ("Tuesday") and the date before that ("21 Oct"). + * [date] as a widget says when something was, seen from [today], by [DayWords]: "Today", "Yesterday", the weekday + * within the week ("Tuesday") and the date before that ("Tue 21 Oct"). A day after [today] reads "Today". */ -fun recentDay(date: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String { - val days = ChronoUnit.DAYS.between(date, today) - return when { - days <= 0 -> "Today" - days == 1L -> "Yesterday" - days < 7 -> date.dayOfWeek.getDisplayName(TextStyle.FULL, locale) - else -> date.format(DateTimeFormatter.ofPattern("d MMM", locale)) - } -} +fun recentDay(date: LocalDate, today: LocalDate, locale: Locale = Locale.getDefault()): String = + DayWords.relative(minOf(date, today), today, locale) diff --git a/app/src/main/kotlin/org/openstrap/app/widget/SyncNowAction.kt b/app/src/main/kotlin/org/openstrap/app/widget/SyncNowAction.kt index 4e0852c4..cb160751 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/SyncNowAction.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/SyncNowAction.kt @@ -1,31 +1,29 @@ package org.openstrap.app.widget -import android.Manifest import android.content.Context -import android.content.pm.PackageManager import android.util.Log import androidx.glance.GlanceId import androidx.glance.action.ActionParameters import androidx.glance.appwidget.action.ActionCallback import org.openstrap.app.OpenStrapApp +import org.openstrap.app.data.catching +import org.openstrap.app.device.BlePermissions import org.openstrap.app.device.ConnectionState /** * The strap status button's tap on a stale sync (widgets spec 4, 5): syncs in place, no activity. Connected, it's the same * sync as pulling Today down. Away, it reconnects as the app does in the background (a Disconnect the user tapped - * holds), and connecting syncs on its own. The line reads Syncing while it runs; [org.openstrap.app.device.WidgetUpdater] + * holds), and connecting syncs on its own. The line reads Syncing while it runs, and [WidgetUpdater] * refreshes the widgets as it starts and ends. */ class SyncNowAction : ActionCallback { override suspend fun onAction(context: Context, glanceId: GlanceId, parameters: ActionParameters) { - try { + catching { val strap = (context.applicationContext as OpenStrapApp).container.strap when { strap.connection.value is ConnectionState.Connected -> strap.syncNow() - context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED -> strap.backgroundConnect() + BlePermissions.granted(context) -> strap.backgroundConnect() } - } catch (e: Exception) { - Log.w("OpenStrap", "widgets: sync from the status line failed: $e") - } + }.onFailure { e -> Log.w(WIDGET_TAG, "widgets: sync from the status line failed: $e") } } } diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetKind.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetKind.kt deleted file mode 100644 index f4897ce6..00000000 --- a/app/src/main/kotlin/org/openstrap/app/widget/WidgetKind.kt +++ /dev/null @@ -1,4 +0,0 @@ -package org.openstrap.app.widget - -/** The five home screen widgets (widgets spec 2). */ -enum class WidgetKind { Moment, Sleep, Steps, Recovery, Start } diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetMoment.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetMoment.kt deleted file mode 100644 index 7b53a8c7..00000000 --- a/app/src/main/kotlin/org/openstrap/app/widget/WidgetMoment.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.openstrap.app.widget - -import org.openstrap.app.AppContainer -import org.openstrap.app.data.DayWindow -import org.openstrap.app.data.MomentInputs -import org.openstrap.app.data.MomentResolver -import org.openstrap.app.data.Workouts -import org.openstrap.app.device.WorkoutState -import java.time.Instant -import java.time.LocalTime -import java.time.ZoneId - -/** - * What [MomentResolver] reads, from the database rather than Today's screen, so a widget follows the same moment as - * Today (widgets spec 2.2: the mood follows the current moment). The same inputs Today gathers: whether a workout is - * recording, the last workout's end, today's main sleep's end, the usual bedtime (Recovery's [usualBedtime]) and the - * start of every workout you started over [MomentResolver.USUAL_WORKOUT_DAYS]. - */ -suspend fun widgetMomentInputs( - container: AppContainer, - usualBedtime: LocalTime?, - now: Instant = Instant.now(), - zone: ZoneId = ZoneId.systemDefault(), -): MomentInputs { - val dao = container.database.healthDao() - val today = now.atZone(zone).toLocalDate() - val t = now.epochSecond - val from = t - MomentResolver.USUAL_WORKOUT_DAYS * 86_400L - val phone = dao.appWorkoutsOnce(from, t) - // The strap's record of a phone workout is that workout, as in the lists ([Workouts.sameWorkout]). - val strap = dao.workoutsOnce(from, t).filter { Workouts.phoneTwin(it, phone, t) == null } - // You started it: not one the strap noticed or filed as "Activity" by itself (WorkoutAnalysis.startedByYou). - val starts = strap.filter { it.subtype != Workouts.SUBTYPE_AUTO_DETECTED && it.sportType != Workouts.GENERIC_ACTIVITY }.map { it.start } + - phone.filter { it.sportType != Workouts.GENERIC_ACTIVITY }.map { it.start } - val lastEnd = (strap.map { it.end } + phone.mapNotNull { it.end }).filter { it <= t }.maxOrNull() - // Today's main sleep, as Today picks it: the longest non-nap session that ended in the night window. - val night = DayWindow.nightEndingOn(today, zone) - val sleepEnd = dao.sleepSessionsOnce(night.from, night.to).filter { !it.nap }.maxByOrNull { it.end - it.start }?.end - return MomentInputs( - day = today, - recording = container.workout.state.value is WorkoutState.Recording, - lastWorkoutEnd = lastEnd, - sleepEnd = sleepEnd, - usualBedtime = usualBedtime, - workoutStarts = starts.sorted(), - ) -} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetParts.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetParts.kt index abcafe5a..ad83c5e6 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/WidgetParts.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetParts.kt @@ -5,7 +5,6 @@ import android.content.Context import android.content.Intent import android.content.res.Configuration import android.graphics.Bitmap -import android.os.Build import android.os.Bundle import android.util.Log import android.util.SizeF @@ -61,26 +60,21 @@ import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch -import kotlinx.coroutines.flow.first -import org.openstrap.app.AppContainer import org.openstrap.app.MainActivity -import org.openstrap.app.OpenStrapApp -import org.openstrap.app.device.SyncState +import org.openstrap.app.data.WidgetKind import org.openstrap.app.ui.glow.draw.drawIcon import org.openstrap.app.ui.theme.AppIcons import org.openstrap.app.ui.theme.MetricColors -import org.openstrap.app.ui.glow.ControlSurface -import org.openstrap.app.device.WidgetUpdater +import org.openstrap.app.ui.theme.ControlSurface import org.openstrap.app.ui.dashboard.momentMood -import org.openstrap.app.ui.glow.EdgeLight -import org.openstrap.app.ui.glow.GlowMood -import org.openstrap.app.ui.glow.GlowStyle +import org.openstrap.app.ui.glow.draw.EdgeLight +import org.openstrap.app.ui.glow.draw.GlowMood +import org.openstrap.app.ui.glow.draw.GlowStyle import org.openstrap.app.ui.glow.draw.drawGlow import org.openstrap.app.ui.glow.draw.renderToBitmap import org.openstrap.app.ui.glow.draw.WeekBarsLook @@ -88,10 +82,8 @@ import org.openstrap.app.ui.glow.draw.dotLayout import org.openstrap.app.ui.glow.draw.edgePaint import org.openstrap.app.ui.glow.draw.renderDots import org.openstrap.app.ui.glow.draw.renderEdge -import java.time.Instant import java.time.LocalDate import java.time.LocalTime -import java.time.ZoneId /* * What every widget shares (widgets spec 6): loading and reloading in provideGlance, the receiver's placed callbacks, @@ -109,8 +101,8 @@ abstract class LoadingWidget(internal val kind: WidgetKind) : GlanceAppWidget /** The picker's generated preview (spec 6.4, Android 15 and later) has the three sizes too. */ override val previewSizeMode = SizeMode.Responsive(WidgetSizes.all) - /** What the widget shows now. */ - protected abstract suspend fun loadShown(context: Context): T + /** What the widget shows from [data], a read for its kind ([WidgetSnapshot.gather]). */ + protected abstract fun shown(data: WidgetData, context: Context): T /** The mockups' sample ([WidgetSamples]) for the picker's preview, lit by day. */ protected abstract fun sample(): T @@ -126,13 +118,16 @@ abstract class LoadingWidget(internal val kind: WidgetKind) : GlanceAppWidget protected abstract fun Content(data: T) override suspend fun provideGlance(context: Context, id: GlanceId) = coroutineScope { - val shown = MutableStateFlow(loadShown(context)) - val status = MutableStateFlow(loadStatusLine(context)) + val widgets = context.widgets + val first = widgets.data(kind) + val shown = MutableStateFlow(shown(first, context)) + val status = MutableStateFlow(statusLine(first)) val resized = WidgetRefresh.resized(kind) launch { WidgetRefresh.version(kind).drop(1).collectLatest { - shown.value = loadShown(context) - status.value = loadStatusLine(context) + val data = widgets.data(kind) + shown.value = shown(data, context) + status.value = statusLine(data) } } provideContent { @@ -154,22 +149,15 @@ internal val LocalResized = staticCompositionLocalOf { 0 } internal val LocalStatusLine = staticCompositionLocalOf { null } /** - * The strap status line now (spec 5), from what settings keep of the strap so it reads with the strap away, and - * whether a sync is running; or the debug gallery's ([WidgetRefresh.statusOverride]). Null while all is well. + * The strap status line (spec 5) as [data] read it, from what settings keep of the strap so it reads with the strap + * away, and whether a sync is running, or the debug gallery's ([WidgetRefresh.statusOverride]). Null while all is well. */ -internal suspend fun loadStatusLine(context: Context, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): StatusLine? = try { - val t = now.epochSecond +internal fun statusLine(data: WidgetData): StatusLine? { + val t = data.now.epochSecond val status = if (WidgetRefresh.statusOverride != null) WidgetRefresh.debugStatus(t) else { - val container = (context.applicationContext as OpenStrapApp).container - val s = container.settings.strapSnapshot.first() - strapStatus(s.paired, s.battery, s.charging, s.lastSyncDone, t, syncing = container.strap.sync.value is SyncState.Running) + data.strap?.let { s -> strapStatus(s.paired, s.battery, s.charging, s.lastSyncDone, t, syncing = data.syncing) } } - status?.let { StatusLine.of(it, t, zone) } -} catch (e: CancellationException) { - throw e -} catch (e: Exception) { - Log.w(WIDGET_TAG, "widgets: strap status not read: $e") - null + return status?.let { StatusLine.of(it, t, data.zone) } } /** @@ -203,19 +191,17 @@ abstract class PlacedWidgetReceiver : GlanceAppWidgetReceiver() { (glanceAppWidget as? LoadingWidget<*>)?.let { WidgetRefresh.resize(it.kind) } } - private fun placedChanged(context: Context) = WidgetUpdater.placedChanged(context) + private fun placedChanged(context: Context) = context.widgets.placedChanged() } /** - * The mood to light a widget in (spec 2.2): the moment's now, from the same inputs Today reads ([widgetMomentInputs]) - * with [usualBedtime] from Recovery, or the debug gallery's. Day when the moment can't be worked out. + * The mood to light a widget in (spec 2.2): the moment at [data]'s time, from the same inputs Today reads + * ([WidgetData.moment]) with [usualBedtime] from Recovery, or the debug gallery's. Day when the moment can't be worked out. */ -internal suspend fun widgetMood(container: AppContainer, usualBedtime: LocalTime?, now: Instant, zone: ZoneId): GlowMood = - WidgetRefresh.moodOverride ?: try { - momentMood(widgetMomentInputs(container, usualBedtime, now, zone).resolve(now, zone)) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { +internal fun widgetMood(data: WidgetData, usualBedtime: LocalTime?): GlowMood = + WidgetRefresh.moodOverride ?: runCatching { + momentMood(checkNotNull(data.momentInputs(usualBedtime)) { "no moment inputs" }.resolve(data.now, data.zone)) + }.getOrElse { e -> Log.w(WIDGET_TAG, "widgets: moment for the mood not worked out: $e") GlowMood.Day } @@ -224,7 +210,7 @@ internal suspend fun widgetMood(container: AppContainer, usualBedtime: LocalTime internal fun openStrap(context: Context): Intent = Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_STRAP, true).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) -/** Opens the detail at [route] (`open_metric`, [org.openstrap.app.ui.dashboard.Detail.fromRoute]) for [day]. */ +/** Opens the detail at [route] (`open_metric`, one of [org.openstrap.app.data.DetailRoutes]) for [day]. */ internal fun openMetric(context: Context, route: String, day: LocalDate?): Intent = Intent(context, MainActivity::class.java) .putExtra(MainActivity.EXTRA_OPEN_METRIC, route) @@ -389,10 +375,9 @@ internal fun edgeBackground(light: EdgeLight, ink: Color, mood: GlowMood, w: Flo return renderEdge(paint.copy(radius = corner * density)) } -/** The launcher's widget corner, dp, on Android 12 and later; null before. */ -private fun systemCorner(context: Context): Float? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { +/** The launcher's widget corner, dp, or null when the system doesn't say. */ +private fun systemCorner(context: Context): Float? = runCatching { context.resources.getDimension(android.R.dimen.system_app_widget_background_radius) / context.resources.displayMetrics.density }.getOrNull() -} else null /** * The cell sizes the launcher gave the widget, dp ([reportedSizes]): the launcher's own now, or the session's @@ -409,18 +394,16 @@ private fun cellSizes(options: Bundle, id: GlanceId, context: Context): List> { val portrait = context.resources.configuration.orientation != Configuration.ORIENTATION_LANDSCAPE - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - @Suppress("DEPRECATION") - val sizes = options.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES) - if (!sizes.isNullOrEmpty()) { - val all = sizes.map { it.width to it.height } - return if (portrait) all.sortedByDescending { it.second / it.first } else all.sortedBy { it.second / it.first } - } + @Suppress("DEPRECATION") + val sizes = options.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES) + if (!sizes.isNullOrEmpty()) { + val all = sizes.map { it.width to it.height } + return if (portrait) all.sortedByDescending { it.second / it.first } else all.sortedBy { it.second / it.first } } val minW = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH).toFloat() val maxW = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH).toFloat() diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetRefresh.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetRefresh.kt index 61de8463..710167ad 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/WidgetRefresh.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetRefresh.kt @@ -3,12 +3,13 @@ package org.openstrap.app.widget import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.openstrap.app.BuildConfig -import org.openstrap.app.ui.glow.GlowMood +import org.openstrap.app.data.WidgetKind +import org.openstrap.app.ui.glow.draw.GlowMood /** * Tells a running widget its data changed. Glance calls a widget's `provideGlance` once per session and recomposes * the running session on an update, so a widget loads in `provideGlance` and loads again each time its kind's - * version moves on. [org.openstrap.app.device.WidgetUpdater] bumps it before every update. + * version moves on. [WidgetUpdater] bumps it before every update. */ object WidgetRefresh { private val versions = WidgetKind.entries.associateWith { MutableStateFlow(0) } diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetSamples.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetSamples.kt index 2f63ff4d..336095bb 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/WidgetSamples.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetSamples.kt @@ -9,13 +9,12 @@ import org.openstrap.app.data.db.Workout import org.openstrap.app.ui.components.ChartPoint import org.openstrap.app.ui.dashboard.TabCopy import org.openstrap.app.ui.dashboard.TodayCopy -import java.time.DayOfWeek import java.time.Instant import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId -import java.time.temporal.TemporalAdjusters import java.util.Locale +import org.openstrap.app.data.Weeks /** * The mockups' sample day as each widget's model, made by the same factories the widgets load through, so the @@ -40,9 +39,9 @@ object WidgetSamples { /** 8,432 steps by 14:00 against 10,000, the week before today with two days met. */ fun steps(today: LocalDate, locale: Locale = Locale.getDefault()): StepsWidgetModel { - val monday = today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + val weekStart = Weeks.start(today, Weeks.firstDay(locale)) val past = listOf(7_800, 11_200, 5_900, 10_400, 6_300, 9_100).mapIndexedNotNull { i, s -> - monday.plusDays(i.toLong()).takeIf { it < today }?.let { it to s } + weekStart.plusDays(i.toLong()).takeIf { it < today }?.let { it to s } }.toMap() val hourly = List(24) { h -> if (h in 7..14) 1_054f else 0f } return StepsWidgetModel.from(today, 8_432, hourly, 14, 10_000, past, locale) diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetSizes.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetSizes.kt index 869169dc..febb9407 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/WidgetSizes.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetSizes.kt @@ -2,6 +2,8 @@ package org.openstrap.app.widget import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import org.openstrap.app.ui.glow.draw.DotFont +import org.openstrap.app.ui.glow.draw.DotSize /** * The three sizes every widget has (widgets spec 2.1), so a stack that gives all its widgets one size fits each of @@ -177,9 +179,9 @@ object WidgetText { object WidgetDots { /** [text]'s dots at [dot] dp, wide and high. */ fun width(text: String, dot: Float): Float = - dot * ((1 + org.openstrap.app.ui.glow.DotSize.DOT_GAP) * org.openstrap.app.ui.glow.draw.DotFont.columns(text) - org.openstrap.app.ui.glow.DotSize.DOT_GAP) + dot * ((1 + DotSize.DOT_GAP) * DotFont.columns(text) - DotSize.DOT_GAP) fun height(dot: Float): Float = - dot * ((1 + org.openstrap.app.ui.glow.DotSize.DOT_GAP) * org.openstrap.app.ui.glow.draw.DotFont.ROWS - org.openstrap.app.ui.glow.DotSize.DOT_GAP) + dot * ((1 + DotSize.DOT_GAP) * DotFont.ROWS - DotSize.DOT_GAP) /** The dot that fits [text] into [room] dp, at most [dot]. */ fun fit(text: String, room: Float, dot: Float): Float = minOf(dot, room / width(text, 1f)).coerceAtLeast(0.5f) diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetSnapshot.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetSnapshot.kt new file mode 100644 index 00000000..6c4dd2c0 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetSnapshot.kt @@ -0,0 +1,352 @@ +package org.openstrap.app.widget + +import android.util.Log +import kotlinx.coroutines.flow.first +import org.openstrap.app.data.DailyMetric +import org.openstrap.app.data.DayWindow +import org.openstrap.app.data.HealthRepository +import org.openstrap.app.data.LiveSteps +import org.openstrap.app.data.MomentInputs +import org.openstrap.app.data.MomentResolver +import org.openstrap.app.data.RecoveryResult +import org.openstrap.app.data.SleepHistory +import org.openstrap.app.data.Summaries +import org.openstrap.app.data.WidgetKind +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.Weeks +import org.openstrap.app.data.Workouts +import org.openstrap.app.data.catching +import org.openstrap.app.data.db.ActivityMinute +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.HealthDao +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.Workout +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.data.settings.StrapSnapshot +import org.openstrap.app.data.settings.UserProfile +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.ui.components.ChartPoint +import org.openstrap.app.ui.dashboard.TabCopy +import org.openstrap.app.ui.dashboard.TodayCopy +import org.openstrap.app.ui.dashboard.usualSport +import org.openstrap.protocol.services.StepTotals +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import kotlin.math.roundToInt + +/** + * One read of what the widgets of [kinds] show, at [now] in [zone] ([WidgetSnapshot.gather]). A section for a kind + * the read wasn't for is null. A read that failed was logged and holds what the widget shows without it, or a failure + * where the widget can't be drawn without it. + */ +@Suppress("LongParameterList") // One section a widget, each null when the read wasn't for it. +class WidgetData( + val kinds: Set, + val now: Instant, + val zone: ZoneId, + /** What settings keep of the strap, for the status line with the strap away, null when it couldn't be read. */ + val strap: StrapSnapshot?, + val syncing: Boolean, + /** What the moment is worked out from, with no usual bedtime: each widget adds its own. Null when not read. */ + val moment: Result?, + /** When you usually go to sleep, without working out Recovery ([HealthRepository.usualBedtime]). */ + val usualBedtime: LocalTime?, + val steps: StepsRead?, + val start: StartRead?, + val recovery: RecoveryRead?, + val sleep: Result?, + val momentWidget: Result?, +) { + /** The moment's inputs with [bedtime], null when they couldn't be read. */ + fun momentInputs(bedtime: LocalTime?): MomentInputs? = moment?.getOrNull()?.copy(usualBedtime = bedtime) +} + +/** The Steps widget's reads: the [goal], and the [day]'s steps, null when they failed. */ +class StepsRead(val goal: Result, val day: StepsToday?) + +/** Today's minutes, its steps with the strap's live total on top ([LiveSteps]) and this week's earlier days' totals. */ +class StepsToday(val minutes: List, val steps: Int, val past: Map) + +/** + * The Start widget's reads: the listed [workouts] and [sides] (null and empty when they failed), whether [location] + * is on, the workout's [state] then. + */ +class StartRead(val workouts: List?, val sides: Map, val location: Boolean, val state: WorkoutState) + +/** The Recovery widget's reads: [today] and, while its score isn't in, [yesterday], null where it wasn't worked out. */ +class RecoveryRead(val todayResult: RecoveryResult?, val today: RecoveryDay?, val yesterday: RecoveryDay?) + +/** The Sleep widget's reads, as [SleepWidgetModel.from] takes them. */ +class SleepRead( + val tonight: SleepWidgetNight?, + val yesterday: SleepWidgetNight?, + val firstNight: Boolean, + val napsToday: Boolean, + val deleted: Boolean, +) + +/** + * Reads what the widgets show, once per refresh for every kind it's asked for (audit A4): the same live steps + * ([LiveSteps], the one the app keeps), main night ([SleepHistory.mainSleep]) and Recovery + * ([HealthRepository.recovery]) as Today. Widgets that share a read, like the workouts behind the moment and the + * Moment widget, read it once. + */ +// The strap's and the workout's state now come in as functions, so a read can be checked without either. +@Suppress("LongParameterList") +class WidgetSnapshot( + private val health: HealthRepository, + private val dao: HealthDao, + private val settings: SettingsStore, + private val liveSteps: LiveSteps, + /** Whether the strap is syncing now. */ + private val syncing: () -> Boolean, + /** The strap's live step count now. */ + private val todaySteps: () -> StepTotals?, + /** The workout's state now. */ + private val workoutState: () -> WorkoutState, + /** Whether the phone's location can be used now. */ + private val location: () -> Boolean, + /** The widgets placed now. */ + private val placed: () -> Set, +) { + suspend fun gather(kinds: Set, now: Instant, zone: ZoneId): WidgetData { + val today = now.atZone(zone).toLocalDate() + val t = now.epochSecond + val strap = catching { settings.strapSnapshot.first() }.onFailure { logUnread("strap status", it) }.getOrNull() + val workouts = if (kinds.any { it in MOOD_KINDS }) catching { recentWorkouts(t) } else null + val span = if (kinds.any { it in NIGHT_KINDS }) catching { sleepSpanSessions(today, zone) } else null + val moment = workouts?.let { w -> catching { momentInputs(w.getOrThrow(), span?.getOrNull(), today, t, zone) } } + val profile = if (kinds.any { it in RECOVERY_KINDS }) catching { settings.profile.first() } else null + val rec = profile?.let { p -> + catching { health.recovery(today, p.getOrThrow()) }.onFailure { logUnread("Recovery for $today", it) }.getOrNull() + } + val bedtime = if (bedtimeNeeded(kinds, rec)) usualBedtime(today) else null + val goal = if (kinds.any { it in STEP_KINDS }) catching { settings.goals.first().steps } else null + val day = goal?.let { stepsToday(today, zone, past = WidgetKind.Steps in kinds) } + val reads = Reads(today, t, zone, workouts, span, moment, rec, bedtime, goal, day) + return WidgetData( + kinds = kinds, + now = now, + zone = zone, + strap = strap, + syncing = syncing(), + moment = moment, + usualBedtime = bedtime, + steps = goal?.takeIf { WidgetKind.Steps in kinds }?.let { StepsRead(it, day) }, + start = if (WidgetKind.Start in kinds) start() else null, + recovery = profile?.takeIf { WidgetKind.Recovery in kinds }?.let { recovery(today, rec, it, zone) }, + sleep = span?.takeIf { WidgetKind.Sleep in kinds }?.let { s -> + catching { sleep(s.getOrThrow(), today, zone) }.onFailure { logUnread("sleep for $today", it) } + }, + momentWidget = if (WidgetKind.Moment in kinds) catching { momentWidget(reads) } else null, + ) + } + + /** What one gather already read, for the Moment widget, which reads on from it. */ + private class Reads( + val today: LocalDate, val t: Long, val zone: ZoneId, + val workouts: Result?, val span: Result>?, val moment: Result?, + val recovery: RecoveryResult?, val bedtime: LocalTime?, val goal: Result?, val day: StepsToday?, + ) + + private suspend fun usualBedtime(today: LocalDate): LocalTime? = + catching { health.usualBedtime(today) }.onFailure { logUnread("usual bedtime", it) }.getOrNull() + + /** The sleep over the Sleep tab's nights up to [today]'s ([sleepSpan]). */ + private suspend fun sleepSpanSessions(today: LocalDate, zone: ZoneId): List = + sleepSpan(today, zone).let { health.sleep.sessionsOnce(it.from, it.to) } + + /** The strap's and the phone's workouts over the usual workout time's look-back, less the strap's record of a phone workout. */ + private suspend fun recentWorkouts(t: Long): RecentWorkouts { + val from = t - MomentResolver.USUAL_WORKOUT_DAYS * 86_400L + val phone = dao.appWorkoutsOnce(from, t) + // The strap's record of a phone workout is that workout, as in the lists ([Workouts.sameWorkout]). + val strap = dao.workoutsOnce(from, t).filter { Workouts.phoneTwin(it, phone, t) == null } + return RecentWorkouts(strap, phone) + } + + /** + * What [MomentResolver] reads, from the database rather than Today's screen, so a widget follows the same moment as + * Today (widgets spec 2.2): whether a workout is recording, the last workout's end, today's main sleep's end (from + * [span] when it was read), and the start of every workout you started. The usual bedtime is each widget's own. + */ + private suspend fun momentInputs(w: RecentWorkouts, span: List?, today: LocalDate, t: Long, zone: ZoneId): MomentInputs { + val starts = w.strap.filter(::startedByYou).map { it.start } + + w.phone.filter { it.sportType != Workouts.GENERIC_ACTIVITY }.map { it.start } + val lastEnd = (w.strap.map { it.end } + w.phone.mapNotNull { it.end }).filter { it <= t }.maxOrNull() + // Today's main sleep, as Today picks it. + val night = DayWindow.nightEndingOn(today, zone) + val sessions = span ?: health.sleep.sessionsOnce(night.from, night.to) + return MomentInputs( + day = today, + recording = workoutState() is WorkoutState.Recording, + lastWorkoutEnd = lastEnd, + sleepEnd = SleepHistory.mainSleep(sessions, night)?.end, + usualBedtime = null, + workoutStarts = starts.sorted(), + ) + } + + /** Today's minutes and steps as Today's Steps card counts them, and with [past] this week's earlier days. */ + private suspend fun stepsToday(today: LocalDate, zone: ZoneId, past: Boolean): StepsToday? = catching { + val day = DayWindow.of(today, zone) + val minutes = dao.activityOnce(day.from, day.to) + val steps = liveSteps.today(today, Summaries.totalSteps(minutes), todaySteps()) + val weekStart = Weeks.start(today) + val earlier = if (past && weekStart < today) { + health.daily(DailyMetric.Steps, weekStart, today.minusDays(1), zone).first() + .filter { it.date < today }.associate { it.date to it.value.roundToInt() } + } else emptyMap() + StepsToday(minutes, steps, earlier) + }.onFailure { logUnread("steps for $today", it) }.getOrNull() + + /** Every workout as the workout lists read them ([Workouts.listed]), the sides last chosen and whether location is on. */ + private suspend fun start(): StartRead { + val state = workoutState() + val workouts = catching { + Workouts.listed( + dao.workouts().first(), dao.sportEffects().first(), dao.lastWorkoutHeartRates().first(), dao.appWorkouts().first(), + dao.setCounts().first().associate { it.workoutStart to it.sets }, LIST_MAX_HR, + ) to settings.workoutSides.first() + }.onFailure { logUnread("workouts for Start", it) }.getOrNull() + return StartRead(workouts?.first, workouts?.second.orEmpty(), workouts != null && location(), state) + } + + /** Today's Recovery with the strap's score, and yesterday's while this morning's isn't in. */ + private suspend fun recovery(today: LocalDate, result: RecoveryResult?, profile: Result, zone: ZoneId): RecoveryRead { + suspend fun day(date: LocalDate, known: RecoveryResult?): RecoveryDay? = catching { + val r = known ?: health.recovery(date, profile.getOrThrow()) + val dayStart = DayWindow.of(date, zone).from + RecoveryDay.of(r, dao.strapReadinessOnce(dayStart, dayStart).firstOrNull()?.score) + }.onFailure { logUnread("Recovery for $date", it) }.getOrNull() + val first = result?.let { day(today, it) } + val yesterday = if (first?.shown?.value == null) day(today.minusDays(1), null) else null + return RecoveryRead(result, first, yesterday) + } + + /** + * This morning's night as the Sleep tab reads it ([SleepHistory.mainSleep] over the night ending today, its stages, + * the week's other nights from [SleepHistory.mainSleeps] over the tab's nights), and yesterday's while this + * morning's isn't synced. + */ + private suspend fun sleep(sessions: List, today: LocalDate, zone: ZoneId): SleepRead { + val nights = SleepHistory.mainSleeps(sessions, zone) + suspend fun night(date: LocalDate): SleepWidgetNight? = + SleepHistory.mainSleep(sessions, DayWindow.nightEndingOn(date, zone))?.let { main -> + SleepWidgetNight.of(date, main, health.sleep.stagesOnce(main), nights) + } + val tonight = night(today) + // A night the user deleted is this morning's answer: yesterday's doesn't stand in for it. + val deleted = tonight == null && DayWindow.nightEndingOn(today, zone).let { w -> health.sleep.nightDeleted(w.from, w.to) } + val yesterday = if (tonight == null && !deleted) night(today.minusDays(1)) else null + val first = tonight == null && yesterday == null && !deleted && nights.isEmpty() && dao.latestSleepEnd().first() == null + val day = DayWindow.of(today, zone) + val naps = sessions.any { it.nap && it.start in day.from..day.to } + return SleepRead(tonight, yesterday, first, naps, deleted) + } + + /** + * Everything Today's hero reads: the moment's inputs, today's Recovery and Energy, last night as the Sleep tab reads + * it, the workouts, the day's heart rate and steps. + */ + private suspend fun momentWidget(r: Reads): MomentWidgetInput { + val (today, t, zone) = Triple(r.today, r.t, r.zone) + val w = checkNotNull(r.workouts).getOrThrow() + val base = checkNotNull(r.moment).getOrThrow() + val rec = r.recovery + val stepsDay = r.day + suspend fun read(what: String, default: T, block: suspend () -> T): T = + catching { block() }.onFailure { logUnread("$what for Moment", it) }.getOrDefault(default) + val inputs = base.copy(usualBedtime = rec?.usualBedtime ?: r.bedtime) + val dayStart = DayWindow.of(today, zone).from + val strapScore = read("the strap's score", null) { dao.strapReadinessOnce(dayStart, dayStart).firstOrNull()?.score } + val night = read("last night", null) { + val sessions = checkNotNull(r.span).getOrThrow() + SleepHistory.mainSleep(sessions, DayWindow.nightEndingOn(today, zone))?.let { main -> + SleepWidgetNight.of(today, main, health.sleep.stagesOnce(main), SleepHistory.mainSleeps(sessions, zone)) + } + } + val yours = w.strap.filter(::startedByYou).map { it.sportType to it.start } + + w.phone.filter { it.sportType != Workouts.GENERIC_ACTIVITY }.map { it.sportType to it.start } + val all = w.strap.map { it.sportType to it.start } + w.phone.map { it.sportType to it.start } + val usual = usualSport(yours) ?: usualSport(all.filter { it.first != Workouts.GENERIC_ACTIVITY }) ?: Workouts.DEFAULT_SPORTS.first() + val ended = w.strap.map { MomentWorkout(it.sportType, it.end) } + + w.phone.mapNotNull { p -> p.end?.let { MomentWorkout(p.sportType, it) } } + val last = ended.filter { it.end <= t }.maxByOrNull { it.end } + val day = DayWindow.of(today, zone) + val dayWorkouts = (w.strap.map { it.start to it.sportType } + w.phone.map { it.start to it.sportType }) + .filter { (start, sport) -> start in day.from..day.to && sport != Workouts.GENERIC_ACTIVITY } + .sortedBy { it.first }.map { it.second } + val recording = (workoutState() as? WorkoutState.Recording)?.let { MomentRecordingInput(it.workout.sportType, it.workout.start) } + val minutes = stepsDay?.minutes.orEmpty() + val hr = read("heart rate", emptyList()) { dao.heartRateOnce(day.from, day.to) } + val series = Summaries.heartRateSeries(hr, minutes) + val heart = Summaries.bucketAverage(series).map { ChartPoint(it.first, it.second.toFloat()) } + val resting = read("resting heart rate", null) { dao.restingHrOnce(day.from, day.to).firstOrNull()?.bpm } + ?: if (series.size >= RESTING_ESTIMATE_MINUTES) Summaries.estimateRestingHr(series) else null + return MomentWidgetInput( + inputs = inputs, + placed = WidgetRefresh.placedOverride ?: placed(), + recovery = rec?.let { TodayCopy.recoveryShown(it.readiness, strapScore, TabCopy.RECOVERY_WAITING) }, + score = rec?.readiness?.score, + alert = rec?.readiness?.strainAlert == true, + noScore = TodayCopy.noScore(rec?.readiness), + firstNights = rec?.charge?.anchored != true, + energy = rec?.charge?.let(MomentEnergy::of), + load = rec?.load, + loadStatus = rec?.let { TodayCopy.loadStatus(it.load, it.chronicLoad, endOfDay = false) }, + night = night, + usualSport = usual, + lastWorkout = last, + recording = recording, + dayWorkouts = dayWorkouts, + heartRate = heart, + restingHr = resting, + // A failed read leaves the shared anchor alone: anchoring on 0 would throw off Today's count too. + steps = stepsDay?.steps ?: todaySteps()?.takeIf { it.isPlausible }?.steps ?: 0, + stepGoal = checkNotNull(r.goal).getOrThrow(), + nightDeleted = night == null && read("a deleted night", false) { + DayWindow.nightEndingOn(today, zone).let { n -> health.sleep.nightDeleted(n.from, n.to) } + }, + ) + } + + private class RecentWorkouts(val strap: List, val phone: List) + + companion object { + /** The widgets lit in the moment's mood ([widgetMood]), and Moment, which is the moment. */ + private val MOOD_KINDS = setOf(WidgetKind.Steps, WidgetKind.Start, WidgetKind.Recovery, WidgetKind.Moment) + + /** The widgets that read the nights, those that read Recovery and those that read today's steps. */ + private val NIGHT_KINDS = setOf(WidgetKind.Sleep, WidgetKind.Moment) + private val RECOVERY_KINDS = setOf(WidgetKind.Recovery, WidgetKind.Moment) + private val STEP_KINDS = setOf(WidgetKind.Steps, WidgetKind.Moment) + + /** Nights the Sleep tab reads for the week's other nights, as Today's sleep (`ui.dashboard.SLEEP_NIGHTS`). */ + internal const val SLEEP_NIGHTS = 14 + + /** From the first of the [SLEEP_NIGHTS] nights to the end of the one ending on [today]. */ + internal fun sleepSpan(today: LocalDate, zone: ZoneId) = + DayWindow(DayWindow.nightEndingOn(today.minusDays(SLEEP_NIGHTS - 1L), zone).from, DayWindow.nightEndingOn(today, zone).to) + + /** + * The max heart rate the lists' zones are shares of. The widget shows no zones, so it skips the app's 90-day + * read for it ([org.openstrap.app.data.RecoveryEngine.maxHeartRate]): nothing it shows depends on it. + */ + private const val LIST_MAX_HR = 190 + + /** Our own resting estimate needs most of a day's minutes, as Today's. */ + private const val RESTING_ESTIMATE_MINUTES = 6 * 60 + } +} + +/** Steps and Start light their mood by the usual bedtime, and Moment when Recovery didn't give one. */ +private fun bedtimeNeeded(kinds: Set, rec: RecoveryResult?): Boolean = + WidgetKind.Steps in kinds || WidgetKind.Start in kinds || (WidgetKind.Moment in kinds && rec?.usualBedtime == null) + +/** You started it: not one the strap noticed or filed as "Activity" by itself (WorkoutAnalysis.startedByYou). */ +private fun startedByYou(w: Workout) = w.subtype != Workouts.SUBTYPE_AUTO_DETECTED && w.sportType != Workouts.GENERIC_ACTIVITY + +private fun logUnread(what: String, e: Throwable) = Log.w(WIDGET_TAG, "widgets: $what not read: $e") diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetStatusLine.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetStatusLine.kt index 5bc2c6af..7eb841af 100644 --- a/app/src/main/kotlin/org/openstrap/app/widget/WidgetStatusLine.kt +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetStatusLine.kt @@ -1,6 +1,7 @@ package org.openstrap.app.widget -import org.openstrap.app.ui.dashboard.clockText +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.TimeOfDay import java.time.Instant import java.time.ZoneId import java.time.format.TextStyle @@ -30,14 +31,15 @@ data class StatusLine(val status: StrapStatus, val options: List) { val then = Instant.ofEpochSecond(status.at).atZone(zone).toLocalDate() val days = ChronoUnit.DAYS.between(then, Instant.ofEpochSecond(now).atZone(zone).toLocalDate()) when { - days <= 0 -> listOf(clockText(status.at, zone)) - days == 1L -> listOf("Yesterday") + days <= 0 -> listOf(TimeOfDay.format(status.at, zone, locale = locale)) + days == 1L -> listOf(DayWords.YESTERDAY) days < 7 -> { val day = then.dayOfWeek.getDisplayName(TextStyle.FULL, locale) val short = then.dayOfWeek.getDisplayName(TextStyle.SHORT, locale) listOf("Synced $short", day, short) } - else -> listOf(full.removePrefix("Synced ")) + // "Tue 13 Oct", then "13 Oct" where even that doesn't fit. + else -> listOf(full.removePrefix("Synced "), DayWords.dayMonth(then, locale)) } } } diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetTriggers.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetTriggers.kt new file mode 100644 index 00000000..bebf693d --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetTriggers.kt @@ -0,0 +1,92 @@ +package org.openstrap.app.widget + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.openstrap.app.data.WidgetKind +import org.openstrap.app.data.catching +import org.openstrap.app.data.settings.StrapSnapshot +import org.openstrap.app.device.SyncState +import org.openstrap.app.device.WorkoutState +import org.openstrap.protocol.services.StepTotals + +/** + * Which widgets a workout's state moving from [previous] to [next] refreshes (widgets spec 7). Starting or ending a + * workout changes the moment and the Start widget, and so does any other change of state class: a summary closing can + * follow a discard, which takes a workout out of Start's week and Moment's after a workout. A pause or a resume stops + * or restarts Start's timer. Nothing before the first state is known ([previous] null). + */ +fun workoutKinds(previous: WorkoutState?, next: WorkoutState): Set { + if (previous == null) return emptySet() + val wasPaused = (previous as? WorkoutState.Recording)?.paused + val paused = (next as? WorkoutState.Recording)?.paused + return when { + (previous is WorkoutState.Recording) != (next is WorkoutState.Recording) -> setOf(WidgetKind.Moment, WidgetKind.Start) + previous::class != next::class -> setOf(WidgetKind.Moment, WidgetKind.Start) + paused != null && wasPaused != null && paused != wasPaused -> setOf(WidgetKind.Start) + else -> emptySet() + } +} + +/** + * Everything a widget's refresh follows (widgets spec 7). The widgets watch these instead of being told: settings, + * the strap and the workout never name a widget. [now] is epoch seconds. + */ +internal class WidgetTriggers( + val goals: Flow, + val workoutSides: Flow, + val strapSnapshot: Flow, + val sync: Flow, + val todaySteps: Flow, + val syncEnded: Flow, + val workoutState: Flow, + val now: () -> Long, +) + +/** + * Watches [t] in this scope, calling [changed] with the kinds to refresh and [stepsChanged] for each live step count. + * Each flow's value when watching starts refreshes nothing, so a process start doesn't refresh every widget: the + * settings skip their first value, and the strap and workout start from idle as they do at start. + */ +internal fun CoroutineScope.watchWidgetTriggers(t: WidgetTriggers, changed: (Set) -> Unit, stepsChanged: () -> Unit) { + val all = WidgetKind.entries.toSet() + // Goals: the Steps goal and Moment's evening line. + launch { t.goals.drop(1).distinctUntilChanged().collect { changed(setOf(WidgetKind.Steps, WidgetKind.Moment)) } } + // The Outdoor or Indoor side last chosen per sport, on Start's tiles. + launch { t.workoutSides.drop(1).distinctUntilChanged().collect { changed(setOf(WidgetKind.Start)) } } + // A sync starting while the widgets say the last one is old: their line turns to Syncing (spec 5). + launch { + var previous: SyncState = SyncState.Idle + t.sync.collect { s -> + if (s is SyncState.Running && previous !is SyncState.Running) launch { + val done = catching { t.strapSnapshot.first().lastSyncDone }.getOrNull() + if (syncStale(done, t.now())) changed(all) + } + previous = s + } + } + // When the status line could change: pairing, the low battery edge, charging. + launch { + var before: StrapSnapshot? = null + t.strapSnapshot.collect { now -> + val was = before + before = now + if (was != null && strapStateChanged(now.paired, now.battery, now.charging, was.paired, was.battery, was.charging)) changed(all) + } + } + launch { t.todaySteps.filterNotNull().collect { stepsChanged() } } + // Everything a widget shows may have moved. + launch { t.syncEnded.collect { changed(all) } } + launch { + var previous: WorkoutState = WorkoutState.Idle + t.workoutState.collect { s -> + val kinds = workoutKinds(previous, s) + if (kinds.isNotEmpty()) changed(kinds) + previous = s + } + } +} diff --git a/app/src/main/kotlin/org/openstrap/app/widget/WidgetUpdater.kt b/app/src/main/kotlin/org/openstrap/app/widget/WidgetUpdater.kt new file mode 100644 index 00000000..36d03f40 --- /dev/null +++ b/app/src/main/kotlin/org/openstrap/app/widget/WidgetUpdater.kt @@ -0,0 +1,337 @@ +package org.openstrap.app.widget + +import android.app.AlarmManager +import android.app.Application +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.content.edit +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.updateAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.openstrap.app.BuildConfig +import org.openstrap.app.OpenStrapApp +import org.openstrap.app.data.MomentInputs +import org.openstrap.app.data.WidgetKind +import org.openstrap.app.data.catching +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.device.StrapController +import org.openstrap.app.device.WorkoutSession +import org.openstrap.app.device.launchForBroadcast +import org.openstrap.app.presentation.StrapBands +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** A widget's receiver, which the launcher knows it by, and its Glance widget. */ +class Registration(val receiver: Class, val widget: () -> GlanceAppWidget) + +/** Each widget, once it exists. A kind not registered here yet is never placed. */ +internal val WIDGETS: Map = mapOf( + WidgetKind.Moment to Registration(MomentWidgetReceiver::class.java) { MomentWidget() }, + WidgetKind.Sleep to Registration(SleepWidgetReceiver::class.java) { SleepWidget() }, + WidgetKind.Steps to Registration(StepsWidgetReceiver::class.java) { StepsWidget() }, + WidgetKind.Recovery to Registration(RecoveryWidgetReceiver::class.java) { RecoveryWidget() }, + WidgetKind.Start to Registration(StartWidgetReceiver::class.java) { StartWidget() }, +) + +/** The widgets on the home screen, any page: a kind counts once its receiver has an id (spec 3.1). */ +internal fun placedWidgets(context: Context): Set = runCatching { + val manager = AppWidgetManager.getInstance(context) ?: return emptySet() + WIDGETS.filter { (_, r) -> manager.getAppWidgetIds(ComponentName(context, r.receiver)).isNotEmpty() }.keys +}.getOrElse { e -> + Log.w(WIDGET_TAG, "widgets: placed check failed: $e") + emptySet() +} + +/** + * The one place that refreshes home screen widgets (widgets spec 7). It watches what they show ([start]): the goals, + * the workout sides, the strap's state and syncs, the live steps and the workout. Nobody tells it, and nothing + * refreshes on a timer it doesn't need. Calls return at once and never throw: the work runs on [scope] and failures + * are logged. + * + * Only widgets that are placed are updated, and the moment and midnight alarms are set only while one is. Each refresh + * reads once for every widget it updates ([WidgetSnapshot.gather]), and the widgets' sessions draw from that read. + */ +class WidgetUpdater( + private val app: Application, + private val scope: CoroutineScope, + private val settings: SettingsStore, + private val strap: StrapController, + private val workout: WorkoutSession, + private val snapshot: WidgetSnapshot, +) { + private val steps = UpdateThrottle(STEPS_EVERY_MS) + + /** The last refresh's read, for the sessions it starts or reloads. */ + @Volatile + private var fresh: WidgetData? = null + + /** Starts watching, sets midnight's alarm and publishes the picker's previews. Called once, as the app starts. */ + fun start() { + val triggers = WidgetTriggers( + goals = settings.goals, + workoutSides = settings.workoutSides, + strapSnapshot = settings.strapSnapshot, + sync = strap.sync, + todaySteps = strap.todaySteps, + syncEnded = strap.syncEnded, + workoutState = workout.state, + now = { System.currentTimeMillis() / 1000 }, + ) + scope.watchWidgetTriggers(triggers, ::changed, ::stepsChanged) + // Widgets turn the day over at midnight. Alarms don't survive a reboot or an update, so set it on each start. + scheduleMidnight() + publishPreviews() + } + + /** Refreshes the placed widgets of [kinds]. */ + fun changed(kinds: Set) { + scope.launch { update(kinds) } + } + + /** Refreshes every placed widget: a sync ended, a new day, the strap's state changed. */ + fun changedAll() = changed(WidgetKind.entries.toSet()) + + /** Today's steps moved: the Steps widget follows, at most every [STEPS_EVERY_MS]. */ + fun stepsChanged() { + val wait = steps.request(System.currentTimeMillis()) ?: return + if (wait == 0L) { changed(setOf(WidgetKind.Steps)); return } + scope.launch { + delay(wait) + steps.ran(System.currentTimeMillis()) + update(setOf(WidgetKind.Steps)) + } + } + + /** The widgets on the home screen ([placedWidgets]). */ + fun placed(): Set = placedWidgets(app) + + /** + * What a [kind] widget shows now: the last refresh's read when it was for [kind] and is recent (the sessions it + * starts or reloads draw from it), else a read of [kind] alone. + */ + internal suspend fun data(kind: WidgetKind, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()): WidgetData { + val last = fresh + val recent = last != null && now.toEpochMilli() - last.now.toEpochMilli() in 0..FRESH_MS + if (recent && kind in last.kinds && last.zone == zone) return last + return snapshot.gather(setOf(kind), now, zone) + } + + /** + * Sets the alarm for the moment's next change ([MomentInputs.nextChange]), from the inputs the Moment widget just + * rendered with, so the two can't drift apart, or for [tick] when that comes first: the time something the widget + * shows moves on by itself within the moment ("38 min since your run"). When it fires Moment refreshes, and its + * render schedules the next. Clears the alarm when there's neither or no Moment widget ([momentAlarm]). A tick + * never wakes the phone ([momentWakes]): it's only worth drawing while the screen is on, so it waits for that. + */ + fun scheduleMoment(inputs: MomentInputs, tick: Instant? = null, now: Instant = Instant.now(), zone: ZoneId = ZoneId.systemDefault()) { + runCatching { + val next = inputs.nextChange(now, zone) + val at = momentAlarm(WidgetKind.Moment in placed(), next, tick) + if (at == null) cancel(ACTION_MOMENT) else setAlarm(ACTION_MOMENT, at.toEpochMilli(), wake = momentWakes(next, tick)) + }.onFailure { Log.w(WIDGET_TAG, "widgets: moment alarm not set: $it") } + } + + /** + * A widget was placed or removed (any kind): midnight's alarm follows whether any is placed, Moment's is cleared + * once none is (a placed one sets its own as it renders), and Moment refreshes for the duplicate rule (spec 3.1). + */ + fun placedChanged() { + scheduleMidnight() + runCatching { if (WidgetKind.Moment !in placed()) cancel(ACTION_MOMENT) } + .onFailure { Log.w(WIDGET_TAG, "widgets: moment alarm not cleared: $it") } + changed(setOf(WidgetKind.Moment)) + } + + /** + * Android 15 and later: gives the launcher's picker each widget's generated preview ([GlanceAppWidget.providePreview]), + * the real layout with sample values (spec 6.4), once per app version. The system limits how often this may be + * called, so a limited or failed call is tried again on the next start. Older systems use the provider XML's + * `previewLayout` and `previewImage`. + */ + private fun publishPreviews() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return + val prefs = app.getSharedPreferences(PREVIEW_PREFS, Context.MODE_PRIVATE) + val version = BuildConfig.VERSION_CODE + if (prefs.getInt(PREVIEW_VERSION, -1) == version) return + scope.launch { + catching { + val manager = GlanceAppWidgetManager(app) + val results = WIDGETS.values.map { manager.setWidgetPreviews(it.receiver.kotlin) } + if (results.all { it == GlanceAppWidgetManager.SET_WIDGET_PREVIEWS_RESULT_SUCCESS }) { + prefs.edit { putInt(PREVIEW_VERSION, version) } + } else { + Log.i(WIDGET_TAG, "widgets: previews not all set ($results), trying again next start") + } + }.onFailure { Log.w(WIDGET_TAG, "widgets: previews not set: $it") } + } + } + + /** Sets the alarm for the next local midnight while any widget is placed, and clears it when none is. */ + fun scheduleMidnight(zone: ZoneId = ZoneId.systemDefault()) { + runCatching { + if (placed().isEmpty()) { cancel(ACTION_MIDNIGHT); return } + val midnight = LocalDate.now(zone).plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + setAlarm(ACTION_MIDNIGHT, midnight) + }.onFailure { Log.w(WIDGET_TAG, "widgets: midnight alarm not set: $it") } + } + + /** [kind]'s receiver and widget, null before it exists: for the debug widget gallery. */ + internal fun registration(kind: WidgetKind): Registration? = WIDGETS[kind] + + internal suspend fun update(kinds: Set) { + val shown = placed().intersect(kinds) + if (shown.isEmpty()) return + // One read for every widget this refresh updates. A session that can't use it reads for itself. + catching { fresh = snapshot.gather(shown, Instant.now(), ZoneId.systemDefault()) } + .onFailure { Log.w(WIDGET_TAG, "widgets: $shown not read: $it") } + for (kind in shown) { + catching { + // A running session loads again on its version, one that isn't loads as it starts. + WidgetRefresh.bump(kind) + WIDGETS.getValue(kind).widget().updateAll(app) + }.onFailure { Log.w(WIDGET_TAG, "widgets: $kind not updated: $it") } + } + scheduleMidnight() + scheduleStale() + } + + /** + * Sets the alarm for the moment the last sync turns stale ([staleAt]), so the status line says so with the strap + * away and nothing else refreshing (spec 5), while any widget is placed, and clears it otherwise. + */ + private suspend fun scheduleStale() { + catching { + val done = settings.strapSnapshot.first().lastSyncDone + val at = staleAt(placed().isNotEmpty(), done, System.currentTimeMillis() / 1000) + if (at == null) cancel(ACTION_STALE) else setAlarm(ACTION_STALE, at * 1000) + }.onFailure { Log.w(WIDGET_TAG, "widgets: stale alarm not set: $it") } + } + + /** + * Sets [action]'s alarm for [atMs]. One that doesn't [wake] is held until the phone is awake for something else. + * Exact while the app may set exact alarms ("Alarms and reminders" allows it), otherwise inside [ALARM_WINDOW_MS]. + * Never `USE_EXACT_ALARM`: that's for clocks and calendars (spec 7). + */ + private fun setAlarm(action: String, atMs: Long, wake: Boolean = true) { + val alarms = app.getSystemService(AlarmManager::class.java) ?: return + val pending = pendingIntent(action) + if (!wake) { + alarms.setWindow(AlarmManager.RTC, atMs, ALARM_WINDOW_MS, pending) + return + } + val exact = alarms.canScheduleExactAlarms() + try { + if (exact) alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, atMs, pending) + else alarms.setWindow(AlarmManager.RTC_WAKEUP, atMs, ALARM_WINDOW_MS, pending) + } catch (e: SecurityException) { + // The exact-alarm access was taken back between the check and the call. + Log.i(WIDGET_TAG, "widgets: exact alarm refused, setting a window: $e") + alarms.setWindow(AlarmManager.RTC_WAKEUP, atMs, ALARM_WINDOW_MS, pending) + } + } + + private fun cancel(action: String) { + app.getSystemService(AlarmManager::class.java)?.cancel(pendingIntent(action)) + } + + private fun pendingIntent(action: String): PendingIntent = PendingIntent.getBroadcast( + app, when (action) { ACTION_MOMENT -> 1; ACTION_MIDNIGHT -> 2; else -> 3 }, + Intent(app, WidgetAlarmReceiver::class.java).setAction(action), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + companion object { + /** Steps change by the second while the strap is connected, and the widget follows at most this often. */ + const val STEPS_EVERY_MS = 15 * 60_000L + + /** Without exact alarms a moment may change this late on a widget (spec 7: a few minutes late is fine). */ + const val ALARM_WINDOW_MS = 5 * 60_000L + + /** How long a refresh's read serves the sessions it starts: they render one after another, a few seconds in all. */ + private const val FRESH_MS = 60_000L + + private const val PREVIEW_PREFS = "widgets" + private const val PREVIEW_VERSION = "previews_version" + + internal const val ACTION_MOMENT = "org.openstrap.app.widget.MOMENT" + internal const val ACTION_MIDNIGHT = "org.openstrap.app.widget.MIDNIGHT" + internal const val ACTION_STALE = "org.openstrap.app.widget.STALE" + + /** + * When Moment's alarm should next fire: the earlier of the moment's [nextChange] and the shown content's [tick], + * only while a Moment widget is [placed]. Null clears it. + */ + fun momentAlarm(placed: Boolean, nextChange: Instant?, tick: Instant?): Instant? = + if (!placed) null else listOfNotNull(nextChange, tick).minOrNull() + + /** Whether Moment's alarm wakes the phone: only for the moment's [nextChange], not for a [tick] that comes first. */ + fun momentWakes(nextChange: Instant?, tick: Instant?): Boolean = + tick == null || (nextChange != null && nextChange <= tick) + + /** + * When the last sync, done at [lastSyncDone] (epoch seconds), turns stale: a second past [StrapBands.STALE_AFTER_S], + * while widgets are [placed] and that's still ahead of [now]. Null for no alarm. + */ + fun staleAt(placed: Boolean, lastSyncDone: Long?, now: Long): Long? = + lastSyncDone?.let { it + StrapBands.STALE_AFTER_S + 1 }?.takeIf { placed && it > now } + } +} + +/** The app's [WidgetUpdater], from a receiver, a widget or an action the system started. */ +internal val Context.widgets: WidgetUpdater get() = (applicationContext as OpenStrapApp).container.widgets + +/** Where [WidgetUpdater]'s alarms land: the moment changed, or a new day began. Not a widget, and not exported. */ +class WidgetAlarmReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val kinds = when (intent.action) { + WidgetUpdater.ACTION_MOMENT -> setOf(WidgetKind.Moment) + WidgetUpdater.ACTION_MIDNIGHT, WidgetUpdater.ACTION_STALE -> WidgetKind.entries.toSet() + else -> return + } + val app = context.applicationContext as OpenStrapApp + val done = goAsync() + launchForBroadcast(app.container.appScope, "widgets: alarm ${intent.action}", done::finish) { + app.container.widgets.update(kinds) + // The update sets the next midnight only when something was placed, and this one has passed either way. + if (intent.action == WidgetUpdater.ACTION_MIDNIGHT) app.container.widgets.scheduleMidnight() + } + } +} + +/** + * At most one update every [everyMs], and none lost: a request inside the gap waits for its end, and requests while + * one waits join it. + */ +class UpdateThrottle(private val everyMs: Long) { + private var last: Long? = null + private var waiting = false + + /** A request at [now] (ms): 0 to update now, the ms to wait before updating, or null when one already waits. */ + @Synchronized + fun request(now: Long): Long? { + if (waiting) return null + val l = last + if (l == null || now - l >= everyMs) { last = now; return 0 } + waiting = true + return l + everyMs - now + } + + /** The waiting update ran at [now] (ms). */ + @Synchronized + fun ran(now: Long) { + last = now + waiting = false + } +} diff --git a/app/src/main/res/font/museo_moderno.ttf b/app/src/main/res/font/museo_moderno.ttf deleted file mode 100644 index e7278936..00000000 Binary files a/app/src/main/res/font/museo_moderno.ttf and /dev/null differ diff --git a/app/src/main/res/xml/share_paths.xml b/app/src/main/res/xml/share_paths.xml new file mode 100644 index 00000000..a586bc98 --- /dev/null +++ b/app/src/main/res/xml/share_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/xml/widget_moment.xml b/app/src/main/res/xml/widget_moment.xml index bfa180e9..d752707b 100644 --- a/app/src/main/res/xml/widget_moment.xml +++ b/app/src/main/res/xml/widget_moment.xml @@ -2,8 +2,8 @@ .` names in code, since many files use those without an import. Comments, KDoc and + * string literals are blanked out first, so only code counts (`LayersTest` stays as the stricter check that also + * keeps KDoc links in data, device, update and presentation clean). + * + * It fails on an edge outside [Architecture.ALLOWED], on an `androidx.compose` name in [Architecture.NO_COMPOSE], + * on `AppContainer` named below the root, and on any cycle between top-level packages. `ui.root` counts as the root + * for the names in the root package (`AppContainer`, `NavRequest`, `PendingAction` and the rest): it is the root's + * composition split out of MainActivity, and `ViewModelFactories` takes the container by design (Decisions 2). For + * every other edge it is plain `ui`. Cycles are counted between top-level packages, so `ui.glow` and `ui.workout` + * are one node and the `ui` sub-package cycles are out of scope, as Decisions 6 says. + * + * `src/debug` and `src/release` are not scanned. They hold the debug intents, the demo data and the galleries, which + * may reach anything the root may, so no rule here could fail on them. + */ +class ArchitectureTest { + @Test + fun mainSourcesKeepTheLayers() { + val files = SourceTree.kotlin.walkTopDown().filter { it.isFile && it.extension == "kt" } + .map { Source(it.relativeTo(SourceTree.kotlin).invariantSeparatorsPath, it.readText()) } + .toList() + assertTrue("found only ${files.size} sources under ${SourceTree.kotlin}", files.size > 100) + val report = Architecture.check(files, Architecture.EXCEPTIONS) + println(report.skippedText()) + if (report.failures.isNotEmpty()) fail(report.failureText()) + } + + @Test + fun checkerCatchesAPlantedImport() { + val report = Architecture.check( + listOf( + source("data/Plant.kt", "package org.openstrap.app.data", "", "import org.openstrap.app.ui.dashboard.TabCopy"), + source("ui/dashboard/TabCopy.kt", "package org.openstrap.app.ui.dashboard", "", "object TabCopy"), + ), + emptyList(), + ) + assertEquals(listOf("data/Plant.kt:3 edge"), report.failures.map { "${it.where} ${it.rule}" }) + assertEquals("org.openstrap.app.ui.dashboard.TabCopy", report.failures.single().target) + } + + @Test + fun checkerCatchesAPlantedQualifiedNameInCodeOnly() { + val report = Architecture.check( + listOf( + source( + "device/Plant.kt", + "package org.openstrap.app.device", + "", + "/** Calls [org.openstrap.app.widget.WidgetUpdater] after a sync. */", + "fun plant() = org.openstrap.app.widget.WidgetUpdater.changed()", + "// org.openstrap.app.widget.WidgetUpdater in a comment", + "val name = \"org.openstrap.app.widget.WidgetUpdater\"", + "val line = \"now \${org.openstrap.app.widget.WidgetUpdater.count} widgets\"", + "/* outer /* nested org.openstrap.app.widget.WidgetUpdater */ still org.openstrap.app.widget.X */", + "val c = '\"'", + ), + source("widget/WidgetUpdater.kt", "package org.openstrap.app.widget", "", "object WidgetUpdater"), + ), + emptyList(), + ) + assertEquals(listOf("device/Plant.kt:4", "device/Plant.kt:7"), report.failures.map { it.where }) + assertTrue(report.failures.all { it.rule == "edge" && it.target.startsWith("org.openstrap.app.widget.WidgetUpdater") }) + } + + @Test + fun checkerKnowsTheOtherRules() { + val known = KnownBreak("export/", listOf("org.openstrap.app.data.Gone"), "a planted break", "never") + val report = Architecture.check( + listOf( + source("widget/W.kt", "package org.openstrap.app.widget", "", "import org.openstrap.app.AppContainer"), + source("ui/root/F.kt", "package org.openstrap.app.ui.root", "", "import org.openstrap.app.AppContainer"), + source("device/D.kt", "package org.openstrap.app.device", "", "import androidx.compose.ui.graphics.Color"), + source("data/A.kt", "package org.openstrap.app.data", "", "fun a() = org.openstrap.app.export.B.b()"), + source("export/B.kt", "package org.openstrap.app.export", "", "import org.openstrap.app.data.a"), + source("ui/glow/G.kt", "package org.openstrap.app.ui.glow", "", "import org.openstrap.app.ui.workout.K"), + source("ui/workout/K.kt", "package org.openstrap.app.ui.workout", "", "import org.openstrap.app.ui.glow.G"), + source("OpenStrapApp.kt", "package org.openstrap.app", "", "class AppContainer"), + ), + listOf(known), + ) + val rules = report.failures.map { "${it.where} ${it.rule}" } + assertEquals(listOf("data/A.kt:3 edge", "device/D.kt:3 compose", "widget/W.kt:3 container"), rules) + assertEquals(listOf(listOf("data", "export")), report.cycles.map { it.nodes }) + assertEquals(listOf(known), report.unused) + } + + private fun source(path: String, vararg lines: String) = Source(path, lines.joinToString("\n")) +} + +/** One Kotlin file, its [path] relative to the app's package folder. */ +internal data class Source(val path: String, val text: String) + +/** + * A known break of the rules that the check lets through and prints, kept until [removedBy]. It covers a finding in + * a file whose path is [from] (or starts with it, when [from] ends with `/`) naming one of [to] or a member of it. + */ +internal data class KnownBreak(val from: String, val to: List, val reason: String, val removedBy: String) { + fun covers(f: Finding): Boolean = + (f.path == from || from.endsWith("/") && f.path.startsWith(from)) && + to.any { f.target == it || f.target.startsWith("$it.") } + + override fun toString() = "$from to ${to.joinToString()}: $reason ($removedBy)" +} + +/** A broken rule: [rule] is `edge`, `compose` or `container`. */ +internal data class Finding(val path: String, val line: Int, val rule: String, val target: String, val detail: String) { + val where get() = "$path:$line" +} + +/** A strongly connected group of top-level packages, with one place for each edge inside it. */ +internal data class Cycle(val nodes: List, val edges: List) + +internal data class Report( + val failures: List, + val cycles: List, + val unused: List, + val skipped: Map>, + val heldOpen: List, +) { + fun failureText(): String = buildString { + if (failures.isNotEmpty()) appendLine("Broken layer rules:") + failures.forEach { appendLine(" ${it.where} ${it.rule}: ${it.detail}") } + if (cycles.isNotEmpty()) appendLine("Cycles between top-level packages:") + cycles.forEach(::appendCycle) + if (unused.isNotEmpty()) appendLine("Exceptions that no longer match anything (delete them):") + unused.forEach { appendLine(" $it") } + } + + fun skippedText(): String = buildString { + appendLine("ArchitectureTest skipped these known breaks:") + skipped.forEach { (k, fs) -> + appendLine(" $k") + fs.forEach { appendLine(" ${it.where} ${it.target}") } + } + if (heldOpen.isNotEmpty()) appendLine("Cycles that only those known breaks keep open:") + heldOpen.forEach(::appendCycle) + } +} + +private fun StringBuilder.appendCycle(c: Cycle) { + appendLine(" ${c.nodes.joinToString(" and ")}") + c.edges.forEach { appendLine(" $it") } +} + +internal object Architecture { + private const val APP = "org.openstrap.app" + private const val ROOT = "root" + + /** What each top-level package may name in `org.openstrap.app`. `root` means only [ROOT_NAMES]. */ + val ALLOWED: Map> = mapOf( + "ble" to setOf(), + "firmware" to setOf(), + "zepp" to setOf("firmware", "data"), + "data" to setOf("ble", "firmware", "zepp", "data"), + "presentation" to setOf("data"), + "device" to setOf("ble", "firmware", "zepp", "data", "presentation", ROOT), + "health" to setOf("ble", "firmware", "zepp", "data", "presentation", "device", ROOT), + "export" to setOf("ble", "firmware", "zepp", "data", "presentation", "device", ROOT), + "update" to setOf("ble", "firmware", "zepp", "data", "presentation", "device", ROOT), + "widget" to setOf( + "ble", "firmware", "zepp", "data", "presentation", "device", "health", "export", "update", ROOT, + "ui.glow.draw", "ui.theme", + ), + "ui" to setOf("ble", "firmware", "zepp", "data", "presentation", "device", "health", "export", "update", ROOT, "ui"), + ) + + /** Packages with no Compose at all, in imports or in code. */ + val NO_COMPOSE = setOf("ble", "firmware", "zepp", "data", "presentation", "device", "health", "export", "update") + + /** The entry points a lower layer may name: to reach the container's owner, build an intent, read the build. */ + val ROOT_NAMES = setOf("OpenStrapApp", "MainActivity", "BuildConfig", "R") + + private val dashboardCopy = listOf( + "TabCopy", "TodayCopy", "ActivityCopy", "momentMood", "todayTitle", "sportNoun", "usualSport", "wakingLead", + "WakingLead", "AFTER_FIRST_NIGHT", "Metric", + ).map { "$APP.ui.dashboard.$it" } + + /** Breaks the check lets through for now, each with the wave that removes it. One that matches nothing fails. */ + val EXCEPTIONS: List = listOf( + KnownBreak( + "widget/", listOf("$APP.ui.components.ChartPoint", "$APP.ui.components.StageSpan"), + "chart models stay in ui.components until ChartModels.kt moves to presentation", "wave 5", + ), + KnownBreak( + "widget/", dashboardCopy + listOf("$APP.ui.workout.SportIcons", "$APP.ui.workout.WorkoutCopy"), + "screen copy and icons 4C could not move while the screens were in flight", "wave 5", + ), + KnownBreak( + "widget/SleepWidgetModel.kt", listOf("$APP.ui.dashboard.SLEEP_NAMES"), + "found by 4E, SleepWidgetModel.kt:9 imports the sleep stage names from ui.dashboard", "wave 5", + ), + KnownBreak( + "device/StrapService.kt", listOf("$APP.AppContainer"), + "found by 4E, StrapService.kt:293 loadFigures and :396 watchEnded take the container by full name", + "wave 5, StrapService split", + ), + KnownBreak( + "data/settings/SettingsStore.kt", listOf("$APP.export.BackupFiles", "$APP.export.SettingsBackup"), + "found by 4E, SettingsStore.kt:93, 634, 661, 711 and 721 name BackupFiles.Failure and SettingsBackup " + + "in code, a data and export cycle", + "wave 5", + ), + KnownBreak( + "presentation/UpdateCopy.kt", + listOf("Install", "InstallFailure", "Release", "Releases", "UpdateState").map { "$APP.update.$it" }, + "found by 4E, UpdateCopy.kt:3 to 7 import the update state types, a presentation and update cycle", + "wave 5", + ), + ) + + private val appName = Regex("""(?, exceptions: List): Report { + val packages = files.associateWith { packageLine.find(it.text)?.groupValues?.get(1) } + val known = packages.values.filterNotNull().toSet() + APP + val refs = mutableListOf() + val findings = mutableListOf>() + for ((src, pkg) in packages) { + if (pkg == null) { + findings += null to Finding(src.path, 1, "edge", "", "no package line") + continue + } + val from = short(pkg) + blankCommentsAndStrings(src.text).lines().forEachIndexed { i, line -> + if (line.trimStart().startsWith("package ")) return@forEachIndexed + appName.findAll(line).forEach { m -> + val segs = m.value.split('.') + val pk = (segs.size downTo 3).map { segs.take(it).joinToString(".") }.first { it in known } + val to = short(pk) + if (pk != pkg) refs += Ref(src, i + 1, from, to, segs.getOrElse(pk.count { it == '.' } + 1) { "*" }, m.value) + } + if (top(from) in NO_COMPOSE) composeName.findAll(line).forEach { m -> + findings += null to Finding(src.path, i + 1, "compose", m.value, "${m.value} in $from") + } + } + } + refs.forEach { r -> rule(r)?.let { findings += r to it } } + + val excused = mutableSetOf() + val skipped = linkedMapOf>() + val failures = mutableListOf() + for ((ref, f) in findings) { + val k = exceptions.firstOrNull { it.covers(f) } + if (k == null) failures += f else { + skipped.getOrPut(k) { mutableListOf() } += f + ref?.let(excused::add) + } + } + val strict = cycles(refs.filter { it !in excused }) + val all = cycles(refs) + return Report( + failures.sortedWith(compareBy({ it.path }, { it.line })), + strict, + exceptions.filter { it !in skipped }, + skipped.mapValues { (_, fs) -> fs.sortedWith(compareBy({ it.path }, { it.line })) }, + all.filter { c -> strict.none { it.nodes.containsAll(c.nodes) } }, + ) + } + + private fun rule(r: Ref): Finding? { + if (r.from == ROOT) return null + if (r.to == ROOT) return when { + wiresRoot(r.from) -> null + r.name == "AppContainer" -> Finding(r.src.path, r.line, "container", r.target, "AppContainer named in ${r.from}") + r.name in ROOT_NAMES && ROOT in ALLOWED[top(r.from)].orEmpty() -> null + else -> Finding(r.src.path, r.line, "edge", r.target, "${r.from} names root ${r.name}") + } + val allowed = ALLOWED[top(r.from)] + ?: return Finding(r.src.path, r.line, "edge", r.target, "${top(r.from)} has no row in ALLOWED") + val ok = top(r.to) == top(r.from) || allowed.any { r.to == it || r.to.startsWith("$it.") } + return if (ok) null else Finding(r.src.path, r.line, "edge", r.target, "${r.from} to ${r.to} (${r.target})") + } + + /** Groups of top-level packages that reach each other, the root left out since it may name anything. */ + private fun cycles(refs: List): List { + val edges = linkedMapOf, Ref>() + refs.filter { top(it.from) != ROOT && top(it.to) != ROOT && top(it.from) != top(it.to) } + .sortedWith(compareBy({ it.src.path }, { it.line })) + .forEach { edges.putIfAbsent(top(it.from) to top(it.to), it) } + val next = edges.keys.groupBy({ it.first }, { it.second }) + fun reach(a: String): Set { + val seen = mutableSetOf() + val todo = ArrayDeque(next[a].orEmpty()) + while (todo.isNotEmpty()) todo.removeFirst().let { if (seen.add(it)) todo += next[it].orEmpty() } + return seen + } + val reaches = next.keys.associateWith(::reach) + return reaches.keys.mapNotNull { a -> + reaches.keys.filter { b -> b in reaches[a].orEmpty() && a in reaches[b].orEmpty() }.sorted().takeIf { it.size > 1 } + }.distinct().sortedBy { it.first() }.map { group -> + Cycle( + group, + edges.filterKeys { (a, b) -> a in group && b in group } + .map { (e, r) -> "${e.first} to ${e.second} at ${r.src.path}:${r.line} (${r.target})" }, + ) + } + } +} + +/** + * [src] with comments, KDoc and string literal text replaced by spaces, newlines kept so line numbers hold. Code + * inside a string template stays, nested block comments are followed to their end. + */ +internal fun blankCommentsAndStrings(src: String): String = Blanker(src).run() + +private class Blanker(private val s: String) { + private val out = StringBuilder(s.length) + private var i = 0 + + fun run(): String { + code(nested = false) + return out.toString() + } + + private fun keep() { + out.append(s[i]) + i++ + } + + private fun blank(n: Int = 1) = repeat(n) { + if (i < s.length) { + out.append(if (s[i] == '\n') '\n' else ' ') + i++ + } + } + + private fun code(nested: Boolean) { + var depth = 0 + while (i < s.length) { + when { + s.startsWith("//", i) -> while (i < s.length && s[i] != '\n') blank() + s.startsWith("/*", i) -> comment() + s.startsWith("\"\"\"", i) -> blank(3).also { string(raw = true) } + s[i] == '"' -> blank().also { string(raw = false) } + s[i] == '\'' -> char() + s[i] == '{' -> depth++.also { keep() } + s[i] == '}' && nested && depth == 0 -> return blank() + s[i] == '}' -> depth--.also { keep() } + else -> keep() + } + } + } + + private fun comment() { + var depth = 0 + do { + when { + s.startsWith("/*", i) -> depth++.also { blank(2) } + s.startsWith("*/", i) -> depth--.also { blank(2) } + else -> blank() + } + } while (depth > 0 && i < s.length) + } + + private fun string(raw: Boolean) { + while (i < s.length) { + when { + raw && s.startsWith("\"\"\"", i) -> { + while (s.startsWith("\"\"\"\"", i)) blank() + return blank(3) + } + !raw && s[i] == '"' -> return blank() + !raw && s[i] == '\\' -> blank(2) + s.startsWith("\${", i) -> blank(2).also { code(nested = true) } + else -> blank() + } + } + } + + private fun char() { + blank() + while (i < s.length && s[i] != '\'') { + if (s[i] == '\\') blank() + blank() + } + blank() + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/PendingActionTest.kt b/app/src/test/kotlin/org/openstrap/app/PendingActionTest.kt new file mode 100644 index 00000000..b7783820 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/PendingActionTest.kt @@ -0,0 +1,74 @@ +package org.openstrap.app + +import android.os.Bundle +import android.os.Parcel +import androidx.compose.runtime.saveable.SaveableStateRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.SportSettings +import org.openstrap.app.data.Strength +import org.openstrap.app.data.WorkoutPlan +import org.openstrap.app.data.WorkoutTarget +import org.robolectric.RobolectricTestRunner +import java.io.Serializable + +/** + * A pending permission action goes through what an activity recreate does to `rememberSaveable`: the registry saves + * it, the values go into the saved instance state bundle, which is parcelled (as when the process dies behind + * Settings), and a new registry restores it for the new composition (audit E2). + */ +@RunWith(RobolectricTestRunner::class) +class PendingActionTest { + private fun recreated(action: PendingAction?): PendingAction? { + val before = SaveableStateRegistry(null) { it is Serializable } + val saver = PendingAction.Saver + before.registerProvider(KEY) { with(saver) { androidx.compose.runtime.saveable.SaverScope { true }.save(action) } } + val bundle = Bundle().apply { + before.performSave().forEach { (key, values) -> putSerializable(key, ArrayList(values)) } + } + val parcel = Parcel.obtain() + val restoredBundle = try { + parcel.writeBundle(bundle) + parcel.setDataPosition(0) + parcel.readBundle(javaClass.classLoader)!! + } finally { + parcel.recycle() + } + @Suppress("DEPRECATION") + val values = restoredBundle.keySet().associateWith { restoredBundle.getSerializable(it) as List } + val after = SaveableStateRegistry(values) { it is Serializable } + return after.consumeRestored(KEY)?.let { saver.restore(it) } + } + + @Test fun `a workout waiting on a permission starts the same after a recreate`() { + val plan = WorkoutPlan( + sportType = 1, askToEnd = false, target = WorkoutTarget.Distance(5000), useGps = true, keepRoute = true, + rest = Strength.Rest(90, 110), setsTarget = 4, logSets = true, structure = "t:30/30x8:w", + ) + val saved = SportSettings(goal = "d:5000", askToEnd = false, keepRoute = true, structure = "t:30/30x8:w") + assertEquals(PendingAction.StartPlan(plan, saved), recreated(PendingAction.StartPlan(plan, saved))) + } + + @Test fun `a plain plan with nothing set and nothing to save comes back as it was`() { + val plan = WorkoutPlan(sportType = 24) + assertEquals(PendingAction.StartPlan(plan, null), recreated(PendingAction.StartPlan(plan, null))) + } + + @Test fun `an install waiting on Install unknown apps keeps its release and whether Settings answered`() { + assertEquals(PendingAction.Install("v3.0.0"), recreated(PendingAction.Install("v3.0.0"))) + assertEquals(PendingAction.Install("v3.0.0", answered = true), recreated(PendingAction.Install("v3.0.0", answered = true))) + } + + @Test fun `nothing pending stays nothing, and unreadable state restores as nothing`() { + assertNull(recreated(null)) + assertNull(PendingAction.Saver.restore(listOf("plan"))) + assertNull(PendingAction.Saver.restore(listOf("something else", 1))) + assertNull(PendingAction.Saver.restore("not a list")) + } + + private companion object { + const val KEY = "pending" + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/PhoneClockTest.kt b/app/src/test/kotlin/org/openstrap/app/PhoneClockTest.kt new file mode 100644 index 00000000..20a28450 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/PhoneClockTest.kt @@ -0,0 +1,69 @@ +package org.openstrap.app + +import android.content.Context +import android.content.Intent +import android.os.Looper +import android.provider.Settings +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.presentation.ClockSetting +import org.openstrap.app.presentation.TimeOfDay +import org.openstrap.app.testing.SourceTree +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.File +import java.time.LocalTime +import java.util.Locale + +/** + * The app takes the phone's 12 or 24 hour clock when it starts and follows it after. The whole app can't start under + * Robolectric (WorkManager needs resources the unit tests don't have), so the start's first step, [PhoneClock.follow], + * runs here on a phone set to 12 hours, and the app's start is checked to take it before anything else. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class PhoneClockTest { + private val app: Context get() = ApplicationProvider.getApplicationContext() + + @After fun reset() { + ClockSetting.use24Hour = true + } + + @Test fun `a 12 hour phone's clock is set when the app starts`() { + Settings.System.putString(app.contentResolver, Settings.System.TIME_12_24, "12") + ClockSetting.use24Hour = true + PhoneClock.follow(app) + assertFalse(ClockSetting.use24Hour) + assertEquals("10:30 PM", TimeOfDay.format(LocalTime.of(22, 30), locale = Locale.US)) + } + + @Test fun `the app's start takes the clock before anything writes a time`() { + val onCreate = File(SourceTree.kotlin, "OpenStrapApp.kt").readText().substringAfter("override fun onCreate()") + val follow = onCreate.indexOf("PhoneClock.follow(this)") + assertTrue("onCreate follows the phone's clock", follow >= 0) + assertTrue("before the container starts", follow < onCreate.indexOf("AppContainer(this)")) + } + + @Test fun `the clock follows the phone's setting when it changes`() { + Settings.System.putString(app.contentResolver, Settings.System.TIME_12_24, "12") + PhoneClock.follow(app) + set("24") + assertTrue(ClockSetting.use24Hour) + assertEquals("22:30", TimeOfDay.format(LocalTime.of(22, 30), locale = Locale.US)) + set("12") + assertFalse(ClockSetting.use24Hour) + } + + /** Android's Settings writes the setting, then tells running apps with a time changed broadcast. */ + private fun set(hours: String) { + Settings.System.putString(app.contentResolver, Settings.System.TIME_12_24, hours) + app.sendBroadcast(Intent(Intent.ACTION_TIME_CHANGED).setPackage(app.packageName)) + shadowOf(Looper.getMainLooper()).idle() + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ble/GattClientTest.kt b/app/src/test/kotlin/org/openstrap/app/ble/GattClientTest.kt new file mode 100644 index 00000000..eff27d56 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ble/GattClientTest.kt @@ -0,0 +1,166 @@ +package org.openstrap.app.ble + +import android.Manifest +import android.app.Application +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** GattClient against Robolectric's GATT shadow (audit C1, C8). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class GattClientTest { + private val context = ApplicationProvider.getApplicationContext() + private val adapter = context.getSystemService(BluetoothManager::class.java).adapter + + @Before + fun setUp() { + shadowOf(context).grantPermissions(Manifest.permission.BLUETOOTH_CONNECT) + } + + private fun gattOf(address: String): BluetoothGatt = shadowOf(adapter.getRemoteDevice(address)).bluetoothGatts.last() + + @Test + fun aTimeoutIsAPlainException() = runTest { + val client = GattClient(context, "AA:BB:CC:00:01:01") + val e = runCatching { client.connect() }.exceptionOrNull() + assertTrue("got $e", e is GattTimeoutException) + assertFalse("got $e", e is CancellationException) + } + + @Test + fun aCallbackForAnotherOpIsNotItsAnswer() = runTest { + val address = "AA:BB:CC:00:01:02" + val client = GattClient(context, address) + val connecting = async { client.connect() } + runCurrent() + val gatt = gattOf(address) + val callback = shadowOf(gatt).gattCallback + val stray = BluetoothGattCharacteristic(StrapUuids.BATTERY, BluetoothGattCharacteristic.PROPERTY_READ, 0) + // A late write or read callback, or an MTU exchange the strap started, arrives while connect waits. + callback.onCharacteristicWrite(gatt, stray, BluetoothGatt.GATT_SUCCESS) + callback.onCharacteristicRead(gatt, stray, byteArrayOf(1), BluetoothGatt.GATT_SUCCESS) + callback.onMtuChanged(gatt, 247, BluetoothGatt.GATT_SUCCESS) + runCurrent() + assertTrue(connecting.isActive) + + shadowOf(gatt).addDiscoverableService(BluetoothGattService(StrapUuids.MAIN_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY)) + callback.onConnectionStateChange(gatt, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED) + connecting.await() + assertTrue(client.connected.value) + } + + @Test + fun anOpAfterCloseIsAGattException() = runTest { + val address = "AA:BB:CC:00:01:03" + val client = GattClient(context, address) + val connecting = async { client.connect() } + runCurrent() + val gatt = gattOf(address) + shadowOf(gatt).addDiscoverableService(BluetoothGattService(StrapUuids.MAIN_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY)) + shadowOf(gatt).gattCallback.onConnectionStateChange(gatt, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED) + connecting.await() + + client.close() + val e = runCatching { client.requestMtu(247) }.exceptionOrNull() + assertTrue("got $e", e is GattException) + } + + @Test + fun aDroppedNotificationIsLogged() = runTest { + val address = "AA:BB:CC:00:01:04" + val logged = mutableListOf() + val client = GattClient(context, address) { logged += it } + val connecting = async { client.connect() } + runCurrent() + val gatt = gattOf(address) + val callback = shadowOf(gatt).gattCallback + shadowOf(gatt).addDiscoverableService(BluetoothGattService(StrapUuids.MAIN_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY)) + callback.onConnectionStateChange(gatt, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED) + connecting.await() + + // A collector stuck on its first chunk: the buffer behind it fills and later chunks are dropped. + val stuck = launch { client.notifications.collect { awaitCancellation() } } + runCurrent() + val c = BluetoothGattCharacteristic(StrapUuids.CHANNEL_DATA, BluetoothGattCharacteristic.PROPERTY_NOTIFY, 0) + repeat(BUFFER + 2) { callback.onCharacteristicChanged(gatt, c, byteArrayOf(it.toByte())) } + assertEquals(logged.toString(), 1, logged.count { it.contains("dropped 1 so far") }) + stuck.cancel() + } + + private suspend fun kotlinx.coroutines.test.TestScope.connected(address: String, service: BluetoothGattService): Pair { + val client = GattClient(context, address) + val connecting = async { client.connect() } + runCurrent() + val gatt = gattOf(address) + shadowOf(gatt).addDiscoverableService(service) + shadowOf(gatt).gattCallback.onConnectionStateChange(gatt, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED) + connecting.await() + return client to gatt + } + + @Test + @Config(sdk = [32]) + fun beforeAndroid13ANotificationComesThroughTheOldCallback() = runTest { + val (client, gatt) = connected("AA:BB:CC:00:01:05", BluetoothGattService(StrapUuids.MAIN_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY)) + val first = async { client.notifications.first() } + runCurrent() + val c = BluetoothGattCharacteristic(StrapUuids.CHANNEL_DATA, BluetoothGattCharacteristic.PROPERTY_NOTIFY, 0) + @Suppress("DEPRECATION") + c.value = byteArrayOf(7, 8) + @Suppress("DEPRECATION") + shadowOf(gatt).gattCallback.onCharacteristicChanged(gatt, c) + // The next notification overwrites the characteristic's value, so the chunk must be a copy. + @Suppress("DEPRECATION") + c.value = byteArrayOf(9) + val n = first.await() + assertEquals(StrapUuids.CHANNEL_DATA, n.characteristic) + assertEquals(listOf(7, 8), n.value.toList()) + } + + @Test + @Config(sdk = [32]) + fun beforeAndroid13AReadAnswersThroughTheOldCallback() = runTest { + val service = BluetoothGattService(StrapUuids.MAIN_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY) + val c = BluetoothGattCharacteristic(StrapUuids.BATTERY, BluetoothGattCharacteristic.PROPERTY_READ, 0) + service.addCharacteristic(c) + val (client, gatt) = connected("AA:BB:CC:00:01:06", service) + val reading = async { client.read(StrapUuids.MAIN_SERVICE, StrapUuids.BATTERY) } + runCurrent() + @Suppress("DEPRECATION") + c.value = byteArrayOf(42) + @Suppress("DEPRECATION") + shadowOf(gatt).gattCallback.onCharacteristicRead(gatt, c, BluetoothGatt.GATT_SUCCESS) + assertEquals(listOf(42), reading.await().toList()) + } + + @Test + fun aBadAddressIsUnusable() = runTest { + val e = runCatching { GattClient(context, "not an address").connect() }.exceptionOrNull() + assertTrue("got $e", e is GattUnusableException) + } + + private companion object { + /** GattClient's notification buffer, plus the one chunk the stuck collector holds. */ + const val BUFFER = 512 + 1 + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/ActivityTest.kt b/app/src/test/kotlin/org/openstrap/app/data/ActivityTest.kt index bcee8b7b..379e8b05 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/ActivityTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/ActivityTest.kt @@ -1,9 +1,7 @@ package org.openstrap.app.data import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Test import org.openstrap.app.data.Activity.Minute @@ -48,13 +46,6 @@ class ActivityTest { assertEquals(0, Activity.mvpa(minutes(99, 99, 99)).moderateMinutes) } - @Test fun `step divergence over 5 percent is flagged`() { - assertFalse(Activity.stepsDiverge(10_000, 9_600)) - assertTrue(Activity.stepsDiverge(10_000, 9_400)) - assertFalse(Activity.stepsDiverge(null, 9_000)) - assertFalse(Activity.stepsDiverge(0, 0)) - } - @Test fun `step length rises from walking to running`() { assertEquals(0.414f * 1.8f, Activity.stepLength(100, 180), 1e-4f) assertEquals((0.414f + 0.018f) * 1.8f, Activity.stepLength(125, 180), 1e-4f) diff --git a/app/src/test/kotlin/org/openstrap/app/data/BacktestTest.kt b/app/src/test/kotlin/org/openstrap/app/data/BacktestTest.kt index 9f56c3e2..d1489adf 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/BacktestTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/BacktestTest.kt @@ -110,7 +110,7 @@ class BacktestTest { listOf("", "-wal", "-shm").map { File(source.path + it) }.filter { it.exists() } .forEach { it.copyTo(File(dir, "openstrap.db" + it.name.removePrefix(source.name))) } Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, File(dir, "openstrap.db").absolutePath) - .allowMainThreadQueries().build() + .addMigrations(*org.openstrap.app.data.db.OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build() } val profile = System.getProperty("openstrap.backtestProfile")?.split(',')?.let { p -> UserProfile(birthYear = p.getOrNull(0)?.toIntOrNull(), sex = p.getOrNull(1)?.let { s -> UserProfile.Sex.entries.firstOrNull { it.name.equals(s, true) } }) diff --git a/app/src/test/kotlin/org/openstrap/app/data/CatchingTest.kt b/app/src/test/kotlin/org/openstrap/app/data/CatchingTest.kt new file mode 100644 index 00000000..128ba004 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/CatchingTest.kt @@ -0,0 +1,61 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** [catching] is runCatching that lets a real cancellation through and keeps a timeout as a failure. */ +class CatchingTest { + @Test + fun successIsSuccess() = runTest { + assertEquals(Result.success(42), catching { delay(10).let { 42 } }) + } + + @Test + fun anErrorIsAFailure() = runTest { + val r = catching { throw IOException("gone") } + assertTrue(r.exceptionOrNull() is IOException) + } + + @Test + fun cancellingTheCallerRethrows() = runTest { + val started = CompletableDeferred() + var returned = false + var rethrown = false + val job = launch { + try { + catching { + started.complete(Unit) + awaitCancellation() + } + returned = true + } catch (e: CancellationException) { + rethrown = true + throw e + } + } + started.await() + job.cancel() + job.join() + assertTrue(rethrown) + assertFalse(returned) + assertTrue(job.isCancelled) + } + + @Test + fun aTimeoutInsideIsAFailure() = runTest { + // TimeoutCancellationException is a CancellationException, but the caller is still active: the C1 trap. + val r = catching { withTimeout(100) { awaitCancellation() } } + assertTrue(r.exceptionOrNull() is TimeoutCancellationException) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/DayBoundariesTest.kt b/app/src/test/kotlin/org/openstrap/app/data/DayBoundariesTest.kt new file mode 100644 index 00000000..110f494e --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/DayBoundariesTest.kt @@ -0,0 +1,71 @@ +package org.openstrap.app.data + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openstrap.app.data.db.ActivityMinute +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.health.CalorieRecords +import java.time.LocalDate +import java.time.ZoneId + +/** Day bounds and the hours in them on the days the clocks change (D8, E11), and one way of counting a night. */ +class DayBoundariesTest { + private val berlin = ZoneId.of("Europe/Berlin") + + /** 25 October 2026: the clocks go back at 03:00, so the day has 25 hours. */ + private val autumn = LocalDate.of(2026, 10, 25) + + /** 29 March 2026: the clocks go forward at 02:00, so the day has 23 hours. */ + private val spring = LocalDate.of(2026, 3, 29) + + private fun at(d: LocalDate, hour: Int, minute: Int = 0) = d.atTime(hour, minute).atZone(berlin).toEpochSecond() + + @Test fun `hourly steps go by the clock on a 25 hour day`() { + val start = Days.start(autumn, berlin) + // 22:30 is the 24th hour after midnight that day: by hours since midnight it fell off the end. + val minutes = listOf(ActivityMinute(at(autumn, 22, 30), 100, 0, 0, null), ActivityMinute(at(autumn, 9, 0), 40, 0, 0, null)) + val hourly = Summaries.hourlySteps(minutes, start, berlin) + assertEquals(100f, hourly[22], 0f) + assertEquals(40f, hourly[9], 0f) + assertEquals(0f, hourly[23], 0f) + } + + @Test fun `hourly steps go by the clock on a 23 hour day`() { + val start = Days.start(spring, berlin) + val hourly = Summaries.hourlySteps(listOf(ActivityMinute(at(spring, 12, 0), 70, 0, 0, null)), start, berlin) + assertEquals(70f, hourly[12], 0f) + // The next day's first minute isn't this day's. + val next = Summaries.hourlySteps(listOf(ActivityMinute(Days.end(spring, berlin), 5, 0, 0, null)), start, berlin) + assertEquals(0f, next.sum(), 0f) + } + + @Test fun `a night window runs from the evening before by the clock`() { + val w = DayWindow.nightEndingOn(autumn.plusDays(1), berlin) + assertEquals(at(autumn, 18), w.from) + assertEquals(at(autumn.plusDays(1), 18), w.to) + val s = DayWindow.nightEndingOn(spring, berlin) + assertEquals(at(spring.minusDays(1), 18), s.from) + assertEquals(23 * 3600L, s.to - s.from) + } + + @Test fun `a day window and the calorie day are the local midnights`() { + val w = DayWindow.of(autumn, berlin) + assertEquals(25 * 3600L - 1, w.to - w.from) + assertEquals(Days.end(autumn, berlin), CalorieRecords.dayEnd(CalorieRecords.dayStart(at(autumn, 12), berlin), berlin)) + } + + @Test fun `the sleep screens count a night as recovery does`() { + val day = LocalDate.of(2026, 10, 17) + // No stages: time in bed less awake, as recovery counts it. + val night = SleepSession(start = at(day, 0) - 3600, end = at(day, 7), awakeMinutes = 30) + assertEquals(RecoveryEngine.asleepMinutes(night) * 60L, SleepHistory.asleepSeconds(night)) + assertEquals(450 * 60L, SleepHistory.asleepSeconds(night)) + // A sleep ending in the evening belongs to the day it ended on, not the next. + val evening = SleepSession(start = at(day, 13), end = at(day, 19)) + assertEquals(day, SleepHistory.nightOf(evening, berlin)) + assertEquals(Days.date(RecoveryEngine.morningOf(evening, berlin), berlin), SleepHistory.nightOf(evening, berlin)) + // The strap's own day wins, as in recovery. + val assigned = evening.copy(day = Days.start(day.plusDays(1), berlin)) + assertEquals(mapOf(day.plusDays(1) to assigned), SleepHistory.mainSleeps(listOf(assigned), berlin)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/DaysWithDataTest.kt b/app/src/test/kotlin/org/openstrap/app/data/DaysWithDataTest.kt new file mode 100644 index 00000000..425966ce --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/DaysWithDataTest.kt @@ -0,0 +1,60 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.HeartRateSample +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.inMemoryDb +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate +import java.time.ZoneOffset + +/** + * The calendar's days with data ([HealthRepository.daysWithData]) watch only the recent days as they change, and + * still show every older one, read again when older data comes in. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DaysWithDataTest { + private val zone = ZoneOffset.UTC + + @get:Rule val zoneRule = ZoneRule(zone) + + private val db = inMemoryDb() + private val dao = db.healthDao() + + @After fun close() = db.close() + + private val today = LocalDate.of(2026, 3, 1) + private val now = today.atTime(12, 0).toEpochSecond(zone) + + private fun noon(d: LocalDate) = d.atTime(12, 0).toEpochSecond(zone) + + @Test fun `old and recent days show, and both follow new data`() = runBlocking { + val old = today.minusDays(60) + val recent = today.minusDays(2) + dao.insertHeartRate(listOf(HeartRateSample(noon(old), 60), HeartRateSample(noon(recent), 60))) + val days = HealthRepository(dao).daysWithData(zone, now) + suspend fun until(expected: Set) = withTimeout(5_000) { days.first { it == expected } } + assertEquals(setOf(old, recent), days.first()) + + // A sync: today's heart rate and last night's sleep. + dao.insertHeartRate(listOf(HeartRateSample(noon(today), 62))) + dao.insertSleepSessions(listOf(SleepSession(noon(today.minusDays(1)) - 3600, noon(today.minusDays(1))))) + until(setOf(old, recent, today.minusDays(1), today)) + + // A restore of older data than any there was. + val older = today.minusDays(400) + dao.insertHeartRate(listOf(HeartRateSample(noon(older), 58))) + until(setOf(older, old, recent, today.minusDays(1), today)) + Unit + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/GamesTest.kt b/app/src/test/kotlin/org/openstrap/app/data/GamesTest.kt new file mode 100644 index 00000000..cab3254e --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/GamesTest.kt @@ -0,0 +1,63 @@ +package org.openstrap.app.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.openstrap.app.data.db.AppWorkoutRound +import org.openstrap.app.ui.workout.StartCopy + +class GamesTest { + @Test + fun effortIsTheTimeWeightedZone() { + assertNull(Games.effort(IntArray(5))) + assertEquals(1, Games.effort(intArrayOf(600, 0, 0, 0, 0))) + // Half in zone 2, half in zone 4: effort 3. + assertEquals(3, Games.effort(intArrayOf(0, 300, 0, 300, 0))) + assertEquals(5, Games.effort(intArrayOf(0, 0, 0, 0, 60))) + } + + @Test + fun hardEffortsAreSpellsOfTenSecondsInZoneFive() { + val max = 190 + val top = 180 // 94 %: zone 5. + fun spell(from: Long, seconds: Int, bpm: Int) = (0 until seconds).map { from + it to bpm } + val readings = spell(0, 15, top) + spell(15, 20, 140) + spell(35, 8, top) + spell(43, 5, 140) + spell(48, 30, top) + // 15 s and 30 s count, the 8 s spell doesn't; a long spell counts once. + assertEquals(2, Games.hardEfforts(readings, max)) + // A gap in the readings breaks a spell. + assertEquals(0, Games.hardEfforts(spell(0, 6, top) + spell(20, 6, top), max)) + } + + @Test + fun periodsRunLikeRoundsAndHalfTimeCanComeEarly() { + val s = Structure.Periods(2, 2700, 900) + assertEquals(s, Structure.parse("p:2x2700/900")) + assertEquals("p:2x2700/900", s.code) + val start = 0L + val active = { t: Long -> t - start } + var r = Phases.tick(s, emptyList(), start, start, active).rounds + // Half time tapped at 40 min. + val ht = Phases.skip(s, r, start, 2400, active) + assertEquals(Switch.RestStarts, ht.switch) + r = ht.rounds + // The break runs its 15 min, then the second half starts on its own. + assertNull(Phases.tick(s, r, start, 3299, active).switch) + val second = Phases.tick(s, r, start, 3300, active) + assertEquals(Switch.WorkStarts, second.switch) + assertEquals(listOf(AppWorkoutRound(0, 0, 0, 2400, 3300), AppWorkoutRound(0, 1, 3300)), second.rounds) + // A game's period ending buzzes long, a break ending as work starts. + assertEquals(WorkoutBuzz.LONG, WorkoutBuzz.of(Switch.RestStarts, game = true)) + assertEquals(WorkoutBuzz.WORK_STARTS, WorkoutBuzz.of(Switch.WorkStarts, game = true)) + } + + @Test + fun periodsAreNamedByTheirPlace() { + assertEquals("1st half", StartCopy.periodName(0, 2)) + assertEquals("2nd half", StartCopy.periodName(1, 2)) + assertEquals("3rd quarter", StartCopy.periodName(2, 4)) + assertEquals("Period 2", StartCopy.periodName(1, 3)) + assertEquals("Half time", StartCopy.breakName(2)) + assertEquals("Break", StartCopy.breakName(4)) + assertEquals("Halves", StartCopy.structureName(Structure.HALVES)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/HealthspanEngineComputeTest.kt b/app/src/test/kotlin/org/openstrap/app/data/HealthspanEngineComputeTest.kt new file mode 100644 index 00000000..faa1fd1e --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/HealthspanEngineComputeTest.kt @@ -0,0 +1,103 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.ActivityMinute +import org.openstrap.app.data.db.NightMetricsRow +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.app.testing.profile +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate +import java.time.ZoneOffset +import kotlin.math.abs + +/** + * [HealthspanEngine.compute] end to end on a real database: six weeks of minutes with heart rate and steps, a night's + * sleep every day, and the resting HR logged for every other night, so the rest are worked out from the night's heart + * rate. The estimate is the early one, from the weeks there are. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class HealthspanEngineComputeTest { + private val zone = ZoneOffset.UTC + + @get:Rule val zoneRule = ZoneRule(zone) + + private val db = inMemoryDb() + private val dao = db.healthDao() + + @After fun close() = db.close() + + /** A Sunday, the day the estimate is made. */ + private val sunday = LocalDate.of(2025, 11, 30) + private val days = 43 + + private suspend fun seed() { + val first = sunday.minusDays(days.toLong()) + val sleeps = (0 until days).map { k -> + val wake = first.plusDays(k + 1L).atStartOfDay(zone).toEpochSecond() + SleepSession(wake - 3600, wake + 7 * 3600, wake, nap = false, deepMinutes = 90, lightMinutes = 300, remMinutes = 90, awakeMinutes = 0) + } + dao.insertSleepSessions(sleeps) + dao.insertSleepStages( + sleeps.flatMap { s -> + listOf(SleepStage(s.start, s.start + 3 * 3600, s.start, SleepStage.LIGHT), SleepStage(s.start + 3 * 3600, s.end, s.start, SleepStage.DEEP)) + }, + ) + dao.insertNightMetrics( + sleeps.filterIndexed { i, _ -> i % 2 == 0 }.map { s -> NightMetricsRow(s.day, 0, null, 0, 52f, null, null, 400, null, null) }, + ) + fun asleep(t: Long) = sleeps.any { t in it.start until it.end } + val from = first.atStartOfDay(zone).toEpochSecond() + val to = sunday.atStartOfDay(zone).toEpochSecond() + val minutes = ArrayList() + var t = from + while (t < to) { + val i = ((t - from) / 60).toInt() + val sleeping = asleep(t) + minutes += ActivityMinute(t, if (sleeping || i % 3 != 0) 0 else 25, 0, 0, if (sleeping) 48 + i % 9 else 70 + i % 11) + t += 60 + } + dao.insertActivity(minutes) + } + + @Test fun `the weekly estimate is made from a real database`() = runTest { + seed() + val outcome = HealthspanEngine(dao).compute(sunday, profile(birthYear = 1986), zone) + assertNotNull(outcome.result) + assertEquals(39, outcome.age) + assertMatchesSnapshot(SNAPSHOT, outcome.toString()) + } + + /** + * The snapshot with its decimals compared to a part in ten thousand, not as text: `Math.exp`, `pow` and `log` may + * differ in the last bit between CPUs, which changes a printed Float on CI's x86 runners but not on an ARM Mac. + */ + private fun assertMatchesSnapshot(expected: String, actual: String) { + val decimal = Regex("-?\\d+\\.\\d+(E-?\\d+)?") + assertEquals(expected.replace(decimal, "#"), actual.replace(decimal, "#")) + val want = decimal.findAll(expected).map { it.value.toDouble() }.toList() + val got = decimal.findAll(actual).map { it.value.toDouble() }.toList() + want.zip(got).forEach { (w, g) -> assertEquals("in $actual", w, g, maxOf(1e-4, abs(w) * 1e-4)) } + } + + private companion object { + /** As worked out before the nights' heart rate and stages were read in batches, with SQLite's days in UTC. */ + const val SNAPSHOT = + "Outcome(result=Result(age=39, estimate=38.072422, low=34, high=42, pace=null, terms=[Term(habit=RestingHr, yea" + + "rs=-0.3779975, value=51.85, reference=58.550003), Term(habit=Regularity, years=-0.342, value=98.50427, referen" + + "ce=81.0), Term(habit=Steps, years=-0.2075821, value=8081.3955, reference=7803.0), Term(habit=Strength, years=" + + "0.0, value=0.0, reference=0.0), Term(habit=Vo2max, years=0.0, value=null, reference=37.94), Term(habit=TimeAsl" + + "eep, years=0.0, value=480.0, reference=390.0)], fitnessCapped=false, early=true, asOf=1764460800, sex=Male), w" + + "ithheld=null, age=39, missing=[], firstEstimate=null, lastWeek=null)" + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/HeartRateRangeTest.kt b/app/src/test/kotlin/org/openstrap/app/data/HeartRateRangeTest.kt new file mode 100644 index 00000000..4df27b7a --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/HeartRateRangeTest.kt @@ -0,0 +1,54 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.ActivityMinute +import org.openstrap.app.data.db.HeartRateSample +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.inMemoryDb +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.ZoneOffset + +/** One range of believed heart rates (D9): 30 to 230 bpm in the queries, the summaries, recovery and a workout. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class HeartRateRangeTest { + @get:Rule val zoneRule = ZoneRule(ZoneOffset.UTC) + + private val db = inMemoryDb() + private val dao = db.healthDao() + + @After fun close() = db.close() + + @Test fun `the edges count everywhere and one past them nowhere`() { + for (bpm in listOf(29, 30, 230, 231)) { + val inRange = bpm in 30..230 + assertEquals("workout $bpm", inRange, WorkoutRecorder.isValid(bpm)) + assertEquals("recovery $bpm", inRange, bpm in Recovery.HR_MIN_BPM..Recovery.HR_MAX_BPM) + val series = Summaries.heartRateSeries(listOf(HeartRateSample(0, bpm)), listOf(ActivityMinute(60, 0, 0, 0, bpm))) + assertEquals("summaries $bpm", if (inRange) 2 else 0, series.size) + } + } + + @Test fun `a workout drops a reading over 230, and recovery keeps one up to it`() { + assertFalse(WorkoutRecorder.isValid(235)) + assertTrue(225 in Recovery.HR_MIN_BPM..Recovery.HR_MAX_BPM) + } + + @Test fun `the queries take the same range`() = runBlocking { + dao.insertHeartRate(listOf(HeartRateSample(0, 29), HeartRateSample(60, 30), HeartRateSample(120, 230), HeartRateSample(180, 231))) + dao.insertActivity(listOf(ActivityMinute(240, 0, 0, 0, 231), ActivityMinute(300, 0, 0, 0, 230))) + val day = dao.dailyHeartRate(0, 3600).first().single() + assertEquals(2, day.count) + assertEquals(230.0, day.max, 0.0) + assertEquals(listOf(1L, 2L, 5L), dao.wornMinutesOnce(0, 3600).sorted()) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/HeartZonesTest.kt b/app/src/test/kotlin/org/openstrap/app/data/HeartZonesTest.kt new file mode 100644 index 00000000..22be5c97 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/HeartZonesTest.kt @@ -0,0 +1,93 @@ +package org.openstrap.app.data + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +class HeartZonesTest { + @After fun standardAgain() = HeartZones.use(HeartZones.STANDARD) + + @Test fun `the standard zones are the tens of percent the app always used`() { + val z = HeartZones.STANDARD + assertNull(z.zoneOf(94, 190)) + assertEquals(0, z.zoneOf(95, 190)) + assertEquals(0, z.zoneOf(113, 190)) + assertEquals(1, z.zoneOf(114, 190)) + assertEquals(4, z.zoneOf(171, 190)) + assertEquals(4, z.zoneOf(220, 190)) + assertEquals(0.5f, z.position(142, 190), 0.01f) + assertEquals(0f, z.position(80, 190), 0f) + assertEquals(1f, z.position(200, 190), 0f) + } + + @Test fun `each zone's bpm is the first heart rate that counts in it`() { + val z = HeartZones.STANDARD + val starts = (0 until 5).map { z.startBpm(it, 192) } + assertEquals(listOf(96, 116, 135, 154, 173), starts) + starts.forEachIndexed { i, b -> assertEquals(i, z.zoneOf(b, 192)); if (i > 0) assertEquals(i - 1, z.zoneOf(b - 1, 192)) } + assertEquals(115, z.endBpm(0, 192)) + assertNull(z.endBpm(4, 192)) + } + + @Test fun `moving an edge keeps the bpm you chose, and its neighbours apart`() { + val z = HeartZones.STANDARD.withStart(1, 115, 192) + assertEquals(115, z.startBpm(1, 192)) + assertEquals(114, z.endBpm(0, 192)) + assertEquals(1, z.zoneOf(115, 192)) + // Not past the zone above, less its width. + assertEquals(135 - HeartZones.MIN_WIDTH_BPM, HeartZones.STANDARD.withStart(1, 170, 192).startBpm(1, 192)) + assertEquals(191, HeartZones.STANDARD.withStart(4, 250, 192).startBpm(4, 192)) + assertEquals(58, HeartZones.STANDARD.withStart(0, 10, 192).startBpm(0, 192)) + // Ending zone 2 lower starts zone 3 there. + assertEquals(130, HeartZones.STANDARD.withEnd(1, 129, 192).startBpm(2, 192)) + assertEquals(HeartZones.STANDARD, HeartZones.STANDARD.withEnd(4, 180, 192)) + } + + @Test fun `zones follow max heart rate, as percents of it`() { + val z = HeartZones.STANDARD.withStart(2, 140, 200) + assertEquals(70f, z.starts[2], 0.01f) + assertEquals(133, z.startBpm(2, 190)) + } + + @Test fun `zones read back as they were stored, and nonsense reads as nothing`() { + val z = HeartZones.STANDARD.withStart(1, 115, 192).withStart(3, 150, 192) + assertEquals(z, HeartZones.parse(z.encode())) + assertEquals(115, HeartZones.parse(z.encode())!!.startBpm(1, 192)) + assertNull(HeartZones.parse(null)) + assertNull(HeartZones.parse("50,60,70,80")) + assertNull(HeartZones.parse("50,70,60,80,90")) + assertNull(HeartZones.parse("50,60,70,80,100")) + assertNull(HeartZones.parse("10,60,70,80,90")) + assertNull(HeartZones.parse("50,60,x,80,90")) + } + + @Test fun `every zone reader follows the zones in use`() { + HeartZones.use(HeartZones.STANDARD.withStart(1, 100, 190)) + assertEquals(1, Workouts.zoneOf(100, 190)) + assertEquals(1, WorkoutRecorder.liveZone(100, 190)) + assertTrue(WorkoutRecorder.zonePosition(100, 190) >= 0.2f) + val seconds = WorkoutRecorder.liveZoneSeconds(listOf(0L to 100, 5L to 100), 190) + assertEquals(5, seconds[1]) + } + + @Test fun `max heart rate says where it comes from`() { + assertEquals(MaxHeartRate.Source.Set, Recovery.maxHeartRateSource(30, tested = 185)) + assertEquals(MaxHeartRate.Source.Learned, Recovery.maxHeartRateSource(30, observedPeak = 195)) + assertEquals(MaxHeartRate.Source.Age, Recovery.maxHeartRateSource(30, observedPeak = 180)) + assertEquals(MaxHeartRate.Source.Strap, Recovery.maxHeartRateSource(null, strapMax = 192)) + assertEquals(MaxHeartRate.Source.Guess, Recovery.maxHeartRateSource(null)) + // A tested value out of range isn't yours. + assertEquals(MaxHeartRate.Source.Age, Recovery.maxHeartRateSource(30, tested = 300)) + } + + @Test fun `the day max heart rate was learned is the latest day at the peak`() { + val d = LocalDate.of(2026, 10, 1) + val maxima = mapOf(d to 196, d.plusDays(2) to 194, d.plusDays(5) to 194, d.plusDays(6) to 170) + assertEquals(194, Recovery.observedPeak(maxima.values.toList())) + assertEquals(d.plusDays(5), Recovery.observedPeakDay(maxima)) + assertNull(Recovery.observedPeakDay(mapOf(d to 196))) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/HistoryTest.kt b/app/src/test/kotlin/org/openstrap/app/data/HistoryTest.kt index 58413e74..31b7f5d9 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/HistoryTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/HistoryTest.kt @@ -47,7 +47,7 @@ class HistoryTest { fun weeklyGroupsFromMonday() { // 17 Oct 2026 is a Saturday: 12–17 Oct are one week, 11 Oct belongs to the week before. val stats = listOf(day(6, 10f), day(5, 20f), day(0, 30f)) - val weeks = History.weekly(stats) + val weeks = History.weekly(stats, java.time.DayOfWeek.MONDAY) assertEquals(listOf(LocalDate.of(2026, 10, 5), LocalDate.of(2026, 10, 12)), weeks.map { it.date }) assertEquals(25f, weeks[1].value, 0.001f) assertEquals(20f, weeks[1].min, 0.001f) diff --git a/app/src/test/kotlin/org/openstrap/app/data/LiveStepsTest.kt b/app/src/test/kotlin/org/openstrap/app/data/LiveStepsTest.kt index 3bc90990..47fa9666 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/LiveStepsTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/LiveStepsTest.kt @@ -3,35 +3,51 @@ package org.openstrap.app.data import org.junit.Assert.assertEquals import org.junit.Test import org.openstrap.protocol.services.StepTotals +import java.time.LocalDate class LiveStepsTest { @Test fun plausibleTotalIsUsedDirectly() { - assertEquals(3000, LiveSteps().today(2456, StepTotals(3000, 2200, 120))) - assertEquals(2456, LiveSteps().today(2456, StepTotals(100, 70, 5))) + assertEquals(3000, LiveSteps().today(DAY, 2456, StepTotals(3000, 2200, 120))) + assertEquals(2456, LiveSteps().today(DAY, 2456, StepTotals(100, 70, 5))) } @Test fun offsetCounterAddsOnlyStepsSinceTheLastSync() { // Captured on firmware 3.25: 11,368 steps with 1,836 m while history held 2,456. val live = LiveSteps() - assertEquals(2456, live.today(2456, StepTotals(11368, 1836, 151))) - assertEquals(2479, live.today(2456, StepTotals(11391, 1853, 152))) + assertEquals(2456, live.today(DAY, 2456, StepTotals(11368, 1836, 151))) + assertEquals(2479, live.today(DAY, 2456, StepTotals(11391, 1853, 152))) // A sync brings those steps into history: re-anchor rather than count them twice. - assertEquals(2480, live.today(2480, StepTotals(11392, 1854, 152))) - assertEquals(2490, live.today(2480, StepTotals(11402, 1861, 152))) + assertEquals(2480, live.today(DAY, 2480, StepTotals(11392, 1854, 152))) + assertEquals(2490, live.today(DAY, 2480, StepTotals(11402, 1861, 152))) } @Test fun counterGoingBackwardsReanchors() { val live = LiveSteps() - live.today(2456, StepTotals(11368, 1836, 151)) - assertEquals(2456, live.today(2456, StepTotals(9000, 10, 1))) - assertEquals(2460, live.today(2456, StepTotals(9004, 13, 1))) + live.today(DAY, 2456, StepTotals(11368, 1836, 151)) + assertEquals(2456, live.today(DAY, 2456, StepTotals(9000, 10, 1))) + assertEquals(2460, live.today(DAY, 2456, StepTotals(9004, 13, 1))) } @Test fun noLiveReadingFallsBackToHistory() { - assertEquals(2456, LiveSteps().today(2456, null)) + assertEquals(2456, LiveSteps().today(DAY, 2456, null)) + } + + @Test + fun aNewDayStartsANewAnchor() { + // One counter serves Today and the widgets for every day: yesterday's anchor must not carry over, even when + // both days' history reads the same and the strap's counter didn't go back at midnight. + val live = LiveSteps() + live.today(DAY, 0, StepTotals(20000, 10, 1)) + assertEquals(100, live.today(DAY, 0, StepTotals(20100, 20, 1))) + assertEquals(0, live.today(DAY.plusDays(1), 0, StepTotals(20150, 25, 1))) + assertEquals(30, live.today(DAY.plusDays(1), 0, StepTotals(20180, 30, 1))) + } + + private companion object { + val DAY: LocalDate = LocalDate.of(2026, 1, 15) } } diff --git a/app/src/test/kotlin/org/openstrap/app/data/MedianVariantsTest.kt b/app/src/test/kotlin/org/openstrap/app/data/MedianVariantsTest.kt new file mode 100644 index 00000000..47cf3581 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/MedianVariantsTest.kt @@ -0,0 +1,37 @@ +package org.openstrap.app.data + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openstrap.app.data.db.WorkoutHeartRate +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneOffset + +/** The medians moved onto [Stats] (D14) keep each caller's own variant. */ +class MedianVariantsTest { + @Test fun `the usual workout time takes the whole minute below a half`() { + val zone = ZoneOffset.UTC + val now = Instant.parse("2026-10-17T20:00:00Z") + // Two Saturdays at 07:00 and 07:01 each: the mean of the middle two, 07:00.5, cut to 07:00. + val starts = listOf(1L, 2L).flatMap { w -> + val d = now.minusSeconds(w * 7 * 86_400L).atZone(zone).toLocalDate() + listOf(d.atTime(7, 0).toEpochSecond(zone), d.atTime(7, 1).toEpochSecond(zone)) + } + assertEquals(LocalTime.of(7, 0), MomentResolver.usualWorkoutTime(starts, now, zone)) + } + + @Test fun `the usual load is the mean of the middle two`() { + assertEquals(105f, Workouts.usualLoad(listOf(100, 110, 90, 120))!!, 0f) + assertEquals(100f, Workouts.usualLoad(listOf(100, 110, 90))!!, 0f) + } + + @Test fun `a heart rate at a moment is a reading that was taken`() { + val hr = listOf(WorkoutHeartRate(0, 100, 120), WorkoutHeartRate(0, 101, 130)) + assertEquals(130, Workouts.heartRateAt(hr.map { it.timestamp to it.bpm }, 100)) + } + + @Test fun `the percentile interpolates`() { + assertEquals(2.5f, Recovery.percentile(listOf(4f, 1f, 3f, 2f), 0.5f)!!, 1e-6f) + assertEquals(1.3f, Recovery.percentile(listOf(4f, 1f, 3f, 2f), 0.1f)!!, 1e-6f) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/MomentLeadTest.kt b/app/src/test/kotlin/org/openstrap/app/data/MomentLeadTest.kt index 95619218..3d360f5f 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/MomentLeadTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/MomentLeadTest.kt @@ -2,7 +2,6 @@ package org.openstrap.app.data import org.junit.Assert.assertEquals import org.junit.Test -import org.openstrap.app.widget.WidgetKind /** Every row of widgets spec 3.1's duplicate table, and the moments around it. */ class MomentLeadTest { diff --git a/app/src/test/kotlin/org/openstrap/app/data/NapTimesTest.kt b/app/src/test/kotlin/org/openstrap/app/data/NapTimesTest.kt deleted file mode 100644 index 5d4b4a69..00000000 --- a/app/src/test/kotlin/org/openstrap/app/data/NapTimesTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.openstrap.app.data - -import java.time.LocalDateTime -import java.time.ZoneId -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertSame -import org.junit.Test -import org.openstrap.app.data.db.NapEdit -import org.openstrap.app.data.db.SleepSession -import org.openstrap.app.data.db.SleepStage - -class NapTimesTest { - private val zone = ZoneId.of("Europe/Berlin") - private fun at(h: Int, m: Int, day: Int = 30) = LocalDateTime.of(2026, 10, day, h, m).atZone(zone).toEpochSecond() - private val nap = SleepSession(at(14, 10), at(14, 48), nap = true) - - @Test fun `a changed nap takes the user's times, anything else stays`() { - val edits = listOf(NapEdit(nap.start, at(14, 2), at(14, 55))) - val main = SleepSession(at(0, 30), at(7, 0)) - val out = NapTimes.applyAll(listOf(main, nap), edits) - assertSame(main, out[0]) - assertEquals(at(14, 2), out[1].start) - assertEquals(at(14, 55), out[1].end) - // A main sleep starting at the same moment is never moved. - assertSame(main, NapTimes.apply(main, mapOf(main.start to NapEdit(main.start, 1, 2)))) - } - - @Test fun `stages are trimmed to the nap's times`() { - val stages = listOf( - SleepStage(at(14, 10), at(14, 20), nap.start, SleepStage.LIGHT), - SleepStage(at(14, 20), at(14, 40), nap.start, SleepStage.DEEP), - SleepStage(at(14, 40), at(14, 48), nap.start, SleepStage.LIGHT), - ) - val out = NapTimes.clip(stages, at(14, 15), at(14, 40)) - assertEquals(listOf(at(14, 15) to at(14, 20), at(14, 20) to at(14, 40)), out.map { it.start to it.end }) - } - - @Test fun `times that can't be a nap are refused`() { - val now = at(18, 0) - assertNull(NapTimes.problem(nap, at(14, 0), at(15, 0), now)) - assertEquals("Waking up has to come after falling asleep.", NapTimes.problem(nap, at(14, 30), at(14, 20), now)) - assertEquals("A nap needs at least 5 minutes.", NapTimes.problem(nap, at(14, 30), at(14, 33), now)) - assertEquals("A nap can be 4 hours at most.", NapTimes.problem(nap, at(11, 0), at(15, 30), now)) - assertEquals("That's later than now.", NapTimes.problem(nap, at(16, 0), at(18, 30), now)) - assertEquals("Keep it within 3 hours of what the strap saw.", NapTimes.problem(nap, at(10, 30), at(11, 0), now)) - } - - @Test fun `the strap's own times again drop the edit`() { - assertNull(NapTimes.edit(nap, nap.start, nap.end)) - assertEquals(NapEdit(nap.start, at(14, 5), nap.end), NapTimes.edit(nap, at(14, 5), nap.end)) - } - - @Test fun `a picked clock is read on the day nearest the nap`() { - assertEquals(at(14, 5), NapTimes.atNear(14 * 60 + 5, nap.start, zone)) - // A late nap running past midnight: 00:20 is the next day's. - assertEquals(at(0, 20, day = 31), NapTimes.atNear(20, at(23, 50), zone)) - // And 23:40 picked for a nap the strap started at 00:10 is the evening before. - assertEquals(at(23, 40, day = 29), NapTimes.atNear(23 * 60 + 40, at(0, 10), zone)) - } -} diff --git a/app/src/test/kotlin/org/openstrap/app/data/PhasesTest.kt b/app/src/test/kotlin/org/openstrap/app/data/PhasesTest.kt new file mode 100644 index 00000000..42ea08ea --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/PhasesTest.kt @@ -0,0 +1,130 @@ +package org.openstrap.app.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.db.AppWorkoutRound + +class PhasesTest { + private val start = 1_000L + /** No pauses: active seconds are seconds since the start. */ + private val active = { t: Long -> t - start } + + /** Ticks [s] once a second from [from] to [to], collecting the switches. */ + private fun run(s: Structure, from: Long, to: Long, rounds: List = emptyList(), meters: (Long) -> Float? = { null }): Pair, List>> { + var r = rounds + val switches = mutableListOf>() + for (t in from..to) { + val step = Phases.tick(s, r, start, t, active, meters) + r = step.rounds + step.switch?.let { switches += t to it } + } + return r to switches + } + + @Test + fun structuresRoundTrip() { + listOf(Structure.TABATA, Structure.FORTY_TWENTY, Structure.EVERY_MINUTE, Structure.Timed(30, 15, 5, warmUp = true), + Structure.Reps(400, null, 90, 6), Structure.Reps(null, 120, 60, 8, warmUp = true)).forEach { + assertEquals(it, Structure.parse(it.code)) + } + assertEquals("t:20/10x8", Structure.TABATA.code) + assertEquals("r:400m/90x6", Structure.Reps(400, null, 90, 6).code) + assertEquals(Structure.Periods(2, 2700, 900), Structure.parse("p:2x2700/900")) + assertNull(Structure.parse("t:0/10x8")) + assertNull(Structure.parse(null)) + } + + @Test + fun tabataRunsEightRoundsOfTwentyAndTenThenEnds() { + val (rounds, switches) = run(Structure.TABATA, start, start + 300) + assertEquals(8, rounds.size) + assertTrue(rounds.all { it.workEnd != null && it.restEnd != null }) + rounds.forEach { r -> assertEquals(20L, r.workEnd!! - r.workStart) } + rounds.dropLast(1).forEach { r -> assertEquals(10L, r.restEnd!! - r.workEnd!!) } + // One rest and one work buzz per switch, seven of each, then the end: 4 minutes less the last rest. + assertEquals(7, switches.count { it.second == Switch.RestStarts }) + assertEquals(7, switches.count { it.second == Switch.WorkStarts }) + assertEquals(start + 230 to Switch.Finished, switches.last()) + assertEquals(Phase.Done, Phases.phase(Structure.TABATA, rounds, active)) + } + + @Test + fun skipEndsWorkOrRestNow() { + var r = run(Structure.TABATA, start, start + 5).first + assertTrue(Phases.phase(Structure.TABATA, r, active) is Phase.Work) + var step = Phases.skip(Structure.TABATA, r, start, start + 6, active) + assertEquals(Switch.RestStarts, step.switch) + r = step.rounds + assertEquals(Phase.Rest(0, start + 6, 16), Phases.phase(Structure.TABATA, r, active)) + step = Phases.skip(Structure.TABATA, r, start, start + 8, active) + assertEquals(Switch.WorkStarts, step.switch) + assertEquals(2, step.rounds.size) + } + + @Test + fun aRestCanBeLengthened() { + val r = run(Structure.TABATA, start, start + 21).first + val rest = Phases.phase(Structure.TABATA, r, active, extra = 10) as Phase.Rest + assertEquals(40L, rest.endsAt) + assertNull(Phases.tick(Structure.TABATA, r, start, start + 35, active, extra = 10).switch) + assertEquals(Switch.WorkStarts, Phases.tick(Structure.TABATA, r, start, start + 40, active, extra = 10).switch) + } + + @Test + fun everyMinuteRestsForWhatsLeftOfTheMinute() { + val s = Structure.EveryMinute(60, 3) + var r = run(s, start, start + 1).first + // Done after 40 s: rest until the minute is up. + r = Phases.skip(s, r, start, start + 40, active).rounds + assertEquals(Phase.Rest(0, start + 40, 60), Phases.phase(s, r, active)) + r = run(s, start + 41, start + 60, r).first + assertEquals(2, r.size) + // Never tapping Done: the next minute starts anyway, with no rest. + val (late, switches) = run(s, start + 61, start + 125, r) + assertEquals(3, late.size) + assertEquals(late[1].workEnd, late[1].restEnd) + assertTrue(switches.any { it.second == Switch.WorkStarts }) + } + + @Test + fun runRepsEndOnTheirDistance() { + val s = Structure.Reps(400, null, 90, 2) + // No distance yet: the rep goes on, however long. + val (first, _) = run(s, start, start + 200) + assertTrue(Phases.phase(s, first, active) is Phase.Work) + // 4 m a second since the rep began: 400 m at 100 s. + assertNull(Phases.tick(s, first, start, start + 99, active, meters = { from -> 4f * (start + 99 - from) }).switch) + assertEquals(Switch.RestStarts, Phases.tick(s, first, start, start + 100, active, meters = { from -> 4f * (start + 100 - from) }).switch) + } + + @Test + fun aWarmUpWaitsForStart() { + val s = Structure.Timed(20, 10, 2, warmUp = true) + val (r, switches) = run(s, start, start + 300) + assertTrue(r.isEmpty()) + assertTrue(switches.isEmpty()) + assertEquals(Phase.WarmUp, Phases.phase(s, r, active)) + val go = Phases.skip(s, r, start, start + 301, active) + assertEquals(Switch.WorkStarts, go.switch) + assertEquals(start + 301, go.rounds.single().workStart) + } + + @Test + fun pausedTimeDoesntCount() { + // Paused for 100 s from 10 s in: work that began at 0 ends at 120 s wall time, 20 s active. + val paused = { t: Long -> (t - start) - (t - start - 10).coerceIn(0, 100) } + val r = listOf(AppWorkoutRound(start, 0, start)) + assertNull(Phases.tick(Structure.TABATA, r, start, start + 115, paused).switch) + assertEquals(Switch.RestStarts, Phases.tick(Structure.TABATA, r, start, start + 120, paused).switch) + } + + @Test + fun peaksAreKeptPerRound() { + val r = listOf(AppWorkoutRound(start, 0, start)) + val step = Phases.tick(Structure.TABATA, r, start, start + 20, active, peak = { a, b -> if (a == start && b == start + 20) 171 else null }) + assertEquals(171, step.rounds.single().peakBpm) + assertEquals(1, Phases.roundsDone(step.rounds)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/ProfileKeyTest.kt b/app/src/test/kotlin/org/openstrap/app/data/ProfileKeyTest.kt new file mode 100644 index 00000000..e6b2b0c6 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/ProfileKeyTest.kt @@ -0,0 +1,39 @@ +package org.openstrap.app.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.openstrap.app.data.settings.UserProfile +import org.openstrap.app.testing.profile + +/** The daily energy profile key is stored and compared after a restart, so it has to be the same in every process (P2). */ +class ProfileKeyTest { + private fun key(p: UserProfile) = DailyEnergyHistory.profileKey(p) + + @Test fun `equal profiles built separately have one key`() { + assertEquals(key(profile(maxHeartRate = 190)), key(profile(maxHeartRate = 190))) + assertEquals(key(UserProfile()), key(UserProfile())) + } + + @Test fun `the key is a fixed number, not one that changes with the process`() { + // An enum's hashCode is the object's identity, so a key built from it differed after every restart. + val p = UserProfile(sex = UserProfile.Sex.Male, weightKg = 70f, heightCm = 180, maxHeartRate = 190) + assertEquals(1_799_101_530L, key(p)) + } + + @Test fun `the key changes with each field that moves a day's energy`() { + val base = profile(maxHeartRate = 190) + val changed = listOf( + profile(maxHeartRate = 190, weightKg = 71f), + profile(maxHeartRate = 190, heightCm = 176), + profile(maxHeartRate = 190, birthYear = 1991), + profile(maxHeartRate = 190, sex = UserProfile.Sex.Female), + profile(maxHeartRate = 190, sex = null), + profile(maxHeartRate = 191), + profile(maxHeartRate = null), + ) + for (p in changed) assertNotEquals(p.toString(), key(base), key(p)) + // Other fields don't move it. + assertEquals(key(base), key(base.copy(sleepNeedMinutes = 480, imperial = true))) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/RecoveryCacheTest.kt b/app/src/test/kotlin/org/openstrap/app/data/RecoveryCacheTest.kt new file mode 100644 index 00000000..c63ae7a8 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/RecoveryCacheTest.kt @@ -0,0 +1,220 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.ActivityMinute +import org.openstrap.app.data.db.DailyEnergyRow +import org.openstrap.app.data.db.HealthDao +import org.openstrap.app.data.db.HeartRateSample +import org.openstrap.app.data.db.HrvSample +import org.openstrap.app.data.db.NightMetricsRow +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.testing.FixedClock +import org.openstrap.app.testing.SourceTree +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.app.testing.profile +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.util.concurrent.atomic.AtomicInteger + +/** + * Recovery is worked out once per day, profile and data version (wave 4 Decisions 4): a hit writes nothing, new data + * in any table Recovery reads works it out again, a day whose Charge runs to the present is kept for the minute only, + * a failure is never kept, and at most eight are kept. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RecoveryCacheTest { + @get:Rule val zone = ZoneRule() + + private lateinit var db: OpenStrapDatabase + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** Reads of the day's minutes, which every Recovery worked out makes. */ + private val computes = AtomicInteger() + private val writes = AtomicInteger() + @Volatile private var failing = false + + private val dao: HealthDao by lazy { + val real = db.healthDao() + object : HealthDao by real { + override suspend fun activityOnce(from: Long, to: Long): List { + computes.incrementAndGet() + check(!failing) { "no minutes" } + return real.activityOnce(from, to) + } + + override suspend fun insertDailyEnergy(rows: List) { + writes.incrementAndGet() + real.insertDailyEnergy(rows) + } + + override suspend fun insertNightMetrics(rows: List) { + writes.incrementAndGet() + real.insertNightMetrics(rows) + } + } + } + + private val date = LocalDate.of(2026, 1, 15) + + /** Five days after [date], so [date] is long past unless a test moves it. */ + private val clock = FixedClock(Instant.parse("2026-01-20T12:00:00Z")) + + @Before fun setUp() { + db = inMemoryDb() + // Heart rate and minutes enough for the day's energy, so a compute writes its row. + runBlocking { + val from = DayWindow.of(date).from + db.healthDao().insertActivity((0 until 120).map { ActivityMinute(from + it * 60L, 20, 40, 1, 80) }) + db.healthDao().insertHeartRate((0 until 120).map { HeartRateSample(from + it * 60L, 80) }) + } + } + + @After fun tearDown() { + scope.cancel() + db.close() + } + + /** A repository following the data, once it has read the data's first state. */ + private fun started() = HealthRepository(dao) { clock.now }.also { repo -> + repo.start(scope, HealthRepository.recoveryInputs(db)) + runBlocking { withTimeout(WAIT_MS) { while (repo.recoveryVersion == 0L) delay(POLL_MS) } } + } + + @Test fun `the same day and profile is worked out once, and a hit writes nothing`() = runBlocking { + val repo = started() + val first = repo.recovery(date, profile()) + val computed = computes.get() + val written = writes.get() + assertTrue(written > 0) + assertSame(first, repo.recovery(date, profile())) + assertEquals(computed, computes.get()) + assertEquals(written, writes.get()) + } + + @Test fun `another profile is worked out on its own`() = runBlocking { + val repo = started() + val light = repo.recovery(date, profile(weightKg = 60f)) + assertNotSame(light, repo.recovery(date, profile(weightKg = 90f))) + assertSame(light, repo.recovery(date, profile(weightKg = 60f))) + } + + @Test fun `new data works it out again`() = recomputesAfter { + db.healthDao().insertHeartRate(listOf(HeartRateSample(DayWindow.of(date).from + 7200, 90))) + } + + @Test fun `new activity minutes alone work it out again`() = recomputesAfter { + db.healthDao().insertActivity(listOf(ActivityMinute(DayWindow.of(date).from + 3 * 3600, 30, 40, 1, null))) + } + + @Test fun `new HRV alone works it out again`() = recomputesAfter { + db.healthDao().insertHrv(listOf(HrvSample(DayWindow.of(date).from + 3 * 3600, 55))) + } + + private fun recomputesAfter(write: suspend () -> Unit) = runBlocking { + val repo = started() + val before = repo.recovery(date, profile()) + assertSame(before, repo.recovery(date, profile())) + write() + withTimeout(WAIT_MS) { + while (repo.recovery(date, profile()) === before) delay(POLL_MS) + } + } + + @Test fun `today is kept for the minute only`() = runBlocking { + clock.instant = Instant.parse("2026-01-15T12:00:10Z") + val repo = started() + val first = repo.recovery(date, profile()) + clock.advance(Duration.ofSeconds(30)) + assertSame(first, repo.recovery(date, profile())) + clock.advance(Duration.ofSeconds(30)) + assertNotSame(first, repo.recovery(date, profile())) + } + + @Test fun `yesterday is kept for the minute only until its lookahead has passed`() = runBlocking { + val yesterday = date.minusDays(1) + clock.instant = Instant.parse("2026-01-15T12:00:00Z") + val repo = started() + val noon = repo.recovery(yesterday, profile()) + clock.advance(Duration.ofMinutes(1)) + assertNotSame(noon, repo.recovery(yesterday, profile())) + // Past 14:00 the run stops at its lookahead, so a later minute changes nothing. + clock.instant = Instant.parse("2026-01-15T15:00:00Z") + val later = repo.recovery(yesterday, profile()) + clock.advance(Duration.ofMinutes(5)) + assertSame(later, repo.recovery(yesterday, profile())) + } + + @Test fun `a past day stays kept as the clock moves`() = runBlocking { + val repo = started() + val first = repo.recovery(date, profile()) + clock.advance(Duration.ofHours(3)) + assertSame(first, repo.recovery(date, profile())) + } + + @Test fun `the cache watches every table Recovery reads`() { + val daos = File(SourceTree.kotlin, "data/db/Daos.kt").readText() + val read = listOf("data/RecoveryEngine.kt", "data/SleepSource.kt").flatMap { f -> + Regex("""dao\.(\w+)\(""").findAll(File(SourceTree.kotlin, f).readText()).map { it.groupValues[1] }.toList() + }.toSet() + val tables = read.flatMap { m -> + val query = Regex("""@Query\("((?:[^"\\]|\\.)*)"\)\s*(?:@\w+\s*)*(?:suspend\s+)?fun $m\(""").find(daos)?.groupValues?.get(1).orEmpty() + Regex("""(?:FROM|JOIN)\s+(\w+)""").findAll(query).map { it.groupValues[1] }.toList() + }.toSet() + assertTrue("found no tables in $tables", "activity_minute" in tables && "sleep_session" in tables) + assertEquals(emptySet(), tables - HealthRepository.RECOVERY_TABLES.toSet()) + } + + @Test fun `a failure is never kept`() = runBlocking { + val repo = started() + failing = true + assertTrue(runCatching { repo.recovery(date, profile()) }.isFailure) + failing = false + val after = computes.get() + val r = repo.recovery(date, profile()) + assertTrue(computes.get() > after) + assertSame(r, repo.recovery(date, profile())) + } + + @Test fun `at most eight are kept, the oldest going first`() = runBlocking { + val repo = started() + val days = (0L..8L).map { date.minusDays(it) } + days.forEach { repo.recovery(it, profile()) } + val computed = computes.get() + repo.recovery(days.last(), profile()) + assertEquals(computed, computes.get()) + repo.recovery(days.first(), profile()) + assertTrue(computes.get() > computed) + } + + @Test fun `without start nothing is kept`() = runBlocking { + val repo = HealthRepository(dao) { clock.now } + val first = repo.recovery(date, profile()) + assertNotSame(first, repo.recovery(date, profile())) + } + + private companion object { + const val WAIT_MS = 10_000L + const val POLL_MS = 20L + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/RecoveryEngineComputeTest.kt b/app/src/test/kotlin/org/openstrap/app/data/RecoveryEngineComputeTest.kt new file mode 100644 index 00000000..5fe2765b --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/RecoveryEngineComputeTest.kt @@ -0,0 +1,141 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.ActivityMinute +import org.openstrap.app.data.db.HeartRateSample +import org.openstrap.app.data.db.HrvSample +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.data.db.StressSample +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.app.testing.profile +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId + +/** + * [RecoveryEngine.compute] end to end on a real database (audit test 9): three weeks of nights from 23:00 to 07:00, + * so every sleep crosses midnight, heart rate every minute, steps by day, HRV on every night but the last, and a + * birthday year before the one the test runs in, so the age is the one the scored day had. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RecoveryEngineComputeTest { + private val zone = ZoneId.of("Europe/Berlin") + + @get:Rule val zoneRule = ZoneRule(zone) + + private val db = inMemoryDb() + private val dao = db.healthDao() + + @After fun close() = db.close() + + /** The day scored: a year before any day the suite runs on, so its age differs from today's. */ + private val date = LocalDate.of(2025, 12, 30) + private val nights = 21 + + private fun at(d: LocalDate, t: LocalTime) = d.atTime(t).atZone(zone).toEpochSecond() + + private suspend fun seed() { + val first = date.minusDays(nights.toLong()) + val sleeps = (0 until nights).map { k -> + val wake = first.plusDays(k + 1L) + val start = at(wake.minusDays(1), LocalTime.of(23, 0)) + SleepSession( + start, at(wake, LocalTime.of(7, 0)), wake.atStartOfDay(zone).toEpochSecond(), nap = false, score = 80, + deepMinutes = 90, lightMinutes = 300, remMinutes = 90, awakeMinutes = 0, wakeCount = 0, + ) + } + dao.insertSleepSessions(sleeps) + dao.insertSleepStages( + sleeps.flatMap { s -> + listOf( + SleepStage(s.start, s.start + 2 * 3600, s.start, SleepStage.LIGHT), + SleepStage(s.start + 2 * 3600, s.start + 210 * 60, s.start, SleepStage.DEEP), + SleepStage(s.start + 210 * 60, s.start + 5 * 3600, s.start, SleepStage.REM), + SleepStage(s.start + 5 * 3600, s.end, s.start, SleepStage.LIGHT), + ) + }, + ) + fun asleep(t: Long) = sleeps.any { t in it.start until it.end } + val from = at(first, LocalTime.MIDNIGHT) + val to = at(date, LocalTime.of(20, 0)) + val hr = ArrayList() + val activity = ArrayList() + val stress = ArrayList() + var t = from + while (t < to) { + val i = ((t - from) / 60).toInt() + val hour = java.time.Instant.ofEpochSecond(t).atZone(zone).hour + val walking = !asleep(t) && hour == 17 + hr += HeartRateSample(t, if (asleep(t)) 50 + i % 7 else if (walking) 115 + i % 10 else 68 + i % 9) + val steps = when { + asleep(t) -> 0 + walking -> 110 + hour in 8..21 && i % 4 == 0 -> 20 + else -> 0 + } + activity += ActivityMinute(t, steps, intensity = if (steps > 0) 40 else 0, kind = 0, heartRate = null) + if (!asleep(t) && i % 5 == 0) stress += StressSample(t, 20 + i % 30) + t += 60 + } + dao.insertHeartRate(hr) + dao.insertActivity(activity) + dao.insertStress(stress) + // HRV every five minutes asleep, on every night but the one before the scored day. + dao.insertHrv( + sleeps.dropLast(1).flatMap { s -> (s.start until s.end step 300L).mapIndexed { j, ts -> HrvSample(ts, 45 + j % 15) } }, + ) + } + + @Test fun `a day is scored from a real database`() = runTest { + seed() + val now = at(date, LocalTime.of(20, 0)) + val r = RecoveryEngine(dao).compute(date, profile(birthYear = 1986), zone, now) + + // Tonight's sleep started the evening before and ended on the scored morning. + val tonight = requireNotNull(r.tonight) + assertEquals(at(date.minusDays(1), LocalTime.of(23, 0)), tonight.start) + assertEquals(at(date, LocalTime.of(7, 0)), tonight.end) + assertEquals(date.atStartOfDay(zone).toEpochSecond(), tonight.day) + assertEquals(480, tonight.asleepMinutes) + // No HRV that night: the night has none, and its resting HR still comes from its heart rate. + assertNull(tonight.hrv) + assertEquals(0, tonight.hrvSamples) + assertNotNull(tonight.restingHr) + // Age 39 on the scored day (1986 to 2025), not today's: Tanaka gives 208 - 0.7 x 39. + assertEquals(180.7f, r.maxHr, 0.01f) + assertTrue(r.maxHrFromAge) + + assertEquals(SNAPSHOT, snapshot(r)) + } + + /** The figures the engine works out, so reading its inputs another way can't change them. */ + private fun snapshot(r: RecoveryResult) = listOf( + r.readiness.score, r.readiness.baselineNights, r.readinessTrend.map { it.second }, + r.load, r.chronicLoad, r.restingHr, r.stressThreshold.value, r.charge.latest, r.charge.atWake, r.charge.points.size, + r.strain, r.strainPercentile, r.activity?.steps, r.activity?.mvpa?.moderateMinutes, r.activity?.distanceMeters, + r.energy?.totalKcal, r.energy?.activeKcal, r.energy?.minutesWithData, r.regularity?.sri, r.sleepHealth?.count, + r.workoutLoad, r.everydayLoad, r.usualBedtime, r.dailyLoads.size, + ).joinToString("|") + + private companion object { + /** + * As worked out before compute read each table once: Readiness is withheld on the night without HRV, the six + * days before it score 84, and the hour's walk each evening is the day's moderate minutes. + */ + const val SNAPSHOT = "null|20|[84, 84, 84, 84, 84, 84, null]|50.73785|50.73785|55.833332|50|51.243366|96.53187|241|" + + "5.1583033|100|9900|60|7172.55|1736.3221|423.3075|1200|100.0|3|0.0|50.73785|23:00|21" + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/RecoveryEngineTest.kt b/app/src/test/kotlin/org/openstrap/app/data/RecoveryEngineTest.kt index d7e96f5c..f818709b 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/RecoveryEngineTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/RecoveryEngineTest.kt @@ -1,5 +1,6 @@ package org.openstrap.app.data +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test @@ -11,6 +12,7 @@ import org.openstrap.app.data.settings.UserProfile import java.lang.reflect.Proxy import java.time.LocalDate import java.time.ZoneOffset +import kotlin.math.roundToInt class RecoveryEngineTest { private val date = LocalDate.of(2026, 10, 1) @@ -44,6 +46,24 @@ class RecoveryEngineTest { assertEquals(183f, maxHr(dao(effort(3, 188) + effort(10, 186)), forty.copy(maxHeartRate = 183)), 0.01f) } + @Test + fun sharedMaxHrIsTheWorkoutRule() = runBlocking { + // Efforts near today, so the shared one (always as of now) has peaks to learn from. + fun near(daysBack: Long, bpm: Int) = (0 until 10).map { i -> + HeartRateSample(LocalDate.now().minusDays(daysBack).atTime(12, 0).atZone(java.time.ZoneId.systemDefault()).toEpochSecond() + i * 60, bpm) + } + val d = dao(near(3, 188) + near(10, 186)) + for (p in listOf(forty, forty.copy(maxHeartRate = 183), forty.copy(maxHeartRate = 250), UserProfile())) { + val shared = MaxHeartRates(d, kotlinx.coroutines.flow.flowOf(p)) + val once = shared.now() + assertEquals(RecoveryEngine(d).maxHeartRate(p).roundToInt(), once.bpm) + assertEquals(once, kotlinx.coroutines.withTimeout(5_000) { shared.current.first { it != null } }) + } + // A tested value wins and says so; one out of range is ignored. + assertEquals(MaxHeartRate(183, MaxHeartRate.Source.Set), MaxHeartRates(d, kotlinx.coroutines.flow.flowOf(forty.copy(maxHeartRate = 183))).now()) + assertEquals(MaxHeartRate.Source.Learned, MaxHeartRates(d, kotlinx.coroutines.flow.flowOf(forty.copy(maxHeartRate = 250))).now().source) + } + @Test fun strapDailyMaxSpikesDontRaiseMaxHr() { val days = listOf(3L, 10L).map { date.minusDays(it).atStartOfDay(zone).toEpochSecond() } diff --git a/app/src/test/kotlin/org/openstrap/app/data/RewardsKeeperTest.kt b/app/src/test/kotlin/org/openstrap/app/data/RewardsKeeperTest.kt new file mode 100644 index 00000000..fa629bb7 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/RewardsKeeperTest.kt @@ -0,0 +1,157 @@ +package org.openstrap.app.data + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.ActivityMinute +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.settings.SettingsStore +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * First launch with history (requests spec 7.2): the badges history earned are stored and wait to be shown once; the + * list's empty first value starts nothing, and later workouts never bring the screen back. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RewardsKeeperTest { + private lateinit var db: OpenStrapDatabase + @get:org.junit.Rule val folder = org.junit.rules.TemporaryFolder() + private val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO + kotlinx.coroutines.SupervisorJob()) + + /** Settings of this test's own: the app's store outlives a test, so one test's started rewards would leak to the next. */ + private val settings by lazy { + SettingsStore.of( + androidx.datastore.preferences.core.PreferenceDataStoreFactory.create(scope = scope) { + folder.newFile("settings.preferences_pb").also { it.delete() } + }, + ) + } + private val start = 1_792_000_000L + + @Before fun setUp() { + db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java).allowMainThreadQueries().build() + } + + @After fun tearDown() { + db.close() + scope.cancel() + } + + private fun workout(day: Int) = AppWorkout(start + day * 86_400L, start + day * 86_400L + 1800, 52) + + @Test fun `history badges show once on a fresh install with history`() = runBlocking { + val dao = db.healthDao() + val history = (0 until 3).map(::workout) + dao.insertAppWorkouts(history) + val keeper = RewardsKeeper(dao, settings) + assertFalse(settings.rewardsStarted.first()) + + // The workouts list starts empty before it loads: with workouts in the database, that starts nothing. + keeper.refresh(emptyList(), 3) + assertFalse(settings.rewardsStarted.first()) + assertFalse(settings.historyBadgesWaiting.first()) + + val listed = history.map { WorkoutRecorder.analyse(it, emptyList(), 190) } + keeper.refresh(listed, 3) + assertTrue(settings.rewardsStarted.first()) + assertTrue(settings.historyBadgesWaiting.first()) + assertTrue(dao.badgesOnce().any { it.id == "first_workout" }) + + // Seen: a later workout's badges are new ones for its summary, never this screen again. + settings.historyBadgesShown() + val next = workout(3) + dao.insertAppWorkouts(listOf(next)) + keeper.refresh(listed + WorkoutRecorder.analyse(next, emptyList(), 190), 3) + assertFalse(settings.historyBadgesWaiting.first()) + assertEquals(4, dao.weekResultsOnce().sumOf { it.count }) + } + @Test fun `the steps per day it keeps follow new minutes, older ones and a rewritten one`() = runBlocking { + val dao = db.healthDao() + val keeper = RewardsKeeper(dao, settings) + val listed = listOf(WorkoutRecorder.analyse(workout(0), emptyList(), 190)) + fun minute(t: Long, steps: Int) = ActivityMinute(t, steps, 0, 0, null) + suspend fun steps() = keeper.facts(listed).days.sumOf { it.steps } + dao.insertActivity(listOf(minute(start, 100), minute(start + 60, 100))) + assertEquals(200, steps()) + // A newer minute, and the last one rewritten by a sync: only the last day is read again. + dao.insertActivity(listOf(minute(start + 60, 150), minute(start + 120, 50))) + assertEquals(300, steps()) + // A minute older than any kept: everything is read again. + dao.insertActivity(listOf(minute(start - 3 * 86_400L, 70))) + assertEquals(370, steps()) + } + + @Test fun `a refresh without the screen counts workouts synced while the app was closed, less the one recording`() = runBlocking { + val dao = db.healthDao() + val keeper = RewardsKeeper(dao, settings) + dao.insertAppWorkouts((0 until 3).map(::workout)) + assertEquals(0, dao.weekResultsOnce().sumOf { it.count }) + // The weekly recap's refresh, while the last one is still recording: it waits until that one ends. + keeper.refreshNow(recordingStart = workout(2).start) + assertEquals(2, dao.weekResultsOnce().sumOf { it.count }) + keeper.refreshNow(recordingStart = null) + assertEquals(3, dao.weekResultsOnce().sumOf { it.count }) + } + + @Test fun `a database with only a strap workout, or only one recording, starts the rewards as it should`() = runBlocking { + val dao = db.healthDao() + val keeper = RewardsKeeper(dao, settings) + // A phone workout still recording is no history: the empty list starts the rewards empty. + dao.upsertAppWorkout(AppWorkout(start, null, 52)) + keeper.refresh(emptyList(), 3) + assertTrue(settings.rewardsStarted.first()) + } + + @Test fun `a strap workout is history, so the empty list waits for it`() = runBlocking { + val dao = db.healthDao() + dao.insertWorkouts(listOf(org.openstrap.app.testing.workout(start))) + RewardsKeeper(dao, settings).refresh(emptyList(), 3) + assertFalse(settings.rewardsStarted.first()) + } + + @Test fun `two refreshes at once run one after the other`() = runBlocking { + val real = db.healthDao() + val history = (0 until 3).map(::workout) + real.insertAppWorkouts(history) + val listed = history.map { WorkoutRecorder.analyse(it, emptyList(), 190) } + val events = java.util.Collections.synchronizedList(mutableListOf()) + // The first read of the stored weeks waits a moment for a second refresh to read them too, as it could if + // nothing kept the two apart: then both would write against the same weeks. + val bothRead = java.util.concurrent.CountDownLatch(2) + val dao = java.lang.reflect.Proxy.newProxyInstance( + org.openstrap.app.data.db.HealthDao::class.java.classLoader, arrayOf(org.openstrap.app.data.db.HealthDao::class.java), + ) { _, m, args -> + when (m.name) { + "weekResultsOnce" -> { + events += "read" + bothRead.countDown() + bothRead.await(500, java.util.concurrent.TimeUnit.MILLISECONDS) + } + "storeRewards" -> events += "write" + } + try { + m.invoke(real, *(args ?: emptyArray())) + } catch (e: java.lang.reflect.InvocationTargetException) { + throw e.cause!! + } + } as org.openstrap.app.data.db.HealthDao + val keeper = RewardsKeeper(dao, settings) + kotlinx.coroutines.coroutineScope { + repeat(2) { launch(kotlinx.coroutines.Dispatchers.Default) { keeper.refresh(listed, 3) } } + } + assertEquals(listOf("read", "write", "read", "write"), events.toList()) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/RewardsTest.kt b/app/src/test/kotlin/org/openstrap/app/data/RewardsTest.kt new file mode 100644 index 00000000..3f89562f --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/RewardsTest.kt @@ -0,0 +1,205 @@ +package org.openstrap.app.data + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.ZoneOffset +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.db.AppWorkoutSet +import org.openstrap.app.data.db.BadgeRow +import org.openstrap.app.data.db.WeekResult + +class RewardsTest { + private val utc = ZoneOffset.UTC + private val monday = DayOfWeek.MONDAY + + /** 2026-10-26 is a Monday. */ + private fun at(date: String, hour: Int) = LocalDate.parse(date).atTime(hour, 0).toEpochSecond(utc) + + private fun workout(date: String, hour: Int, minutes: Int = 30, sport: Int = 52, mine: Boolean = true, gps: Float? = null, stretches: List = emptyList()) = + at(date, hour).let { s -> Rewards.Done(s, s, s + minutes * 60L, sport, minutes * 60L, mine, gps, gps, stretches) } + + @Test + fun aWeekRunsMondayToSundayOrTheLocalesFirstDay() { + assertEquals(LocalDate.parse("2026-10-26"), Rewards.weekStart(LocalDate.parse("2026-11-01"), monday)) + assertEquals(LocalDate.parse("2026-11-02"), Rewards.weekStart(LocalDate.parse("2026-11-02"), monday)) + assertEquals(LocalDate.parse("2026-11-01"), Rewards.weekStart(LocalDate.parse("2026-11-01"), DayOfWeek.SUNDAY)) + } + + @Test + fun theWeekCountsAcrossItsBoundary() { + // Sunday night belongs to the week before, Monday morning to the next. + val ws = listOf(workout("2026-10-27", 7), workout("2026-10-29", 7), workout("2026-11-01", 23), workout("2026-11-02", 6)) + val weeks = Rewards.weeks(ws, emptyList(), 3, LocalDate.parse("2026-11-03"), monday, utc) + assertEquals(listOf(LocalDate.parse("2026-10-26").toEpochDay(), LocalDate.parse("2026-11-02").toEpochDay()), weeks.map { it.weekStart }) + assertEquals(listOf(3, 1), weeks.map { it.count }) + assertEquals(listOf(true, false), weeks.map { it.met }) + } + + @Test + fun onlyWorkoutsYouStartedOfTenMinutesCountAndTwoADay() { + val ws = listOf( + workout("2026-10-27", 7), workout("2026-10-27", 12), workout("2026-10-27", 18), + workout("2026-10-28", 7, minutes = 9), workout("2026-10-29", 7, mine = false), + ) + val week = Rewards.weeks(ws, emptyList(), 3, LocalDate.parse("2026-10-30"), monday, utc).single() + assertEquals(2, week.count) + assertFalse(week.met) + } + + @Test + fun aDeleteOrATrimWorksTheWeekOutAgain() { + val ws = listOf(workout("2026-10-27", 7), workout("2026-10-28", 7), workout("2026-10-29", 7)) + val today = LocalDate.parse("2026-10-30") + assertTrue(Rewards.weeks(ws, emptyList(), 3, today, monday, utc).single().met) + // Deleted: two left. + assertEquals(2, Rewards.weeks(ws.drop(1), emptyList(), 3, today, monday, utc).single().count) + // Trimmed under ten minutes: it no longer counts. + val trimmed = ws.toMutableList().also { it[2] = it[2].copy(activeSeconds = 8 * 60L) } + assertFalse(Rewards.weeks(trimmed, emptyList(), 3, today, monday, utc).single().met) + } + + @Test + fun pastWeeksKeepTheirGoalAndThisWeekTakesTheNewOne() { + val ws = listOf(workout("2026-10-20", 7), workout("2026-10-21", 7), workout("2026-10-27", 7), workout("2026-10-28", 7)) + val kept = listOf(WeekResult(LocalDate.parse("2026-10-19").toEpochDay(), 2, 2, true)) + val weeks = Rewards.weeks(ws, kept, 4, LocalDate.parse("2026-10-30"), monday, utc) + assertEquals(listOf(2, 4), weeks.map { it.goal }) + assertEquals(listOf(true, false), weeks.map { it.met }) + // The goal lowered mid week applies to this week at once. + assertTrue(Rewards.weeks(ws, kept, 2, LocalDate.parse("2026-10-30"), monday, utc).last().met) + } + + @Test + fun weeksInARowCountUpToThisWeekWithoutBreakingOnIt() { + fun week(date: String, met: Boolean) = WeekResult(LocalDate.parse(date).toEpochDay(), 3, if (met) 3 else 1, met) + val weeks = listOf(week("2026-09-28", true), week("2026-10-05", false), week("2026-10-12", true), week("2026-10-19", true), week("2026-10-26", false)) + // This week isn't met yet: the run is the two before it. + assertEquals(2, Rewards.inARow(weeks, LocalDate.parse("2026-10-26"))) + assertEquals(3, Rewards.inARow(weeks.dropLast(1) + week("2026-10-26", true), LocalDate.parse("2026-10-26"))) + // A week missing altogether breaks the run. + assertEquals(0, Rewards.inARow(weeks, LocalDate.parse("2026-11-09"))) + assertEquals(2, Rewards.bestRun(weeks)) + } + + @Test + fun theWeekLineSaysWhichWorkoutMetTheGoal() { + val ws = listOf(workout("2026-10-27", 7), workout("2026-10-28", 7)) + val third = workout("2026-10-29", 7) + assertEquals(Rewards.WeekLine(3, 3, true), Rewards.weekLine(third, ws + third, 3, monday, utc)) + val fourth = workout("2026-10-30", 7) + assertEquals(Rewards.WeekLine(4, 3, false), Rewards.weekLine(fourth, ws + third + fourth, 3, monday, utc)) + } + + @Test + fun theFastestStretchComesFromTheRoute() { + // 3 m/s for 2 km, then 4 m/s for 2 km, one fix a second. + val points = (0..666).map { Route.Point(it.toLong(), 0.0, it * 3.0 / 111_195.0, 5f) } + + (1..500).map { Route.Point(666L + it, 0.0, (1998 + it * 4.0) / 111_195.0, 5f) } + val stretches = Rewards.stretches(points) + val km = stretches.single { it.meters == 1000 } + // The fastest kilometre is at 4 m/s: 250 s. + assertTrue(km.seconds in 245..255) + assertTrue(stretches.none { it.meters == 5000 }) + assertEquals(stretches, Rewards.Stretch.parse(Rewards.Stretch.format(stretches))) + } + + @Test + fun runBestsAreFromGpsOnlyAndATrimDropsAStretchOutsideIt() { + val gps = workout("2026-10-27", 7, sport = 1, gps = 5200f, stretches = listOf(Rewards.Stretch(5000, 1600, at("2026-10-27", 7) + 60))) + val steps = workout("2026-10-28", 7, minutes = 60, sport = 1).copy(meters = 9000f) + val bests = Rewards.bests(listOf(gps, steps), emptyList(), utc).associateBy { it.kind } + assertEquals(1600.0, bests[Rewards.Kind.fastest(5000)]!!.value, 0.0) + assertEquals(5200.0, bests[Rewards.Kind.LONGEST_RUN]!!.value, 0.0) + assertEquals(steps.start, bests[Rewards.Kind.LONGEST_WORKOUT]!!.workoutStart) + val trimmed = gps.copy(shownStart = gps.start + 120) + assertFalse(Rewards.bests(listOf(trimmed), emptyList(), utc).any { it.kind == Rewards.Kind.fastest(5000) }) + } + + @Test + fun aNewBestBeatsAnOldOneAndAFirstRecordIsNotOne() { + fun lift(date: String, kg: Float) = workout(date, 7).let { w -> w.copy(sets = listOf(AppWorkoutSet(w.start, 0, w.start, w.start + 60, null, "Bench press", 10, kg))) } + val first = lift("2026-10-20", 60f) + assertTrue(Rewards.newBests(first, listOf(first), emptyList(), utc).none { it.best.kind.startsWith("lift:") }) + val second = lift("2026-10-27", 65f) + val new = Rewards.newBests(second, listOf(first, second), emptyList(), utc).single { it.best.kind.startsWith("lift:") } + assertEquals("Bench press", Rewards.Kind.liftName(new.best.kind)) + assertEquals(60.0, new.before.value, 0.0) + assertEquals(65.0, new.best.value, 0.0) + } + + @Test + fun aLiftTypedInAnotherCaseStillBeatsItsBest() { + fun lift(date: String, name: String, kg: Float) = + workout(date, 7).let { w -> w.copy(sets = listOf(AppWorkoutSet(w.start, 0, w.start, w.start + 60, null, name, 10, kg))) } + val first = lift("2026-10-20", "Bench press", 60f) + val second = lift("2026-10-27", "bench press", 65f) + val new = Rewards.newBests(second, listOf(first, second), emptyList(), utc).single { it.best.kind.startsWith("lift:") } + assertEquals(60.0, new.before.value, 0.0) + assertEquals(65.0, new.best.value, 0.0) + } + + @Test + fun firstLaunchEarnsBadgesForPastHistoryWhenTheyWereEarned() { + // Three weeks of three workouts, a run of 5 km among them, and a big day of steps. + val ws = (0 until 3).flatMap { wk -> + val mon = LocalDate.parse("2026-10-05").plusWeeks(wk.toLong()) + (0 until 3).map { d -> workout(mon.plusDays(d.toLong()).toString(), 7) } + } + workout("2026-10-15", 18, sport = 1, gps = 5100f) + val days = listOf(Rewards.Day(LocalDate.parse("2026-10-10"), 12_000)) + val weeks = Rewards.weeks(ws, emptyList(), 3, LocalDate.parse("2026-10-30"), monday, utc) + val badges = Rewards.badges(ws, days, weeks, monday, utc).associateBy { it.id } + assertEquals(ws.first().start, badges["first_workout"]!!.workoutStart) + assertEquals(ws[2].start, badges["goal_first"]!!.workoutStart) + assertEquals(ws.last().start, badges["first_5k"]!!.workoutStart) + assertEquals(ws.last().start, badges["first_run"]!!.workoutStart) + assertEquals(LocalDate.parse("2026-10-10").toEpochDay(), badges["first_10k_steps"]!!.day) + assertEquals(ws.sortedBy { it.start }[9].start, badges["workouts_10"]!!.workoutStart) + assertFalse("goal_4" in badges) + assertEquals(24, Rewards.BADGES.size) + assertEquals(Rewards.BADGES.size, Rewards.BADGES.map { it.id }.toSet().size) + } + + @Test + fun aBadgeAlreadyEarnedIsNotNewAgain() { + val old = workout("2026-10-20", 7, sport = 1) + val again = workout("2026-10-27", 7, sport = 1) + // The first run was deleted, but its badge stays stored. + val stored = listOf(BadgeRow("first_run", old.end, old.start), BadgeRow("first_workout", old.end, old.start)) + assertTrue(Rewards.newBadges(again, listOf(again), emptyList(), emptyList(), stored, monday, utc).none { it.id == "first_run" }) + assertEquals(listOf("first_workout", "first_run"), Rewards.newBadges(again, listOf(again), emptyList(), emptyList(), emptyList(), monday, utc).map { it.id }) + } + + @Test + fun aLockedBadgeSaysHowFarToGo() { + val ws = listOf(workout("2026-10-27", 7, sport = 1, gps = 7200f)) + val km = Rewards.BADGES.single { it.id == "km_100" } + assertEquals(7L to 100L, Rewards.progress(km, ws, emptyList(), emptyList(), LocalDate.parse("2026-10-26"))) + assertEquals(1L to 10L, Rewards.progress(Rewards.BADGES.single { it.id == "workouts_10" }, ws, emptyList(), emptyList(), LocalDate.parse("2026-10-26"))) + } + + @Test + fun aWorkoutTooShortToCountEarnsNoFirstsOrCounts() { + // 1:09 on the treadmill, then a 5 minute run: neither counts toward the goal, so neither earns a first. + val tiny = workout("2026-10-27", 7, sport = 2).copy(activeSeconds = 69, end = at("2026-10-27", 7) + 69) + val short = workout("2026-10-28", 7, minutes = 5, sport = 1, gps = 900f) + val badges = Rewards.badges(listOf(tiny, short), emptyList(), emptyList(), monday, utc).map { it.id } + assertTrue(badges.isEmpty()) + assertEquals(0L to 10L, Rewards.progress(Rewards.BADGES.single { it.id == "workouts_10" }, listOf(tiny, short), emptyList(), emptyList(), LocalDate.parse("2026-10-26"))) + // Its distance still adds to the total. + assertEquals(0L to 100L, Rewards.progress(Rewards.BADGES.single { it.id == "km_100" }, listOf(tiny, short), emptyList(), emptyList(), LocalDate.parse("2026-10-26"))) + // A real one earns them. + val real = workout("2026-10-29", 7, sport = 2) + assertEquals(listOf("first_workout", "first_run", "first_indoor"), Rewards.badges(listOf(tiny, short, real), emptyList(), emptyList(), monday, utc).map { it.id }) + } + + @Test + fun theFirstGoalBadgeKnowsTheGoalItWasMetAt() { + val ws = listOf(workout("2026-10-27", 7), workout("2026-10-28", 7), workout("2026-10-29", 7)) + val weeks = Rewards.weeks(ws, emptyList(), 3, LocalDate.parse("2026-10-30"), monday, utc) + val row = Rewards.badges(ws, emptyList(), weeks, monday, utc).single { it.id == "goal_first" } + assertEquals(3, Rewards.goalAt(row, weeks, monday, utc)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/RoomDayRepositoryTest.kt b/app/src/test/kotlin/org/openstrap/app/data/RoomDayRepositoryTest.kt new file mode 100644 index 00000000..992564aa --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/RoomDayRepositoryTest.kt @@ -0,0 +1,109 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.ActivityMinute +import org.openstrap.app.data.db.HrvSample +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.protocol.services.StepTotals +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate +import java.time.ZoneOffset + +/** A day as the repository reads and resolves it: the main sleep, naps, nights, trends and steps. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RoomDayRepositoryTest { + @get:Rule val zone = ZoneRule() + + private lateinit var db: OpenStrapDatabase + private val date = LocalDate.of(2026, 1, 15) + + private fun at(d: LocalDate, h: Int, m: Int = 0) = d.atTime(h, m).toEpochSecond(ZoneOffset.UTC) + + /** Last night's sleep, a shorter one after it the same night, the night before's, and today's nap. */ + private val main = SleepSession(at(date.minusDays(1), 23), at(date, 7), score = 80) + private val short = SleepSession(at(date, 8), at(date, 9)) + private val before = SleepSession(at(date.minusDays(2), 23), at(date.minusDays(1), 7), score = 70) + private val nap = SleepSession(at(date, 14), at(date, 14, 40), nap = true) + + @Before fun setUp() { + db = inMemoryDb() + runBlocking { + db.healthDao().insertSleepSessions(listOf(main, short, before, nap)) + db.healthDao().insertActivity((0 until 10).map { ActivityMinute(at(date, 10) + it * 60L, 100, 40, 1, 90) }) + db.healthDao().insertHrv(listOf(HrvSample(at(date, 3), 60), HrvSample(at(date.minusDays(1), 12), 40))) + } + } + + @After fun tearDown() = db.close() + + private fun repo(strapSteps: Flow = flowOf(null)) = RoomDayRepository(HealthRepository(db.healthDao()), LiveSteps(), strapSteps) + + private fun day(repo: RoomDayRepository = repo(), isToday: Boolean = false, check: (DayData) -> Boolean = { true }): DayData = + runBlocking { withTimeout(10_000) { repo.day(date, isToday).first(check) } } + + @Test fun `the main sleep is the night's longest, with the night before's score and the day's naps`() { + val d = day() + assertEquals(main, d.sleep.main) + assertEquals(main, d.sleep.mainAsDetected) + assertEquals(70, d.sleep.previousScore) + assertEquals(listOf(nap), d.sleep.naps) + assertEquals(mapOf(nap.start to nap), d.sleep.napsAsDetected) + assertNull(d.sleep.deletedMain) + } + + @Test fun `the main sleep follows the same rule as the widgets`() { + val d = day() + assertEquals(SleepHistory.mainSleep(listOf(main, short, before, nap), DayWindow.nightEndingOn(date)), d.sleep.main) + } + + @Test fun `a deleted main sleep gives way to the next, and with none left the longest deleted is offered back`() = runBlocking { + val repo = repo() + val health = HealthRepository(db.healthDao()) + health.deleteSleep(main) + assertEquals(short, day(repo) { it.sleep.main != main }.sleep.main) + health.deleteSleep(short) + val d = day(repo) { it.sleep.main == null } + assertEquals(main, d.sleep.deletedMain?.strap) + } + + @Test fun `the nights run the fortnight to the day shown`() { + val d = day() + assertEquals(SleepHistory.NIGHTS, d.nights.size) + assertEquals(date, d.nights.last().date) + assertEquals(main, d.nights.last().session) + assertEquals(60f, d.nights.last().hrv) + assertEquals(listOf(60), d.sleepHrv.map { it.milliseconds }) + } + + @Test fun `the week's trends run to the day shown, with nothing where a day has no data`() { + val d = day() + assertEquals((6L downTo 0L).map { date.minusDays(it) }, d.hrvTrend.map { it.date }) + assertEquals(listOf(null, null, null, null, null, 40f, 60f), d.hrvTrend.map { it.value }) + assertEquals(7, d.restingTrend.size) + assertEquals(List(7) { false }, d.restingEstimated) + assertEquals(40f, d.baselines.hrv) + } + + @Test fun `only today adds the strap's live count`() { + val live = flowOf(StepTotals(1500, 1200, null)) + assertEquals(1000, day(repo(live)).steps) + assertEquals(1500, day(repo(live), isToday = true).steps) + assertEquals(1000, day(repo(live), isToday = true).minutes.sumOf { it.steps }) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/RoomWorkoutRepositoryTest.kt b/app/src/test/kotlin/org/openstrap/app/data/RoomWorkoutRepositoryTest.kt new file mode 100644 index 00000000..01f9223d --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/RoomWorkoutRepositoryTest.kt @@ -0,0 +1,83 @@ +package org.openstrap.app.data + +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.AppWorkoutHeartRate +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.db.WorkoutHeartRate +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.appWorkout +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.app.testing.profile +import org.openstrap.app.testing.workout +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The workouts as the repository reads them: a summary read writes nothing, and every list collection gets its zones. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class RoomWorkoutRepositoryTest { + @get:Rule val zone = ZoneRule() + + private lateinit var db: OpenStrapDatabase + private val start = 1_792_000_000L + + @Before fun setUp() { + db = inMemoryDb() + } + + @After fun tearDown() = db.close() + + private fun repo(): RoomWorkoutRepository { + val p = flowOf(profile(maxHeartRate = 190)) + return RoomWorkoutRepository(db.healthDao(), p, MaxHeartRates(db.healthDao(), p)) + } + + @Test fun `reading a summary writes nothing, and the backfill writes the effect`() = runBlocking { + val dao = db.healthDao() + dao.insertAppWorkouts(listOf(appWorkout(start))) + dao.insertAppWorkoutHeartRate((0L until 1800L).map { AppWorkoutHeartRate(start, start + it, 150, false) }) + val repo = repo() + assertNotNull(withTimeout(WAIT_MS) { repo.phoneSummary(start).first { it != null } }) + assertNull(dao.appWorkoutOnce(start)?.epoc) + repo.backfillEffect(start) + assertNotNull(dao.appWorkoutOnce(start)?.epoc) + } + + @Test fun `two collections of the list each get their zones`() = runBlocking { + val dao = db.healthDao() + dao.insertWorkouts(listOf(workout(start))) + dao.insertWorkoutHeartRate((0L until 1800L).map { WorkoutHeartRate(start, start + it, 150) }) + val repo = repo() + val zoned = { l: List -> l.size == 1 && l.single().zoneSeconds.sum() > 0 } + val lists = withTimeout(WAIT_MS) { + listOf(async { repo.workouts().first(zoned) }, async { repo.workouts().first(zoned) }).map { it.await() } + } + assertEquals(lists[0], lists[1]) + } + + @Test fun `the strength rest recovery averages the rests that fell under the level`() { + val sets = listOf( + org.openstrap.app.data.db.AppWorkoutSet(start, 0, start + 60, start + 100, start + 200), + org.openstrap.app.data.db.AppWorkoutSet(start, 1, start + 300, start + 360, start + 460), + ) + val readings = listOf(start + 130 to 105, start + 380 to 120, start + 410 to 100) + assertEquals(40L, WorkoutSummaryData.restRecovery(sets, readings, 110)) + assertNull(WorkoutSummaryData.restRecovery(sets, readings, null)) + } + + private companion object { + const val WAIT_MS = 10_000L + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/RouteTest.kt b/app/src/test/kotlin/org/openstrap/app/data/RouteTest.kt index 673b8f5a..6eb73866 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/RouteTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/RouteTest.kt @@ -1,6 +1,7 @@ package org.openstrap.app.data import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -20,6 +21,14 @@ class RouteTest { assertEquals(300.0, Route.distance(run(100)), 1.0) } + @Test + fun aMapNeedsTheRouteToGoSomewhere() { + // Standing still with the fixes wandering a few metres is a dot, not a route. + assertFalse(Route.mappable((0..90).map { east(it.toLong(), (it % 3) * 2.0) })) + assertFalse(Route.mappable(listOf(east(0, 0.0)))) + assertTrue(Route.mappable(run(10))) + } + @Test fun poorFixesAndJumpsDoNotCount() { val points = run(100).toMutableList() @@ -66,10 +75,8 @@ class RouteTest { } @Test - fun paceAndItsFormat() { + fun currentPace() { assertEquals(333.0, Route.currentPace(run(100))!!, 2.0) - assertEquals("5′33″", Route.formatPace(333.0)) - assertNull(Route.formatPace(null)) } /** @@ -225,4 +232,17 @@ class RouteTest { val smoothed = Route.distance(points) assertTrue("smoothed $smoothed m", smoothed in 178.0..188.0) } + + @Test + fun climbCountsRisesPastTheNoise() { + fun at(t: Long, h: Double?, acc: Float = 5f) = Route.Point(t, 51.5, -0.1, acc, altitude = h) + // Up 10 m, down 6, up 8: 18 m climbed. The 2 m wobbles along the way don't count. + val hill = listOf(100.0, 102.0, 101.0, 105.0, 110.0, 108.0, 104.0, 106.0, 104.0, 112.0).mapIndexed { i, h -> at(i.toLong(), h) } + assertEquals(18.0, Route.climb(hill)!!, 1e-6) + // Flat with noise under the step: nothing. + assertEquals(0.0, Route.climb(listOf(100.0, 102.0, 100.0, 103.0, 101.0).mapIndexed { i, h -> at(i.toLong(), h) })!!, 1e-6) + // A bad fix's height is left out; no heights at all is no climb. + assertEquals(0.0, Route.climb(listOf(at(0, 100.0), at(1, 150.0, acc = 80f), at(2, 101.0)))!!, 1e-6) + assertNull(Route.climb(listOf(at(0, null), at(1, null)))) + } } diff --git a/app/src/test/kotlin/org/openstrap/app/data/SharedHelpersTest.kt b/app/src/test/kotlin/org/openstrap/app/data/SharedHelpersTest.kt new file mode 100644 index 00000000..bfdbe9bd --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/SharedHelpersTest.kt @@ -0,0 +1,158 @@ +package org.openstrap.app.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.openstrap.app.testing.ZoneRule +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import kotlin.random.Random + +/** Stats, Days, HeartRate and SportTypes, the shared helpers the engines move onto in wave 3. */ +class SharedHelpersTest { + // A zone the tests' own zones differ from, so nothing leans on the default. + @get:Rule val zoneRule = ZoneRule(ZoneId.of("Asia/Kathmandu")) + + private val berlin = ZoneId.of("Europe/Berlin") + + /* ----------------------------------------------- Stats ----------------------------------------------- */ + + @Test + fun median() { + assertNull(Stats.median(emptyList())) + assertEquals(3f, Stats.median(listOf(5f, 1f, 3f))!!, 0f) + assertEquals(2.5f, Stats.median(listOf(4f, 1f, 3f, 2f))!!, 0f) + assertEquals(7f, Stats.median(listOf(7f))!!, 0f) + assertEquals(2.5f, Stats.median(listOf(1, 2, 3, 4))!!, 0f) + assertNull(Stats.median(emptyList())) + } + + @Test + fun medianMatchesTheCopiesItReplaces() { + val r = Random(7) + repeat(200) { + val values = List(1 + r.nextInt(30)) { r.nextFloat() * 100 } + val s = values.sorted() + val n = s.size + // UsualSteps and Goals. + val usual = if (n % 2 == 1) s[n / 2] else (s[n / 2 - 1] + s[n / 2]) / 2f + assertEquals(usual, Stats.median(values)!!, 1e-4f) + } + } + + @Test + fun upperMiddle() { + assertNull(Stats.upperMiddle(emptyList())) + assertEquals(3, Stats.upperMiddle(listOf(4, 1, 3, 2))) + assertEquals(2, Stats.upperMiddle(listOf(3, 1, 2))) + } + + @Test + fun quantile() { + assertNull(Stats.quantile(emptyList(), 0.5f)) + assertEquals(1f, Stats.quantile(listOf(3f, 1f, 2f), 0f)!!, 0f) + assertEquals(3f, Stats.quantile(listOf(3f, 1f, 2f), 1f)!!, 0f) + assertEquals(1.5f, Stats.quantile(listOf(3f, 1f, 2f), 0.25f)!!, 1e-6f) + assertEquals(3f, Stats.quantile(listOf(3f, 1f, 2f), 2f)!!, 0f) + assertEquals(1f, Stats.quantile(listOf(3f, 1f, 2f), -1f)!!, 0f) + assertEquals(10.0, Stats.quantileOfSorted(listOf(10.0), 0.9), 0.0) + assertThrows(IllegalArgumentException::class.java) { Stats.quantileOfSorted(emptyList(), 0.5) } + } + + @Test + fun quantileMatchesRecoverysPercentile() { + val r = Random(11) + repeat(200) { + val values = List(1 + r.nextInt(30)) { r.nextFloat() * 100 } + val p = r.nextFloat() + assertEquals(Recovery.percentile(values, p)!!, Stats.quantile(values, p)!!, 1e-3f) + } + } + + /* ----------------------------------------------- Days ------------------------------------------------ */ + + @Test + fun anOrdinaryDayIs24Hours() { + val d = LocalDate.of(2026, 6, 10) + assertEquals(24 * 3600L, Days.end(d, berlin) - Days.start(d, berlin)) + assertEquals(Instant.parse("2026-06-09T22:00:00Z").epochSecond, Days.start(d, berlin)) + } + + @Test + fun theDayTheClocksGoForwardIs23Hours() { + val d = LocalDate.of(2026, 3, 29) + val start = Days.start(d, berlin) + assertEquals(Instant.parse("2026-03-28T23:00:00Z").epochSecond, start) + assertEquals(23 * 3600L, Days.end(d, berlin) - start) + // Any moment in the day gives the same bounds, its last second too. + for (t in listOf(start, start + 2 * 3600, Days.end(d, berlin) - 1)) { + assertEquals(start, Days.start(t, berlin)) + assertEquals(Days.end(d, berlin), Days.end(t, berlin)) + assertEquals(d, Days.date(t, berlin)) + } + assertEquals(d.plusDays(1), Days.date(Days.end(d, berlin), berlin)) + } + + @Test + fun theDayTheClocksGoBackIs25Hours() { + val d = LocalDate.of(2026, 10, 25) + val start = Days.start(d, berlin) + assertEquals(Instant.parse("2026-10-24T22:00:00Z").epochSecond, start) + assertEquals(25 * 3600L, Days.end(d, berlin) - start) + assertEquals(start, Days.start(Instant.parse("2026-10-25T01:30:00Z").epochSecond, berlin)) + } + + @Test + fun aDayWhoseMidnightIsSkippedStartsAtItsFirstMoment() { + // Chile moves its clocks at midnight: on the first Sunday of September 2026, 00:00 becomes 01:00. + val santiago = ZoneId.of("America/Santiago") + val d = LocalDate.of(2026, 9, 6) + if (santiago.rules.getTransition(LocalDateTime.of(d, LocalTime.MIDNIGHT)) == null) return + assertEquals(LocalTime.of(1, 0), Instant.ofEpochSecond(Days.start(d, santiago)).atZone(santiago).toLocalTime()) + assertEquals(23 * 3600L, Days.end(d, santiago) - Days.start(d, santiago)) + } + + @Test + fun daysMatchDayWindow() { + for (d in listOf(LocalDate.of(2026, 3, 29), LocalDate.of(2026, 10, 25), LocalDate.of(2026, 1, 1))) { + val w = DayWindow.of(d, berlin) + assertEquals(w.from, Days.start(d, berlin)) + assertEquals(w.to + 1, Days.end(d, berlin)) + } + } + + /* --------------------------------------------- HeartRate --------------------------------------------- */ + + @Test + fun validHeartRateIs30To230() { + assertEquals(30..230, HeartRate.VALID) + assertEquals("BETWEEN 30 AND 230", HeartRate.SQL_VALID) + assertFalse(HeartRate.isValid(29)) + assertTrue(HeartRate.isValid(30)) + assertTrue(HeartRate.isValid(230)) + assertFalse(HeartRate.isValid(231)) + } + + /* --------------------------------------------- SportTypes -------------------------------------------- */ + + @Test + fun sportSetsAreTheOnesTheyReplace() { + assertEquals(setOf(1, 2, 11), SportTypes.RUNS) + assertEquals(setOf(24, 50, 52, 222, 226), SportTypes.STRENGTH) + assertEquals(setOf(3, 15, 40, 215, 224, 225), SportTypes.WALKS) + assertTrue(SportTypes.HIKES.all { it in SportTypes.WALKS }) + } + + @Test + fun everySportHasAName() { + val all = SportTypes.ON_FOOT + SportTypes.RIDES + SportTypes.SWIMS + SportTypes.STRENGTH + SportTypes.ROWING_MACHINE + for (id in all) assertTrue("no name for $id", Workouts.sportName(id) != "Workout") + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/SleepEditsTest.kt b/app/src/test/kotlin/org/openstrap/app/data/SleepEditsTest.kt new file mode 100644 index 00000000..22308296 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/SleepEditsTest.kt @@ -0,0 +1,203 @@ +package org.openstrap.app.data + +import java.time.LocalDateTime +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.db.SleepEdit +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.presentation.SleepEditCopy + +class SleepEditsTest { + private val zone = ZoneId.of("Europe/Berlin") + + /** [SleepEdits.check] as the edit sheet says it. */ + private fun problem(strap: SleepSession, start: Long, end: Long, now: Long, others: List = emptyList(), zone: ZoneId = this.zone) = + SleepEdits.check(strap, start, end, now, others)?.let { SleepEditCopy.text(it, zone) } + private fun at(h: Int, m: Int, day: Int = 30) = LocalDateTime.of(2026, 10, day, h, m).atZone(zone).toEpochSecond() + private val nap = SleepSession(at(14, 10), at(14, 48), nap = true) + private val main = SleepSession(at(23, 20, day = 29), at(7, 21), score = 81, deepMinutes = 90, lightMinutes = 240, remMinutes = 100, awakeMinutes = 30) + private val now = at(18, 0) + + private fun moved(s: SleepSession, start: Long, end: Long) = SleepEdit(s.start, s.nap, start, end, editedAt = now) + private fun deleted(s: SleepSession) = SleepEdit(s.start, s.nap, deleted = true, editedAt = now) + + @Test fun `a moved nap takes the user's times, anything else stays`() { + val out = SleepEdits.apply(listOf(main, nap), listOf(moved(nap, at(14, 2), at(14, 55)))) + assertSame(main, out[0]) + assertEquals(at(14, 2), out[1].start) + assertEquals(at(14, 55), out[1].end) + } + + @Test fun `a moved main sleep takes the user's times`() { + val out = SleepEdits.apply(listOf(main, nap), listOf(moved(main, at(23, 55, day = 29), at(7, 21)))) + assertEquals(at(23, 55, day = 29), out[0].start) + assertSame(nap, out[1]) + // The strap's totals are for its own span, so they go until the stages are clipped. + assertNull(out[0].deepMinutes) + } + + @Test fun `a deleted session is left out, and restoring it brings back exactly what was there`() { + val gone = deleted(main) + assertEquals(listOf(nap), SleepEdits.apply(listOf(main, nap), listOf(gone))) + assertNull(SleepEdits.restore(gone, now)) + assertEquals(listOf(main, nap), SleepEdits.apply(listOf(main, nap), emptyList())) + } + + @Test fun `moved then deleted, then restored, keeps the user's times`() { + val m = moved(nap, at(14, 2), at(14, 55)) + val d = SleepEdits.delete(nap, m, now + 60) + assertTrue(d.deleted) + assertEquals(at(14, 2), d.start) + assertEquals(emptyList(), SleepEdits.apply(listOf(nap), listOf(d))) + val r = SleepEdits.restore(d, now + 120)!! + assertFalse(r.deleted) + assertEquals(at(14, 2) to at(14, 55), SleepEdits.apply(listOf(nap), listOf(r)).single().let { it.start to it.end }) + } + + @Test fun `an edit never crosses kinds`() { + // A main sleep starting where a nap's edit is keyed is never moved or removed by it. + val other = SleepSession(nap.start, at(20, 0)) + assertSame(other, SleepEdits.apply(listOf(other), listOf(deleted(nap))).single()) + val nearby = SleepSession(nap.start + 300, at(20, 0)) + assertSame(nearby, SleepEdits.apply(listOf(nearby), listOf(deleted(nap))).single()) + } + + @Test fun `a resync that moves the strap's start a little keeps the edit, a bigger move doesn't`() { + val e = moved(nap, at(14, 2), at(14, 55)) + val shifted = nap.copy(start = nap.start + 4 * 60) + assertEquals(at(14, 2), SleepEdits.apply(listOf(shifted), listOf(e)).single().start) + val far = nap.copy(start = nap.start + 16 * 60) + assertSame(far, SleepEdits.apply(listOf(far), listOf(e)).single()) + // The exact start wins over a nearer fuzzy one, and an edit goes to one session only. + val exact = SleepEdits.resolve(listOf(shifted, nap), listOf(e)) + assertNull(exact[0].edit) + assertEquals(e, exact[1].edit) + } + + @Test fun `stages are trimmed to the session's times, and a moved session's totals come from them`() { + val stages = listOf( + SleepStage(at(14, 10), at(14, 20), nap.start, SleepStage.LIGHT), + SleepStage(at(14, 20), at(14, 40), nap.start, SleepStage.DEEP), + SleepStage(at(14, 40), at(14, 48), nap.start, SleepStage.LIGHT), + ) + val out = SleepEdits.clip(stages, at(14, 15), at(14, 40)) + assertEquals(listOf(at(14, 15) to at(14, 20), at(14, 20) to at(14, 40)), out.map { it.start to it.end }) + val shown = SleepEdits.moved(nap, at(14, 15), at(14, 40)) + val restaged = SleepEdits.restage(shown, nap, stages) + assertEquals(20, restaged.deepMinutes) + assertEquals(5, restaged.lightMinutes) + } + + @Test fun `a move under 15 minutes in all keeps the strap's score, a bigger one works it out again`() { + assertEquals(81, SleepEdits.moved(main, main.start + 5 * 60, main.end - 9 * 60).score) + val big = SleepEdits.moved(main, main.start + 35 * 60, main.end) + assertNotNull(big.score) + assertTrue(big.score!! < 81) + // A nap has no score of its own. + assertNull(SleepEdits.moved(nap.copy(score = 50), nap.start - 30 * 60, nap.end).score) + } + + @Test fun `35 minutes cut from the start never raises the strap's score`() { + // Last night as the strap scored it, 83, then the start moved from 23:20 to 23:55. + val night = SleepSession(at(23, 20, day = 29), at(7, 21), score = 83, deepMinutes = 95, lightMinutes = 296, remMinutes = 60, awakeMinutes = 30) + fun stage(h1: Int, m1: Int, h2: Int, m2: Int, st: Int) = + SleepStage(at(h1, m1, day = if (h1 >= 12) 29 else 30), at(h2, m2, day = if (h2 >= 12) 29 else 30), night.start, st) + val stages = listOf( + stage(23, 20, 23, 30, SleepStage.AWAKE), stage(23, 30, 23, 55, SleepStage.LIGHT), + stage(23, 55, 1, 30, SleepStage.DEEP), stage(1, 30, 1, 40, SleepStage.AWAKE), + stage(1, 40, 4, 0, SleepStage.LIGHT), stage(4, 0, 5, 0, SleepStage.REM), + stage(5, 0, 5, 20, SleepStage.AWAKE), stage(5, 20, 7, 21, SleepStage.LIGHT), + ) + val start = at(23, 55, day = 29) + val shown = SleepEdits.restage(SleepEdits.moved(night, start, night.end), night, stages) + assertTrue("scored ${shown.score}", shown.score!! <= 83) + assertTrue("scored ${SleepEdits.moved(night, start, night.end).score}", SleepEdits.moved(night, start, night.end).score!! <= 83) + // Moving it back to the strap's own times gives the strap's score again. + assertEquals(83, SleepEdits.restage(SleepEdits.moved(night, night.start, night.end), night, stages).score) + } + + @Test fun `a big move shifts the strap's score by the change in OpenStrap's own, never past the direction of the move`() { + assertEquals(70, SleepEdits.anchored(83, 72, 85, lengthChange = -2100)) + // Shorter but the clipped stages read better: held at the strap's. + assertEquals(83, SleepEdits.anchored(83, 90, 85, lengthChange = -2100)) + // Longer but reading worse: held at the strap's too. + assertEquals(83, SleepEdits.anchored(83, 80, 85, lengthChange = 1800)) + assertEquals(100, SleepEdits.anchored(95, 100, 80, lengthChange = 1800)) + assertEquals(0, SleepEdits.anchored(10, 20, 60, lengthChange = -3600)) + } + + @Test fun `times that can't be saved are refused, by kind`() { + assertNull(problem(nap, at(14, 0), at(15, 0), now)) + assertEquals("Waking up has to come after falling asleep.", problem(nap, at(14, 30), at(14, 20), now)) + assertEquals("A nap needs at least 5 minutes.", problem(nap, at(14, 30), at(14, 33), now)) + assertEquals("A nap can be 4 hours at most.", problem(nap, at(11, 0), at(15, 30), now)) + assertEquals("That's later than now.", problem(nap, at(16, 0), at(18, 30), now)) + assertEquals("Keep it within 3 hours of what the strap saw.", problem(nap, at(10, 30), at(11, 0), now)) + assertNull(problem(main, at(23, 55, day = 29), at(7, 21), now)) + assertEquals("Sleep needs at least an hour.", problem(main, at(6, 30), at(7, 21), now)) + assertEquals("Sleep can be 16 hours at most.", problem(main.copy(end = at(10, 0)), at(20, 50, day = 29), at(12, 55), at(20, 0))) + } + + @Test fun `times that run into another sleep or nap are refused`() { + val earlier = SleepSession(at(13, 0), at(14, 5), nap = true) + assertEquals("That runs into your nap at 13:00.", problem(nap, at(14, 0), at(14, 48), now, listOf(earlier, main), zone)) + val evening = SleepSession(at(22, 0, day = 29), at(23, 0, day = 29), nap = true) + assertEquals("That runs into your nap at 22:00.", problem(main, at(22, 50, day = 29), at(7, 21), now, listOf(evening), zone)) + assertEquals("That runs into your sleep at 23:20.", problem(evening, at(22, 0, day = 29), at(23, 30, day = 29), now, listOf(main), zone)) + // Touching isn't running into. + assertNull(problem(nap, at(14, 5), at(14, 48), now, listOf(earlier), zone)) + } + + @Test fun `the strap's own times again drop the edit, unless it's deleted`() { + assertNull(SleepEdits.edit(nap, nap.start, nap.end, null, now)) + assertNull(SleepEdits.edit(nap, nap.start, nap.end, moved(nap, at(14, 5), nap.end), now)) + assertEquals(SleepEdit(nap.start, true, at(14, 5), nap.end, editedAt = now), SleepEdits.edit(nap, at(14, 5), nap.end, null, now)) + val d = SleepEdits.edit(nap, nap.start, nap.end, deleted(nap), now)!! + assertTrue(d.deleted) + assertFalse(d.moved) + // A fuzzy-matched edit keeps its own key. + val old = moved(nap, at(14, 2), at(14, 55)).copy(strapStart = nap.start - 120) + assertEquals(nap.start - 120, SleepEdits.edit(nap, at(14, 0), at(14, 55), old, now)!!.strapStart) + } + + @Test fun `a delete is kept 7 days, then leaves a marker`() { + val d = deleted(main).copy(start = at(23, 55, day = 29), end = at(7, 21)) + assertFalse(SleepEdits.expired(d, now + 6 * 86_400L)) + assertTrue(SleepEdits.expired(d, now + 7 * 86_400L)) + assertEquals(7, SleepEdits.daysLeft(d, now + 60)) + assertEquals(6, SleepEdits.daysLeft(d, now + 86_400L + 60)) + assertEquals(1, SleepEdits.daysLeft(d, now + 7 * 86_400L - 60)) + val m = SleepEdits.marker(d) + assertTrue(m.deleted) + assertNull(m.start) + // The marker still drops a resynced session, even one a few minutes off. + assertEquals(emptyList(), SleepEdits.apply(listOf(main.copy(start = main.start + 180)), listOf(m))) + assertFalse(SleepEdits.expired(moved(main, 1, 2), now + 30 * 86_400L)) + } + + @Test fun `a nap whose heart rate stayed as in the hour before, with no deep sleep, may not be a nap`() { + fun readings(before: Float, during: Float) = + (0 until 12).map { nap.start - 3600 + it * 300L to before } + (0 until 7).map { nap.start + it * 300L to during } + assertEquals(72, SleepEdits.stillHeartRate(readings(74f, 72f), nap.start, nap.end, deepSeconds = 0)) + // A real drop into sleep, or any deep sleep, isn't flagged. + assertNull(SleepEdits.stillHeartRate(readings(74f, 62f), nap.start, nap.end, deepSeconds = 0)) + assertNull(SleepEdits.stillHeartRate(readings(74f, 72f), nap.start, nap.end, deepSeconds = 600)) + // Nothing to compare with says nothing. + assertNull(SleepEdits.stillHeartRate(readings(74f, 72f).filter { it.first >= nap.start }, nap.start, nap.end, deepSeconds = 0)) + } + + @Test fun `a picked clock is read on the day nearest the session`() { + assertEquals(at(14, 5), SleepEdits.atNear(14 * 60 + 5, nap.start, zone)) + // A late nap running past midnight: 00:20 is the next day's. + assertEquals(at(0, 20, day = 31), SleepEdits.atNear(20, at(23, 50), zone)) + // And 23:40 picked for a nap the strap started at 00:10 is the evening before. + assertEquals(at(23, 40, day = 29), SleepEdits.atNear(23 * 60 + 40, at(0, 10), zone)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/SleepHistoryTest.kt b/app/src/test/kotlin/org/openstrap/app/data/SleepHistoryTest.kt index 9d2fe9a0..225ac33f 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/SleepHistoryTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/SleepHistoryTest.kt @@ -3,6 +3,7 @@ package org.openstrap.app.data import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import org.openstrap.app.data.db.SleepEdit import org.openstrap.app.data.db.SleepSession import java.time.LocalDate import java.time.ZoneOffset @@ -20,8 +21,8 @@ class SleepHistoryTest { @Test fun sleepBelongsToTheDayItEnds() { assertEquals(end, SleepHistory.nightOf(night(0, -60, 7 * 60).end, zone)) - // A sleep ending at 19:00 counts towards the next night. - assertEquals(end.plusDays(1), SleepHistory.nightOf(night(0, 13 * 60, 19 * 60).end, zone)) + // A sleep ending at 19:00 counts towards the day it ended on, as recovery counts it. + assertEquals(end, SleepHistory.nightOf(night(0, 13 * 60, 19 * 60).end, zone)) } @Test @@ -32,6 +33,32 @@ class SleepHistoryTest { assertEquals(mapOf(end to long), SleepHistory.mainSleeps(listOf(short, nap, long), zone)) } + @Test + fun mainSleepOverEditsGoesByTheTimesShown() { + val window = DayWindow.nightEndingOn(end, zone) + val long = night(0, -60, 7 * 60) + val short = night(0, 8 * 60, 9 * 60) + val nap = night(0, -120, 9 * 60, nap = true) + // The short one moved to run all morning, so it's now the longest as shown. + val moved = SleepEdits.Resolved(short, SleepEdit(short.start, nap = false, start = short.start - 5 * 3600, end = short.end + 3 * 3600)) + val resolved = listOf(SleepEdits.Resolved(long, null), moved, SleepEdits.Resolved(nap, null)) + assertEquals(moved, SleepHistory.mainSleep(resolved, window)) + // The same pick as over the sessions as shown, so Today and the widgets agree. + assertEquals(SleepHistory.mainSleep(resolved.mapNotNull { it.shown }, window), SleepHistory.mainSleep(resolved, window)?.shown) + } + + @Test + fun mainSleepOverEditsLeavesTheDeletedOut() { + val window = DayWindow.nightEndingOn(end, zone) + val long = night(0, -60, 7 * 60) + val short = night(0, 8 * 60, 9 * 60) + val gone = SleepEdits.Resolved(long, SleepEdit(long.start, nap = false, deleted = true)) + val resolved = listOf(gone, SleepEdits.Resolved(short, null)) + assertEquals(short, SleepHistory.mainSleep(resolved, window)?.strap) + assertEquals(gone, SleepHistory.mainSleep(resolved, window, deleted = true)) + assertNull(SleepHistory.mainSleep(listOf(gone), window)) + } + @Test fun asleepUsesStageTotals() { val s = night(0, -60, 7 * 60).copy(deepMinutes = 60, lightMinutes = 240, remMinutes = 90, awakeMinutes = 30) diff --git a/app/src/test/kotlin/org/openstrap/app/data/SleepReadersTest.kt b/app/src/test/kotlin/org/openstrap/app/data/SleepReadersTest.kt new file mode 100644 index 00000000..5f546e8c --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/SleepReadersTest.kt @@ -0,0 +1,38 @@ +package org.openstrap.app.data + +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.io.File + +/** + * Sleep is read through [SleepSource] (or the repository's wrappers of it), so every screen, widget and model counts + * the same sleep after a move or a delete (sleep edit spec 3). Only the source itself, the sync that writes the + * strap's sessions and the backup that copies them as they are may read the DAO's sleep sessions or stages. + */ +class SleepReadersTest { + private val allowed = setOf("data/SleepSource.kt", "device/HistorySync.kt", "export/CsvTables.kt", "data/db/Daos.kt") + + /** A call of a sleep session or stage read, with what it's called on. */ + private val call = Regex("""(\w+)(\(\))?\.sleep(Sessions|Stages)\w*\(""") + + /** Receivers that are the repository's wrappers, not the DAO: a view model's and the day repository's. */ + private val wrappers = setOf("repo", "health") + + @Test fun `nothing reads the DAO's sleep outside the source, the sync and the backup`() { + val roots = listOf("src/main", "src/debug", "app/src/main", "app/src/debug").map(::File).filter { it.isDirectory } + assertTrue("found no source", roots.isNotEmpty()) + val found = mutableListOf() + roots.forEach { root -> + val kotlin = File(root, "kotlin/org/openstrap/app") + kotlin.walkTopDown().filter { it.isFile && it.extension == "kt" }.forEach { f -> + val path = f.relativeTo(kotlin).invariantSeparatorsPath + if (path in allowed) return@forEach + f.readLines().forEachIndexed { i, line -> + call.findAll(line).filter { it.groupValues[1] !in wrappers }.forEach { found += "$path:${i + 1} ${it.value}" } + } + } + } + if (found.isNotEmpty()) fail("Read sleep through SleepSource instead:\n" + found.joinToString("\n")) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/SleepSourceTest.kt b/app/src/test/kotlin/org/openstrap/app/data/SleepSourceTest.kt new file mode 100644 index 00000000..679e0601 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/SleepSourceTest.kt @@ -0,0 +1,144 @@ +package org.openstrap.app.data + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.db.SleepEdit +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepStage +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Sleep read through the edits against a real database: what every screen and model sees. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SleepSourceTest { + private lateinit var db: OpenStrapDatabase + private val dao get() = db.healthDao() + private val h = 3600L + private val main = SleepSession(100 * h, 108 * h, score = 80, deepMinutes = 100, lightMinutes = 300, remMinutes = 60) + private val nap = SleepSession(114 * h, 115 * h, nap = true) + + @Before fun open() { + db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java).allowMainThreadQueries().build() + } + @After fun close() = db.close() + + private suspend fun seed() { + dao.insertSleepSessions(listOf(main, nap)) + // Two hours of deep, then light to the end. + dao.insertSleepStages(listOf( + SleepStage(100 * h, 102 * h, main.start, SleepStage.DEEP), + SleepStage(102 * h, 108 * h, main.start, SleepStage.LIGHT), + )) + } + + @Test fun `a moved main sleep has its stages clipped and its totals worked out from them`() = runTest { + seed() + dao.upsertSleepEdit(SleepEdit(main.start, nap = false, start = 101 * h, end = 108 * h, editedAt = 1)) + val source = SleepSource(dao) + val shown = source.sessionsOnce(0, 200 * h) + val m = shown.single { !it.nap } + assertEquals(101 * h, m.start) + assertEquals(60, m.deepMinutes) + assertEquals(360, m.lightMinutes) + assertTrue(m.score != 80) + assertEquals(listOf(101 * h to 102 * h, 102 * h to 108 * h), source.stagesOnce(m).map { it.start to it.end }) + assertEquals(source.stagesOnce(m), source.stages(m).first()) + // The flow reads the same. + assertEquals(shown, source.sessions(0, 200 * h).first()) + } + + @Test fun `a deleted session is left out, and comes back on restore`() = runTest { + seed() + dao.upsertSleepEdit(SleepEdit(nap.start, nap = true, deleted = true, editedAt = 1)) + val source = SleepSource(dao) + assertEquals(listOf(main), source.sessionsOnce(0, 200 * h)) + assertTrue(source.resolvedOnce(0, 200 * h).single { it.strap.nap }.deleted) + dao.deleteSleepEdit(nap.start) + assertEquals(listOf(main, nap), source.sessionsOnce(0, 200 * h)) + } + + @Test fun `the window is on the end as shown`() = runTest { + seed() + // The nap moved to end an hour later, past the window's end at 115 h. + dao.upsertSleepEdit(SleepEdit(nap.start, nap = true, start = 114 * h, end = 116 * h, editedAt = 1)) + val source = SleepSource(dao) + assertEquals(listOf(main), source.sessionsOnce(0, 115 * h)) + assertEquals(116 * h, source.sessionsOnce(0, 117 * h).single { it.nap }.end) + } + + @Test fun `delete, undo and restore through the repository bring back exactly what was there`() = runTest { + seed() + val repo = HealthRepository(dao) + repo.setSleepTimes(main, 101 * h, 108 * h) + val moved = repo.sleep.sessionsOnce(0, 200 * h) + repo.deleteSleep(main) + assertEquals(listOf(nap), repo.sleep.sessionsOnce(0, 200 * h)) + assertEquals(listOf(main.start), repo.sleep.deletedOnce(200 * h).map { it.strap.start }) + repo.restoreSleep(main) + assertEquals(moved, repo.sleep.sessionsOnce(0, 200 * h)) + assertEquals(emptyList(), repo.sleep.deletedOnce(200 * h)) + // A delete of an unmoved nap leaves no trace once restored. + repo.deleteSleep(nap) + repo.restoreSleep(nap) + assertEquals(listOf(main.start), dao.sleepEditsOnce().map { it.strapStart }) + } + + @Test fun `after 7 days a delete goes for good, and a resync doesn't bring it back`() = runTest { + seed() + val day = 86_400L + dao.upsertSleepEdit(SleepEdit(main.start, nap = false, start = 101 * h, end = 108 * h, deleted = true, editedAt = 200 * h)) + val source = SleepSource(dao) + // Still restorable on day 6, and nothing goes. + assertEquals(0, source.purgeExpired(200 * h + 6 * day)) + assertEquals(1, source.deletedOnce(200 * h + 6 * day).size) + // Day 7: the session and its stages go, the edit stays as a marker without times. + assertEquals(1, source.purgeExpired(200 * h + 7 * day)) + assertEquals(listOf(nap), dao.sleepSessionsOnce(0, 200 * h)) + assertEquals(emptyList(), dao.sleepStagesOnce(main.start)) + val marker = dao.sleepEditsOnce().single() + assertTrue(marker.deleted && marker.start == null && marker.end == null) + assertEquals(emptyList(), source.deletedOnce(200 * h + 7 * day)) + // The strap sends the night again, a few minutes off: it stays out, and the next clean up takes it away. + dao.insertSleepSessions(listOf(main.copy(start = main.start + 120))) + assertEquals(listOf(nap), source.sessionsOnce(0, 200 * h)) + assertEquals(1, source.purgeExpired(200 * h + 8 * day)) + assertEquals(listOf(nap), dao.sleepSessionsOnce(0, 200 * h)) + } + + @Test fun `a resync that shifts the strap's start keeps the move, and the stages follow`() = runTest { + seed() + dao.upsertSleepEdit(SleepEdit(main.start, nap = false, start = 101 * h, end = 108 * h, editedAt = 1)) + // The strap re-sends the night starting 4 minutes later. + dao.deleteSleepStagesOf(main.start) + dao.deleteSleepSession(main.start) + val shifted = main.copy(start = main.start + 240) + dao.insertSleepSessions(listOf(shifted)) + dao.insertSleepStages(listOf(SleepStage(shifted.start, 108 * h, shifted.start, SleepStage.LIGHT))) + val source = SleepSource(dao) + val m = source.sessionsOnce(0, 200 * h).single { !it.nap } + assertEquals(101 * h, m.start) + assertEquals(listOf(101 * h to 108 * h), source.stagesOnce(m).map { it.start to it.end }) + } + @Test fun `deleting a nap leaves the edit of a nap next to it alone`() = runTest { + // Two naps ten minutes apart; the first was moved. The second has no edit of its own to find. + val first = SleepSession(114 * h, 115 * h, nap = true) + val second = SleepSession(114 * h + 600, 115 * h + 600, nap = true) + dao.insertSleepSessions(listOf(first, second)) + val moved = SleepEdit(first.start, nap = true, start = 114 * h + 300, end = 115 * h, editedAt = 1) + dao.upsertSleepEdit(moved) + HealthRepository(dao).deleteSleep(second) + val edits = dao.sleepEditsOnce().associateBy { it.strapStart } + assertEquals(moved, edits[first.start]) + assertTrue(edits.getValue(second.start).deleted) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/StrengthTest.kt b/app/src/test/kotlin/org/openstrap/app/data/StrengthTest.kt index 30c80d2d..9faa2ace 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/StrengthTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/StrengthTest.kt @@ -47,4 +47,45 @@ class StrengthTest { assertEquals(Strength.Rest(120, null), Strength.Rest.parse(Strength.Rest(120, null).code)) assertNull(Strength.Rest.parse("x")) } + + private fun set(i: Int, exercise: String?, reps: Int?, kg: Float?) = + org.openstrap.app.data.db.AppWorkoutSet(1, i, 100L * i, 100L * i + 40, exercise = exercise, reps = reps, weightKg = kg) + + @Test + fun theLogCardFillsFromTheLastSetOfThatExercise() { + val sets = listOf(set(0, "Bench press", 10, 60f), set(1, "Squat", 8, 100f), set(2, "Bench press", 8, 62.5f), set(3, "Bench press", null, null)) + // This workout's last logged set of it comes first, matched whatever the case. + assertEquals(8 to 62.5f, Strength.prefill("bench press", sets, 12, 50f)) + // Not logged here yet: what the exercise list remembers, else the defaults. + assertEquals(12 to 50f, Strength.prefill("Deadlift", sets, 12, 50f)) + assertEquals(Strength.DEFAULT_REPS to Strength.DEFAULT_KG, Strength.prefill("Deadlift", sets, null, null)) + } + + @Test + fun kgLiftedCountsOnlyLoggedSets() { + val sets = listOf(set(0, "Bench press", 10, 60f), set(1, "Squat", 8, 100f), set(2, null, null, null), set(3, "Plank", 1, null)) + assertEquals(1400f, Strength.kgLifted(sets)!!, 1e-3f) + assertNull(Strength.kgLifted(listOf(set(0, null, null, null)))) + } + + @Test + fun yourExercisesComeBeforeTheCommonLifts() { + val list = Strength.exerciseList(listOf("Cable fly", "squat")) + assertEquals(listOf("Cable fly", "squat", "Bench press"), list.take(3)) + assertEquals(1, list.count { it.equals("Squat", ignoreCase = true) }) + } + + @Test + fun logSteppersAndWeights() { + assertEquals(62.5f, Strength.stepKg(60f, 1)) + assertEquals(0f, Strength.stepKg(0f, -1)) + assertEquals(1, Strength.stepReps(1, -1)) + assertEquals("60", Strength.kgText(60f)) + assertEquals("62.5", Strength.kgText(62.5f)) + assertEquals(148, Strength.peak(listOf(10L to 120, 20L to 148, 50L to 160), 0, 40)) + // Both ends count; nothing in the span, or nothing at all, is no peak. + assertEquals(160, Strength.peak(listOf(10L to 120, 20L to 148, 50L to 160), 20, 50)) + assertEquals(null, Strength.peak(listOf(10L to 120, 50L to 160), 20, 40)) + assertEquals(null, Strength.peak(emptyList(), 0, 40)) + } } diff --git a/app/src/test/kotlin/org/openstrap/app/data/SummariesTest.kt b/app/src/test/kotlin/org/openstrap/app/data/SummariesTest.kt index 7843e95d..fae0e57d 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/SummariesTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/SummariesTest.kt @@ -12,10 +12,11 @@ class SummariesTest { @Test fun hourlyStepsBucketsByHour() { - val day = 1_000_000L + val day = 1_000_000L / 86_400 * 86_400 val buckets = Summaries.hourlySteps( listOf(minute(day, 10), minute(day + 59 * 60, 5), minute(day + 3600, 7), minute(day + 25 * 3600, 99)), day, + java.time.ZoneOffset.UTC, ) assertEquals(24, buckets.size) assertEquals(15f, buckets[0]) diff --git a/app/src/test/kotlin/org/openstrap/app/data/TodayTest.kt b/app/src/test/kotlin/org/openstrap/app/data/TodayTest.kt new file mode 100644 index 00000000..fef8a161 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/TodayTest.kt @@ -0,0 +1,72 @@ +package org.openstrap.app.data + +import android.content.Context +import android.content.Intent +import android.os.Looper +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.testing.FixedClock +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.time.Duration +import java.time.Instant +import java.time.LocalDate + +/** [Today] moves on at midnight on the minute tick, and at once when the phone's clock or zone changes (audit C10). */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class TodayTest { + private val clock = FixedClock(Instant.parse("2026-01-15T23:59:30Z")) + private val jan15 = LocalDate.of(2026, 1, 15) + + @Test fun `today moves on at the first minute past midnight, once`() = runTest { + val dates = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { Today.dates({ clock.clock }).collect { dates += it } } + runCurrent() + assertEquals(listOf(jan15), dates) + clock.advance(Duration.ofMinutes(1)) + // Not before the minute ends: the clock is only read on the tick. + advanceTimeBy(29_000) + assertEquals(listOf(jan15), dates) + advanceTimeBy(1_001) + assertEquals(listOf(jan15, jan15.plusDays(1)), dates) + // Later minutes of the same day add nothing. + clock.advance(Duration.ofMinutes(5)) + advanceTimeBy(Duration.ofMinutes(5).toMillis()) + assertEquals(listOf(jan15, jan15.plusDays(1)), dates) + } + + @Test fun `a change of the phone's clock is read at once`() = runTest { + val changes = MutableSharedFlow() + val dates = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { Today.dates({ clock.clock }, changes).collect { dates += it } } + runCurrent() + clock.advance(Duration.ofDays(3)) + changes.emit(Unit) + assertEquals(listOf(jan15, jan15.plusDays(3)), dates) + } + + @Test fun `time, zone and date broadcasts each count as a change`() = runTest { + val context = ApplicationProvider.getApplicationContext() + var seen = 0 + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { Today.changes(context).collect { seen++ } } + runCurrent() + for (action in listOf(Intent.ACTION_TIME_CHANGED, Intent.ACTION_TIMEZONE_CHANGED, Intent.ACTION_DATE_CHANGED)) { + context.sendBroadcast(Intent(action)) + shadowOf(Looper.getMainLooper()).idle() + runCurrent() + } + assertEquals(3, seen) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/Vo2maxTest.kt b/app/src/test/kotlin/org/openstrap/app/data/Vo2maxTest.kt index 3c2f99e1..c69dd7f7 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/Vo2maxTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/Vo2maxTest.kt @@ -102,8 +102,6 @@ class Vo2maxTest { assertEquals(70..80, Vo2max.Friend.fitnessAge(10f, m)) // Women use their own medians: 25.7 is the 40-49 median. assertEquals(35..55, Vo2max.Friend.fitnessAge(25.7f, UserProfile.Sex.Female)) - assertEquals("typical of a woman aged about 35–55", Vo2max.Friend.fitnessAgeText(35..55, UserProfile.Sex.Female)) - assertEquals("typical of a man aged about 45–65", Vo2max.Friend.fitnessAgeText(45..65, m)) } @Test fun `fitness age is withheld with the estimate`() { diff --git a/app/src/test/kotlin/org/openstrap/app/data/WeekStartTest.kt b/app/src/test/kotlin/org/openstrap/app/data/WeekStartTest.kt new file mode 100644 index 00000000..cdbf86f6 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/WeekStartTest.kt @@ -0,0 +1,60 @@ +package org.openstrap.app.data + +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.openstrap.app.data.db.BadgeRow +import org.openstrap.app.data.Weeks +import org.openstrap.app.testing.LocaleRule +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.Locale + +/** + * Rewards and history agree on the week (D7) in a Sunday-first locale, and the rows stored by week (week_result, + * and a badge's week through [Rewards.goalAt]) still line up when read back. + */ +class WeekStartTest { + @get:Rule val locale = LocaleRule(Locale.US) + + private val zone = ZoneOffset.UTC + + /** Wednesday 14 Oct 2026: its US week starts on Sunday the 11th, its German one on Monday the 12th. */ + private val wednesday = LocalDate.of(2026, 10, 14) + private val sunday = LocalDate.of(2026, 10, 11) + + private fun at(d: LocalDate, hour: Int) = d.atTime(hour, 0).toEpochSecond(zone) + + private fun done(d: LocalDate) = Rewards.Done(start = at(d, 8), end = at(d, 9), sport = SportTypes.OUTDOOR_RUN, activeSeconds = 3600) + + @Test fun `every week starts on the locale's first day`() { + assertEquals(DayOfWeek.SUNDAY, Weeks.firstDay()) + assertEquals(sunday, Rewards.weekStart(wednesday, Weeks.firstDay())) + val stats = listOf(DayStat(sunday, 10f, 10f, 10f, 1), DayStat(wednesday, 30f, 30f, 30f, 1)) + assertEquals(listOf(sunday), History.weekly(stats).map { it.date }) + } + + @Test fun `stored weeks and a badge's week line up in a Sunday-first locale`() { + val firstDay = Weeks.firstDay() + // Sunday and Wednesday are one US week, so two workouts count in it. + val workouts = listOf(done(sunday), done(wednesday)) + val weeks = Rewards.weeks(workouts, emptyList(), goal = 2, today = wednesday, firstDay = firstDay, zone = zone) + assertEquals(listOf(sunday.toEpochDay()), weeks.map { it.weekStart }) + assertEquals(2, weeks.single().count) + // Read back as kept rows, the same weeks come out, and the week keeps its stored goal. + val kept = weeks.map { it.copy(goal = 3) } + val again = Rewards.weeks(workouts, kept, goal = 2, today = wednesday.plusDays(7), firstDay = firstDay, zone = zone) + assertEquals(3, again.first { it.weekStart == sunday.toEpochDay() }.goal) + // A badge earned on the Wednesday finds that week's goal. + val badge = BadgeRow("first_workout", earnedAt = at(wednesday, 9), workoutStart = at(wednesday, 8)) + assertEquals(3, Rewards.goalAt(badge, kept, firstDay, zone)) + } + + @Test fun `a Monday-first locale keeps Monday weeks`() { + Locale.setDefault(Locale.GERMANY) + assertEquals(LocalDate.of(2026, 10, 12), Rewards.weekStart(wednesday, Weeks.firstDay())) + val stats = listOf(DayStat(sunday, 10f, 10f, 10f, 1), DayStat(wednesday, 30f, 30f, 30f, 1)) + assertEquals(listOf(LocalDate.of(2026, 10, 5), LocalDate.of(2026, 10, 12)), History.weekly(stats).map { it.date }) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/WeeksTest.kt b/app/src/test/kotlin/org/openstrap/app/data/WeeksTest.kt new file mode 100644 index 00000000..eabd63c2 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/WeeksTest.kt @@ -0,0 +1,76 @@ +package org.openstrap.app.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.openstrap.app.presentation.localeParameters +import org.openstrap.app.testing.LocaleRule +import org.openstrap.app.testing.ZoneRule +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.WeekFields +import java.util.Locale + +/** A week starts on the phone's locale's first day, in each test language, over the clock change Sundays. */ +@RunWith(Parameterized::class) +class WeeksTest(private val locale: Locale) { + companion object { + @JvmStatic @Parameterized.Parameters(name = "{0}") + fun locales() = localeParameters() + + private val DST_START = LocalDate.of(2026, 3, 29) + private val DST_END = LocalDate.of(2026, 10, 25) + } + + @get:Rule val localeRule = LocaleRule(locale) + @get:Rule val zoneRule = ZoneRule(ZoneId.of("Europe/Berlin")) + + @Test + fun firstDayIsTheLocales() { + assertEquals(WeekFields.of(locale).firstDayOfWeek, Weeks.firstDay()) + assertEquals(Weeks.firstDay(locale), Weeks.firstDay()) + when (locale.toLanguageTag()) { + "de-DE" -> assertEquals(DayOfWeek.MONDAY, Weeks.firstDay()) + "en-US" -> assertEquals(DayOfWeek.SUNDAY, Weeks.firstDay()) + } + } + + @Test + fun theWeekHoldsTheDay() { + for (date in listOf(DST_START, DST_END, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 12, 31))) { + val start = Weeks.start(date) + assertEquals(Weeks.firstDay(), start.dayOfWeek) + assertTrue(!start.isAfter(date) && date.isBefore(start.plusDays(7))) + assertEquals(start.plusDays(6), Weeks.end(date)) + val days = Weeks.days(date) + assertEquals(7, days.size) + assertEquals((0L..6L).map(start::plusDays), days) + assertTrue(date in days) + } + } + + @Test + fun clockChangeSundays() { + // Both are Sundays: the first day of a US week, the last of a German one. + assertEquals(DST_START, Weeks.start(DST_START, DayOfWeek.SUNDAY)) + assertEquals(LocalDate.of(2026, 3, 23), Weeks.start(DST_START, DayOfWeek.MONDAY)) + assertEquals(DST_END, Weeks.end(DST_END, DayOfWeek.MONDAY)) + assertEquals(LocalDate.of(2026, 10, 31), Weeks.end(DST_END, DayOfWeek.SUNDAY)) + if (locale.toLanguageTag() == "en-US") assertEquals(DST_START, Weeks.start(DST_START)) + if (locale.toLanguageTag() == "de-DE") assertEquals(LocalDate.of(2026, 3, 23), Weeks.start(DST_START)) + } + + @Test + fun matchesTheRewardsWeek() { + for (first in DayOfWeek.entries) { + for (offset in 0L..13L) { + val date = DST_START.plusDays(offset) + assertEquals(Rewards.weekStart(date, first), Weeks.start(date, first)) + } + } + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/WorkoutSetupTest.kt b/app/src/test/kotlin/org/openstrap/app/data/WorkoutSetupTest.kt new file mode 100644 index 00000000..4ead64a5 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/WorkoutSetupTest.kt @@ -0,0 +1,185 @@ +package org.openstrap.app.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.Workouts.Family + +class WorkoutSetupTest { + @Test + fun everySportHasOneFamily() { + // Every id, kept or hidden, maps to a family without throwing; unknown ids too. + Workouts.SPORT_IDS.forEach { Workouts.family(it) } + assertEquals(Family.Distance, Workouts.family(1)) + assertEquals(Family.Distance, Workouts.family(3)) + assertEquals(Family.Distance, Workouts.family(4)) + assertEquals(Family.Distance, Workouts.family(15)) + assertEquals(Family.IndoorCardio, Workouts.family(2)) + assertEquals(Family.IndoorCardio, Workouts.family(8)) + assertEquals(Family.IndoorCardio, Workouts.family(9)) + assertEquals(Family.IndoorCardio, Workouts.family(23)) + assertEquals(Family.IndoorCardio, Workouts.family(40)) + assertEquals(Family.IndoorCardio, Workouts.family(5)) + SportTypes.STRENGTH.forEach { assertEquals(Family.Strength, Workouts.family(it)) } + assertEquals(Family.Intervals, Workouts.family(49)) + listOf(53, 55, 60, 61).forEach { assertEquals(Family.MindBody, Workouts.family(it)) } + listOf(17, 18, 85, 205, 204).forEach { assertEquals(Family.Games, Workouts.family(it)) } + } + + @Test + fun sportsNotListedFallBackByTheirSides() { + // Swims have an indoor side: indoor cardio. A sport with one kind and no list: games. + assertEquals(Family.IndoorCardio, Workouts.family(6)) + assertEquals(Family.IndoorCardio, Workouts.family(7)) + assertEquals(Family.Games, Workouts.family(70)) + assertEquals(Family.Games, Workouts.family(9999)) + } + + @Test + fun everyOutdoorSportIsADistanceOne() { + Workouts.OUTDOOR.forEach { assertEquals("sport $it", Family.Distance, Workouts.family(it)) } + } + + @Test + fun settingsRoundTrip() { + val s = SportSettings(goal = "d:5000", askToEnd = false, gps = false, keepRoute = true, rest = "r:90:110", sets = 5, log = true, structure = "t:20/10x8") + assertEquals(s, SportSettings.parse(s.encode())) + assertEquals(SportSettings(), SportSettings.parse(SportSettings().encode())) + } + + @Test + fun theRouteIsKeptUnlessTurnedOff() { + assertTrue(SportSettings().keepRoute) + // Lines saved before the map was on by default carry k=0, which no longer counts. + assertTrue(SportSettings.parse("a=1|p=1|k=0|l=0").keepRoute) + assertFalse(SportSettings.parse(SportSettings(keepRoute = false).encode()).keepRoute) + } + + @Test + fun unreadableSettingsFallBackToTheDefaults() { + assertEquals(SportSettings(), SportSettings.parse(null)) + assertEquals(SportSettings(), SportSettings.parse("nonsense|g=x:1|s=99|r=bad|a=maybe")) + // What's readable is kept. + assertEquals(SportSettings(goal = "t:1800"), SportSettings.parse("g=t:1800|zz=1")) + } + + @Test + fun chooseFindsYourUsualAndTwoRecent() { + val now = 100 * 86_400L + val day = 86_400L + // Newest first: walk, run, strength, run, treadmill (a run), yoga. + val started = listOf(3 to now - day, 1 to now - 2 * day, 52 to now - 3 * day, 1 to now - 4 * day, 2 to now - 5 * day, 60 to now - 6 * day) + val c = WorkoutSetup.choose(started, now) + assertEquals(1, c.usual) + assertEquals(listOf(3, 52), c.recent) + } + + @Test + fun noUsualOutsideTheLastFourWeeks() { + val now = 100 * 86_400L + val c = WorkoutSetup.choose(listOf(60 to now - 40 * 86_400L), now) + assertNull(c.usual) + assertEquals(listOf(60, 1, 3), c.recent) + } + + @Test + fun recentChipsAreTheSportsThenTheNewestSavedWorkouts() { + fun saved(name: String, structure: String) = org.openstrap.app.data.db.SavedWorkout(name = name, sport = 1, structure = structure, createdAt = 0) + val chips = WorkoutSetup.recentChips( + listOf(52, 60), + // Newest first; one whose rounds no longer read is left out. + listOf(saved("Tuesday intervals", "r:400m/90x6"), saved("Broken", "nonsense"), saved("Hills", "r:200m/60x8"), saved("Oldest", "r:1000m/120x4")), + ) + assertEquals( + listOf(RecentChip.Sport(52), RecentChip.Sport(60), RecentChip.Saved("Tuesday intervals", 1, "r:400m/90x6"), RecentChip.Saved("Hills", 1, "r:200m/60x8")), + chips, + ) + assertEquals(listOf(RecentChip.Sport(1)), WorkoutSetup.recentChips(listOf(1), emptyList())) + } + + @Test + fun aNewUserGetsRunWalkAndStrength() { + val c = WorkoutSetup.choose(emptyList(), 0) + assertNull(c.usual) + assertEquals(listOf(1, 3, 52), c.recent) + } + + @Test + fun startingYourUsualTakesLastTimesSettings() { + val run = SportSettings(goal = "d:5000", keepRoute = true, askToEnd = false) + val p = WorkoutSetup.plan(1, run, emptyMap(), location = true) + assertEquals(WorkoutPlan(1, askToEnd = false, target = WorkoutTarget.Distance(5000), useGps = true, keepRoute = true), p) + // On the treadmill side last time: no GPS, and 5 km from steps still counts. + val treadmill = WorkoutSetup.plan(1, run, mapOf(1 to true), location = true) + assertEquals(2, treadmill.sportType) + assertFalse(treadmill.useGps) + assertEquals(WorkoutTarget.Distance(5000), treadmill.target) + // Location off: no GPS, never blocked. + assertFalse(WorkoutSetup.plan(1, run, emptyMap(), location = false).useGps) + // A ride without GPS has no distance to aim for. + assertNull(WorkoutSetup.plan(4, run, emptyMap(), location = false).target) + // A kcal goal from before isn't one Ready offers, so it isn't started either. + assertNull(WorkoutSetup.plan(1, SportSettings(goal = "k:300"), emptyMap(), location = true).target) + // Strength takes its rest and sets. + val lift = WorkoutSetup.plan(52, SportSettings(rest = "r:120:110", sets = 4), emptyMap(), location = true) + assertEquals(Strength.Rest(120, 110), lift.rest) + assertEquals(4, lift.setsTarget) + assertFalse(lift.useGps) + } + + @Test + fun offeredGoals() { + assertTrue(WorkoutSetup.offered(WorkoutTarget.Distance(5000), hasDistance = true, kcal = false)) + assertFalse(WorkoutSetup.offered(WorkoutTarget.Distance(5000), hasDistance = false, kcal = true)) + assertFalse(WorkoutSetup.offered(WorkoutTarget.Kcal(300), hasDistance = true, kcal = false)) + assertTrue(WorkoutSetup.offered(null, hasDistance = false, kcal = false)) + } + + @Test + fun distanceGoals() { + assertEquals(WorkoutTarget.Distance(5000), WorkoutTarget.parse("d:5000")) + assertNull(WorkoutTarget.parse("d:0")) + assertEquals("5 km", WorkoutTarget.Distance(5000).label) + assertEquals("21.1 km", WorkoutTarget.Distance(21_100).label) + assertEquals("800 m", WorkoutTarget.Distance(800).label) + assertEquals("Zone 2 to 3", WorkoutTarget.Zones(1, 2).label) + } + + @Test + fun aDistanceGoalBuzzesAtHalfwayThenOnceReached() { + val coach = Coach(WorkoutTarget.Distance(5000)) + assertNull(coach.distance(0, 1000f)) + assertEquals(Cue.Halfway, coach.distance(10, 2500f)) + assertNull(coach.distance(20, 3000f)) + assertEquals(Cue.GoalReached, coach.distance(30, 5001f)) + assertEquals(30L, coach.reachedAt) + assertNull(coach.distance(40, 6000f)) + // Other goals never buzz on distance; a distance goal never on time. + assertNull(Coach(WorkoutTarget.Time(600)).distance(0, 10_000f)) + assertNull(Coach(WorkoutTarget.Distance(5000)).feed(0, 2, 10_000)) + // Picked up again after reaching it, it stays quiet. + assertNull(Coach(WorkoutTarget.Distance(5000), reachedAt = 5).distance(10, 6000f)) + } + + @Test fun `Ready's goal kinds, a distance where there is one, intervals for a run`() { + val k = WorkoutSetup.GoalKind.entries + assertEquals(k, WorkoutSetup.goalKinds(1, hasDistance = true)) + assertEquals(k, WorkoutSetup.goalKinds(2, hasDistance = true)) + assertEquals(k - WorkoutSetup.GoalKind.Intervals, WorkoutSetup.goalKinds(3, hasDistance = true)) + assertEquals(listOf(WorkoutSetup.GoalKind.None, WorkoutSetup.GoalKind.Time, WorkoutSetup.GoalKind.Zone), WorkoutSetup.goalKinds(9, hasDistance = false)) + assertEquals(WorkoutSetup.GoalKind.Intervals, WorkoutSetup.goalKind(WorkoutTarget.Time(60), Structure.Reps(400, null, 90, 6))) + assertEquals(WorkoutSetup.GoalKind.Zone, WorkoutSetup.goalKind(WorkoutTarget.Zones(1, 2), null)) + assertEquals(WorkoutSetup.GoalKind.None, WorkoutSetup.goalKind(WorkoutTarget.Kcal(300), null)) + } + + @Test fun `a second zone tapped makes a range, a third starts over`() { + val z2 = WorkoutSetup.pickZone(null, 1) + assertEquals(WorkoutTarget.Zones(1, 1), z2) + assertEquals(WorkoutTarget.Zones(1, 2), WorkoutSetup.pickZone(z2, 2)) + assertEquals(WorkoutTarget.Zones(0, 1), WorkoutSetup.pickZone(z2, 0)) + assertEquals(WorkoutTarget.Zones(1, 1), WorkoutSetup.pickZone(z2, 1)) + assertEquals(WorkoutTarget.Zones(4, 4), WorkoutSetup.pickZone(WorkoutTarget.Zones(1, 2), 4)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/ZoneCacheTest.kt b/app/src/test/kotlin/org/openstrap/app/data/ZoneCacheTest.kt similarity index 98% rename from app/src/test/kotlin/org/openstrap/app/ui/workout/ZoneCacheTest.kt rename to app/src/test/kotlin/org/openstrap/app/data/ZoneCacheTest.kt index 843b92dc..ff775b6d 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/workout/ZoneCacheTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/ZoneCacheTest.kt @@ -1,4 +1,4 @@ -package org.openstrap.app.ui.workout +package org.openstrap.app.data import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/DailyEnergyMigrationTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/DailyEnergyMigrationTest.kt index 052cb0ca..a6e1a10a 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/db/DailyEnergyMigrationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/db/DailyEnergyMigrationTest.kt @@ -1,17 +1,14 @@ package org.openstrap.app.data.db -import android.database.sqlite.SQLiteDatabase -import androidx.room.Room -import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.test.runTest -import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.openstrap.app.testing.MigrationHarness import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -import java.io.File /** * A version 19 database opens at 20 with its phone workouts intact and without kcal, and an empty daily energy table @@ -20,47 +17,27 @@ import java.io.File @RunWith(RobolectricTestRunner::class) @Config(sdk = [35]) class DailyEnergyMigrationTest { + @get:Rule val migrations = MigrationHarness() + @Test fun dailyEnergyAndWorkoutKcalAfterMigration() = runTest { - val file = File(System.getProperty("java.io.tmpdir"), "openstrap-migration-${System.nanoTime()}.db") - // Built from the exported schema, so it's the database version 19 really made. - val schema = JSONObject(File("schemas/org.openstrap.app.data.db.OpenStrapDatabase/19.json").readText()).getJSONObject("database") - SQLiteDatabase.openOrCreateDatabase(file, null).use { db -> - val entities = schema.getJSONArray("entities") - for (i in 0 until entities.length()) { - val e = entities.getJSONObject(i) - val table = e.getString("tableName") - db.execSQL(e.getString("createSql").replace("\${TABLE_NAME}", table)) - val indices = e.optJSONArray("indices") ?: continue - for (j in 0 until indices.length()) db.execSQL(indices.getJSONObject(j).getString("createSql").replace("\${TABLE_NAME}", table)) - } - val setup = schema.getJSONArray("setupQueries") - for (i in 0 until setup.length()) db.execSQL(setup.getString(i)) - db.execSQL("INSERT INTO app_workout (start, `end`, sportType, source, pauses, averageHeartRate) VALUES (1000, 2800, 1, 0, '', 140)") - db.version = 19 - } - - val room = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) - .allowMainThreadQueries().build() - try { - val dao = room.healthDao() - val w = dao.appWorkoutOnce(1000)!! - assertEquals(140, w.averageHeartRate) - assertNull(w.activeKcal) - assertNull(w.totalKcal) - dao.updateAppWorkoutKcal(1000, 210f, 260f) - assertEquals(260f, dao.appWorkoutOnce(1000)!!.totalKcal) + val room = migrations.open(migrations.create(19) { + execSQL("INSERT INTO app_workout (start, `end`, sportType, source, pauses, averageHeartRate) VALUES (1000, 2800, 1, 0, '', 140)") + }) + val dao = room.healthDao() + val w = dao.appWorkoutOnce(1000)!! + assertEquals(140, w.averageHeartRate) + assertNull(w.activeKcal) + assertNull(w.totalKcal) + dao.updateAppWorkoutKcal(1000, 210f, 260f) + assertEquals(260f, dao.appWorkoutOnce(1000)!!.totalKcal) - assertEquals(emptyList(), dao.dailyEnergyDaysOnce()) - fun row(day: Long, total: Float) = DailyEnergyRow(day, 1, total, 400f, 1650f, 1440, false, 120f, 200f, List(24) { "1.0" }.joinToString(",")) - dao.insertDailyEnergy(listOf(row(86_400, 2100f), row(172_800, 2300f))) - // The same day again replaces it. - dao.insertDailyEnergy(listOf(row(172_800, 2350f))) - assertEquals(listOf(2100f, 2350f), dao.dailyEnergyOnce(0, 200_000).map { it.totalKcal }) - assertEquals(24, dao.dailyEnergyOnce(86_400, 86_400).single().hourlyKcal.size) - } finally { - room.close() - listOf("", "-wal", "-shm", "-journal").forEach { File(file.path + it).delete() } - } + assertEquals(emptyList(), dao.dailyEnergyDaysOnce()) + fun row(day: Long, total: Float) = DailyEnergyRow(day, 1, total, 400f, 1650f, 1440, false, 120f, 200f, List(24) { "1.0" }.joinToString(",")) + dao.insertDailyEnergy(listOf(row(86_400, 2100f), row(172_800, 2300f))) + // The same day again replaces it. + dao.insertDailyEnergy(listOf(row(172_800, 2350f))) + assertEquals(listOf(2100f, 2350f), dao.dailyEnergyOnce(0, 200_000).map { it.totalKcal }) + assertEquals(24, dao.dailyEnergyOnce(86_400, 86_400).single().hourlyKcal.size) } } diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/DeleteStrapWorkoutTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/DeleteStrapWorkoutTest.kt index 047bd0a1..f7488bad 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/db/DeleteStrapWorkoutTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/db/DeleteStrapWorkoutTest.kt @@ -1,7 +1,5 @@ package org.openstrap.app.data.db -import androidx.room.Room -import androidx.test.core.app.ApplicationProvider import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After @@ -10,6 +8,8 @@ import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.app.testing.workout import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -23,12 +23,12 @@ class DeleteStrapWorkoutTest { private val junk = 1_760_000_000L private val real = junk + 4 * 3600 - private fun workout(start: Long, minutes: Int) = - Workout(start, start + minutes * 60, 16, minutes * 60, null, 110, 130, 80, null, null, 10, null, null, subtype = 0) + private fun strapWorkout(start: Long, minutes: Int) = + workout(start, minutes, sportType = 16, averageHeartRate = 110, maxHeartRate = 130, minHeartRate = 80, trainingLoad = 10, subtype = 0) @Before fun setUp() { - db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java).allowMainThreadQueries().build() + db = inMemoryDb() dao = db.healthDao() } @@ -36,7 +36,7 @@ class DeleteStrapWorkoutTest { fun tearDown() = db.close() private suspend fun seed(junkEffect: Boolean) { - dao.insertWorkouts(listOf(workout(junk, 30), workout(real, 40))) + dao.insertWorkouts(listOf(strapWorkout(junk, 30), strapWorkout(real, 40))) dao.insertWorkoutHeartRate((0L..2000L step 10).map { WorkoutHeartRate(junk, junk + it, 100) }) dao.insertWorkoutHeartRate((0L..2400L step 10).map { WorkoutHeartRate(real, real + it, 140) }) dao.insertRecoveryHeartRate(listOf(RecoveryHeartRateRow(junk, byteArrayOf(100, 90)), RecoveryHeartRateRow(real, byteArrayOf(120, 100)))) diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/DeletedWorkoutMigrationTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/DeletedWorkoutMigrationTest.kt index 9a523d24..21ed8d40 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/db/DeletedWorkoutMigrationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/db/DeletedWorkoutMigrationTest.kt @@ -37,7 +37,7 @@ class DeletedWorkoutMigrationTest { } val room = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) - .allowMainThreadQueries().build() + .addMigrations(*OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build() try { val dao = room.healthDao() assertEquals(listOf(1000L), dao.workoutsOnce(0, Long.MAX_VALUE).map { it.start }) diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/HabitAgeWeekMigrationTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/HabitAgeWeekMigrationTest.kt index bcf9e8e7..de5173f7 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/db/HabitAgeWeekMigrationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/db/HabitAgeWeekMigrationTest.kt @@ -37,7 +37,7 @@ class HabitAgeWeekMigrationTest { } val room = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) - .allowMainThreadQueries().build() + .addMigrations(*OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build() try { val dao = room.healthDao() assertEquals(1, room.openHelper.readableDatabase.query("SELECT * FROM health_alert").use { it.count }) diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/MigrationChainTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/MigrationChainTest.kt new file mode 100644 index 00000000..dbd12944 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/db/MigrationChainTest.kt @@ -0,0 +1,81 @@ +package org.openstrap.app.data.db + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.testing.MigrationHarness +import org.openstrap.app.testing.SourceTree +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The whole migration chain (audit test 2): a database from every exported version opens at the current one, which + * Room checks table by table and index by index against the schema, and a version 1 database keeps its rows all the + * way. An old install that fails here would crash at launch with no way back, as there is no destructive fallback. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class MigrationChainTest { + @get:Rule val harness = MigrationHarness() + + /** The newest exported schema, which Room writes for the version the database is at. */ + private val current = SourceTree.schemas.listFiles()!!.filter { it.extension == "json" }.maxOf { it.nameWithoutExtension.toInt() } + + @Test fun `every exported version opens at the current one`() = runTest { + val versions = SourceTree.schemas.listFiles()!!.filter { it.extension == "json" }.map { it.nameWithoutExtension.toInt() }.sorted() + assertEquals((1..current).toList(), versions) + assertEquals(27, current) + for (v in 1 until current) { + val db = harness.open(harness.create(v)) + // Any read opens it, running the migrations and Room's check of the result. + assertEquals("from $v", 0, db.healthDao().workoutCount()) + assertEquals("from $v", current, db.openHelper.readableDatabase.version) + db.close() + } + } + + @Test fun `a version 1 database keeps its data through every migration`() = runTest { + val file = harness.create(1) { + execSQL("INSERT INTO heart_rate (timestamp, bpm, source) VALUES (1000, 61, 0)") + execSQL("INSERT INTO activity_minute (timestamp, steps, intensity, kind, heartRate) VALUES (1020, 12, 30, 1, 64)") + execSQL("INSERT INTO sleep_session (start, `end`) VALUES (2000, 30800)") + execSQL("INSERT INTO sleep_stage (start, `end`, sessionStart, stage) VALUES (2000, 9200, 2000, 2)") + execSQL("INSERT INTO spo2 (timestamp, percent) VALUES (3000, 97)") + execSQL("INSERT INTO stress (timestamp, value) VALUES (3060, 33)") + execSQL("INSERT INTO hrv (timestamp, milliseconds) VALUES (3120, 48)") + execSQL("INSERT INTO resting_hr (day, bpm) VALUES (86400, 55)") + } + val dao = harness.open(file).healthDao() + assertEquals(listOf(HeartRateSample(1000, 61, 0)), dao.heartRateOnce(0, 5000)) + assertEquals(listOf(ActivityMinute(1020, 12, 30, 1, 64)), dao.activityOnce(0, 5000)) + // A session from before days were kept has day 0, and isn't a nap. + assertEquals(listOf(SleepSession(2000, 30800)), dao.sleepSessionsOnce(0, 40_000)) + assertEquals(listOf(SleepStage(2000, 9200, 2000, 2)), dao.sleepStagesOfSessionsEndingOnce(0, 40_000)) + assertEquals(listOf(Spo2Sample(3000, 97)), dao.spo2Once(0, 5000)) + assertEquals(listOf(StressSample(3060, 33)), dao.stressOnce(0, 5000)) + assertEquals(listOf(HrvSample(3120, 48)), dao.hrvOnce(0, 5000)) + assertEquals(listOf(RestingHeartRate(86400, 55)), dao.restingHrOnce(0, 100_000)) + assertEquals(setOf(0L, 8L), dao.hoursWithData(0, 100_000).first().toSet()) + } + + @Test fun `version 27 adds the end indices and leaves the rows`() = runTest { + val file = harness.create(26) { + execSQL("INSERT INTO sleep_session (start, `end`, day, nap) VALUES (2000, 30800, 0, 0)") + execSQL("INSERT INTO app_workout (start, `end`, sportType, source, pauses) VALUES (1000, 2800, 52, 0, '')") + execSQL("INSERT INTO app_workout_dismissed (start, `end`) VALUES (5000, 6000)") + } + val db = harness.open(file) + val dao = db.healthDao() + assertEquals(1, dao.sleepSessionsOnce(0, 40_000).size) + assertEquals(listOf(1000L), dao.appWorkoutsStartingOnce(0, 5000).map { it.start }) + assertEquals(listOf(DismissedWorkout(5000, 6000)), dao.dismissedWorkoutsStartingOnce(0, 9000)) + val indices = db.openHelper.readableDatabase.query("SELECT name FROM sqlite_master WHERE type = 'index'").use { c -> + buildSet { while (c.moveToNext()) add(c.getString(0)) } + } + assertTrue(indices.toString(), indices.containsAll(setOf("index_sleep_session_nap_end", "index_app_workout_end", "index_app_workout_dismissed_end"))) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/PaiProjectionMigrationTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/PaiProjectionMigrationTest.kt index 76385754..446bb7d1 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/db/PaiProjectionMigrationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/db/PaiProjectionMigrationTest.kt @@ -38,7 +38,7 @@ class PaiProjectionMigrationTest { } val room = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) - .allowMainThreadQueries().build() + .addMigrations(*OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build() try { val dao = room.healthDao() val old = dao.paiOnce(0, 2000).single() diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/ReplaceTransactionsTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/ReplaceTransactionsTest.kt new file mode 100644 index 00000000..d5ac3f2d --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/db/ReplaceTransactionsTest.kt @@ -0,0 +1,107 @@ +package org.openstrap.app.data.db + +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.SleepSource +import org.openstrap.app.testing.inMemoryDb +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The delete-then-insert replaces run all or none (audit P4): a write that fails half way leaves what was there. A + * trigger stands in for the failure, such as a full disk, or a kill between the statements. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ReplaceTransactionsTest { + private lateinit var db: OpenStrapDatabase + private val dao get() = db.healthDao() + private val h = 3600L + private val day = 99 * h + private val night = SleepSession(100 * h, 108 * h, day, nap = false, score = 80) + private val stages = listOf( + SleepStage(100 * h, 102 * h, night.start, SleepStage.DEEP), + SleepStage(102 * h, 108 * h, night.start, SleepStage.LIGHT), + ) + + @Before fun open() { + db = inMemoryDb() + } + @After fun close() = db.close() + + private fun failInsertsInto(table: String) = + db.openHelper.writableDatabase.execSQL("CREATE TRIGGER fail_$table BEFORE INSERT ON $table BEGIN SELECT RAISE(ABORT, 'disk full'); END") + + private fun failDeletesFrom(table: String, where: String) = + db.openHelper.writableDatabase.execSQL("CREATE TRIGGER fail_$table BEFORE DELETE ON $table WHEN $where BEGIN SELECT RAISE(ABORT, 'disk full'); END") + + @Test fun `replaceSleepDay swaps the night for the new copy`() = runTest { + dao.replaceSleepDay(day, nap = false, listOf(night), stages) + val newer = night.copy(start = 101 * h, score = 85) + dao.replaceSleepDay(day, nap = false, listOf(newer), listOf(SleepStage(101 * h, 108 * h, newer.start, SleepStage.REM))) + + assertEquals(listOf(newer), dao.sleepSessionsOnce(0, 200 * h)) + assertEquals(emptyList(), dao.sleepStagesOnce(night.start)) + assertEquals(1, dao.sleepStagesOnce(newer.start).size) + } + + @Test fun `replaceSleepDay leaves the other kind alone`() = runTest { + val nap = SleepSession(114 * h, 115 * h, day, nap = true) + dao.replaceSleepDay(day, nap = false, listOf(night), stages) + dao.replaceSleepDay(day, nap = true, listOf(nap), emptyList()) + dao.replaceSleepDay(day, nap = true, emptyList(), emptyList()) + + assertEquals(listOf(night), dao.sleepSessionsOnce(0, 200 * h)) + assertEquals(stages, dao.sleepStagesOnce(night.start)) + } + + @Test fun `replaceSleepDay that fails mid insert keeps the old night`() = runTest { + dao.replaceSleepDay(day, nap = false, listOf(night), stages) + failInsertsInto("sleep_stage") + + val newer = night.copy(start = 101 * h, score = 85) + val thrown = runCatching { dao.replaceSleepDay(day, nap = false, listOf(newer), listOf(SleepStage(101 * h, 108 * h, newer.start, SleepStage.REM))) } + assertTrue(thrown.isFailure) + + assertEquals(listOf(night), dao.sleepSessionsOnce(0, 200 * h)) + assertEquals(stages, dao.sleepStagesOnce(night.start)) + } + + @Test fun `replaceWorkoutDetail that fails mid insert keeps the old stream`() = runTest { + val start = 500 * h + val old = listOf(WorkoutHeartRate(start, start + 1, 120), WorkoutHeartRate(start, start + 2, 121)) + dao.replaceWorkoutDetail(start, old) + dao.replaceWorkoutDetail(start, old.take(1)) + assertEquals(old.take(1), dao.workoutHeartRateOnce(start)) + + failInsertsInto("workout_hr") + assertTrue(runCatching { dao.replaceWorkoutDetail(start, old) }.isFailure) + assertEquals(old.take(1), dao.workoutHeartRateOnce(start)) + } + + @Test fun `purgeExpired that fails half way removes nothing`() = runTest { + val dayS = 86_400L + val other = SleepSession(300 * h, 308 * h, 299 * h, nap = false) + dao.insertSleepSessions(listOf(night, other)) + dao.insertSleepStages(stages + SleepStage(300 * h, 308 * h, other.start, SleepStage.LIGHT)) + // Two deletes past their week, one of them moved first, so its edit becomes a marker. + dao.insertSleepEdits(listOf( + SleepEdit(night.start, nap = false, start = 101 * h, end = 108 * h, deleted = true, editedAt = 310 * h), + SleepEdit(other.start, nap = false, deleted = true, editedAt = 310 * h), + )) + val editsBefore = dao.sleepEditsOnce() + // The second session's delete fails, after the first session and its stages went. + failDeletesFrom("sleep_session", "old.start = ${other.start}") + + assertTrue(runCatching { SleepSource(dao).purgeExpired(310 * h + 7 * dayS) }.isFailure) + + assertEquals(listOf(night, other), dao.sleepSessionsOnce(0, 400 * h)) + assertEquals(stages, dao.sleepStagesOnce(night.start)) + assertEquals(editsBefore, dao.sleepEditsOnce()) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/RouteMigrationTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/RouteMigrationTest.kt index 5fa80860..0fd6664a 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/db/RouteMigrationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/db/RouteMigrationTest.kt @@ -39,7 +39,7 @@ class RouteMigrationTest { } val room = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) - .allowMainThreadQueries().build() + .addMigrations(*OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build() try { val rows = room.healthDao().routeOnce(100) assertEquals(listOf(Route.Point(105, 51.5, -0.1, 4.5f)), rows.map { it.point }) diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/SleepEditMigrationTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/SleepEditMigrationTest.kt new file mode 100644 index 00000000..c401a1e4 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/db/SleepEditMigrationTest.kt @@ -0,0 +1,56 @@ +package org.openstrap.app.data.db + +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** A version 24 database opens at 25 (and on to the latest) with each nap's times kept as a nap's edit in `sleep_edit`, not deleted. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class SleepEditMigrationTest { + @Test + fun napEditsBecomeSleepEdits() = runTest { + val file = File(System.getProperty("java.io.tmpdir"), "openstrap-migration-${System.nanoTime()}.db") + // Built from the exported schema, so it's the database version 24 really made. + val schema = JSONObject(File("schemas/org.openstrap.app.data.db.OpenStrapDatabase/24.json").readText()).getJSONObject("database") + SQLiteDatabase.openOrCreateDatabase(file, null).use { db -> + val entities = schema.getJSONArray("entities") + for (i in 0 until entities.length()) { + val e = entities.getJSONObject(i) + val table = e.getString("tableName") + db.execSQL(e.getString("createSql").replace("\${TABLE_NAME}", table)) + val indices = e.optJSONArray("indices") ?: continue + for (j in 0 until indices.length()) db.execSQL(indices.getJSONObject(j).getString("createSql").replace("\${TABLE_NAME}", table)) + } + val setup = schema.getJSONArray("setupQueries") + for (i in 0 until setup.length()) db.execSQL(setup.getString(i)) + db.execSQL("INSERT INTO nap_edit (strapStart, start, `end`) VALUES (1000, 900, 2900)") + db.version = 24 + } + + val before = System.currentTimeMillis() / 1000 + val room = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) + .addMigrations(*OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build() + try { + val dao = room.healthDao() + val e = dao.sleepEditsOnce().single() + assertEquals(SleepEdit(1000, nap = true, start = 900, end = 2900, deleted = false, editedAt = e.editedAt), e) + assertTrue(e.editedAt >= before) + // The new columns take a main sleep's delete with no times. + dao.upsertSleepEdit(SleepEdit(5000, nap = false, deleted = true, editedAt = 7)) + assertEquals(listOf(1000L, 5000L), dao.sleepEditsOnce().map { it.strapStart }.sorted()) + } finally { + room.close() + listOf("", "-wal", "-shm", "-journal").forEach { File(file.path + it).delete() } + } + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/TargetReachedMigrationTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/TargetReachedMigrationTest.kt index f9ef4ae2..426db493 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/db/TargetReachedMigrationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/db/TargetReachedMigrationTest.kt @@ -38,7 +38,7 @@ class TargetReachedMigrationTest { } val room = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) - .allowMainThreadQueries().build() + .addMigrations(*OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build() try { val dao = room.healthDao() val w = dao.appWorkoutOnce(1000)!! diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/WearEventMigrationTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/WearEventMigrationTest.kt index ca000ce2..94cda769 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/db/WearEventMigrationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/db/WearEventMigrationTest.kt @@ -38,7 +38,7 @@ class WearEventMigrationTest { } val room = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) - .allowMainThreadQueries().build() + .addMigrations(*OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build() try { val dao = room.healthDao() assertEquals(1, room.openHelper.readableDatabase.query("SELECT * FROM health_alert").use { it.count }) diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/WorkoutEffectMigrationTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/WorkoutEffectMigrationTest.kt index 15c5d155..c957f5c2 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/db/WorkoutEffectMigrationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/db/WorkoutEffectMigrationTest.kt @@ -41,7 +41,7 @@ class WorkoutEffectMigrationTest { } val room = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) - .allowMainThreadQueries().build() + .addMigrations(*OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build() try { val dao = room.healthDao() val w = dao.appWorkoutOnce(100)!! diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/WorkoutSubtypeMigrationTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/WorkoutSubtypeMigrationTest.kt index 5f2373ae..02d8033b 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/db/WorkoutSubtypeMigrationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/db/WorkoutSubtypeMigrationTest.kt @@ -39,7 +39,7 @@ class WorkoutSubtypeMigrationTest { } val room = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) - .allowMainThreadQueries().build() + .addMigrations(*OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build() try { val old = room.healthDao().workout(100).first()!! assertEquals(700L, old.end) diff --git a/app/src/test/kotlin/org/openstrap/app/data/db/WorkoutsMigrationTest.kt b/app/src/test/kotlin/org/openstrap/app/data/db/WorkoutsMigrationTest.kt new file mode 100644 index 00000000..8f5dd67d --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/db/WorkoutsMigrationTest.kt @@ -0,0 +1,69 @@ +package org.openstrap.app.data.db + +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** + * A version 24 database opens at 26 (sleep edits at 25, then [WorkoutsMigration]) with its workouts and sets intact, no structure or log on + * them, and the new tables there to write to. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class WorkoutsMigrationTest { + @Test + fun workoutsKeepTheirSetsAndGainTheNewTables() = runTest { + val file = File(System.getProperty("java.io.tmpdir"), "openstrap-migration-${System.nanoTime()}.db") + // Built from the exported schema, so it's the database version 24 really made. + val schema = JSONObject(File("schemas/org.openstrap.app.data.db.OpenStrapDatabase/24.json").readText()).getJSONObject("database") + SQLiteDatabase.openOrCreateDatabase(file, null).use { db -> + val entities = schema.getJSONArray("entities") + for (i in 0 until entities.length()) { + val e = entities.getJSONObject(i) + val table = e.getString("tableName") + db.execSQL(e.getString("createSql").replace("\${TABLE_NAME}", table)) + val indices = e.optJSONArray("indices") ?: continue + for (j in 0 until indices.length()) db.execSQL(indices.getJSONObject(j).getString("createSql").replace("\${TABLE_NAME}", table)) + } + val setup = schema.getJSONArray("setupQueries") + for (i in 0 until setup.length()) db.execSQL(setup.getString(i)) + db.execSQL("INSERT INTO app_workout (start, `end`, sportType, source, pauses, target) VALUES (1000, 2800, 52, 0, '', NULL)") + db.execSQL("INSERT INTO app_workout_set (workoutStart, `index`, start, `end`, restEnd) VALUES (1000, 0, 1010, 1060, 1150)") + db.version = 24 + } + + val room = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) + .addMigrations(*OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build() + try { + val dao = room.healthDao() + val w = dao.appWorkoutOnce(1000)!! + assertNull(w.structure) + assertNull(w.stretches) + val set = dao.setsOnce(1000).single() + assertEquals(1060L, set.end) + assertNull(set.exercise) + assertNull(set.reps) + assertNull(set.weightKg) + dao.upsertSets(listOf(set.copy(exercise = "Bench press", reps = 8, weightKg = 60f))) + assertEquals(60f, dao.setsOnce(1000).single().weightKg) + dao.upsertAppWorkout(w.copy(structure = "t:20/10x8")) + assertEquals("t:20/10x8", dao.appWorkoutOnce(1000)!!.structure) + dao.upsertExercise(ExerciseRow("Bench press", 8, 60f, 2800)) + assertEquals(listOf("Bench press"), dao.exercisesOnce().map { it.name }) + dao.upsertRounds(listOf(AppWorkoutRound(1000, 0, 1000, 1020, 1030, 171))) + assertEquals(171, dao.roundsOnce(1000).single().peakBpm) + } finally { + room.close() + listOf("", "-wal", "-shm", "-journal").forEach { File(file.path + it).delete() } + } + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/data/settings/CalorieGoalTest.kt b/app/src/test/kotlin/org/openstrap/app/data/settings/CalorieGoalTest.kt index b41cdac7..de66b033 100644 --- a/app/src/test/kotlin/org/openstrap/app/data/settings/CalorieGoalTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/data/settings/CalorieGoalTest.kt @@ -2,9 +2,15 @@ package org.openstrap.app.data.settings import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Rule import org.junit.Test +import org.openstrap.app.presentation.GoalCopy +import org.openstrap.app.testing.LocaleRule +/** In English: the German words are in [org.openstrap.app.presentation.DataCopyTest]. */ class CalorieGoalTest { + @get:Rule val locale = LocaleRule() + @Test fun `the buttons step by 10 and stop at the strap's range`() { assertEquals(460, CalorieGoal.step(450, Goals.CALORIES_STEP)) assertEquals(440, CalorieGoal.step(450, -Goals.CALORIES_STEP)) @@ -23,7 +29,7 @@ class CalorieGoalTest { assertNull(CalorieGoal.parse("3001")) assertNull(CalorieGoal.parse("")) assertNull(CalorieGoal.parse("abc")) - assertEquals("Pick a number from 200 to 3,000", CalorieGoal.OUT_OF_RANGE) + assertEquals("Pick a number from 200 to 3,000", GoalCopy.outOfRange(Goals.CALORIES)) } @Test fun `the suggestion is the median of the full days plus a tenth, rounded to 10`() { @@ -43,7 +49,7 @@ class CalorieGoalTest { } @Test fun `the summary names the calorie goal`() { - assertEquals("8,000 steps, 450 active kcal, 8 h sleep, 10 active hours", Goals(calories = 450).summary()) + assertEquals("8,000 steps, 450 active kcal, 8 h sleep, 10 active hours", GoalCopy.summary(Goals(calories = 450))) assertEquals(300, Goals().calories) } } diff --git a/app/src/test/kotlin/org/openstrap/app/data/settings/ForgetDeviceTest.kt b/app/src/test/kotlin/org/openstrap/app/data/settings/ForgetDeviceTest.kt new file mode 100644 index 00000000..9ca6192f --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/data/settings/ForgetDeviceTest.kt @@ -0,0 +1,82 @@ +package org.openstrap.app.data.settings + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.lang.reflect.Modifier +import java.util.Collections + +/** Forgetting the strap removes only what is about the strap (audit test 6, P1), and settings flows skip repeats (C9). */ +class ForgetDeviceTest { + @get:Rule val folder = TemporaryFolder() + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val store: DataStore by lazy { + PreferenceDataStoreFactory.create(scope = scope) { folder.newFile("settings.preferences_pb").also { it.delete() } } + } + private val settings by lazy { SettingsStore.of(store) } + + @After fun stop() = scope.cancel() + + /** Every key name in [SettingsKeys], read off the object so a key added there is in this test too. */ + private val keyNames: List = SettingsKeys::class.java.declaredFields + .filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java && !it.name.endsWith("_PREFIX") } + .map { it.get(null) as String } + + @Test fun `forgetting the strap keeps every setting that isn't about the strap`() = runTest { + val dynamic = listOf( + SettingsKeys.SPORT_SETTINGS_PREFIX + "7", + SettingsKeys.LAST_SYNC_PREFIX + "daily_energy_profile", + SettingsKeys.LAST_SYNC_PREFIX + "workout_effect_model", + SettingsKeys.LAST_SYNC_PREFIX + "body_signals_push", + SettingsKeys.HISTORY_CURSOR_PREFIX + "1", + SettingsKeys.HISTORY_CURSOR_PREFIX + "16", + "a_key_added_later", + ) + val all = keyNames + dynamic + store.edit { prefs -> all.forEach { prefs[stringPreferencesKey(it)] = "x" } } + + settings.forgetDevice() + + val strap = setOf( + "device_address", "device_auth_key", "device_name", + "companion_association_id", "companion_declined", "held_disconnected", + "strap_battery", "strap_charging", "strap_last_sync_done", + "zepp_app_token", "zepp_user_id", "zepp_api_base", "zepp_china", "zepp_login_token", "zepp_app_token_at", "zepp_app_ttl", + "last_sync_gdsp_1", "last_sync_gdsp_16", + ) + assertEquals(all.toSet() - strap, store.data.first().asMap().keys.map { it.name }.toSet()) + assertEquals(strap - setOf("last_sync_gdsp_1", "last_sync_gdsp_16"), SettingsStore.STRAP_KEYS) + } + + @Test fun `a write to another setting doesn't re-emit a flow`() = runTest { + val seen = Collections.synchronizedList(mutableListOf()) + val job = scope.launch { settings.goals.collect { seen += it } } + suspend fun until(n: Int) = withContext(Dispatchers.Default) { withTimeout(5_000) { while (seen.size < n) delay(10) } } + until(1) + settings.setLiveHeartRate(false) + settings.setWeeklyRecap(false) + val changed = seen[0].copy(steps = seen[0].steps + 1_000) + settings.saveGoals(changed) + until(2) + job.cancel() + // Emissions are in write order, so a repeat from either unrelated write would sit between the two. + assertEquals(listOf(seen[0], changed), seen.toList()) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/device/BlePermissionsTest.kt b/app/src/test/kotlin/org/openstrap/app/device/BlePermissionsTest.kt new file mode 100644 index 00000000..5cad19e6 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/device/BlePermissionsTest.kt @@ -0,0 +1,80 @@ +package org.openstrap.app.device + +import android.Manifest +import android.app.Application +import android.provider.Settings +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class BlePermissionsTest { + private val app = ApplicationProvider.getApplicationContext() + + @Test fun `the strap needs scan and connect`() { + assertEquals(listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT), BlePermissions.bluetooth) + } + + @Test @Config(sdk = [32]) + fun `on Android 12 the request asks for scan and connect, not notifications`() { + assertEquals( + listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT), + BlePermissions.required().toList(), + ) + } + + @Test fun `the request asks for scan, connect and notifications`() { + assertEquals( + listOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.POST_NOTIFICATIONS), + BlePermissions.required().toList(), + ) + } + + @Test fun `granted only once both scan and connect are`() { + shadowOf(app).grantPermissions(Manifest.permission.ACCESS_FINE_LOCATION) + assertFalse(BlePermissions.granted(app)) + shadowOf(app).grantPermissions(Manifest.permission.BLUETOOTH_CONNECT) + assertFalse(BlePermissions.granted(app)) + shadowOf(app).grantPermissions(Manifest.permission.BLUETOOTH_SCAN) + assertTrue(BlePermissions.granted(app)) + shadowOf(app).denyPermissions(Manifest.permission.BLUETOOTH_CONNECT) + assertFalse(BlePermissions.granted(app)) + } + + @Test fun `a request result needs both, and notifications don't matter`() { + val both = mapOf(Manifest.permission.BLUETOOTH_SCAN to true, Manifest.permission.BLUETOOTH_CONNECT to true) + assertTrue(BlePermissions.granted(both + (Manifest.permission.POST_NOTIFICATIONS to false))) + assertFalse(BlePermissions.granted(both + (Manifest.permission.BLUETOOTH_SCAN to false))) + assertFalse(BlePermissions.granted(mapOf(Manifest.permission.BLUETOOTH_CONNECT to true))) + } + + @Test fun `turned down for good once Android owes no rationale for any of them`() { + val activity = Robolectric.buildActivity(android.app.Activity::class.java).setup().get() + val pm = shadowOf(activity.packageManager) + val phone = listOf(Manifest.permission.READ_PHONE_STATE) + // Denied once: Android would explain it next time, so the dialog still shows. + pm.setShouldShowRequestPermissionRationale(Manifest.permission.READ_PHONE_STATE, true) + assertFalse(BlePermissions.turnedDownForGood(activity, phone)) + // Denied twice: no rationale and no dialog, so the app's settings page is the only way. + pm.setShouldShowRequestPermissionRationale(Manifest.permission.READ_PHONE_STATE, false) + assertTrue(BlePermissions.turnedDownForGood(activity, phone)) + // One Bluetooth permission still asks again, so the pair isn't blocked yet. + pm.setShouldShowRequestPermissionRationale(Manifest.permission.BLUETOOTH_SCAN, true) + assertFalse(BlePermissions.turnedDownForGood(activity, BlePermissions.bluetooth)) + pm.setShouldShowRequestPermissionRationale(Manifest.permission.BLUETOOTH_SCAN, false) + assertTrue(BlePermissions.turnedDownForGood(activity, BlePermissions.bluetooth)) + } + + @Test fun `settings opens the app's own page`() { + val intent = BlePermissions.settingsIntent(app) + assertEquals(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, intent.action) + assertEquals("package:${app.packageName}", intent.data.toString()) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/device/BroadcastWorkTest.kt b/app/src/test/kotlin/org/openstrap/app/device/BroadcastWorkTest.kt new file mode 100644 index 00000000..5c8d8dfc --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/device/BroadcastWorkTest.kt @@ -0,0 +1,50 @@ +package org.openstrap.app.device + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** A receiver's pending result is finished however its work ends, and within the broadcast window (audit C11). */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class BroadcastWorkTest { + @Test fun `work that hangs is cut off at the timeout and finished`() = runTest { + var finished = 0 + launchForBroadcast(this, "test", { finished++ }) { awaitCancellation() } + advanceTimeBy(BROADCAST_TIMEOUT_MS - 1) + runCurrent() + assertEquals(0, finished) + advanceTimeBy(2) + runCurrent() + assertEquals(1, finished) + } + + @Test fun `work that fails is finished`() = runTest { + var finished = 0 + launchForBroadcast(this, "test", { finished++ }) { error("no database") } + runCurrent() + assertEquals(1, finished) + } + + @Test fun `work cancelled with the scope is finished`() = runTest { + var finished = 0 + val scope = CoroutineScope(StandardTestDispatcher(testScheduler) + Job()) + launchForBroadcast(scope, "test", { finished++ }) { awaitCancellation() } + runCurrent() + scope.cancel() + runCurrent() + assertEquals(1, finished) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/device/HealthAlertRouteTest.kt b/app/src/test/kotlin/org/openstrap/app/device/HealthAlertRouteTest.kt new file mode 100644 index 00000000..3172f3ed --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/device/HealthAlertRouteTest.kt @@ -0,0 +1,17 @@ +package org.openstrap.app.device + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openstrap.app.ui.dashboard.Metric +import org.openstrap.protocol.services.HealthAlert + +/** Each health alert opens a metric screen that exists, though the notifier names it without the screens' types. */ +class HealthAlertRouteTest { + @Test + fun everyAlertNamesAMetric() { + for (type in HealthAlert.Type.entries) Metric.valueOf(HealthAlertNotifier.route(type)) + assertEquals(Metric.HeartRate.name, HealthAlertNotifier.route(HealthAlert.Type.IRREGULAR_RHYTHM)) + assertEquals(Metric.Stress.name, HealthAlertNotifier.route(HealthAlert.Type.HIGH_STRESS)) + assertEquals(Metric.Spo2.name, HealthAlertNotifier.route(HealthAlert.Type.LOW_SPO2)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/device/HistoryKeepTest.kt b/app/src/test/kotlin/org/openstrap/app/device/HistoryKeepTest.kt new file mode 100644 index 00000000..bbcfe5fc --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/device/HistoryKeepTest.kt @@ -0,0 +1,59 @@ +package org.openstrap.app.device + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openstrap.protocol.history.ActivitySample +import org.openstrap.protocol.history.HistoryDataType +import org.openstrap.protocol.history.SpotHeartRate + +/** Which synced records are stored, and how far the cursor may move, around the present minute. */ +class HistoryKeepTest { + /** 18:42:15 on a day in 2026, so the current minute is 18:42. */ + private val minute = 1_793_630_520L + private val now = minute + 15 + + private fun activity(at: Long, steps: Int) = ActivitySample(at, 0, 0, steps, 80, null) + + @Test fun `activity keeps only minutes that have ended`() { + // On the strap: 18:41 done, 18:42 still counting, then stale slots for 18:43 and 18:44. + val chunk = listOf(activity(minute - 60, 58), activity(minute, 49), activity(minute + 60, 12), activity(minute + 120, 0)) + + assertEquals(listOf(minute - 60), HistorySync.keepable(HistoryDataType.ACTIVITY, chunk, now).map { it.timestamp }) + } + + @Test fun `the activity cursor stops at the current minute so it is fetched once it ends`() { + assertEquals(minute, HistorySync.latestCursor(HistoryDataType.ACTIVITY, now)) + } + + @Test fun `other types still allow a strap clock a little ahead`() { + val readings = listOf(SpotHeartRate(now + 60, 0, 70), SpotHeartRate(now + 600, 0, 70)) + + assertEquals(listOf(now + 60), HistorySync.keepable(HistoryDataType.HEART_RATE_MANUAL, readings, now).map { it.timestamp }) + assertEquals(now, HistorySync.latestCursor(HistoryDataType.HEART_RATE_MANUAL, now)) + } + + @Test fun `blank recent activity slots leave the cursor so the minute is fetched once the strap writes it`() { + // At 19:13:34 the strap sent 19:12 blank: it hadn't written that minute yet. The parser skips blank slots + // and points past the whole chunk. + val at = 1_793_632_414L + val blankFrom1912 = 1_793_632_320L + 59 * 60 + + assertEquals(null, HistorySync.nextCursor(HistoryDataType.ACTIVITY, blankFrom1912, emptyList(), at)) + } + + @Test fun `blank activity slots older than the wait are a real gap and the cursor moves past them`() { + val gapEnds = now - 3600 + + assertEquals(gapEnds, HistorySync.nextCursor(HistoryDataType.ACTIVITY, gapEnds, emptyList(), now)) + } + + @Test fun `after stored minutes the cursor sits right after the last one`() { + val kept = listOf(activity(minute - 120, 5), activity(minute - 60, 7)) + + assertEquals(minute, HistorySync.nextCursor(HistoryDataType.ACTIVITY, minute + 3600, kept, now)) + } + + @Test fun `records from before the strap's clock was set are dropped for every type`() { + assertEquals(emptyList(), HistorySync.keepable(HistoryDataType.ACTIVITY, listOf(activity(60, 10)), now)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/device/HistorySyncTest.kt b/app/src/test/kotlin/org/openstrap/app/device/HistorySyncTest.kt new file mode 100644 index 00000000..cef472ee --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/device/HistorySyncTest.kt @@ -0,0 +1,291 @@ +package org.openstrap.app.device + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.ble.GattException +import org.openstrap.app.ble.Notification +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.protocol.ByteWriter +import org.openstrap.protocol.ChannelSession +import org.openstrap.protocol.StrapLink +import org.openstrap.protocol.history.FetchCodec +import org.openstrap.protocol.history.GdspTime +import org.openstrap.protocol.history.HistoryDataType +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.UUID + +/** + * A strap that speaks the device side of GDSP over HUAMI(0004) and (0005) (04 §1): it answers the version query with + * the types it holds, each START with the records at or after the cursor, a few per chunk, and every ACK. + */ +private class SimulatedStrap : HistoryGatt { + override val connected = MutableStateFlow(true) + private val out = MutableSharedFlow(extraBufferCapacity = 4096) + override val notifications = out + + /** Each type's records by code, as (timestamp, record bytes), oldest first. */ + val records = mutableMapOf>>() + /** Records per chunk for a type, 2 when not set. */ + val perChunk = mutableMapOf() + /** (type, cursor) of every START, in order. */ + val starts = mutableListOf>() + var onEnable: suspend () -> Unit = {} + var onStart: suspend (Int) -> Unit = {} + private var pending = ByteArray(0) + + override suspend fun enableNotifications(service: UUID, uuid: UUID) = onEnable() + + override suspend fun write(service: UUID, uuid: UUID, value: ByteArray, withResponse: Boolean) { + // The sync's two listeners must be there before the first reply, or it is lost. + out.subscriptionCount.first { it >= 2 } + when (value[0].toInt()) { + FetchCodec.OP_QUERY_VERSION -> { + val w = ByteWriter().u8(0x10).u8(0).u8(1).u8(1).u8(1).u16(records.size) + records.keys.forEach { w.u8(it).u8(1) } + control(w.toByteArray()) + } + FetchCodec.OP_START -> { + val type = value[1].toInt() and 0xFF + val since = GdspTime( + (value[2].toInt() and 0xFF) or ((value[3].toInt() and 0xFF) shl 8), + value[4].toInt(), value[5].toInt(), value[6].toInt(), value[7].toInt(), value[8].toInt(), value[9].toInt(), + ).toEpochSecond() + starts += type to since + onStart(type) + val chunk = records[type].orEmpty().filter { it.first >= since }.take(perChunk[type] ?: 2) + pending = chunk.fold(ByteArray(0)) { acc, r -> acc + r.second } + val header = Instant.ofEpochSecond(chunk.firstOrNull()?.first ?: since).atZone(ZoneOffset.UTC) + val w = ByteWriter().u8(0x10).u8(1).u8(1).u32(pending.size.toLong()) + .u16(header.year).u8(header.monthValue).u8(header.dayOfMonth).u8(header.hour).u8(header.minute).u8(header.second).u8(0) + control(w.u8(0).toByteArray()) + } + FetchCodec.OP_TRANSFER -> { + pending.toList().chunked(19).forEachIndexed { i, c -> out.emit(Notification(HISTORY_DATA, byteArrayOf(i.toByte()) + c.toByteArray())) } + control(byteArrayOf(0x10, 2, 1)) + } + FetchCodec.OP_ACK -> control(byteArrayOf(0x10, 3, 1)) + } + } + + private suspend fun control(bytes: ByteArray) = out.emit(Notification(HISTORY_CONTROL, bytes)) + + companion object { + val HISTORY_CONTROL: UUID = UUID.fromString("00000004-0000-3512-2118-0009af100700") + val HISTORY_DATA: UUID = UUID.fromString("00000005-0000-3512-2118-0009af100700") + } +} + +private class MemoryCursors(var onSet: suspend (String, Long) -> Unit = { _, _ -> }) : SyncCursors { + val values = mutableMapOf() + + override suspend fun get(key: String) = values[key] + + override suspend fun set(key: String, epochSeconds: Long) { + onSet(key, epochSeconds) + values[key] = epochSeconds + } +} + +/** A session with no channel endpoints, so history control runs over HUAMI(0004), all of it on [SimulatedStrap]. */ +private fun gattOnlySession() = ChannelSession(object : StrapLink { + override val payloadMtu = 20 + override suspend fun writeControl(data: ByteArray) = Unit + override suspend fun writeData(data: ByteArray) = Unit + override val controlNotifications = kotlinx.coroutines.flow.emptyFlow() + override val dataNotifications = kotlinx.coroutines.flow.emptyFlow() +}) + +/** HistorySync against a simulated strap and a real database (audit test 3, C2, C3 and P4). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class HistorySyncTest { + private lateinit var db: OpenStrapDatabase + private val dao get() = db.healthDao() + private val strap = SimulatedStrap() + private val cursors = MemoryCursors() + private val state = MutableStateFlow(SyncState.Idle) + private val log = mutableListOf() + private val zone = ZoneId.systemDefault() + + /** Ten hours ago, on a whole minute: inside the first sync's two day lookback. */ + private val base = (System.currentTimeMillis() / 1000 - 10 * 3600) / 60 * 60 + + @Before fun open() { + db = inMemoryDb() + } + @After fun close() = db.close() + + private fun sync() = HistorySync(gattOnlySession(), strap, dao, cursors, state, { synchronized(log) { log += it } }) + + private fun hrv(ts: Long, rmssd: Int) = ts to ByteWriter().u32(ts).u8(0).u8(rmssd).toByteArray() + private fun sdnn(ts: Long, centiMs: Long) = ts to ByteWriter().u32(ts).u8(0).u32(centiMs).toByteArray() + + /** A version 1 PAI record: a PAI row, and the resting HR it carries for a day without a real one. */ + private fun pai(ts: Long, daily: Float, total: Float, restHr: Int) = ts to ByteBuffer.allocate(69).order(ByteOrder.LITTLE_ENDIAN).apply { + put(1); putInt(ts.toInt()); putInt(0) + repeat(10) { putFloat(0f) } + repeat(3) { putShort(0) } + putFloat(daily); putFloat(total); putShort(restHr.toShort()); putShort(180); put(0); put(0) + }.array() + + private fun table(sql: String): List> = db.openHelper.readableDatabase.query(sql).use { c -> + buildList { while (c.moveToNext()) add((0 until c.columnCount).map { if (c.getType(it) == android.database.Cursor.FIELD_TYPE_FLOAT) c.getDouble(it) else c.getLong(it) }) } + } + + private fun dayStart(ts: Long) = Instant.ofEpochSecond(ts).atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() + + private fun key(t: HistoryDataType) = "gdsp_${t.code}" + + private fun seedStrap() { + strap.records[HistoryDataType.HRV_RMSSD.code] = listOf(hrv(base, 41), hrv(base + 300, 0), hrv(base + 600, 55), hrv(base + 900, 62)) + strap.records[HistoryDataType.HRV_SDNN.code] = listOf(sdnn(base, 4_250), sdnn(base + 60, 90_000)) + strap.records[HistoryDataType.PAI.code] = listOf(pai(base, 12.5f, 80f, 52)) + strap.perChunk[HistoryDataType.PAI.code] = 1 + } + + /** In the test's own coroutine, so the test waits through Room's threads for the sync to end. */ + private suspend fun runSync() = sync().run() + + @Test fun `records land in the right rows`() = runTest { + seedStrap() + runSync() + + assertTrue(state.value is SyncState.Done) + // A zero RMSSD is no reading. + assertEquals(listOf(listOf(base, 41L), listOf(base + 600, 55L), listOf(base + 900, 62L)), table("SELECT timestamp, milliseconds FROM hrv ORDER BY timestamp")) + // 0.01 ms on the wire, and 900 ms is out of range. + assertEquals(listOf(listOf(base, 42.5)), table("SELECT timestamp, milliseconds FROM hrv_sdnn")) + assertEquals(listOf(listOf(base, 12.5, 80.0, 52L)), table("SELECT timestamp, daily, total, restingHeartRate FROM pai")) + assertEquals(listOf(listOf(dayStart(base), 52L)), table("SELECT day, bpm FROM resting_hr")) + // Each cursor ends past its last record: HRV went in two chunks and the second START started after the first. + assertEquals(base + 901, cursors.values[key(HistoryDataType.HRV_RMSSD)]) + assertTrue(strap.starts.contains(HistoryDataType.HRV_RMSSD.code to base + 301)) + } + + @Test fun `the cursor moves only once the chunk's rows are in`() = runTest { + seedStrap() + val seen = mutableListOf>() + cursors.onSet = { k, v -> if (k == key(HistoryDataType.HRV_RMSSD)) seen += v to table("SELECT * FROM hrv WHERE timestamp < $v").size } + runSync() + + // Every record before each cursor was stored when it moved. + assertEquals(listOf(base + 301 to 1, base + 901 to 3), seen) + } + + @Test fun `a chunk that fails to store keeps none of its rows and leaves the cursor`() = runTest { + seedStrap() + // The PAI chunk writes a resting HR row, then a pai row: the second fails. + db.openHelper.writableDatabase.execSQL("CREATE TRIGGER no_pai BEFORE INSERT ON pai BEGIN SELECT RAISE(ABORT, 'disk full'); END") + runSync() + + assertEquals(emptyList(), table("SELECT * FROM resting_hr")) + assertEquals(null, cursors.values[key(HistoryDataType.PAI)]) + // The other types went on. + assertEquals(3, table("SELECT * FROM hrv").size) + assertTrue(state.value is SyncState.Done) + } + + @Test fun `a kill after the commit and before the cursor stores the chunk again without duplicates`() = runTest { + seedStrap() + // The first cursor write dies with the process: the rows are in, the cursor is not. + var killed = false + cursors.onSet = { k, _ -> if (!killed && k == key(HistoryDataType.HRV_RMSSD)) { killed = true; error("process killed") } } + runSync() + val afterKill = table("SELECT timestamp, milliseconds FROM hrv ORDER BY timestamp") + assertEquals(listOf(listOf(base, 41L)), afterKill) + assertEquals(null, cursors.values[key(HistoryDataType.HRV_RMSSD)]) + + runSync() + val once = listOf( + table("SELECT timestamp, milliseconds FROM hrv ORDER BY timestamp"), table("SELECT * FROM hrv_sdnn"), + table("SELECT * FROM pai"), table("SELECT * FROM resting_hr"), + ) + assertEquals(3, once[0].size) + + // A full re-sync of the same data from scratch changes nothing. + cursors.values.clear() + runSync() + assertEquals(once, listOf( + table("SELECT timestamp, milliseconds FROM hrv ORDER BY timestamp"), table("SELECT * FROM hrv_sdnn"), + table("SELECT * FROM pai"), table("SELECT * FROM resting_hr"), + )) + assertTrue(state.value is SyncState.Done) + } + + @Test fun `a GATT timeout ends the sync as Failed, not Running`() = runTest { + seedStrap() + // withTimeout throws TimeoutCancellationException, a CancellationException, while the sync is still active. + strap.onEnable = { withTimeout(10) { awaitCancellation() } } + runSync() + + val s = state.value + assertTrue("$s", s is SyncState.Failed) + } + + @Test fun `a GATT timeout as a plain exception ends the sync as Failed`() = runTest { + seedStrap() + strap.onEnable = { throw GattException("GATT operation timed out") } + runSync() + + assertEquals(SyncState.Failed("GATT operation timed out"), state.value) + } + + @Test fun `a timeout on one type while the link is up skips that type only`() = runTest { + seedStrap() + strap.onStart = { type -> if (type == HistoryDataType.HRV_RMSSD.code) withTimeout(10) { awaitCancellation() } } + runSync() + + assertTrue("${state.value}", state.value is SyncState.Done) + assertEquals(emptyList(), table("SELECT * FROM hrv")) + assertEquals(1, table("SELECT * FROM pai").size) + } + + @Test fun `a timeout after the link dropped fails the sync`() = runTest { + seedStrap() + strap.onStart = { strap.connected.value = false; withTimeout(10) { awaitCancellation() } } + runSync() + + assertEquals(SyncState.Failed("The link to the strap dropped."), state.value) + } + + @Test fun `a cancelled sync does not stay Running`() = runTest { + seedStrap() + val started = CompletableDeferred() + strap.onStart = { started.complete(Unit); awaitCancellation() } + val job = launch { sync().run() } + started.await() + assertTrue(state.value is SyncState.Running) + + job.cancelAndJoin() + assertTrue("${state.value}", state.value is SyncState.Failed) + } + + @Test fun `a database error while repairing earlier syncs fails the sync instead of crashing`() = runTest { + seedStrap() + db.openHelper.writableDatabase.execSQL("DROP TABLE activity_minute") + val thrown = runCatching { sync().run() }.exceptionOrNull() + + assertEquals(null, thrown) + assertTrue("${state.value}", state.value is SyncState.Failed) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/device/SetupStepTest.kt b/app/src/test/kotlin/org/openstrap/app/device/SetupStepTest.kt new file mode 100644 index 00000000..08509a64 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/device/SetupStepTest.kt @@ -0,0 +1,84 @@ +package org.openstrap.app.device + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.ble.GattTimeoutException +import org.openstrap.app.testing.assertCleanCopy +import org.openstrap.protocol.ProtocolTimeoutException + +/** afterAuth's steps (audit C5) and the connect failure copy (E4, E6). */ +class SetupStepTest { + @Test + fun aCancelledConnectStopsAtTheStepInsteadOfFailing() = runTest { + val log = mutableListOf() + val inStep = CompletableDeferred() + var after = false + var failed = false + val connect = launch { + try { + setupStep("settings version query", { log += it }) { + inStep.complete(Unit) + awaitCancellation() + } + after = true + } catch (e: Exception) { + // What the connect catch sees: it must find the job cancelled, not a failure to report. + if (kotlinx.coroutines.currentCoroutineContext()[kotlinx.coroutines.Job]?.isActive == true) failed = true + throw e + } + } + inStep.await() + connect.cancel() + runCurrent() + assertTrue(connect.isCancelled) + assertEquals(false, after) + assertEquals(false, failed) + assertTrue("$log", log.isEmpty()) + } + + @Test + fun aTimedOutStepIsLoggedAndSetupGoesOn() = runTest { + val log = mutableListOf() + val plain = setupStep("mtu query", { log += it }) { throw ProtocolTimeoutException("No reply") } + // withTimeout's own expiry, while the caller is still active, is a failure too. + val timed = setupStep("time sync", { log += it }) { withTimeout(10) { awaitCancellation() } } + val ok = setupStep("battery read", { log += it }) { 80 } + assertNull(plain) + assertNull(timed) + assertEquals(80, ok) + assertEquals(2, log.size) + assertTrue(log[0], log[0].startsWith("mtu query failed: ")) + } + + @Test + fun connectFailuresReadAsCopy() { + val reasons = listOf( + GattTimeoutException("No answer to connect in 30000 ms"), + ProtocolTimeoutException("No reply"), + org.openstrap.app.ble.GattException("Disconnected (status 133)"), + org.openstrap.protocol.ProtocolException("Unsupported channel"), + org.openstrap.app.ble.GattUnusableException("Not a Bluetooth address: x", IllegalArgumentException("x")), + org.openstrap.app.ble.GattUnusableException("No Bluetooth permission", SecurityException("denied")), + IllegalStateException("boom"), + ).map(ConnectErrors::reason) + reasons.forEach { assertCleanCopy(it) } + // No raw exception text. + assertTrue("$reasons", reasons.none { it.contains("133") || it.contains("boom") || it.contains("30000") || it.contains("Unsupported") }) + } + + @Test + fun onlyPermissionAndAddressFailuresAreFinal() { + assertTrue(ConnectErrors.isFinal(org.openstrap.app.ble.GattUnusableException("bad", IllegalArgumentException()))) + assertTrue(ConnectErrors.isFinal(SecurityException("denied"))) + assertEquals(false, ConnectErrors.isFinal(GattTimeoutException("slow"))) + assertEquals(false, ConnectErrors.isFinal(ProtocolTimeoutException("slow"))) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/device/StrapConnectTest.kt b/app/src/test/kotlin/org/openstrap/app/device/StrapConnectTest.kt new file mode 100644 index 00000000..c188fffc --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/device/StrapConnectTest.kt @@ -0,0 +1,209 @@ +package org.openstrap.app.device + +import android.Manifest +import android.app.Application +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.ble.GattClient +import org.openstrap.app.ble.StrapUuids +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.settings.PairedDevice +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.testing.inMemoryDb +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.concurrent.CountDownLatch +import kotlin.concurrent.thread + +/** + * StrapManager's connect against Robolectric's GATT shadow (audit test 5): a strap that never answers ends in Failed + * with a reconnect (C1), and connects racing each other leave one client open (C5). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class StrapConnectTest { + private val context = ApplicationProvider.getApplicationContext() + private val adapter = context.getSystemService(BluetoothManager::class.java).adapter + private lateinit var db: OpenStrapDatabase + private lateinit var settings: SettingsStore + + @Before + fun setUp() { + shadowOf(context).grantPermissions(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN) + shadowOf(adapter).setEnabled(true) + db = inMemoryDb(context) + settings = SettingsStore(context) + } + + @After + fun tearDown() { + runBlocking { settings.forgetDevice() } + db.close() + } + + private fun pair(address: String) = runBlocking { + settings.savePairedDevice(PairedDevice(address, "00112233445566778899aabbccddeeff", "Helio Strap")) + settings.setHeldDisconnected(false) + } + + private fun device(address: String): BluetoothDevice = adapter.getRemoteDevice(address) + private fun gatts(address: String): List = shadowOf(device(address)).bluetoothGatts.toList() + private fun open(address: String) = gatts(address).filterNot { shadowOf(it).isClosed } + + /** The strap's channel characteristics, so connect and notification setup go through. */ + private fun channelService(): BluetoothGattService { + val service = BluetoothGattService(StrapUuids.MAIN_SERVICE, BluetoothGattService.SERVICE_TYPE_PRIMARY) + for (uuid in listOf(StrapUuids.CHANNEL_CONTROL, StrapUuids.CHANNEL_DATA)) { + val c = BluetoothGattCharacteristic( + uuid, + BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE or BluetoothGattCharacteristic.PROPERTY_NOTIFY, + BluetoothGattCharacteristic.PERMISSION_WRITE, + ) + c.addDescriptor(BluetoothGattDescriptor(GattClient.CCCD, BluetoothGattDescriptor.PERMISSION_WRITE)) + service.addCharacteristic(c) + } + return service + } + + @Test + fun aGattOpThatNeverAnswersEndsInFailedWithAReconnect() = runTest { + val address = "AA:BB:CC:00:00:01" + pair(address) + val strap = StrapManager(context, settings, db.healthDao(), backgroundScope) + strap.connect() + // connectGatt is called, and its callback never comes: the op times out. + val failed = strap.connection.first { it is ConnectionState.Failed } as ConnectionState.Failed + assertEquals("The strap stopped answering.", failed.reason) + assertFalse(failed.keyRejected) + assertTrue(strap.log.value.joinToString("\n"), strap.log.value.any { it.endsWith("reconnecting in 5s") }) + // close() ran, so the GATT slot is free. + assertTrue(gatts(address).isNotEmpty()) + assertTrue(open(address).isEmpty()) + } + + @Test + fun aSilentStrapOnALiveLinkEndsInFailedWithAReconnect() = runTest { + val address = "AA:BB:CC:00:00:02" + pair(address) + val strap = StrapManager(context, settings, db.healthDao(), backgroundScope) + strap.connect() + // connectGatt runs in the same step that moves the state to Connecting. + strap.connection.first { it == ConnectionState.Connecting } + val gatt = gatts(address).single() + shadowOf(gatt).addDiscoverableService(channelService()) + shadowOf(gatt).gattCallback.onConnectionStateChange(gatt, BluetoothGatt.GATT_SUCCESS, BluetoothProfile.STATE_CONNECTED) + // The link is up and every write goes out, but the strap never answers the version query: the stuck + // "Authenticating" of audit C1. + strap.connection.first { it == ConnectionState.Authenticating } + val failed = strap.connection.first { it is ConnectionState.Failed } as ConnectionState.Failed + assertEquals("The strap stopped answering.", failed.reason) + assertTrue(strap.log.value.joinToString("\n"), strap.log.value.any { it.endsWith("reconnecting in 5s") }) + assertTrue(shadowOf(gatt).isClosed) + } + + @Test + fun aDisconnectDuringSetupIsNotAFailure() = runTest { + val address = "AA:BB:CC:00:00:03" + pair(address) + val strap = StrapManager(context, settings, db.healthDao(), backgroundScope) + val seen = mutableListOf() + backgroundScope.launch { strap.connection.collect { seen += it } } + strap.connect() + strap.connection.first { it == ConnectionState.Connecting } + assertEquals(1, gatts(address).size) + strap.disconnect() + strap.connection.first { it == ConnectionState.Disconnected } + // Well past every timeout: the cancelled connect neither reports Failed nor schedules a reconnect. + delay(10 * 60_000L) + assertTrue("$seen", seen.none { it is ConnectionState.Failed }) + assertEquals(ConnectionState.Disconnected, strap.connection.value) + assertTrue(strap.log.value.joinToString("\n"), strap.log.value.none { it.contains("reconnecting") }) + assertTrue(open(address).isEmpty()) + } + + @Test + fun aMissingPermissionFailsForGoodWithoutAReconnect() = runTest { + val address = "AA:BB:CC:00:00:04" + pair(address) + // What connectGatt throws once Nearby devices is turned off for the app. + shadowOf(device(address)).setGattConnectionInterceptor { throw SecurityException("Bluetooth connect permission required.") } + val strap = StrapManager(context, settings, db.healthDao(), backgroundScope) + strap.connect() + val failed = strap.connection.first { it is ConnectionState.Failed } as ConnectionState.Failed + assertTrue(failed.reason, failed.reason.contains("Nearby devices")) + delay(10 * 60_000L) + assertTrue(strap.log.value.joinToString("\n"), strap.log.value.none { it.contains("reconnecting") }) + } + + @Test + fun aBadAddressFailsForGoodWithoutAReconnect() = runTest { + pair("not an address") + val strap = StrapManager(context, settings, db.healthDao(), backgroundScope) + strap.connect() + val failed = strap.connection.first { it is ConnectionState.Failed } as ConnectionState.Failed + assertTrue(failed.reason, failed.reason.contains("pair it again")) + delay(10 * 60_000L) + assertTrue(strap.log.value.joinToString("\n"), strap.log.value.none { it.contains("reconnecting") }) + } + + @Test + fun racingConnectsLeaveOneClientOpen() { + val address = "AA:BB:CC:00:00:05" + pair(address) + // Real threads, as the reconnect timer, Bluetooth coming back and the user's tap can all land at once. + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val strap = StrapManager(context, settings, db.healthDao(), scope) + val go = CountDownLatch(1) + val threads = List(THREADS) { + thread { + go.await() + repeat(CALLS) { strap.connect() } + } + } + go.countDown() + threads.forEach { it.join() } + runBlocking { + withTimeout(10_000) { strap.connection.first { it == ConnectionState.Connecting } } + // Let the last connect reach connectGatt and every cancelled one unwind. + delay(1_000) + } + assertEquals("open clients of ${gatts(address).size}", 1, open(address).size) + strap.disconnect() + runBlocking { withTimeout(10_000) { strap.connection.first { it == ConnectionState.Disconnected } } } + runBlocking { delay(200) } + assertTrue(open(address).isEmpty()) + } finally { + scope.cancel() + } + } + + private companion object { + const val THREADS = 8 + const val CALLS = 25 + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/device/WorkoutLocationTest.kt b/app/src/test/kotlin/org/openstrap/app/device/WorkoutLocationTest.kt index 61b9814f..d525a236 100644 --- a/app/src/test/kotlin/org/openstrap/app/device/WorkoutLocationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/device/WorkoutLocationTest.kt @@ -23,14 +23,7 @@ class WorkoutLocationTest { latitude = 51.5 longitude = -0.1 accuracy = 5f - if (mock) markMock() - } - - // `setIsFromMockProvider` is hidden below API 31, so set it the way the system does. - @Suppress("DEPRECATION") - private fun Location.markMock() { - if (android.os.Build.VERSION.SDK_INT >= 31) isMock = true - else Location::class.java.getMethod("setIsFromMockProvider", Boolean::class.java).invoke(this, true) + if (mock) isMock = true } private fun received(vararg fixes: Location): List { @@ -48,12 +41,6 @@ class WorkoutLocationTest { assertEquals(Route.Point(1_000, 51.5, -0.1, 5f), kept.first()) } - @Test - @Config(sdk = [28]) - fun mockFixesAreDroppedBeforeApi31() { - assertEquals(1, received(fix(mock = true), fix(mock = false)).size) - } - private fun started(fused: Boolean): WorkoutLocation { val app = ApplicationProvider.getApplicationContext() shadowOf(app).grantPermissions(Manifest.permission.ACCESS_FINE_LOCATION) diff --git a/app/src/test/kotlin/org/openstrap/app/device/WorkoutSessionTest.kt b/app/src/test/kotlin/org/openstrap/app/device/WorkoutSessionTest.kt new file mode 100644 index 00000000..f4b1a3ef --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/device/WorkoutSessionTest.kt @@ -0,0 +1,422 @@ +package org.openstrap.app.device + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.MaxHeartRates +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.AppWorkoutHeartRate +import org.openstrap.app.data.db.HealthDao +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.testing.FixedClock +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.app.testing.profile +import org.openstrap.protocol.services.Alarm +import org.openstrap.protocol.services.AlarmCapabilities +import org.openstrap.protocol.services.Setting +import org.openstrap.protocol.services.SettingsGroup +import org.openstrap.protocol.services.SportSession +import org.openstrap.protocol.services.StepTotals +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Duration +import java.util.Collections + +/** + * The workout lifecycle on a clock the test moves (audit test 4), and the C4 race: a state write made while a locked + * save is suspended survives it. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class WorkoutSessionTest { + private val context = ApplicationProvider.getApplicationContext() + private val clock = FixedClock() + private val strap = FakeStrap() + private lateinit var db: OpenStrapDatabase + private lateinit var dao: SwitchableDao + + /** What would have crashed the app: an exception no coroutine caught. */ + private val uncaught = Collections.synchronizedList(mutableListOf()) + private var savedHandler: Thread.UncaughtExceptionHandler? = null + + @Before + fun setUp() { + db = inMemoryDb() + dao = SwitchableDao(db.healthDao()) + savedHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { _, e -> uncaught += e } + } + + @After + fun tearDown() { + Thread.setDefaultUncaughtExceptionHandler(savedHandler) + scope.cancel() + db.close() + assertTrue("uncaught: $uncaught", uncaught.isEmpty()) + } + + /** The app's scope as `AppContainer.appScope` builds it, but what it catches lands in [uncaught] for the test. */ + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + CoroutineExceptionHandler { _, e -> uncaught += e }) + + private fun session() = + WorkoutSession(context, dao, SettingsStore(context), strap, MaxHeartRates(dao, flowOf(profile())), clock = { clock.now }, scope = scope) + + private fun StateFlow.await(what: String = "state", predicate: (T) -> Boolean): T = runBlocking { + try { + withTimeout(WAIT_MS) { first(predicate) } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + throw AssertionError("$what never came, last $value", e) + } + } + + private fun WorkoutSession.recording(predicate: (WorkoutState.Recording) -> Boolean = { true }) = + state.await("recording") { it is WorkoutState.Recording && predicate(it) } as WorkoutState.Recording + + private fun stored(start: Long) = runBlocking { db.healthDao().appWorkoutOnce(start) } + + /** The stored workout once [predicate] holds: an edit to a saved one runs on the session's scope. */ + private fun storedOnce(start: Long, predicate: (AppWorkout?) -> Boolean): AppWorkout? = runBlocking { + withTimeout(WAIT_MS) { + var w = stored(start) + while (!predicate(w)) { + delay(POLL_MS) + w = stored(start) + } + w + } + } + + @Test + fun startPauseResumeAndEndSavesTheActiveTime() { + val s = session() + val t0 = clock.now + s.start(sportType = 1) + assertEquals(t0, s.recording().workout.start) + assertNull(stored(t0)?.end) + + clock.advance(Duration.ofSeconds(120)) + s.pause() + assertEquals(t0 + 120, s.recording { it.paused }.workout.pausedAt) + clock.advance(Duration.ofSeconds(60)) + s.resume() + val resumed = s.recording { !it.paused }.workout + assertEquals(listOf(t0 + 120..t0 + 180), resumed.pauseRanges) + assertEquals(120, WorkoutRecorder.activeSeconds(resumed, clock.now)) + + clock.advance(Duration.ofSeconds(60)) + s.end() + assertEquals(WorkoutState.Ended(t0), s.state.await { it is WorkoutState.Ended }) + val saved = stored(t0)!! + assertEquals(t0 + 240, saved.end) + assertEquals(180, WorkoutRecorder.activeSeconds(saved, saved.end!!)) + } + + @Test + fun endedWhilePausedEndsWhereThePauseBegan() { + val s = session() + val t0 = clock.now + s.start(sportType = 1) + s.recording() + clock.advance(Duration.ofSeconds(90)) + s.pause() + s.recording { it.paused } + clock.advance(Duration.ofSeconds(600)) + s.end() + s.state.await { it is WorkoutState.Ended } + assertEquals(t0 + 90, stored(t0)!!.end) + } + + @Test + fun underAMinuteIsNotKept() { + val s = session() + val t0 = clock.now + s.start(sportType = 1) + s.recording() + clock.advance(Duration.ofSeconds(30)) + s.end() + s.state.await { it is WorkoutState.Idle } + assertNull(stored(t0)) + } + + @Test + fun discardDeletesTheWorkoutAndClosesItsSummary() { + val s = session() + val t0 = clock.now + s.start(sportType = 1) + s.recording() + clock.advance(Duration.ofSeconds(300)) + s.end() + s.state.await { it is WorkoutState.Ended } + s.discard(t0) + s.state.await { it is WorkoutState.Idle } + assertNull(stored(t0)) + } + + @Test + fun aWorkoutLeftRecordingComesBackAfterProcessDeath() { + val t0 = clock.now - 600 + runBlocking { + db.healthDao().upsertAppWorkout(AppWorkout(start = t0, end = null, sportType = 1)) + db.healthDao().insertAppWorkoutHeartRate((1..9).map { AppWorkoutHeartRate(t0, t0 + it, 100 + it) }) + } + val s = session() + s.restore() + s.restored.await { it } + val r = s.recording() + assertEquals(t0, r.workout.start) + assertEquals(WorkoutRecorder.Stats(105, 109, 101), s.stats.value) + assertEquals(600, WorkoutRecorder.activeSeconds(r.workout, clock.now)) + + s.end() + s.state.await { it is WorkoutState.Ended } + assertEquals(clock.now, stored(t0)!!.end) + } + + @Test + fun aPauseBetweenTwoReadingsIsNotTimeInZone() { + val s = session() + s.start(sportType = 1) + s.recording() + reading(s, 150) + clock.advance(Duration.ofSeconds(1)) + reading(s, 150) + assertEquals(1, s.zoneSeconds.value.sum()) + + s.pause() + s.recording { it.paused } + clock.advance(Duration.ofSeconds(100)) + s.resume() + s.recording { !it.paused } + clock.advance(Duration.ofSeconds(1)) + reading(s, 150) + assertEquals(1, s.zoneSeconds.value.sum()) + clock.advance(Duration.ofSeconds(1)) + reading(s, 150) + assertEquals(2, s.zoneSeconds.value.sum()) + } + + @Test + fun aTrimShowsLessAndUndoPutsTheRecordingBack() { + val s = session() + val t0 = clock.now + s.start(sportType = 1) + s.recording() + clock.advance(Duration.ofSeconds(600)) + s.end() + s.state.await { it is WorkoutState.Ended } + + s.trim(t0, from = t0 + 60, to = t0 + 300) + val trimmed = storedOnce(t0) { it?.end == t0 + 300 }!! + assertEquals(t0 + 60, trimmed.shownStart) + assertEquals(t0 + 600, trimmed.recordedEnd) + + s.undoTrim(t0) + val undone = storedOnce(t0) { it?.end == t0 + 600 }!! + assertNull(undone.shownStart) + assertNull(undone.recordedEnd) + } + + /** A live reading at the clock's second, once the session has taken it. */ + private fun reading(s: WorkoutSession, bpm: Int) { + val t = clock.now + strap.readings.tryEmit(bpm) + s.recent.await("reading at $t") { it.lastOrNull()?.first == t } + } + + @Test + fun anEndPromptSetDuringASuspendedSaveSurvivesIt() { + val s = session() + // Auto-start's seed: a minute of effort, then nine calm minutes, so the wearer looks stopped. + val t0 = clock.now + val seed = (1L..60L).map { t0 + it to 170 } + (61L..600L).map { t0 + it to 70 } + clock.advance(Duration.ofSeconds(600)) + s.start(sportType = 1, from = t0, seed = seed) + s.recording() + + val gate = dao.hold() + s.pause() + runBlocking { withTimeout(WAIT_MS) { gate.entered.await() } } + // The pause's save is under way, and meanwhile the 30 s check finds the stop. + s.checkStopped() + assertEquals(t0 + 60, (s.state.value as WorkoutState.Recording).endPrompt) + gate.release.complete(Unit) + + val paused = s.recording { it.paused } + assertEquals(t0 + 60, paused.endPrompt) + } + + @Test + fun aSaveThatFailsMidWorkoutKeepsItRecording() { + val s = session() + s.start(sportType = 1) + s.recording() + dao.failing = true + clock.advance(Duration.ofSeconds(120)) + s.pause() + // Paused in memory, and the notification can say it isn't saved. + s.recording { it.paused && it.saveFailed } + clock.advance(Duration.ofSeconds(10)) + s.resume() + s.recording { !it.paused } + clock.advance(Duration.ofSeconds(1)) + reading(s, 120) + + // The next save that works writes the whole workout, pause and all. + dao.failing = false + clock.advance(Duration.ofSeconds(60)) + s.pause() + s.recording { it.paused && !it.saveFailed } + val saved = stored(s.recording().workout.start)!! + assertEquals(1, saved.pauseRanges.size) + assertEquals(clock.now, saved.pausedAt) + } + + @Test + fun aWorkoutThatCantBeSavedAtTheEndKeepsRecording() { + val s = session() + val t0 = clock.now + s.start(sportType = 1) + s.recording() + clock.advance(Duration.ofSeconds(300)) + dao.failing = true + // Held, so the pause below queues behind the end and shows once the end has given up. + val gate = dao.hold() + s.end() + runBlocking { withTimeout(WAIT_MS) { gate.entered.await() } } + s.pause() + gate.release.complete(Unit) + // The end's save failed: still recording, so End can be tried again, and it shows. + s.recording { it.paused && it.saveFailed } + assertNull(stored(t0)?.end) + + dao.failing = false + s.end() + s.state.await { it is WorkoutState.Ended } + assertNotNull(stored(t0)?.end) + } + + @Test + fun theSeedIsRecordedBeforeTheLiveReadings() { + val t0 = clock.now + clock.advance(Duration.ofSeconds(120)) + // Auto-start's readings up to now, and the strap already holding a live one for this same second. + val seed = (1L..120L).map { t0 + it to 100 } + strap.readings.tryEmit(190) + val s = session() + s.start(sportType = 1, from = t0, seed = seed) + s.recording() + s.recent.await { it.size == seed.size } + // One a second: the live reading at the seed's last second is the same second, not a second reading. + Thread.sleep(SETTLE_MS) + assertEquals(seed, s.recent.value) + assertEquals(WorkoutRecorder.Stats(100, 100, 100), s.stats.value) + } + + /** [HealthDao] that can fail every workout save, or hold the next one until released. */ + private class SwitchableDao(private val real: HealthDao) : HealthDao by real { + @Volatile var failing = false + @Volatile private var gate: Gate? = null + + class Gate { + val entered = CompletableDeferred() + val release = CompletableDeferred() + } + + fun hold() = Gate().also { gate = it } + + override suspend fun upsertAppWorkout(workout: AppWorkout) { + gate?.let { + gate = null + it.entered.complete(Unit) + it.release.await() + } + if (failing) throw android.database.sqlite.SQLiteException("disk I/O error") + real.upsertAppWorkout(workout) + } + + override suspend fun upsertSets(sets: List) { + if (failing) throw android.database.sqlite.SQLiteException("disk I/O error") + real.upsertSets(sets) + } + + override suspend fun insertAppWorkoutHeartRate(samples: List) { + if (failing) throw android.database.sqlite.SQLiteException("disk I/O error") + real.insertAppWorkoutHeartRate(samples) + } + } + + /** A strap that is never connected, whose live heart rate is [readings]. */ + private class FakeStrap : StrapController { + val readings = MutableSharedFlow(replay = 1, extraBufferCapacity = 64) + override val connection: StateFlow = MutableStateFlow(ConnectionState.Disconnected) + override val sync: StateFlow = MutableStateFlow(SyncState.Idle) + override val syncRecord: StateFlow = MutableStateFlow(SyncRecord()) + override val syncEnded: SharedFlow = MutableSharedFlow() + override val liveHeartRate: StateFlow = MutableStateFlow(null) + override val heartRateReadings: SharedFlow = readings + override val liveHeartRateEnabled: StateFlow = MutableStateFlow(false) + override val todaySteps: StateFlow = MutableStateFlow(null) + override val info: StateFlow = MutableStateFlow(null) + override val firmwareUpdating: StateFlow = MutableStateFlow(false) + override val log: StateFlow> = MutableStateFlow(emptyList()) + override val alarmsChanged: SharedFlow = MutableSharedFlow() + override val settingsChanged: SharedFlow = MutableSharedFlow() + override val sportSessionPushes: SharedFlow = MutableSharedFlow() + override val findingStrap: StateFlow = MutableStateFlow(false) + override fun connect() = Unit + override fun autoConnect() = Unit + override fun backgroundConnect() = Unit + override fun disconnect() = Unit + override fun syncNow() = Unit + override fun setLiveHeartRate(enabled: Boolean) = Unit + override fun setAppVisible(visible: Boolean) = Unit + override fun setWorkoutActive(active: Boolean) = Unit + override fun setEffortWatch(active: Boolean) = Unit + override suspend fun installFirmware(file: ByteArray, type: Int, onProgress: (org.openstrap.protocol.firmware.DfuProgress) -> Unit) = Unit + override suspend fun readAlarms(): Pair> = throw StrapCommandException("not connected") + override suspend fun saveAlarm(alarm: Alarm, isNew: Boolean) = Unit + override suspend fun deleteAlarm(index: Int) = Unit + override suspend fun clearAlarms() = Unit + override suspend fun readHealthSettings(): SettingsGroup = throw StrapCommandException("not connected") + override suspend fun writeHealthSettings(version: Int, settings: List) = Unit + override suspend fun restartStrap() = Unit + override suspend fun buzz(pattern: List) = Unit + override suspend fun sportSession(state: SportSession.State, sportType: Int, startTime: Long, elapsedSeconds: Long) = Unit + override suspend fun sportSessionKeepAlive() = Unit + override suspend fun startCallBuzz(pulsed: Boolean) = Unit + override suspend fun stopCallBuzz() = Unit + override suspend fun findStrap() = Unit + override suspend fun stopFindingStrap() = Unit + } + + private companion object { + const val WAIT_MS = 10_000L + /** Long enough for a reading the session wrongly took to show. */ + const val SETTLE_MS = 300L + const val POLL_MS = 20L + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/export/CsvBackupsTest.kt b/app/src/test/kotlin/org/openstrap/app/export/CsvBackupsTest.kt new file mode 100644 index 00000000..448dad8a --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/export/CsvBackupsTest.kt @@ -0,0 +1,88 @@ +package org.openstrap.app.export + +import android.content.Context +import android.net.Uri +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.openstrap.app.data.BackupResult +import org.openstrap.app.data.db.HeartRateSample +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.settings.Goals +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.testing.inMemoryDb +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File + +/** Save a copy and Restore through the repository: records and settings round trip, and a bad file is a failure, not a crash. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CsvBackupsTest { + @get:Rule val folder = TemporaryFolder() + private val context: Context = ApplicationProvider.getApplicationContext() + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private lateinit var oldDb: OpenStrapDatabase + private lateinit var newDb: OpenStrapDatabase + private val logged = mutableListOf() + + private fun settings(name: String) = + SettingsStore.of(PreferenceDataStoreFactory.create(scope = scope) { folder.newFile("$name.preferences_pb").also { it.delete() } }) + + private val oldSettings by lazy { settings("old") } + private val newSettings by lazy { settings("new") } + private val oldPhone by lazy { CsvBackups(context, oldDb, oldSettings, CsvExporter(oldDb.healthDao()), logged::add) } + private val newPhone by lazy { CsvBackups(context, newDb, newSettings, CsvExporter(newDb.healthDao()), logged::add) } + + @Before fun open() { + oldDb = inMemoryDb(); newDb = inMemoryDb() + } + + @After fun close() { + oldDb.close(); newDb.close(); scope.cancel() + } + + private fun file(name: String): Uri = Uri.fromFile(File(folder.root, name)) + + @Test fun `a copy restores its records and settings on another phone`() = runBlocking { + val day = 1792195200L + oldDb.healthDao().insertHeartRate((0 until 48).map { HeartRateSample(day + it * 3600L, 60 + it % 10) }) + oldSettings.saveGoals(Goals(steps = 9_000)) + val zip = file("copy.zip") + + assertEquals(BackupResult.Saved, oldPhone.exportCsv(zip)) + assertEquals(BackupResult.Restored(48, settings = true, skipped = 0), newPhone.importCsv(zip)) + assertEquals(9_000, newSettings.goals.first().steps) + // Twice changes nothing. + assertEquals(BackupResult.Restored(0, settings = false, skipped = 0), newPhone.importCsv(zip)) + } + + @Test fun `the settings alone round trip`() = runBlocking { + oldSettings.saveGoals(Goals(steps = 7_500)) + val json = file("settings.json") + assertEquals(BackupResult.Saved, oldPhone.exportSettings(json)) + assertEquals(BackupResult.Restored(0, settings = true, skipped = 0), newPhone.importSettings(json)) + assertEquals(7_500, newSettings.goals.first().steps) + } + + @Test fun `a file that isn't a copy fails without throwing`() = runBlocking { + val notZip = File(folder.root, "notes.zip").apply { writeText("not a zip") } + val result = newPhone.importCsv(Uri.fromFile(notZip)) + assertTrue("$result", result is BackupResult.Failed) + val missing = newPhone.importCsv(file("gone.zip")) + assertTrue("$missing", missing is BackupResult.Failed) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/export/CsvPagingTest.kt b/app/src/test/kotlin/org/openstrap/app/export/CsvPagingTest.kt new file mode 100644 index 00000000..d1e1565e --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/export/CsvPagingTest.kt @@ -0,0 +1,67 @@ +package org.openstrap.app.export + +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.AppWorkoutHeartRate +import org.openstrap.app.data.db.HeartRateSample +import org.openstrap.app.data.db.WorkoutHeartRate +import org.openstrap.app.testing.inMemoryDb +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.ByteArrayOutputStream +import java.util.zip.ZipInputStream + +/** + * The backup reads its biggest tables by keyset pages ([Paged]): page by page, the rows come out the same and in the + * same order as one read of the range, none skipped or repeated across a page's edge, and the range's ends kept. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CsvPagingTest { + private val db = inMemoryDb() + private val dao = db.healthDao() + + @After fun close() = db.close() + + private fun table(file: String) = CSV_TABLES.single { it.file == file } + + private suspend fun pages(file: String, from: Long, to: Long, size: Int): List> { + val out = mutableListOf>() + (table(file).read as Paged<*>).forEachPage(dao, from, to, size) { out += it } + return out + } + + @Test fun `a timestamp table's pages are its range in order`() = runTest { + dao.insertHeartRate((1..10).map { HeartRateSample(it * 60L, 60 + it) }) + val pages = pages("heart_rate.csv", 120, 540, 3) + assertEquals(listOf(3, 3, 2), pages.map { it.size }) + assertEquals(dao.heartRateOnce(120, 540), pages.flatten()) + // A range that ends on a page's edge ends with an empty read, not a repeat. + assertEquals(listOf(3, 3), pages("heart_rate.csv", 60, 360, 3).map { it.size }) + } + + @Test fun `a workout's samples page across workouts without a gap`() = runTest { + // Three workouts whose samples share timestamps, so the key has to be the pair. + val samples = listOf(1000L, 2000L, 3000L, 4000L).flatMap { start -> (0 until 4).map { WorkoutHeartRate(start, 5000L + it, 120 + it) } } + dao.insertWorkoutHeartRate(samples) + val pages = pages("workout_hr.csv", 2000, 3000, 3) + assertEquals(samples.filter { it.workoutStart in 2000..3000 }, pages.flatten()) + dao.insertAppWorkoutHeartRate(samples.map { AppWorkoutHeartRate(it.workoutStart, it.timestamp, it.bpm) }) + assertEquals(dao.appWorkoutHeartRateAll(), pages("app_workout_hr.csv", 0, Long.MAX_VALUE / 2, 5).flatten()) + } + + @Test fun `the export writes every row of a table bigger than a page`() = runTest { + val n = CSV_PAGE * 2 + 1 + dao.insertHeartRate((0 until n).map { HeartRateSample(it * 60L, 70) }) + val zip = ByteArrayOutputStream().also { CsvExporter(dao).exportZip(it) }.toByteArray() + val lines = ZipInputStream(zip.inputStream()).use { z -> + generateSequence { z.nextEntry }.first { it.name == "heart_rate.csv" } + z.bufferedReader().readLines() + } + assertEquals(n + 1, lines.size) + assertEquals((0 until n).map { it * 60L }, lines.drop(1).map { it.split(',')[1].toLong() }) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/export/CsvReaderTest.kt b/app/src/test/kotlin/org/openstrap/app/export/CsvReaderTest.kt new file mode 100644 index 00000000..2e212e5e --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/export/CsvReaderTest.kt @@ -0,0 +1,43 @@ +package org.openstrap.app.export + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.export.CsvExporter.Companion.writeCsv +import java.io.StringWriter + +class CsvReaderTest { + private fun read(csv: String) = CsvReader(csv.reader()).all() + + @Test fun `a quoted cell keeps its line breaks, commas and quotes`() { + val csv = "id,note\n1,\"first line\nsecond, with \"\"quotes\"\"\"\n2,plain\n" + assertEquals(listOf(listOf("id", "note"), listOf("1", "first line\nsecond, with \"quotes\""), listOf("2", "plain")), read(csv)) + } + + @Test fun `CRLF and CR end a record, blank lines are skipped, and an empty last cell is kept`() { + assertEquals(listOf(listOf("a", "b"), listOf("1", ""), listOf("2", "x")), read("a,b\r\n\r\n1,\r2,x")) + assertEquals(emptyList>(), read("")) + } + + @Test fun `whatever the exporter writes reads back cell for cell`() { + val awkward = listOf("", "plain", "a,b", "say \"hi\"", "two\nlines", "cr\rhere", "crlf\r\nhere", "\"", ",", "\n") + val rows = awkward.map { listOf(it, 7, null) } + val csv = StringWriter().also { it.writeCsv("text,n,none", rows) }.toString() + val back = read(csv) + assertEquals(listOf("text", "n", "none"), back.first()) + // A row of empty cells is still a record: only an empty line is skipped. + assertEquals(awkward.map { listOf(it, "7", "") }, back.drop(1)) + } + + @Test fun `a phone workout from a backup before the strap's effect columns gets the entity's defaults`() { + val header = "start,start_epoch,end_epoch,sport_type,source,aerobic_te,recovery_minutes" + val records = read("$header\n2026-10-17T19:20:00Z,1792264800,1792268400,1,0,3.1,600\n") + val index = records[0].withIndex().associate { (i, n) -> n to i } + val w = CSV_TABLES.first { it.file == "app_workout.csv" }.parse(CsvRow(index, records[1])) as AppWorkout + assertEquals(3.1f, w.aerobicTrainingEffect) + assertEquals(null, w.anaerobicTrainingEffect) + assertFalse(w.effectFromStrap) + assertEquals(null, w.ownRecoveryMinutes) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/export/CsvRestoreTest.kt b/app/src/test/kotlin/org/openstrap/app/export/CsvRestoreTest.kt index 67b6c51e..29c9951c 100644 --- a/app/src/test/kotlin/org/openstrap/app/export/CsvRestoreTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/export/CsvRestoreTest.kt @@ -13,6 +13,11 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.AppWorkoutRound +import org.openstrap.app.data.db.BadgeRow +import org.openstrap.app.data.db.ExerciseRow +import org.openstrap.app.data.db.SavedWorkout +import org.openstrap.app.data.db.WeekResult import org.openstrap.app.data.db.DailyEnergyRow import org.openstrap.app.data.db.DeletedWorkout import org.openstrap.app.data.db.HealthDao @@ -242,4 +247,27 @@ class CsvRestoreTest { assertEquals(24, phone.healthDao().dailyEnergyOnce(day - 86_400, day - 86_400).single().hourlyKcal.size) assertEquals(listOf(w), phone.healthDao().appWorkoutsAll()) } + @Test fun `badges, past weeks, saved workouts, exercises and rounds come back, once`() = runTest { + val d = old.healthDao() + val w = AppWorkout(day - 86_400 + 17 * 3600, day - 86_400 + 18 * 3600, 30) + d.insertAppWorkouts(listOf(w)) + d.upsertRounds(listOf(AppWorkoutRound(w.start, 0, w.start, w.start + 60, w.start + 90, 168), AppWorkoutRound(w.start, 1, w.start + 90, w.start + 150, null, 172))) + d.insertBadges(listOf(BadgeRow("first_workout", w.end!!, workoutStart = w.start), BadgeRow("steps_10k", day - 3 * 86_400, day = 20_740))) + d.upsertWeekResults(listOf(WeekResult(20_733, 3, 4, true), WeekResult(20_740, 5, 2, false))) + d.upsertSavedWorkout(SavedWorkout(name = "Tuesday intervals", sport = 30, structure = "i:6x60/30", createdAt = day - 5 * 86_400)) + d.upsertExercise(ExerciseRow("Bench press", 8, 60f, w.end!!)) + // The phone already logged the bench press since: it keeps its own last reps and weight. + phone.healthDao().upsertExercise(ExerciseRow("Bench press", 5, 70f, day)) + val zip = backup(old) + restore(zip) + val p = phone.healthDao() + assertEquals(d.badgesOnce(), p.badgesOnce()) + assertEquals(d.weekResultsOnce(), p.weekResultsOnce()) + assertEquals(d.roundsOnce(w.start), p.roundsOnce(w.start)) + assertEquals(listOf("Tuesday intervals"), p.savedWorkoutsOnce().map { it.name }) + assertEquals(listOf(ExerciseRow("Bench press", 5, 70f, day)), p.exercisesOnce()) + val once = contents(phone) + restore(zip) + assertEquals(once, contents(phone)) + } } diff --git a/app/src/test/kotlin/org/openstrap/app/export/CsvRoundTripTest.kt b/app/src/test/kotlin/org/openstrap/app/export/CsvRoundTripTest.kt new file mode 100644 index 00000000..c7de406f --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/export/CsvRoundTripTest.kt @@ -0,0 +1,144 @@ +package org.openstrap.app.export + +import kotlinx.coroutines.test.runTest +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.export.CsvExporter.Companion.writeCsv +import org.openstrap.app.testing.SourceTree +import org.openstrap.app.testing.inMemoryDb +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.StringWriter +import java.lang.reflect.Modifier + +/** + * Every table in the backup, one row each with every column set, through CSV and back (audit test 1). The rows are + * written from the newest Room schema, so a column added to an entity and not to its CSV table fails here. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class CsvRoundTripTest { + private val source = inMemoryDb() + private val target = inMemoryDb() + + @After fun close() { + source.close() + target.close() + } + + private class Column(val field: String, val name: String, val affinity: String) + + /** The newest exported schema's columns, by table. */ + private val schema: Map> by lazy { + val newest = SourceTree.schemas.listFiles()!!.filter { it.extension == "json" }.maxBy { it.nameWithoutExtension.toInt() } + val entities = JSONObject(newest.readText()).getJSONObject("database").getJSONArray("entities") + (0 until entities.length()).associate { i -> + val e = entities.getJSONObject(i) + val fields = e.getJSONArray("fields") + e.getString("tableName") to (0 until fields.length()).map { j -> + val f = fields.getJSONObject(j) + Column(f.getString("fieldPath"), f.getString("columnName"), f.getString("affinity")) + } + } + } + + /** A value for every column, none of them a default: the CSV must carry each one for the row to come back equal. */ + private fun value(table: String, column: Column): Any = when (column.affinity) { + // A deleted workout that names the row in workout, or its sport effect, would keep them out of the restore. + "INTEGER" -> if (table == "workout_deleted") 3L else 2L + "REAL" -> 2.5 + "BLOB" -> byteArrayOf(1, 0xab.toByte()) + // A comma, a quote and a line break: the cell has to be quoted and read back whole. + else -> "a, \"b\"\nc" + } + + private fun seed(db: OpenStrapDatabase) { + for (t in CSV_TABLES) { + val columns = schema.getValue(t.key.table) + db.openHelper.writableDatabase.execSQL( + "INSERT INTO ${t.key.table} (${columns.joinToString { "`${it.name}`" }}) VALUES (${columns.joinToString { "?" }})", + columns.map { value(t.key.table, it) }.toTypedArray(), + ) + } + } + + private suspend fun rows(db: OpenStrapDatabase, t: CsvTable<*>): List = t.read(db.healthDao(), 0, Long.MAX_VALUE / 2) + + /** An entity's fields by name, arrays as lists so they compare by content, less those left out of the CSV on purpose. */ + private fun fields(table: String, row: Any?): Map = row!!.javaClass.declaredFields + .filter { !Modifier.isStatic(it.modifiers) && !it.isSynthetic && it.name !in LEFT_OUT[table].orEmpty() } + .associate { f -> + f.isAccessible = true + f.name to f.get(row).let { if (it is ByteArray) it.toList() else it } + } + + /** Sets every Boolean field of [row] false, as the seeded row has them true. */ + private fun flipBooleans(row: Any) { + for (f in row.javaClass.declaredFields) { + if (Modifier.isStatic(f.modifiers)) continue + f.isAccessible = true + when (f.type) { + Boolean::class.javaPrimitiveType -> f.setBoolean(row, false) + java.lang.Boolean::class.java -> f.set(row, false) + } + } + } + + @Test fun `every table of the schema is backed up, or left out on purpose`() { + val backedUp = CSV_TABLES.map { it.key.table }.toSet() + assertEquals(schema.keys - NOT_BACKED_UP, backedUp) + assertEquals(CSV_TABLES.size, backedUp.size) + } + + @Test fun `each table's CSV columns carry every field of its entity`() = runTest { + seed(source) + for (t in CSV_TABLES) { + @Suppress("UNCHECKED_CAST") + val table = t as CsvTable + val row = rows(source, t).single()!! + val entityFields = row.javaClass.declaredFields.filter { !Modifier.isStatic(it.modifiers) }.map { it.name }.toSet() + for (column in schema.getValue(t.key.table)) assertTrue("${t.file}: ${column.field} isn't a field", column.field in entityFields) + // Booleans both ways, so one whose default is true is caught too. + for (pass in 0..1) { + if (pass == 1) flipBooleans(row) + val csv = StringWriter().also { it.writeCsv(t.header, listOf(table.row(row))) }.toString() + val records = CsvReader(csv.reader()).all() + assertEquals("${t.file}: one header and one row", 2, records.size) + val index = records[0].withIndex().associate { (i, n) -> n to i } + assertEquals(t.file, fields(t.key.table, row), fields(t.key.table, table.parse(CsvRow(index, records[1])))) + } + } + } + + @Test fun `a backup of every table restores into an empty database row for row`() = runTest { + seed(source) + val zip = ByteArrayOutputStream().also { CsvExporter(source.healthDao()).exportZip(it) }.toByteArray() + val result = CsvImporter(target).importZip(ByteArrayInputStream(zip)) + assertEquals(0, result.skipped) + for (t in CSV_TABLES) { + val before = rows(source, t) + assertEquals("${t.file} rows", 1, before.size) + assertEquals(t.file, before.map { fields(t.key.table, it) }, rows(target, t).map { fields(t.key.table, it) }) + } + } + + companion object { + /** Tables no backup carries: raw_record is undecoded strap records, kept to parse later and never read. */ + private val NOT_BACKED_UP = setOf("raw_record") + + /** Fields a table's CSV leaves out on purpose, by table. */ + private val LEFT_OUT = mapOf( + // Room numbers saved workouts itself; created_epoch is their key in the backup. + "saved_workout" to setOf("id"), + // Only set while a workout records, and a recording workout isn't exported. + "app_workout" to setOf("pausedAt"), + ) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/export/SettingsBackupTest.kt b/app/src/test/kotlin/org/openstrap/app/export/SettingsBackupTest.kt index 099989ba..f990193b 100644 --- a/app/src/test/kotlin/org/openstrap/app/export/SettingsBackupTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/export/SettingsBackupTest.kt @@ -59,6 +59,34 @@ class SettingsBackupTest { assertEquals(emptyMap(), SettingsBackup.fromJson("{}")) } + @Test fun `the goals, celebrations and each sport's Ready settings go in`() { + val more = mapOf( + "goal_calories" to 2_400, "goal_workouts_week" to 4, "celebrate_goal" to false, "weekly_recap" to false, + "sport_settings_7" to "gps=1", "sport_settings_12" to "gps=0", + ) + val json = SettingsBackup.toJson(more + mapOf("sport_settings_x" to "bad", "sport_settings_" to "bad")) + assertEquals(more, SettingsBackup.fromJson(json)) + } + + @Test fun `every setting is either backed up or left out on purpose`() { + // Left out: the strap and Zepp sign-in, what needs a permission on this phone, the backup folder, and what the + // app keeps for itself rather than what the user chose. + val leftOut = setOf( + "device_address", "device_auth_key", "device_name", + "zepp_app_token", "zepp_user_id", "zepp_api_base", "zepp_china", "zepp_login_token", "zepp_app_token_at", "zepp_app_ttl", + "companion_association_id", "companion_declined", "held_disconnected", + "strap_battery", "strap_charging", "strap_last_sync_done", "strap_sessions", + "buzz_calls", "buzz_notifications", "health_connect_auto", "health_connect_synced", "health_connect_auto_stopped", + "backup_folder", "backup_folder_name", "backup_last_done", "backup_failed", "backup_failed_at", + "goal_moment_week", "recap_week", "rewards_started", "history_badges_waiting", "habit_age_seen", + "update_checked_at", "update_notified_tag", "update_installing_tag", "update_seen_tag", + ) + val keys = org.openstrap.app.data.settings.SettingsKeys::class.java.declaredFields + .filter { java.lang.reflect.Modifier.isStatic(it.modifiers) && it.type == String::class.java && !it.name.endsWith("_PREFIX") } + .map { it.get(null) as String } + for (key in keys) assertTrue("$key is neither backed up nor left out", SettingsBackup.carries(key) != (key in leftOut)) + } + @Test fun `a restore writes only what this phone has never set`() { val missing = SettingsBackup.missing(settings, present = setOf("goal_steps", "profile_sex", "device_auth_key")) assertFalse("goal_steps" in missing) diff --git a/app/src/test/kotlin/org/openstrap/app/health/AutoHealthConnectTest.kt b/app/src/test/kotlin/org/openstrap/app/health/AutoHealthConnectTest.kt index a238e434..d3c4e9a0 100644 --- a/app/src/test/kotlin/org/openstrap/app/health/AutoHealthConnectTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/health/AutoHealthConnectTest.kt @@ -65,4 +65,10 @@ class AutoHealthConnectTest { assertEquals(HealthConnectAuto.Stopped.PERMISSIONS, AutoHealthConnect.stopReason(available = true, granted = false)) assertNull(AutoHealthConnect.stopReason(available = true, granted = true)) } + + @Test fun `an edited sleep is written over every time it can be shown at, not past now`() { + val start = 1_000_000L + assertEquals(start - 3 * 3600 to start + 19 * 3600, AutoHealthConnect.sleepRange(start, start + 30 * 3600)) + assertEquals(start - 3 * 3600 to start + 9 * 3600, AutoHealthConnect.sleepRange(start, start + 9 * 3600)) + } } diff --git a/app/src/test/kotlin/org/openstrap/app/health/RecordStoreTest.kt b/app/src/test/kotlin/org/openstrap/app/health/RecordStoreTest.kt new file mode 100644 index 00000000..ba2026e2 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/health/RecordStoreTest.kt @@ -0,0 +1,84 @@ +package org.openstrap.app.health + +import androidx.health.connect.client.records.Record +import androidx.health.connect.client.records.SleepSessionRecord +import androidx.health.connect.client.records.metadata.Metadata +import androidx.health.connect.client.time.TimeRangeFilter +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset +import kotlin.reflect.KClass + +/** + * Health Connect replaces ([RecordStore.write]) put the new records in before the old ones go, so a failed insert + * leaves what was there. Then only OpenStrap's records not written again are removed. + */ +class RecordStoreTest { + private val range = TimeRangeFilter.between(Instant.ofEpochSecond(0), Instant.ofEpochSecond(100_000)) + + private fun sleep(start: Long, id: String, recordId: String = "") = SleepSessionRecord( + startTime = Instant.ofEpochSecond(start), startZoneOffset = ZoneOffset.UTC, + endTime = Instant.ofEpochSecond(start + 3600), endZoneOffset = ZoneOffset.UTC, + metadata = Metadata.autoRecorded(device = androidx.health.connect.client.records.metadata.Device(type = 0), clientRecordId = id), + ).let { if (recordId.isEmpty()) it else it.withId(recordId) } + + /** A copy of [this] as Health Connect hands it back, with its record id. */ + private fun SleepSessionRecord.withId(recordId: String): SleepSessionRecord { + val f = Metadata::class.java.getDeclaredField("id").apply { isAccessible = true } + f.set(metadata, recordId) + return this + } + + /** Health Connect holding [held], each written by OpenStrap, where [readable] false refuses the read of its own. */ + private inner class FakeStore(held: List, val readable: Boolean = true, val failInsert: Boolean = false) : RecordStore { + val records = held.toMutableList() + val calls = mutableListOf() + + override suspend fun insert(records: List) { + calls += "insert" + if (failInsert) error("Health Connect said no") + this.records.removeAll { r -> records.any { it.metadata.clientRecordId == r.metadata.clientRecordId } } + this.records += records + } + + override suspend fun ownRecords(type: KClass, range: TimeRangeFilter): List? = + if (readable) records.filter { type.isInstance(it) } else null + + override suspend fun deleteIds(type: KClass, ids: List) { + calls += "deleteIds" + records.removeAll { it.metadata.id in ids } + } + + override suspend fun deleteRange(type: KClass, range: TimeRangeFilter) { + calls += "deleteRange" + records.removeAll { type.isInstance(it) } + } + } + + @Test fun `new records go in, then only stale ones go`() = runTest { + val store = FakeStore(listOf(sleep(1000, "openstrap:sleep:1000", "a"), sleep(9000, "openstrap:sleep:9000", "b"))) + // The nap at 9000 was folded into the night: only the night is written again. + store.write(listOf(sleep(1000, "openstrap:sleep:1000")), listOf(SleepSessionRecord::class to range)) + assertEquals(listOf("insert", "deleteIds"), store.calls) + assertEquals(listOf("openstrap:sleep:1000"), store.records.map { it.metadata.clientRecordId }) + } + + @Test fun `a failed insert leaves the old records`() = runTest { + val old = listOf(sleep(1000, "openstrap:sleep:1000", "a"), sleep(9000, "openstrap:sleep:9000", "b")) + val store = FakeStore(old, failInsert = true) + val failure = runCatching { store.write(listOf(sleep(1000, "openstrap:sleep:1000")), listOf(SleepSessionRecord::class to range)) } + assertTrue(failure.isFailure) + assertEquals(listOf("insert"), store.calls) + assertEquals(old, store.records) + } + + @Test fun `without a read of its own records the range is cleared first, as before`() = runTest { + val store = FakeStore(listOf(sleep(9000, "openstrap:sleep:9000", "b")), readable = false) + store.write(listOf(sleep(1000, "openstrap:sleep:1000")), listOf(SleepSessionRecord::class to range)) + assertEquals(listOf("deleteRange", "insert"), store.calls) + assertEquals(listOf("openstrap:sleep:1000"), store.records.map { it.metadata.clientRecordId }) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/presentation/BandsTest.kt b/app/src/test/kotlin/org/openstrap/app/presentation/BandsTest.kt new file mode 100644 index 00000000..075006cb --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/presentation/BandsTest.kt @@ -0,0 +1,105 @@ +package org.openstrap.app.presentation + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.testing.assertCleanCopy +import org.openstrap.app.ui.dashboard.ActivityCopy +import org.openstrap.app.ui.dashboard.InfoCopy +import org.openstrap.app.ui.dashboard.TabCopy +import org.openstrap.app.ui.dashboard.TodayCopy +import org.openstrap.app.ui.dashboard.STALE_AFTER_S +import org.openstrap.app.widget.syncStale + +/** The bands are the ones the screens wrote by hand, at every score. */ +class BandsTest { + @Test + fun recoveryBoundaries() { + assertEquals(listOf(0, 1, 1, 2, 2, 3, 3), listOf(54, 55, 69, 70, 84, 85, 100).map(RecoveryBands::bar)) + assertEquals("Take it easy", RecoveryBands.word(54)) + assertEquals("Moderate", RecoveryBands.word(55)) + assertEquals("Ready", RecoveryBands.word(70)) + assertEquals("Ready for more", RecoveryBands.word(85)) + assertEquals("Take it easy", RecoveryBands.word(95, alert = true)) + assertCleanCopy(RecoveryBands.WORDS) + } + + @Test + fun recoveryMatchesTheScreens() { + assertEquals(InfoCopy.RECOVERY_SCALE, RecoveryBands.WORDS) + for (score in 0..100) { + assertEquals(InfoCopy.recoveryBar(score), RecoveryBands.bar(score)) + for (alert in listOf(false, true)) assertEquals(TodayCopy.recoveryWord(score, alert), RecoveryBands.word(score, alert)) + } + } + + @Test + fun sleepBoundaries() { + assertEquals(listOf(0, 1, 1, 2, 2, 3, 3), listOf(59, 60, 79, 80, 89, 90, 100).map(SleepBands::bar)) + assertEquals(listOf("Poor", "Fair", "Good", "Great"), listOf(0, 60, 80, 90).map(SleepBands::word)) + assertCleanCopy(SleepBands.WORDS) + } + + @Test + fun sleepMatchesTheScreens() { + assertEquals(InfoCopy.SLEEP_SCALE, SleepBands.WORDS) + for (score in 0..100) { + assertEquals(InfoCopy.sleepBar(score), SleepBands.bar(score)) + assertEquals(TabCopy.sleepScoreWord(score), SleepBands.word(score)) + } + } + + @Test + fun loadIsTodaysRule() { + assertNull(LoadBands.of(50f, null)) + assertNull(LoadBands.of(50f, 0f)) + assertEquals(LoadBand.Usual, LoadBands.of(125f, 100f)) + assertEquals(LoadBand.Above, LoadBands.of(125.1f, 100f)) + assertEquals(LoadBand.Usual, LoadBands.of(75f, 100f)) + assertEquals(LoadBand.Below, LoadBands.of(74.9f, 100f)) + assertEquals(LoadBand.SoFar, LoadBands.of(10f, 100f, endOfDay = false)) + assertEquals(LoadBand.Above, LoadBands.of(130f, 100f, endOfDay = false)) + assertEquals(75f..125f, LoadBands.usualRange(100f)) + assertNull(LoadBands.usualRange(0f)) + assertCleanCopy(LoadBand.entries.map { it.text }) + } + + @Test + fun loadMatchesTodayAndActivity() { + assertEquals(TodayCopy.LOAD_BAND, LoadBands.BAND) + for (usual in listOf(null, 0f, 40f, 100f, 333f)) { + for (load in (0..500 step 5).map { it.toFloat() }) { + for (endOfDay in listOf(true, false)) { + assertEquals(TodayCopy.loadStatus(load, usual, endOfDay)?.name, LoadBands.of(load, usual, endOfDay)?.name) + } + // Activity's bar is Today's rule for a whole day. + assertEquals(ActivityCopy.loadBar(load, usual), LoadBands.of(load, usual)?.let { ActivityCopy.LOAD_SCALE.indexOf(it.text) }) + } + assertEquals(ActivityCopy.usualBand(usual), LoadBands.usualRange(usual)) + } + assertEquals(TodayCopy.LoadStatus.entries.map { it.text }, LoadBand.entries.map { it.text }) + } + + @Test + fun strapStaleAfterThreeHours() { + assertEquals(3 * 3600L, StrapBands.STALE_AFTER_S) + val now = 1_800_000_000L + assertFalse(StrapBands.stale(null, now)) + assertFalse(StrapBands.stale(now - 3 * 3600, now)) + assertTrue(StrapBands.stale(now - 3 * 3600 - 1, now)) + // The widgets' rule, which the app's 90 minutes gave way to (wave 3C): one threshold now. + for (age in listOf(0L, 5400L, 10_800L, 10_801L, 86_400L)) assertEquals(syncStale(now - age, now), StrapBands.stale(now - age, now)) + assertEquals(StrapBands.STALE_AFTER_S, STALE_AFTER_S) + } + + @Test + fun lowBatteryAtFifteen() { + assertTrue(StrapBands.lowBattery(15, charging = false)) + assertTrue(StrapBands.lowBattery(0, charging = false)) + assertFalse(StrapBands.lowBattery(16, charging = false)) + assertFalse(StrapBands.lowBattery(5, charging = true)) + assertFalse(StrapBands.lowBattery(null, charging = false)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/presentation/DataCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/presentation/DataCopyTest.kt new file mode 100644 index 00000000..4d32bd43 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/presentation/DataCopyTest.kt @@ -0,0 +1,104 @@ +package org.openstrap.app.presentation + +import org.junit.Assert.assertEquals +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.SleepEdits +import org.openstrap.app.data.Vo2max +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.settings.Goals +import org.openstrap.app.testing.LocaleRule +import org.openstrap.app.testing.assertCleanCopy +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.Locale + +/** The text the data package used to write itself (A10), now built here, in each language and clock setting. */ +class DataCopyTest { + @get:Rule val localeRule = LocaleRule(Locale.GERMANY) + + @After fun clock() { ClockSetting.use24Hour = true } + + private val zone = ZoneOffset.UTC + private val day = LocalDate.of(2026, 10, 17) + private fun at(h: Int, m: Int = 0) = day.atTime(h, m).toEpochSecond(zone) + + @Test fun `a signed change never reads as minus nothing, whatever the decimal mark`() { + assertEquals("±0,0", Signed.of(-0.04f, 1)) + assertEquals("−0,4", Signed.of(-0.4f, 1)) + assertEquals("+3", Signed.of(3)) + assertEquals("±0", Signed.of(0)) + assertEquals("±0.0", Signed.of(-0.04f, 1, Locale.US)) + assertEquals("−3", Signed.of(-3.2f, 0, Locale.US)) + } + + @Test fun `a sleep edit's overlap follows the clock setting`() { + val nap = SleepSession(start = at(14), end = at(15), nap = true) + val earlier = SleepSession(start = at(13), end = at(14, 10), nap = true) + val now = at(20) + assertEquals(SleepEdits.Problem.RunsInto(nap = true, start = at(13)), SleepEdits.check(nap, at(14), at(14, 48), now, listOf(earlier))) + val problem = SleepEdits.check(nap, at(14), at(14, 48), now, listOf(earlier))!! + assertEquals("That runs into your nap at 13:00.", SleepEditCopy.text(problem, zone)) + ClockSetting.use24Hour = false + val twelve = SleepEditCopy.text(problem, zone) + assertEquals("That runs into your nap at ${TimeOfDay.format(at(13), zone, use24Hour = false)}.", twelve) + assertCleanCopy(twelve) + } + + @Test fun `every sleep edit problem has its sentence`() { + val all = listOf( + SleepEdits.Problem.WakeBeforeSleep, SleepEdits.Problem.TooShort(true), SleepEdits.Problem.TooShort(false), + SleepEdits.Problem.TooLong(true), SleepEdits.Problem.TooLong(false), SleepEdits.Problem.Future, + SleepEdits.Problem.TooFarFromStrap, SleepEdits.Problem.RunsInto(false, at(1)), + ).map { SleepEditCopy.text(it, zone) } + assertEquals(all.size, all.toSet().size) + assertCleanCopy(all) + } + + @Test fun `the calorie range groups its digits in the phone's language`() { + assertEquals("Pick a number from 200 to 3.000", GoalCopy.outOfRange(Goals.CALORIES)) + assertEquals("Pick a number from 200 to 3,000", GoalCopy.outOfRange(200..3000, Locale.US)) + assertEquals("8.000 steps, 450 active kcal, 8 h sleep, 10 active hours", GoalCopy.summary(Goals(calories = 450))) + assertEquals( + "8,000 steps, 300 active kcal, 7 h 30 sleep, 10 active hours", + GoalCopy.summary(Goals(sleepMinutes = 450), Locale.US), + ) + } + + @Test fun `the VO2max words, with the bands as ranges in words`() { + assertEquals("Needs an age between 20 and 79.", Vo2maxCopy.reason(Vo2max.Withheld.Age)) + assertEquals("Top 10 %", Vo2maxCopy.band(Vo2max.Friend.Band.Top10)) + // The ranges say "to" now: copy uses no en dash between words. + assertEquals("30th to 50th percentile", Vo2maxCopy.band(Vo2max.Friend.Band.P30to50)) + assertCleanCopy(Vo2max.Withheld.entries.map(Vo2maxCopy::reason) + Vo2max.Friend.Band.entries.map(Vo2maxCopy::band)) + } + + @Test fun `recovery's contributor lines use the app's durations`() { + val nights = (1..20).map { org.openstrap.app.testing.night(it) } + val tonight = org.openstrap.app.testing.night(21, asleep = 460) + val sleep = Recovery.readiness(tonight, nights).contributors.first { it.factor == Recovery.Factor.Sleep } + assertEquals("7 h 40 asleep", RecoveryCopy.detail(sleep.detail)) + assertCleanCopy(RecoveryCopy.detail(sleep.detail)) + } + + @Test fun `every recovery contributor line, in German and English`() { + val all = listOf( + Recovery.Detail.Hrv(52, -8), Recovery.Detail.RestingHr(54, 1.6f), Recovery.Detail.Asleep(460), + Recovery.Detail.SleepDebt(0), Recovery.Detail.SleepDebt(75), Recovery.Detail.LowestHeartRate(95), + Recovery.Detail.Temperature(-0.04f), Recovery.Detail.Breathing(0.4f), Recovery.Detail.BloodOxygen(96.25f, -1.2f), + Recovery.Detail.Load(140, 95), + ) + assertEquals( + listOf( + "52 ms, −8% vs usual", "54 bpm, +2 vs usual", "7 h 40 asleep", "No sleep debt", "1 h 15 of sleep debt", + "Lowest heart rate 1 h 35 before waking", "±0,0 °C vs usual", "+0,4 /min vs usual", "96,3 %, −1,2 vs usual", + "Load 140, usual 95", + ), + all.map { RecoveryCopy.detail(it) }, + ) + assertEquals("96.3 %, −1.2 vs usual", RecoveryCopy.detail(Recovery.Detail.BloodOxygen(96.25f, -1.2f), Locale.US)) + assertCleanCopy(all.map { RecoveryCopy.detail(it) } + all.map { RecoveryCopy.detail(it, Locale.US) }) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/presentation/DayWordsTest.kt b/app/src/test/kotlin/org/openstrap/app/presentation/DayWordsTest.kt new file mode 100644 index 00000000..f8a34baf --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/presentation/DayWordsTest.kt @@ -0,0 +1,156 @@ +package org.openstrap.app.presentation + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.openstrap.app.testing.LocaleRule +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.assertCleanCopy +import java.time.DayOfWeek +import java.time.Instant +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.DecimalStyle +import java.time.format.TextStyle +import java.util.Locale + +/** One rule for day words, in each test language, seen from the days the clocks change. */ +@RunWith(Parameterized::class) +class DayWordsTest(private val locale: Locale) { + companion object { + @JvmStatic @Parameterized.Parameters(name = "{0}") + fun locales() = localeParameters() + + /** Both Sundays, the days the clocks change in Berlin. */ + private val DST_START = LocalDate.of(2026, 3, 29) + private val DST_END = LocalDate.of(2026, 10, 25) + } + + @get:Rule val localeRule = LocaleRule(locale) + @get:Rule val zoneRule = ZoneRule(ZoneId.of("Asia/Kathmandu")) + + private val produced = mutableListOf() + private fun String.kept() = also(produced::add) + + @After + fun cleanCopy() = assertCleanCopy(produced) + + private fun weekday(d: LocalDate) = d.dayOfWeek.getDisplayName(TextStyle.FULL, locale) + /** The date in [locale]'s words and its own digits, Arabic-Indic under `ar`. */ + private fun dated(d: LocalDate, pattern: String = "EEE d MMM") = + d.format(DateTimeFormatter.ofPattern(pattern, locale).withDecimalStyle(DecimalStyle.of(locale))) + private fun n(ascii: String) = localNumber(ascii, locale) + + @Test + fun todayYesterdayThenTheWeekdayThenTheDate() { + for (today in listOf(DST_START, DST_END)) { + assertEquals("Today", DayWords.relative(today, today).kept()) + assertEquals("Yesterday", DayWords.relative(today.minusDays(1), today).kept()) + for (back in 2L..6L) assertEquals(weekday(today.minusDays(back)), DayWords.relative(today.minusDays(back), today).kept()) + assertEquals(dated(today.minusDays(7)), DayWords.relative(today.minusDays(7), today).kept()) + assertEquals(dated(today.minusDays(40)), DayWords.relative(today.minusDays(40), today).kept()) + } + } + + @Test + fun anEarlierYearAddsTheYear() { + val d = LocalDate.of(2025, 10, 21) + assertEquals(dated(d, "EEE d MMM yyyy"), DayWords.relative(d, DST_START).kept()) + // Across new year, still within the week: the weekday, no year. + assertEquals(weekday(LocalDate.of(2025, 12, 30)), DayWords.relative(LocalDate.of(2025, 12, 30), LocalDate.of(2026, 1, 2))) + } + + @Test + fun aDayAheadTakesItsDate() { + assertEquals(dated(DST_START.plusDays(1)), DayWords.relative(DST_START.plusDays(1), DST_START)) + } + + @Test + fun inEnglishItReadsAsDecided() { + if (locale.language != "en") return + assertEquals("Friday", DayWords.relative(LocalDate.of(2026, 3, 27), DST_START)) + assertEquals("Sun 22 Mar", DayWords.relative(LocalDate.of(2026, 3, 22), DST_START)) + assertEquals("Tue 21 Oct 2025", DayWords.relative(LocalDate.of(2025, 10, 21), DST_START)) + assertEquals("Tue 20 Oct", DayWords.short(LocalDate.of(2026, 10, 20))) + } + + @Test + fun lowercaseAfterOtherWords() { + assertEquals("today", DayWords.inSentence(DST_END, DST_END)) + assertEquals("yesterday", DayWords.inSentence(DST_END.minusDays(1), DST_END)) + assertEquals(weekday(DST_END.minusDays(3)), DayWords.inSentence(DST_END.minusDays(3), DST_END)) + assertEquals(dated(DST_END.minusDays(9)), DayWords.inSentence(DST_END.minusDays(9), DST_END)) + } + + @Test + fun shortIsAlwaysDated() { + assertEquals(dated(DST_START), DayWords.short(DST_START).kept()) + assertEquals(dated(LocalDate.of(2025, 10, 21)), DayWords.short(LocalDate.of(2025, 10, 21))) + } + + private fun epoch(utc: String) = Instant.parse(utc).epochSecond + private fun amPm(time: LocalTime) = DateTimeFormatter.ofPattern("a", locale).format(time) + + @Test + fun withATimeOnTheDayTheClocksGoForward() { + val now = epoch("2026-03-29T12:00:00Z") + // 00:30 on the 29th in Berlin, an hour before the change: today. + assertEquals(n("00:30"), DayWords.at(epoch("2026-03-28T23:30:00Z"), now, BERLIN, use24Hour = true).kept()) + // 23:59 on the 28th. + assertEquals(n("yesterday 23:59"), DayWords.at(epoch("2026-03-28T22:59:00Z"), now, BERLIN, use24Hour = true).kept()) + assertEquals(n("yesterday 11:59 ") + amPm(LocalTime.of(23, 59)), DayWords.at(epoch("2026-03-28T22:59:00Z"), now, BERLIN, use24Hour = false).kept()) + assertEquals( + weekday(LocalDate.of(2026, 3, 26)) + n(" 10:42"), + DayWords.at(epoch("2026-03-26T09:42:00Z"), now, BERLIN, use24Hour = true).kept(), + ) + assertEquals(dated(LocalDate.of(2026, 3, 2)) + n(" 10:42"), DayWords.at(epoch("2026-03-02T09:42:00Z"), now, BERLIN, use24Hour = true).kept()) + } + + @Test + fun withATimeOnTheDayTheClocksGoBack() { + val now = epoch("2026-10-25T23:30:00Z") // 00:30 on the 26th in Berlin, after a 25 hour day + assertEquals(n("yesterday 00:30"), DayWords.at(epoch("2026-10-24T22:30:00Z"), now, BERLIN, use24Hour = true).kept()) + assertEquals(n("yesterday 02:30"), DayWords.at(epoch("2026-10-25T01:30:00Z"), now, BERLIN, use24Hour = true)) + assertEquals(n("00:10"), DayWords.at(epoch("2026-10-25T23:10:00Z"), now, BERLIN, use24Hour = true)) + assertEquals(n("12:10 ") + amPm(LocalTime.of(0, 10)), DayWords.at(epoch("2026-10-25T23:10:00Z"), now, BERLIN, use24Hour = false).kept()) + } + + @Test + fun theDayFollowsTheZoneGivenNotThePhones() { + // 23:30 UTC on the 28th is already the 29th in Berlin, and the 29th in Kathmandu too, but not in New York. + val now = epoch("2026-03-29T12:00:00Z") + val t = epoch("2026-03-28T23:30:00Z") + assertEquals(n("yesterday 19:30"), DayWords.at(t, now, ZoneId.of("America/New_York"), use24Hour = true)) + } + + @Test + fun datesAndClocksUseTheLanguagesDigits() { + val d = LocalDate.of(2026, 10, 21) + val short = DayWords.short(d).kept() + if (locale.language == "ar") assertTrue("no Arabic-Indic digits in $short", short.contains('\u0662') && short.none { it in '0'..'9' }) + if (locale.language != "ar") assertTrue(short.contains("21")) + val now = epoch("2026-03-29T12:00:00Z") + val at = DayWords.at(epoch("2026-03-29T09:42:00Z"), now, BERLIN, use24Hour = true) + assertEquals(n("11:42"), at) + } + + @Test + fun whenDoneSaysTodayYesterdayOrTheDate() { + val now = epoch("2026-03-29T12:00:00Z") + assertEquals("today at " + n("11:42"), DayWords.whenDone(epoch("2026-03-29T09:42:00Z"), now, BERLIN, use24Hour = true).kept()) + assertEquals("yesterday at " + n("23:59"), DayWords.whenDone(epoch("2026-03-28T22:59:00Z"), now, BERLIN, use24Hour = true).kept()) + assertEquals("on " + dated(LocalDate.of(2026, 3, 20)), DayWords.whenDone(epoch("2026-03-20T09:42:00Z"), now, BERLIN, use24Hour = true).kept()) + } + + @Test + fun weekdaysAreTheLanguagesOwn() { + if (locale.language == "de") assertEquals("Freitag", DayWords.relative(LocalDate.of(2026, 3, 27), DST_START)) + assertEquals(DayOfWeek.FRIDAY.getDisplayName(TextStyle.FULL, locale), DayWords.relative(LocalDate.of(2026, 3, 27), DST_START)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/presentation/LayersTest.kt b/app/src/test/kotlin/org/openstrap/app/presentation/LayersTest.kt new file mode 100644 index 00000000..d0af08ec --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/presentation/LayersTest.kt @@ -0,0 +1,36 @@ +package org.openstrap.app.presentation + +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openstrap.app.testing.SourceTree +import java.io.File + +/** + * The service, the notifications and the updater read their words and colours from presentation, never from the + * screens: device and update code imports nothing from ui or Compose, and presentation nothing from either of those. + * Data sits below them all: it hands over values and names nothing in presentation or ui, not even in a KDoc link. + */ +class LayersTest { + private fun offenders(dir: String, banned: List): List = + File(SourceTree.kotlin, dir).walkTopDown().filter { it.extension == "kt" }.flatMap { f -> + f.readLines().filter { line -> banned.any { line.contains(it) } }.map { "${f.name}: ${it.trim()}" } + }.toList() + + private val ui = listOf("org.openstrap.app.ui.", "androidx.compose") + + @Test + fun deviceAndUpdateDontReachIntoTheScreens() { + assertEquals(emptyList(), offenders("device", ui)) + assertEquals(emptyList(), offenders("update", ui)) + } + + @Test + fun dataNamesNothingAboveIt() { + assertEquals(emptyList(), offenders("data", ui + "org.openstrap.app.presentation")) + } + + @Test + fun presentationStandsOnItsOwn() { + assertEquals(emptyList(), offenders("presentation", ui + "org.openstrap.app.device.")) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/presentation/Locales.kt b/app/src/test/kotlin/org/openstrap/app/presentation/Locales.kt new file mode 100644 index 00000000..1d8f4a87 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/presentation/Locales.kt @@ -0,0 +1,30 @@ +package org.openstrap.app.presentation + +import java.text.DecimalFormatSymbols +import java.time.ZoneId +import java.util.Locale + +/** The languages the presentation tests run under: a comma decimal mark, another script's digits, and the US. */ +val TEST_LOCALES: List = listOf(Locale.forLanguageTag("de-DE"), Locale.forLanguageTag("ar"), Locale.forLanguageTag("en-US")) + +/** Parameters for a JUnit Parameterized run, one per [TEST_LOCALES]. */ +fun localeParameters(): List> = TEST_LOCALES.map { arrayOf(it) } + +/** A zone with both clock changes: 2026-03-29 has 23 hours and 2026-10-25 has 25. */ +val BERLIN: ZoneId = ZoneId.of("Europe/Berlin") + +/** + * [ascii], written with a dot decimal mark, a comma thousands mark and ASCII digits, as [locale] writes numbers: its + * digits and marks. What `String.format(locale, ...)` gives, worked out apart from it. + */ +fun localNumber(ascii: String, locale: Locale): String { + val s = DecimalFormatSymbols.getInstance(locale) + return ascii.map { c -> + when { + c in '0'..'9' -> s.zeroDigit + (c - '0') + c == '.' -> s.decimalSeparator + c == ',' -> s.groupingSeparator + else -> c + } + }.joinToString("") +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/NotificationCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/presentation/NotificationCopyTest.kt similarity index 85% rename from app/src/test/kotlin/org/openstrap/app/ui/workout/NotificationCopyTest.kt rename to app/src/test/kotlin/org/openstrap/app/presentation/NotificationCopyTest.kt index 87fc33ea..305eaf1d 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/workout/NotificationCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/presentation/NotificationCopyTest.kt @@ -1,6 +1,5 @@ -package org.openstrap.app.ui.workout +package org.openstrap.app.presentation -import androidx.compose.ui.graphics.toArgb import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -11,6 +10,9 @@ import org.openstrap.app.data.WorkoutTarget /** The ongoing workout notification's card: what it says collapsed and expanded, its colour and its zone strip (spec 6.3). */ class NotificationCopyTest { + // English figures: the German and Arabic ones are WorkoutWordsTest's and the presentation tests'. + @get:org.junit.Rule val localeRule = org.openstrap.app.testing.LocaleRule() + private val zones = listOf(180, 420, 540, 260, 20) private fun card( @@ -56,7 +58,7 @@ class NotificationCopyTest { assertEquals("152 bpm", c.bpm) assertEquals("4 min in this zone", c.detail) assertEquals(3, numbers(c)) - assertEquals("16 min in Zone 2–3", card(gps = false, target = WorkoutTarget.Zones(1, 2)).detail) + assertEquals("16 min in Zone 2 to 3", card(gps = false, target = WorkoutTarget.Zones(1, 2)).detail) assertEquals("Goal 30 min", card(gps = false, target = WorkoutTarget.Time(1800)).detail) assertEquals("Goal 500 kcal", card(gps = false, target = WorkoutTarget.Kcal(500)).detail) // No reading, no zone time. @@ -69,7 +71,9 @@ class NotificationCopyTest { assertNull(waiting.zone) assertNull(waiting.zoneLine) val away = card(connected = false) - assertEquals("Reconnecting", away.bpm) + // The strap gone reads as on every live screen. + assertEquals("Waiting for the strap", away.bpm) + assertEquals(WorkoutWords.noHeartRate(paused = false, connected = false), away.bpm) assertNull(away.zone) assertEquals("Still recording", away.zoneLine) assertTrue(away.running) @@ -81,10 +85,11 @@ class NotificationCopyTest { } @Test fun `bpm takes the zone colour, grey without one`() { - ZONE_COLORS.forEachIndexed { z, color -> assertEquals(color.toArgb(), NotificationCopy.accent(z)) } - assertEquals(NotificationCopy.MUTED.toArgb(), NotificationCopy.accent(null)) - assertEquals(ZONE_COLORS[3].toArgb(), NotificationCopy.accent(card().zone)) - assertEquals(NotificationCopy.MUTED.toArgb(), NotificationCopy.accent(card(paused = true).zone)) + WorkoutColors.ZONES.forEachIndexed { z, color -> assertEquals(color, NotificationCopy.accent(z)) } + // The app's paused grey, the same as the live screen's, no longer a darker one of the notification's own. + assertEquals(0xFF9AA0B4.toInt(), NotificationCopy.accent(null)) + assertEquals(WorkoutColors.ZONE_4, NotificationCopy.accent(card().zone)) + assertEquals(WorkoutColors.PAUSED_GREY, NotificationCopy.accent(card(paused = true).zone)) } @Test fun `the strip shares its width by the time in each zone`() { diff --git a/app/src/test/kotlin/org/openstrap/app/presentation/RewardsCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/presentation/RewardsCopyTest.kt new file mode 100644 index 00000000..a936613b --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/presentation/RewardsCopyTest.kt @@ -0,0 +1,121 @@ +package org.openstrap.app.presentation + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.Rewards +import org.openstrap.app.data.db.PersonalBest +import org.openstrap.app.data.db.WeekResult + +class RewardsCopyTest { + // English figures: the German and Arabic ones are WorkoutWordsTest's and the presentation tests'. + @get:org.junit.Rule val localeRule = org.openstrap.app.testing.LocaleRule() + + private val utc = ZoneOffset.UTC + + private fun at(date: String, hour: Int) = LocalDate.parse(date).atTime(hour, 0).toEpochSecond(utc) + + private fun run(date: String, fiveK: Long? = null) = at(date, 7).let { s -> + Rewards.Done(s, s, s + 1800, 1, 1800, true, 5200f, 5200f, listOfNotNull(fiveK?.let { Rewards.Stretch(5000, it, s + 10) })) + } + + @Test + fun bestsReadByTheirKind() { + assertEquals("Fastest 5 km", RewardsCopy.bestTitle(Rewards.Kind.fastest(5000))) + assertEquals("Fastest half marathon", RewardsCopy.bestTitle(Rewards.Kind.fastest(21097))) + assertEquals("Heaviest bench press", RewardsCopy.bestTitle(Rewards.Kind.lift("Bench press"), Locale.UK)) + assertEquals("26:41", RewardsCopy.bestValue(Rewards.Kind.fastest(5000), 1601.0)) + // The longest run is one workout's distance, written as a workout writes it. + assertEquals("14.21 km", RewardsCopy.bestValue(Rewards.Kind.LONGEST_RUN, 14_210.0, Locale.UK)) + assertEquals("14,21 km", RewardsCopy.bestValue(Rewards.Kind.LONGEST_RUN, 14_210.0, Locale.GERMANY)) + assertEquals("1.23 km farther", RewardsCopy.beatBy(PersonalBest(Rewards.Kind.LONGEST_RUN, 6230.0, 0), PersonalBest(Rewards.Kind.LONGEST_RUN, 5000.0, 0), Locale.UK)) + assertEquals("820 m farther", RewardsCopy.beatBy(PersonalBest(Rewards.Kind.LONGEST_RUN, 5820.0, 0), PersonalBest(Rewards.Kind.LONGEST_RUN, 5000.0, 0), Locale.UK)) + assertEquals("23.410", RewardsCopy.bestValue(Rewards.Kind.MOST_STEPS, 23_410.0, Locale.GERMANY)) + assertEquals("62.5 kg", RewardsCopy.bestValue(Rewards.Kind.lift("Squat"), 62.5)) + assertEquals("5K", RewardsCopy.bestShort(Rewards.Kind.fastest(5000))) + } + + @Test + fun aBestsDottedArtKeepsAsciiDigitsInEveryLanguage() { + for (locale in TEST_LOCALES) { + Locale.setDefault(locale) + assertEquals("10K", RewardsCopy.bestShort(Rewards.Kind.fastest(10_000))) + assertEquals("HM", RewardsCopy.bestShort(Rewards.Kind.fastest(21097))) + } + } + + @Test + fun aNewBestSaysHowFarItBeatTheLast() { + val before = PersonalBest(Rewards.Kind.fastest(5000), 1623.0, at("2026-08-12", 7)) + val now = PersonalBest(Rewards.Kind.fastest(5000), 1601.0, at("2026-10-30", 7)) + assertEquals("22 s faster than your best from August.", RewardsCopy.beatLine(Rewards.NewBest(now, before), at("2026-10-30", 9), utc, Locale.UK)) + val lastYear = before.copy(date = at("2025-08-12", 7)) + assertEquals("22 s faster than your best from August 2025.", RewardsCopy.beatLine(Rewards.NewBest(now, lastYear), at("2026-10-30", 9), utc, Locale.UK)) + assertEquals("2.5 kg more", RewardsCopy.beatBy(PersonalBest("lift:Bench press", 65.0, 0), PersonalBest("lift:Bench press", 62.5, 0))) + } + + @Test + fun theGoalLineIsAlwaysLastAndSaysWhenThisWorkoutMetIt() { + val earlier = listOf(run("2026-10-20", 1650), run("2026-10-27"), run("2026-10-28")) + val third = run("2026-10-29", 1601) + val r = RewardsCopy.summary(third, earlier + third, emptyList(), emptyList(), emptyList(), 3, DayOfWeek.MONDAY, utc, at("2026-10-29", 9)) + assertTrue(r.goalMet) + assertEquals(EarnedLine.Kind.Goal, r.earned.last().kind) + assertEquals("Weekly goal met", r.earned.last().text) + assertEquals("3 of 3", r.earned.last().value) + // The 5 km best beat October 20th's. + assertEquals("Fastest 5 km", r.best?.let { RewardsCopy.bestTitle(it.best.kind) }) + assertEquals(LocalDate.parse("2026-10-26").toEpochDay(), r.weekStart) + // A fourth the same week meets nothing new. + val fourth = run("2026-10-30") + val r4 = RewardsCopy.summary(fourth, earlier + third + fourth, emptyList(), emptyList(), emptyList(), 3, DayOfWeek.MONDAY, utc, at("2026-10-30", 9)) + assertFalse(r4.goalMet) + assertNull(r4.best) + assertEquals(listOf(EarnedLine.Kind.Goal), r4.earned.map { it.kind }) + } + + @Test + fun aWorkoutTooShortToCountHasNoGoalLine() { + val earlier = listOf(run("2026-10-27"), run("2026-10-28"), run("2026-10-29")) + val short = at("2026-10-30", 7).let { s -> Rewards.Done(s, s, s + 90, 1, 90, true, 0f, 0f, emptyList()) } + val r = RewardsCopy.summary(short, earlier + short, emptyList(), emptyList(), emptyList(), 3, DayOfWeek.MONDAY, utc, at("2026-10-30", 9)) + assertTrue(r.earned.isEmpty()) + assertFalse(r.goalMet) + } + + @Test + fun goalLineSaysWhatsLeft() { + assertEquals("2 more to go this week", RewardsCopy.goalLine(Rewards.WeekLine(1, 3, false), 0).note) + assertEquals("6 weeks in a row", RewardsCopy.goalLine(Rewards.WeekLine(4, 4, true), 6).note) + assertEquals("Weekly goal", RewardsCopy.goalLine(Rewards.WeekLine(5, 4, false), 6).text) + } + + @Test + fun runsOfWeeksAndProgressRead() { + assertEquals("Goal met 6 weeks in a row. Best is 9.", RewardsCopy.runLine(6, 9)) + assertEquals("Goal met 1 week in a row, your best yet.", RewardsCopy.runLine(1, 1)) + assertEquals("712 of 1,000 km", RewardsCopy.progressLine(712, 1000, "km", Locale.UK)) + assertEquals("4.2 of 5 km", RewardsCopy.progressLine(4200, 5000, "m", Locale.UK)) + assertEquals("712 of 1.000 km", RewardsCopy.progressLine(712, 1000, "km", Locale.GERMANY)) + assertEquals("4,2 of 5 km", RewardsCopy.progressLine(4200, 5000, "m", Locale.GERMANY)) + assertEquals("27 October to 2 November", RewardsCopy.weekRange(LocalDate.parse("2026-10-27"), Locale.UK)) + assertEquals("5 of 4 workouts, goal met. 6 weeks in a row.", RewardsCopy.recapText(WeekResult(0, 4, 5, true), 6)) + assertEquals("2 of 4 workouts.", RewardsCopy.recapText(WeekResult(0, 4, 2, false), 0)) + } + + @Test + fun theLongestWorkoutReadsAsADurationAndTheGoalBadgeAsItsGoal() { + assertEquals("50 min", RewardsCopy.bestValue(Rewards.Kind.LONGEST_WORKOUT, 3000.0)) + assertEquals("1 h 12", RewardsCopy.bestValue(Rewards.Kind.LONGEST_WORKOUT, 4320.0)) + val goal = Rewards.BADGES.single { it.id == "goal_first" } + assertEquals("3/3", RewardsCopy.face(goal, 3)) + assertEquals("✓", RewardsCopy.face(goal, null)) + assertEquals("10K", RewardsCopy.face(Rewards.BADGES.single { it.id == "first_10k" }, 3)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/presentation/UnitsTest.kt b/app/src/test/kotlin/org/openstrap/app/presentation/UnitsTest.kt new file mode 100644 index 00000000..6293fcbd --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/presentation/UnitsTest.kt @@ -0,0 +1,227 @@ +package org.openstrap.app.presentation + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.openstrap.app.presentation.Units.Precision +import org.openstrap.app.testing.LocaleRule +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.assertCleanCopy +import org.openstrap.app.ui.glow.draw.DotFont +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** Every unit in each test language, the phone's default set to it, in a zone that isn't the one the times use. */ +@RunWith(Parameterized::class) +class UnitsTest(private val locale: Locale) { + companion object { + @JvmStatic @Parameterized.Parameters(name = "{0}") + fun locales() = localeParameters() + } + + @get:Rule val localeRule = LocaleRule(locale) + @get:Rule val zoneRule = ZoneRule(ZoneId.of("Asia/Kathmandu")) + + private fun n(ascii: String) = localNumber(ascii, locale) + private fun assertDots(text: String) = assertTrue("not all in the dot font: $text", text.all(DotFont::has)) + private val produced = mutableListOf() + private fun T.kept(): T = also { it?.let(produced::add) } + + @org.junit.After + fun cleanCopy() = assertCleanCopy(produced) + + @Test + fun workoutDistanceHasTwoDecimalsAndDayDistanceOne() { + assertEquals(n("5.21") + " km", Units.distance(5210f, Precision.Workout).kept()) + assertEquals(n("5.2") + " km", Units.distance(5210f, Precision.Day).kept()) + assertEquals(n("1.00") + " km", Units.distance(1000f, Precision.Workout).kept()) + assertEquals(n("12.0") + " km", Units.distance(11_960f, Precision.Day).kept()) + } + + @Test + fun decimalMarkFollowsTheLanguage() { + when (locale.language) { + "de" -> assertEquals("5,2 km", Units.distance(5210f, Precision.Day)) + "en" -> assertEquals("5.2 km", Units.distance(5210f, Precision.Day)) + } + assertEquals(Units.distance(5210f, Precision.Day, locale = locale), Units.distance(5210f, Precision.Day)) + } + + @Test + fun underAKilometreIsMetresAndUnderTenIsNothing() { + assertEquals(n("820") + " m", Units.distance(820f, Precision.Workout).kept()) + assertEquals(n("999") + " m", Units.distance(999.6f, Precision.Day).kept()) + assertEquals(n("10") + " m", Units.distance(10f, Precision.Day).kept()) + assertNull(Units.distance(9.9f, Precision.Day)) + assertNull(Units.distance(0f, Precision.Workout, fromSteps = true)) + } + + @Test + fun distanceFromStepsAlwaysHasTheMark() { + assertEquals("≈ " + n("5.2") + " km", Units.distance(5210f, Precision.Day, fromSteps = true).kept()) + assertEquals("≈ " + n("5.21") + " km", Units.distance(5210f, Precision.Workout, fromSteps = true).kept()) + assertEquals("≈ " + n("850") + " m", Units.distance(850f, Precision.Day, fromSteps = true).kept()) + } + + @Test + fun kmFigureAlone() { + assertEquals(n("5.21"), Units.km(5210f, Precision.Workout)) + assertEquals(n("0.8"), Units.km(820f, Precision.Day)) + } + + @Test + fun dotFontKeepsTheDotAndAsciiDigits() { + val dotted = listOf( + Units.km(5210f, Precision.Workout, Units.DOT_LOCALE), + Units.count(23_410, Units.DOT_LOCALE), + Units.workoutClock(3723, Units.DOT_LOCALE), + Units.clockDuration(27_600, Units.DOT_LOCALE), + Pace.clock(324.0, Units.DOT_LOCALE)!!, + dotFormat("%.1f", 14.25), + dotFormat("%02d", 7), + dotFormat("%,d", 1_234_567), + ) + assertEquals(listOf("5.21", "23,410", "1:02:03", "7:40", "5:24", "14.3", "07", "1,234,567"), dotted) + dotted.forEach(::assertDots) + } + + @Test + fun countGroupsByTheLanguage() { + assertEquals(n("23,410"), Units.count(23_410).kept()) + assertEquals(n("950"), Units.count(950)) + if (locale.language == "de") assertEquals("23.410", Units.count(23_410)) + } + + @Test + fun durationIsHoursAndMinutes() { + assertEquals(n("40") + " min", Units.duration(40 * 60L).kept()) + assertEquals(n("7") + " h", Units.duration(7 * 3600L).kept()) + assertEquals(n("7") + " h " + n("40"), Units.duration(7 * 3600L + 40 * 60 + 59).kept()) + assertEquals(n("7") + " h " + n("05"), Units.duration(7 * 3600L + 5 * 60).kept()) + assertEquals(n("0") + " min", Units.duration(59)) + assertEquals(n("0") + " min", Units.duration(-300)) + assertEquals(n("25") + " h " + n("01"), Units.duration(25 * 3600L + 60)) + } + + @Test + fun clockDurationLooksLikeAClock() { + assertEquals(n("7:40"), Units.clockDuration(7 * 3600L + 40 * 60).kept()) + assertEquals(n("0:05"), Units.clockDuration(5 * 60L)) + assertEquals(n("12:00"), Units.clockDuration(12 * 3600L)) + } + + @Test + fun workoutTimerAsItWas() { + assertEquals(n("0:00"), Units.workoutClock(0).kept()) + assertEquals(n("0:45"), Units.workoutClock(45)) + assertEquals(n("12:34"), Units.workoutClock(754)) + assertEquals(n("59:59"), Units.workoutClock(3599)) + assertEquals(n("1:00:00"), Units.workoutClock(3600)) + assertEquals(n("1:02:03"), Units.workoutClock(3723).kept()) + assertEquals(n("10:00:00"), Units.workoutClock(36_000)) + } + + @Test + fun aFinishedWorkoutUnderAMinuteIsSeconds() { + assertEquals(n("45") + " s", Units.secondsOrClock(45).kept()) + assertEquals(n("0") + " s", Units.secondsOrClock(0)) + assertEquals(n("59") + " s", Units.secondsOrClock(59)) + assertEquals(n("1:00"), Units.secondsOrClock(60)) + assertEquals(n("1:02:03"), Units.secondsOrClock(3723)) + } + + @Test + fun paceShowsFromOneHundredMetres() { + assertNull(Pace.of(null, 600)) + assertNull(Pace.of(99.9f, 600)) + assertEquals(300.0, Pace.of(100f, 30)!!, 1e-9) + assertEquals(324.0, Pace.of(5000f, 1620)!!, 1e-9) + } + + @Test + fun paceReadsPerKm() { + assertEquals(n("5:24") + " /km", Pace.text(324.0).kept()) + assertEquals(n("5:24") + " /km", Pace.text(324.4)) + assertEquals(n("5:25"), Pace.clock(324.5)) + assertEquals(n("60:00") + " /km", Pace.text(3600.0)) + } + + @Test + fun noPaceSlowerThanAnHourAKmOrWithNone() { + assertNull(Pace.text(null)) + assertNull(Pace.text(0.0)) + assertNull(Pace.text(-5.0)) + assertNull(Pace.text(3600.5)) + assertNull(Pace.clock(7200.0)) + } + + private fun epoch(utc: String) = Instant.parse(utc).epochSecond + private fun amPm(time: LocalTime) = DateTimeFormatter.ofPattern("a", locale).format(time) + + @Test + fun clocksUseTheLanguagesDigits() { + val t = epoch("2026-06-10T19:40:00Z") + val clock = TimeOfDay.format(t, BERLIN, use24Hour = true) + if (locale.language == "ar") assertEquals("\u0662\u0661:\u0664\u0660", clock) + assertEquals(String.format(locale, "%02d:%02d", 21, 40), clock) + } + + @Test + fun timeOfDayFollowsThe24HourSetting() { + val t = epoch("2026-06-10T19:40:00Z") // 21:40 in Berlin + assertEquals(n("21:40"), TimeOfDay.format(t, BERLIN, use24Hour = true).kept()) + assertEquals(n("9:40 ") + amPm(LocalTime.of(21, 40)), TimeOfDay.format(t, BERLIN, use24Hour = false).kept()) + assertEquals(n("08:05"), TimeOfDay.format(LocalTime.of(8, 5), use24Hour = true)) + assertEquals(n("8:05 ") + amPm(LocalTime.of(8, 5)), TimeOfDay.format(LocalTime.of(8, 5), use24Hour = false)) + if (locale.language == "en") assertEquals("9:40 PM", TimeOfDay.format(t, BERLIN, use24Hour = false)) + } + + @Test + fun timeOfDayDefaultsToTheClockSetting() { + val t = epoch("2026-06-10T19:40:00Z") + val saved = ClockSetting.use24Hour + try { + ClockSetting.use24Hour = false + assertEquals(TimeOfDay.format(t, BERLIN, use24Hour = false), TimeOfDay.format(t, BERLIN)) + ClockSetting.use24Hour = true + assertEquals(n("21:40"), TimeOfDay.format(t, BERLIN)) + } finally { + ClockSetting.use24Hour = saved + } + } + + @Test + fun timeOfDayOnTheDayTheClocksGoForward() { + // 2026-03-29: 02:00 CET becomes 03:00 CEST. + assertEquals(n("01:30"), TimeOfDay.format(epoch("2026-03-29T00:30:00Z"), BERLIN, use24Hour = true)) + assertEquals(n("03:30"), TimeOfDay.format(epoch("2026-03-29T01:30:00Z"), BERLIN, use24Hour = true)) + assertEquals(n("3:30 ") + amPm(LocalTime.of(3, 30)), TimeOfDay.format(epoch("2026-03-29T01:30:00Z"), BERLIN, use24Hour = false).kept()) + } + + @Test + fun timeOfDayOnTheDayTheClocksGoBack() { + // 2026-10-25: 03:00 CEST becomes 02:00 CET, so 02:30 happens twice. + assertEquals(n("02:30"), TimeOfDay.format(epoch("2026-10-25T00:30:00Z"), BERLIN, use24Hour = true)) + assertEquals(n("02:30"), TimeOfDay.format(epoch("2026-10-25T01:30:00Z"), BERLIN, use24Hour = true)) + assertEquals(n("03:30"), TimeOfDay.format(epoch("2026-10-25T02:30:00Z"), BERLIN, use24Hour = true)) + assertEquals(n("2:30 ") + amPm(LocalTime.of(2, 30)), TimeOfDay.format(epoch("2026-10-25T01:30:00Z"), BERLIN, use24Hour = false).kept()) + } + + @Test + fun minutesOfDayWrap() { + assertEquals(n("22:30"), TimeOfDay.minutes(22 * 60 + 30, use24Hour = true).kept()) + assertEquals(n("00:30"), TimeOfDay.minutes(1470, use24Hour = true)) + assertEquals(n("23:30"), TimeOfDay.minutes(-30, use24Hour = true)) + assertEquals(n("00:00"), TimeOfDay.minutes(0, use24Hour = true)) + assertEquals(n("10:30 ") + amPm(LocalTime.of(22, 30)), TimeOfDay.minutes(22 * 60 + 30, use24Hour = false).kept()) + assertFalse(TimeOfDay.minutes(22 * 60 + 30, use24Hour = false).startsWith("22")) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/device/UpdateCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/presentation/UpdateCopyTest.kt similarity index 98% rename from app/src/test/kotlin/org/openstrap/app/ui/device/UpdateCopyTest.kt rename to app/src/test/kotlin/org/openstrap/app/presentation/UpdateCopyTest.kt index 72f03c64..e6c584dc 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/device/UpdateCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/presentation/UpdateCopyTest.kt @@ -1,4 +1,4 @@ -package org.openstrap.app.ui.device +package org.openstrap.app.presentation import org.junit.Assert.assertEquals import org.junit.Test diff --git a/app/src/test/kotlin/org/openstrap/app/presentation/WorkoutColorsTest.kt b/app/src/test/kotlin/org/openstrap/app/presentation/WorkoutColorsTest.kt new file mode 100644 index 00000000..2dcd1e7e --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/presentation/WorkoutColorsTest.kt @@ -0,0 +1,33 @@ +package org.openstrap.app.presentation + +import androidx.compose.ui.graphics.toArgb +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openstrap.app.ui.glow.PausedColor +import org.openstrap.app.ui.theme.MetricColors +import org.openstrap.app.ui.workout.ZONE_COLORS + +/** The workout colours are the live screen's. */ +class WorkoutColorsTest { + @Test + fun zonesAreTheLiveScreens() { + assertEquals(ZONE_COLORS.map { it.toArgb() }, WorkoutColors.ZONES) + assertEquals(MetricColors.sleepLight.toArgb(), WorkoutColors.ZONE_1) + assertEquals(MetricColors.heart.toArgb(), WorkoutColors.ZONE_5) + } + + @Test + fun pausedIsTheLiveScreensGreyAndRestIsZoneTwo() { + assertEquals(PausedColor.toArgb(), WorkoutColors.PAUSED_GREY) + assertEquals(ZONE_COLORS[1].toArgb(), WorkoutColors.REST_GREEN) + } + + @Test + fun zoneByIndex() { + assertEquals(WorkoutColors.ZONE_1, WorkoutColors.zone(0)) + assertEquals(WorkoutColors.ZONE_5, WorkoutColors.zone(4)) + assertEquals(WorkoutColors.PAUSED_GREY, WorkoutColors.zone(null)) + assertEquals(WorkoutColors.PAUSED_GREY, WorkoutColors.zone(5)) + assertEquals(WorkoutColors.PAUSED_GREY, WorkoutColors.zone(-1)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/presentation/WorkoutWordsTest.kt b/app/src/test/kotlin/org/openstrap/app/presentation/WorkoutWordsTest.kt new file mode 100644 index 00000000..1e45efd2 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/presentation/WorkoutWordsTest.kt @@ -0,0 +1,58 @@ +package org.openstrap.app.presentation + +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.openstrap.app.data.WorkoutTarget +import org.openstrap.app.testing.LocaleRule +import org.openstrap.app.testing.assertCleanCopy +import org.openstrap.app.ui.workout.Glance +import org.openstrap.app.ui.workout.WorkoutCopy +import java.util.Locale + +/** The workout's shared words, and the notification and live lines that read them, on a German phone. */ +class WorkoutWordsTest { + @get:Rule val localeRule = LocaleRule(Locale.GERMANY) + + @Test + fun theStrapGoneReadsOneWay() { + assertEquals("Waiting for the strap", WorkoutWords.noHeartRate(paused = false, connected = false)) + assertEquals(WorkoutWords.STRAP_GONE, WorkoutCopy.noHeartRate(paused = false, connected = false)) + assertEquals("Paused", WorkoutWords.noHeartRate(paused = true, connected = false)) + assertEquals(WorkoutWords.STRAP_GONE_NOTE, WorkoutCopy.STRAP_GONE_NOTE) + assertCleanCopy(listOf(WorkoutWords.STRAP_GONE, WorkoutWords.STRAP_GONE_NOTE)) + } + + @Test + fun aKilometreIsNamedOneWay() { + assertEquals("Km 3 in 5:42", WorkoutWords.split(2, 342)) + assertEquals("Km 12 in 1:02:03", WorkoutWords.split(11, 3723)) + } + + @Test + fun zoneWords() { + assertEquals("Zone 3, tempo", WorkoutWords.zoneLine(2)) + assertEquals("Zone 3," to "tempo", WorkoutCopy.zoneTitle(2)) + assertEquals("Under a minute in this zone", WorkoutWords.zoneTime(59)) + assertEquals("4 min in this zone", WorkoutWords.zoneTime(250)) + assertEquals("312 kcal", WorkoutWords.kcalText(312.7f)) + } + + @Test + fun theNotificationWritesDistanceInTheLanguage() { + val card = NotificationCopy.card( + "Outdoor run", 1421, paused = false, connected = true, bpm = 152, maxHr = 190, gps = true, distanceMeters = 4210f, + distanceFromSteps = false, target = null as WorkoutTarget?, zoneSeconds = listOf(0, 0, 0, 60, 0), confirming = false, + ) + assertEquals("4,21 km", card.detail) + assertCleanCopy(card.text) + } + + @Test + fun theLiveLinesWriteDistanceInTheLanguageAndTheDotFigureWithADot() { + assertEquals("4,32 km", Glance.kmWithUnit(4320f)) + assertEquals("0 m", Glance.kmWithUnit(0f)) + assertEquals("4.32", Glance.kmFigure(4320f)) + assertEquals("5:42", Glance.paceClock(342.0)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/testing/Builders.kt b/app/src/test/kotlin/org/openstrap/app/testing/Builders.kt new file mode 100644 index 00000000..39f8bd7f --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/testing/Builders.kt @@ -0,0 +1,67 @@ +package org.openstrap.app.testing + +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.Workout +import org.openstrap.app.data.settings.UserProfile + +/** A strap workout with only what the test cares about named. Everything the strap may leave out is null. */ +fun workout( + start: Long = 0, + minutes: Int = 30, + end: Long = start + minutes * 60L, + sportType: Int = 1, + exerciseSeconds: Int? = (end - start).toInt(), + calories: Int? = null, + averageHeartRate: Int? = null, + maxHeartRate: Int? = null, + minHeartRate: Int? = null, + aerobicTrainingEffect: Float? = null, + anaerobicTrainingEffect: Float? = null, + trainingLoad: Int? = null, + vo2max: Int? = null, + steps: Int? = null, + subtype: Int? = null, +) = Workout( + start, end, sportType, exerciseSeconds, calories, averageHeartRate, maxHeartRate, minHeartRate, + aerobicTrainingEffect, anaerobicTrainingEffect, trainingLoad, vo2max, steps, subtype, +) + +/** A finished phone workout. The rest of [AppWorkout] keeps its defaults, so set it with `copy`. */ +fun appWorkout( + start: Long = 0, + minutes: Int = 30, + end: Long? = start + minutes * 60L, + sportType: Int = 1, + pauses: String = "", + averageHeartRate: Int? = null, + maxHeartRate: Int? = null, + target: String? = null, + activeKcal: Float? = null, + totalKcal: Float? = null, +) = AppWorkout( + start = start, end = end, sportType = sportType, pauses = pauses, averageHeartRate = averageHeartRate, + maxHeartRate = maxHeartRate, target = target, activeKcal = activeKcal, totalKcal = totalKcal, +) + +private const val DAY = 86_400L + +/** + * An ordinary valid night for day [i] (epoch day [i]): 8 h asleep from 7 h before midnight to half past, enough HRV + * samples and heart rate minutes to count, and the given vitals. + */ +fun night(i: Int, hrv: Float? = 50f, rhr: Float? = 55f, temp: Float? = 33.5f, breath: Float? = 14f, asleep: Int = 480) = + Recovery.Night( + day = i * DAY, start = i * DAY - 7 * 3600, end = i * DAY + 30 * 60, asleepMinutes = asleep, awakeMinutes = 20, + deepMinutes = 80, remMinutes = 95, hrv = hrv, restingHr = rhr, rhrMeanSleep = rhr?.plus(4f), nadirTime = i * DAY - 4 * 3600, + skinTemperature = temp, breathRate = breath, hrvSamples = 20, hrMinutes = 420, + ) + +/** A filled-in profile, a man born in 1990 at 70 kg and 175 cm, where [UserProfile]'s own defaults are all unknown. */ +fun profile( + birthYear: Int? = 1990, + sex: UserProfile.Sex? = UserProfile.Sex.Male, + weightKg: Float? = 70f, + heightCm: Int? = 175, + maxHeartRate: Int? = null, +) = UserProfile(birthYear = birthYear, sex = sex, weightKg = weightKg, heightCm = heightCm, maxHeartRate = maxHeartRate) diff --git a/app/src/test/kotlin/org/openstrap/app/testing/Compose.kt b/app/src/test/kotlin/org/openstrap/app/testing/Compose.kt new file mode 100644 index 00000000..4db98435 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/testing/Compose.kt @@ -0,0 +1,30 @@ +package org.openstrap.app.testing + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.core.app.ApplicationProvider +import org.junit.rules.ExternalResource +import org.junit.rules.RuleChain +import org.robolectric.Shadows.shadowOf + +/** + * Compose UI tests under Robolectric. The unit tests run without the merged manifest, so the activity the compose rule + * hosts its content in is registered with Robolectric's package manager first. Use it as + * `@get:Rule val rules = ComposeRules()` and drive `rules.compose`. + */ +class ComposeRules(val compose: ComposeContentTestRule = createComposeRule()) : org.junit.rules.TestRule { + private val chain = RuleChain.outerRule(HostActivity()).around(compose) + + override fun apply(base: org.junit.runners.model.Statement, description: org.junit.runner.Description) = + chain.apply(base, description) + + private class HostActivity : ExternalResource() { + override fun before() { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app, ComponentActivity::class.java)) + } + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/testing/Databases.kt b/app/src/test/kotlin/org/openstrap/app/testing/Databases.kt new file mode 100644 index 00000000..6456fbae --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/testing/Databases.kt @@ -0,0 +1,58 @@ +package org.openstrap.app.testing + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import org.json.JSONObject +import org.junit.rules.ExternalResource +import org.openstrap.app.data.db.OpenStrapDatabase +import java.io.File + +/** An empty database at the current version, in memory. Robolectric only. Close it after the test. */ +fun inMemoryDb(context: Context = ApplicationProvider.getApplicationContext()): OpenStrapDatabase = + Room.inMemoryDatabaseBuilder(context, OpenStrapDatabase::class.java).allowMainThreadQueries().build() + +/** + * Builds a database file at an old version from that version's exported schema, so it is the database that version + * really made, then opens it through Room so every migration since runs. A JUnit rule: it closes what it opened and + * deletes its files after each test. + * + * A stand-in until `room-testing` is on the test classpath, when this can wrap `MigrationTestHelper` instead. + */ +class MigrationHarness : ExternalResource() { + private val files = mutableListOf() + private val opened = mutableListOf() + + /** A database file at [version] with every table and index of that version, then [seed] run on it. */ + fun create(version: Int, seed: SQLiteDatabase.() -> Unit = {}): File { + val file = File(System.getProperty("java.io.tmpdir"), "openstrap-migration-${System.nanoTime()}.db") + files += file + val schema = JSONObject(File(SourceTree.schemas, "$version.json").readText()).getJSONObject("database") + SQLiteDatabase.openOrCreateDatabase(file, null).use { db -> + val entities = schema.getJSONArray("entities") + for (i in 0 until entities.length()) { + val e = entities.getJSONObject(i) + val table = e.getString("tableName") + db.execSQL(e.getString("createSql").replace("\${TABLE_NAME}", table)) + val indices = e.optJSONArray("indices") ?: continue + for (j in 0 until indices.length()) db.execSQL(indices.getJSONObject(j).getString("createSql").replace("\${TABLE_NAME}", table)) + } + val setup = schema.getJSONArray("setupQueries") + for (i in 0 until setup.length()) db.execSQL(setup.getString(i)) + db.seed() + db.version = version + } + return file + } + + /** [file] opened through Room at the current version, migrating it on the way. */ + fun open(file: File): OpenStrapDatabase = + Room.databaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java, file.absolutePath) + .addMigrations(*OpenStrapDatabase.MIGRATIONS).allowMainThreadQueries().build().also { opened += it } + + override fun after() { + opened.forEach { it.close() } + files.forEach { f -> listOf("", "-wal", "-shm", "-journal").forEach { File(f.path + it).delete() } } + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/testing/Sources.kt b/app/src/test/kotlin/org/openstrap/app/testing/Sources.kt new file mode 100644 index 00000000..d3a437cc --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/testing/Sources.kt @@ -0,0 +1,43 @@ +package org.openstrap.app.testing + +import org.junit.Assert.fail +import java.io.File + +/** + * Where the app module's sources are, for tests that read them. The `openstrap.srcRoot` system property names + * `app/src/main` when Gradle sets it. Without it, the working directory decides: the module when Gradle runs the + * tests, the repo root in some IDE runs. + */ +object SourceTree { + val root: File by lazy { + System.getProperty("openstrap.srcRoot")?.let(::File)?.absoluteFile + ?: listOf(File("src/main"), File("app/src/main")).first { it.isDirectory }.absoluteFile + } + + /** The app's Kotlin package. */ + val kotlin: File get() = File(root, "kotlin/org/openstrap/app") + + /** The app module, the folder that holds `src` and `schemas`. */ + val module: File get() = root.parentFile.parentFile + + /** Room's exported schemas, one `.json` each. */ + val schemas: File get() = File(module, "schemas/org.openstrap.app.data.db.OpenStrapDatabase") +} + +/** + * The punctuation copy never uses (spec 7.1 rule 6): interpunct, semicolon, em dash and en dash. An en dash between + * two digits is a range, like 22:10–06:40 or 5–10, and stays allowed. + */ +private val BANNED = setOf('·', ';', '—', '–') + +private fun String.bannedAt(i: Int): Boolean = + this[i] in BANNED && !(this[i] == '–' && getOrNull(i - 1)?.isDigit() == true && getOrNull(i + 1)?.isDigit() == true) + +/** Fails with every line of [text] that uses banned punctuation. */ +fun assertCleanCopy(text: String) { + val bad = text.lines().filter { line -> line.indices.any { line.bannedAt(it) } } + if (bad.isNotEmpty()) fail("copy uses ·, ;, — or –:\n" + bad.joinToString("\n")) +} + +/** [assertCleanCopy] for each of [texts]. */ +fun assertCleanCopy(texts: Iterable) = assertCleanCopy(texts.joinToString("\n")) diff --git a/app/src/test/kotlin/org/openstrap/app/testing/TestKitTest.kt b/app/src/test/kotlin/org/openstrap/app/testing/TestKitTest.kt new file mode 100644 index 00000000..7042b5a5 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/testing/TestKitTest.kt @@ -0,0 +1,77 @@ +package org.openstrap.app.testing + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.Description +import org.junit.runners.model.Statement +import java.io.File +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.util.Locale +import java.util.TimeZone + +/** The shared test helpers do what their KDoc says. */ +class TestKitTest { + @get:Rule val locale = LocaleRule(Locale.GERMANY) + + @Test + fun localeRuleSetsTheDefault() = assertEquals("1,5", "%.1f".format(1.5)) + + @Test + fun rulesPutTheDefaultsBack() { + val zoneBefore = TimeZone.getDefault() + val localeBefore = Locale.getDefault() + var inside: Pair? = null + val body = object : Statement() { + override fun evaluate() { + inside = TimeZone.getDefault().id to Locale.getDefault() + } + } + ZoneRule(ZoneId.of("Asia/Tokyo")).apply(LocaleRule(Locale.FRANCE).apply(body, Description.EMPTY), Description.EMPTY).evaluate() + assertEquals("Asia/Tokyo" to Locale.FRANCE, inside) + assertEquals(zoneBefore, TimeZone.getDefault()) + assertEquals(localeBefore, Locale.getDefault()) + } + + @Test + fun fixedClockMovesOnlyWhenAsked() { + val c = FixedClock(Instant.parse("2026-03-29T23:30:00Z"), ZoneId.of("Europe/London")) + assertEquals(LocalDate.of(2026, 3, 30), c.today) + assertEquals(c.instant, Instant.now(c.clock)) + c.advance(Duration.ofHours(1)) + assertEquals(1_774_830_600L, c.now) + } + + @Test + fun cleanCopyBansThePunctuationButNotRanges() { + assertCleanCopy(listOf("Slept 22:10–06:40", "5–10 min", "Good, steady")) + for (bad in listOf("Rest · 2 min", "Easy; slow", "Now — later", "Easy – later")) { + assertThrows(AssertionError::class.java) { assertCleanCopy(bad) } + } + } + + @Test + fun sourceTreeFindsTheModule() { + assertTrue(File(SourceTree.kotlin, "OpenStrapApp.kt").isFile) + assertTrue(File(SourceTree.schemas, "1.json").isFile) + } + + @Test + fun buildersFillTheRest() { + val w = workout(start = 100, minutes = 2, averageHeartRate = 120) + assertEquals(220L, w.end) + assertEquals(120, w.exerciseSeconds) + assertNull(w.calories) + assertEquals(1_800L, appWorkout(minutes = 30).end) + val n = night(3) + assertTrue(n.valid) + assertEquals(3 * 86_400L, n.day) + assertEquals(1990, profile().birthYear) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/testing/Time.kt b/app/src/test/kotlin/org/openstrap/app/testing/Time.kt new file mode 100644 index 00000000..a3fd64c6 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/testing/Time.kt @@ -0,0 +1,54 @@ +package org.openstrap.app.testing + +import org.junit.rules.ExternalResource +import java.time.Clock +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.Locale +import java.util.TimeZone + +/** + * A clock that stays where it is put. Production code reads time as epoch seconds passed in (`now: Long`) or from + * `Instant.now()`, so this hands out both: [now] for the first and [clock] for `Instant.now(clock)` and the like. + */ +class FixedClock(var instant: Instant = Instant.parse("2026-01-15T12:00:00Z"), val zone: ZoneId = ZoneOffset.UTC) { + /** Epoch seconds. */ + val now: Long get() = instant.epochSecond + val today: LocalDate get() = instant.atZone(zone).toLocalDate() + val clock: Clock get() = Clock.fixed(instant, zone) + + fun advance(by: Duration) { + instant += by + } +} + +/** Runs each test in [locale], then puts the JVM's default locale back, so copy tests pass on any machine. */ +class LocaleRule(private val locale: Locale = Locale.US) : ExternalResource() { + private lateinit var saved: Triple + + override fun before() { + saved = Triple(Locale.getDefault(), Locale.getDefault(Locale.Category.FORMAT), Locale.getDefault(Locale.Category.DISPLAY)) + Locale.setDefault(locale) + } + + override fun after() { + Locale.setDefault(saved.first) + Locale.setDefault(Locale.Category.FORMAT, saved.second) + Locale.setDefault(Locale.Category.DISPLAY, saved.third) + } +} + +/** Runs each test in [zone], then puts the JVM's default time zone back. */ +class ZoneRule(private val zone: ZoneId = ZoneOffset.UTC) : ExternalResource() { + private lateinit var saved: TimeZone + + override fun before() { + saved = TimeZone.getDefault() + TimeZone.setDefault(TimeZone.getTimeZone(zone)) + } + + override fun after() = TimeZone.setDefault(saved) +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/CopyPunctuationTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/CopyPunctuationTest.kt index 4d92d4be..226dec97 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/CopyPunctuationTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/CopyPunctuationTest.kt @@ -3,6 +3,7 @@ package org.openstrap.app.ui import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test +import org.openstrap.app.testing.SourceTree import java.io.File /** @@ -18,8 +19,8 @@ class CopyPunctuationTest { ("ui/zepp/ZeppSignIn.kt" to "; ") to "Joins cookies into the Cookie header sent to Zepp, never shown", ) - private val root: File = listOf(File("src/main"), File("app/src/main")).first { it.isDirectory } - private val kotlinRoot = File(root, "kotlin/org/openstrap/app") + private val root: File = SourceTree.root + private val kotlinRoot = SourceTree.kotlin /** Everything under ui/ and widget/, every *Copy*.kt, the notification text in StrapService and data the UI shows as is. */ private fun sources(): List { diff --git a/app/src/test/kotlin/org/openstrap/app/ui/DesignTokenTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/DesignTokenTest.kt index e51424ef..17fcc00b 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/DesignTokenTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/DesignTokenTest.kt @@ -8,11 +8,11 @@ import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test import org.openstrap.app.ui.glow.BarMaxWidth -import org.openstrap.app.ui.glow.DotSize +import org.openstrap.app.ui.glow.draw.DotSize import androidx.compose.ui.graphics.luminance import org.openstrap.app.ui.glow.GlowLayout -import org.openstrap.app.ui.glow.GlowPalette -import org.openstrap.app.ui.glow.GlowStyle +import org.openstrap.app.ui.glow.draw.GlowPalette +import org.openstrap.app.ui.glow.draw.GlowStyle import org.openstrap.app.ui.theme.MetricColors import java.io.File @@ -113,7 +113,7 @@ class DesignTokenTest { // Spec 2.3 and audit 2.8: one number in dots a card; the rest is text. Each card is a function of its own. A // call that passes no number (EdgeValue(null, ...)) draws none. val found = mutableListOf() - val dotted = Regex("""\b(HeroNumber|HeroFigure|DotMatrixText|EdgeValue|FigureRow|GlowHeroNumber)\((?!\s*null\b)""") + val dotted = Regex("""\b(HeroNumber|HeroFigure|DotMatrixText|EdgeValue|FigureRow)\((?!\s*null\b)""") fun cards(vararg names: String) = Regex("""fun (${names.joinToString("|")})\(""") listOf( "glow/GlowTodayScreen.kt" to Regex("""private fun (\w+Hero)\("""), @@ -245,12 +245,11 @@ class DesignTokenTest { * (audit section 4). The list shrinks with each step and never grows. */ val typeNotMigrated = mapOf( - "components/Charts.kt" to 2, "dashboard/SleepPanels.kt" to 1, "glow/AllMetricsScreen.kt" to 1, "glow/GlowActivityScreen.kt" to 2, - "glow/GlowCharts.kt" to 5, - "glow/GlowWorkoutMode.kt" to 2, + "glow/GlowCharts.kt" to 4, + "glow/live/LiveFrame.kt" to 2, "glow/TabParts.kt" to 1, ) } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/DetailRulesTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/DetailRulesTest.kt index ddc0dce0..482182eb 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/DetailRulesTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/DetailRulesTest.kt @@ -7,7 +7,7 @@ import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test import org.openstrap.app.ui.glow.BackButtonSize -import org.openstrap.app.ui.glow.ControlSurface +import org.openstrap.app.ui.theme.ControlSurface import java.io.File /** @@ -62,7 +62,7 @@ class DetailRulesTest { val progress = charts.substringAfter("fun rememberGaugeProgress(").substringBefore("\n}\n") assertTrue("a gauge starts at its value, not at zero", "Animatable(target)" in progress) val template = sources.getValue("glow/GlowMetricDetail.kt").substringAfter("fun DetailTemplate(").substringBefore("\n}\n") - assertTrue("the gauge block is centred", Regex("""if \(heroValue != null\) Column\([^\n]*CenterHorizontally""").containsMatchIn(template)) + assertTrue("the gauge block is centred", Regex("""if \(gauge != null\) Column\(\s*[^\n]*CenterHorizontally""").containsMatchIn(template)) } @Test fun `every detail's header ends in the (i)`() { diff --git a/app/src/test/kotlin/org/openstrap/app/ui/FrameRulesTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/FrameRulesTest.kt index ea4aac97..b9205765 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/FrameRulesTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/FrameRulesTest.kt @@ -99,7 +99,8 @@ class FrameRulesTest { assertTrue("the avatar records itself", Regex("""growClick\(onOpenYou\)""").containsMatchIn(header)) assertTrue("the strap chip records itself", Regex("""growClick\(onOpenStrap\)""").containsMatchIn(header)) val root = source("MainActivity.kt") - assertTrue("the pages over the tab grow from the origin", "GrowFromButton(" in root && "growOrigin.take()" in root) + val pages = source("ui/root/PagesOverTab.kt") + assertTrue("the pages over the tab grow from the origin", "GrowFromButton(" in pages && "growOrigin.take()" in root) } @Test fun `tabs keep their phone gutters folded, and unfolded are at most 960 dp with a 24 dp gutter`() { @@ -254,7 +255,6 @@ class FrameRulesTest { assertTrue("PageRow no longer squeezes its value beside the name", "widthIn(max = 160.dp)" !in row) assertTrue("PageSwitchRow keeps its name on one line", "RowName(title)" in parts.substringAfter("fun PageSwitchRow(")) assertTrue("ListRow keeps its name on one line", "RowName(name)" in source("ui/glow/GlowList.kt").substringAfter("fun ListRow(")) - assertTrue("SettingRow keeps its name on one line", "RowName(title)" in source("ui/glow/GlowWorkoutMode.kt").substringAfter("fun SettingRow(")) } /* --------------------------------- A fade under pinned buttons (spec 3.2) --------------------------------- */ @@ -280,7 +280,7 @@ class FrameRulesTest { assertTrue("WorkoutPage's content ends clear of them", "PinnedFade.listClearance(pinnedHeight)" in workout) assertTrue("WorkoutPage's old short fade is gone", "0.25f to Color.Black" !in workout) // Every page with pinned buttons uses WorkoutPage's or Profile's. - for (f in listOf("ui/glow/GlowWorkoutMode.kt", "ui/glow/GlowWorkoutSummary.kt")) { + for (f in listOf("ui/glow/GlowWorkoutMode.kt", "ui/glow/ReadyScreen.kt", "ui/glow/ChooseScreen.kt", "ui/glow/GlowWorkoutSummary.kt")) { val src = source(f) assertTrue("$f pins its buttons through WorkoutPage", "pinned = {" !in src || "WorkoutPage(" in src) } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsViewModelTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsViewModelTest.kt new file mode 100644 index 00000000..40a9c7ac --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/alarms/PhoneAlertsViewModelTest.kt @@ -0,0 +1,123 @@ +package org.openstrap.app.ui.alarms + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.openstrap.app.data.settings.BuzzPattern +import org.openstrap.app.data.settings.CallBuzz +import org.openstrap.app.data.settings.QuietHours +import org.openstrap.app.data.settings.SettingsStore + +/** The Phone alerts page writes through its view model, and quiet hours change from what is stored (audit A2). */ +@OptIn(ExperimentalCoroutinesApi::class) +class PhoneAlertsViewModelTest { + @get:Rule val folder = TemporaryFolder() + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val store: DataStore by lazy { + PreferenceDataStoreFactory.create(scope = scope) { folder.newFile("settings.preferences_pb").also { it.delete() } } + } + /** Closed, reads wait as a cold store's first read does, and writes still land. */ + private val readable = MutableStateFlow(true) + private val settings by lazy { + SettingsStore.of(object : DataStore by store { + override val data: Flow = store.data.onStart { readable.first { it } } + }) + } + private val alerts = FakeAlerts() + private val vms = mutableListOf() + + @Before fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @After fun tearDown() { + vms.forEach { it.viewModelScope.coroutineContext[Job]?.cancel() } + scope.cancel() + Dispatchers.resetMain() + } + + private fun vm() = PhoneAlertsViewModel(settings, alerts).also { vms += it } + + private val stored = QuietHours(on = true, from = 21 * 60 + 30, to = 6 * 60, calls = false) + + @Test fun `a quiet hours tap before the settings load keeps the user's hours`() = runBlocking { + settings.setQuietHours(stored) + readable.value = false + val vm = vm() + // The page still shows the defaults: the store hasn't answered the view model yet. + val tap = vm.updateQuietHours { it.copy(calls = true) } + assertEquals(QuietHours(), vm.quiet.value) + readable.value = true + tap.join() + assertEquals(stored.copy(calls = true), settings.quietHours.first()) + } + + @Test fun `two quick changes to quiet hours both count`() = runBlocking { + settings.setQuietHours(stored) + val vm = vm() + val first = vm.updateQuietHours { it.copy(from = 23 * 60) } + val second = vm.updateQuietHours { it.copy(to = 5 * 60) } + first.join() + second.join() + assertEquals(stored.copy(from = 23 * 60, to = 5 * 60), settings.quietHours.first()) + } + + @Test fun `picking a pattern keeps it and plays it, and a failed buzz is said until the page closes`() = runBlocking { + val vm = vm() + vm.pickPattern(BuzzPattern.entries.last()).join() + assertEquals(BuzzPattern.entries.last(), settings.notificationPattern.first()) + assertEquals(listOf(BuzzPattern.entries.last()), alerts.played) + assertNull(vm.previewError.value) + + alerts.fail = "The strap didn't answer." + vm.pickCallBuzz(CallBuzz.PULSED).join() + assertEquals(CallBuzz.PULSED, settings.callBuzzStyle.first()) + assertEquals("The strap didn't answer.", vm.callPreviewError.value) + vm.clearPreviewErrors() + assertNull(vm.callPreviewError.value) + } + + @Test fun `the switches write the settings`() = runBlocking { + val vm = vm() + vm.setBuzzOnCalls(true).join() + vm.setBuzzOnNotifications(true).join() + assertEquals(true, settings.buzzOnCalls.first()) + assertEquals(true, settings.buzzOnNotifications.first()) + } + + private class FakeAlerts : BuzzAlerts { + override val callPermission: StateFlow = MutableStateFlow(true) + override val notificationAccess: StateFlow = MutableStateFlow(true) + val played = mutableListOf() + var fail: String? = null + override fun refresh() = Unit + override fun hasCallPermission() = true + override suspend fun preview(pattern: BuzzPattern) = play(pattern) + override suspend fun previewCall(style: CallBuzz) = play(style) + private fun play(what: Any) { + fail?.let { throw IllegalStateException(it) } + played += what + } + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/alarms/StrapRowsTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/alarms/StrapRowsTest.kt index 0c6de605..fb61d5fd 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/alarms/StrapRowsTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/alarms/StrapRowsTest.kt @@ -44,4 +44,24 @@ class StrapRowsTest { assertEquals("Notifications", phoneAlertsSummary(calls = false, notifications = true)) assertEquals("Off", phoneAlertsSummary(calls = false, notifications = false)) } + + @Test fun `alarm and quiet hour times follow the phone's clock, the dots without letters`() { + val was = org.openstrap.app.presentation.ClockSetting.use24Hour + val locale = java.util.Locale.getDefault() + try { + java.util.Locale.setDefault(java.util.Locale.US) + assertEquals("06:40" to null, dotClock(6, 40, use24Hour = true)) + assertEquals("6:40" to "AM", dotClock(6, 40, use24Hour = false)) + assertEquals("10:00" to "PM", dotClock(22, 0, use24Hour = false)) + org.openstrap.app.presentation.ClockSetting.use24Hour = false + val alarms = listOf(Alarm(0, 6, 40, Alarm.WEEKDAYS, enabled = true)) + assertEquals("6:40 AM weekdays", alarmsSummary(AlarmsUiState(caps = caps, alarms = alarms))) + assertEquals("10:00 PM", quietClock(22 * 60)) + org.openstrap.app.presentation.ClockSetting.use24Hour = true + assertEquals("22:00", quietClock(22 * 60)) + } finally { + org.openstrap.app.presentation.ClockSetting.use24Hour = was + java.util.Locale.setDefault(locale) + } + } } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/ActivityCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/ActivityCopyTest.kt index b12561db..4abe26d1 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/ActivityCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/ActivityCopyTest.kt @@ -85,6 +85,21 @@ class ActivityCopyTest { val today = LocalDate.of(2026, 10, 24) assertEquals("today at 18:30", ActivityCopy.ended(today, "18:30", today)) assertEquals("yesterday at 07:10", ActivityCopy.ended(today.minusDays(1), "07:10", today)) - assertEquals("Counted from your run, which ended Tue 20 Oct at 18:30.", ActivityCopy.recover("Run", ActivityCopy.ended(LocalDate.of(2026, 10, 20), "18:30", today, Locale.UK))) + assertEquals("Counted from your run, which ended Tuesday at 18:30.", ActivityCopy.recover("Run", ActivityCopy.ended(LocalDate.of(2026, 10, 20), "18:30", today, Locale.UK))) + } + + @Test fun `time to recover names the day as every other line does`() { + val today = LocalDate.of(2026, 10, 24) + assertEquals("today at 18:30", ActivityCopy.ended(today, "18:30", today, Locale.UK)) + assertEquals("yesterday at 07:10", ActivityCopy.ended(today.minusDays(1), "07:10", today, Locale.UK)) + assertEquals("Tuesday at 18:30", ActivityCopy.ended(LocalDate.of(2026, 10, 20), "18:30", today, Locale.UK)) + assertEquals("Tue 13 Oct at 18:30", ActivityCopy.ended(LocalDate.of(2026, 10, 13), "18:30", today, Locale.UK)) + } + + @Test fun `Load's bar and band are Today's rule`() { + assertEquals(null, ActivityCopy.loadBar(40f, null)) + assertEquals(listOf(0, 1, 1, 2), listOf(29f, 30f, 50f, 51f).map { ActivityCopy.loadBar(it, 40f) }) + assertEquals(30f..50f, ActivityCopy.usualBand(40f)) + assertEquals(listOf("Below your usual", "Usual", "Above your usual"), ActivityCopy.LOAD_SCALE) } } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/CaloriesCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/CaloriesCopyTest.kt index 55d383dd..076139c0 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/CaloriesCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/CaloriesCopyTest.kt @@ -52,6 +52,19 @@ class CaloriesCopyTest { assertEquals("active kcal, 23:00 to 00:00", CaloriesCopy.hourLabel(23)) } + @Test fun `hours follow the phone's clock`() { + val was = org.openstrap.app.presentation.ClockSetting.use24Hour + try { + org.openstrap.app.presentation.ClockSetting.use24Hour = false + val hours = List(24) { if (it == 7) 228.4f else it.toFloat() } + assertEquals("Peak 7:00 AM, 228", CaloriesCopy.peak(hours, Locale.US)) + assertEquals("active kcal, 11:00 PM to 12:00 AM", CaloriesCopy.hourLabel(23, Locale.US)) + assertEquals("steps, 9:00 AM to 10:00 AM", ActivityCopy.hourLabel(9, Locale.US)) + } finally { + org.openstrap.app.presentation.ClockSetting.use24Hour = was + } + } + @Test fun `a strap workout's active kcal is its kcal less rest for its minutes`() { // 300 kcal over 50 min, b = 1.2 a minute: 300 - 60 = 240. assertEquals(240, Workouts.activeKcal(strap(midnight + 7 * 3600, 50, 300), b)) @@ -78,6 +91,9 @@ class CaloriesCopyTest { assertEquals("38 min walking or running, outside workouts", rows[2].line) assertNull(rows[2].workoutStart) assertEquals("1 h 05", CaloriesCopy.minutes(65 * 60L)) + // On the hour it reads as every length of time does, and to the nearest minute. + assertEquals("1 h", CaloriesCopy.minutes(60 * 60L - 20)) + assertEquals("42 min", CaloriesCopy.minutes(41 * 60L + 30)) assertEquals("No walking outside workouts", CaloriesCopy.walkingLine(0)) } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/DayViewModelClockTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/DayViewModelClockTest.kt new file mode 100644 index 00000000..921ee84d --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/DayViewModelClockTest.kt @@ -0,0 +1,118 @@ +package org.openstrap.app.ui.dashboard + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.HealthRepository +import org.openstrap.app.data.LiveSteps +import org.openstrap.app.data.RoomDayRepository +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.testing.FixedClock +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.protocol.services.StepTotals +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.util.Collections + +/** The day's state is worked out off the main thread (audit C7), and moves on to the new day at midnight (C10). */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DayViewModelClockTest { + @get:Rule val zone = ZoneRule() + + private val scheduler = TestCoroutineScheduler() + private val clock = FixedClock(Instant.parse("2026-01-15T23:59:30Z")) + private val jan15 = LocalDate.of(2026, 1, 15) + private lateinit var db: OpenStrapDatabase + private val collectors = mutableListOf() + + @Before fun open() { + Dispatchers.setMain(UnconfinedTestDispatcher(scheduler)) + db = inMemoryDb() + } + + @After fun close() { + collectors.forEach { it.cancel() } + db.close() + Dispatchers.resetMain() + } + + private fun DayViewModel.watched(): DayViewModel = also { vm -> + collectors += CoroutineScope(Dispatchers.Main).launch { vm.state.collect {} } + } + + private fun DayViewModel.awaitDay(date: LocalDate): DayUiState = runBlocking { + withTimeout(WAIT_MS) { state.first { it.loaded && it.date == date } } + } + + /** Past the minute tick, on the test's virtual time. */ + private fun nextMinute() { + clock.advance(Duration.ofMinutes(1)) + scheduler.advanceTimeBy(Duration.ofMinutes(1).toMillis()) + scheduler.runCurrent() + } + + @Test fun `the day's state is worked out off the main thread`() { + val threads = Collections.synchronizedList(mutableListOf()) + // Today's live steps are read inside the state's flow, so where they're read is where the state is worked out. + val steps = flow { + threads += Thread.currentThread().name + emit(null) + } + val vm = DayViewModel(RoomDayRepository(HealthRepository(db.healthDao()), LiveSteps(), steps), clock = { clock.clock }).watched() + vm.awaitDay(jan15) + assertTrue("read on $threads", threads.isNotEmpty() && threads.all { it.startsWith("DefaultDispatcher") }) + vm.viewModelScope.coroutineContext[Job]?.cancel() + } + + @Test fun `a screen left open on today moves to the new day at midnight`() { + val vm = DayViewModel(RoomDayRepository(HealthRepository(db.healthDao()), LiveSteps()), clock = { clock.clock }).watched() + vm.awaitDay(jan15) + nextMinute() + val s = vm.awaitDay(jan15.plusDays(1)) + assertEquals(clock.today, s.date) + vm.viewModelScope.coroutineContext[Job]?.cancel() + } + + @Test fun `a day picked in the past stays put at midnight, and next day stops at the new today`() { + val vm = DayViewModel(RoomDayRepository(HealthRepository(db.healthDao()), LiveSteps()), clock = { clock.clock }).watched() + vm.select(jan15.minusDays(1)) + vm.awaitDay(jan15.minusDays(1)) + nextMinute() + vm.awaitDay(jan15.minusDays(1)) + vm.nextDay() + vm.awaitDay(jan15) + vm.nextDay() + vm.awaitDay(jan15.plusDays(1)) + vm.nextDay() + assertEquals(jan15.plusDays(1), vm.awaitDay(jan15.plusDays(1)).date) + vm.viewModelScope.coroutineContext[Job]?.cancel() + } + + private companion object { + const val WAIT_MS = 10_000L + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/DayViewModelUiStateTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/DayViewModelUiStateTest.kt new file mode 100644 index 00000000..fca197ed --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/DayViewModelUiStateTest.kt @@ -0,0 +1,190 @@ +package org.openstrap.app.ui.dashboard + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.DayData +import org.openstrap.app.data.DayRepository +import org.openstrap.app.data.DayValue +import org.openstrap.app.data.DayWindow +import org.openstrap.app.data.Healthspan +import org.openstrap.app.data.RecoveryResult +import org.openstrap.app.data.SleepDay +import org.openstrap.app.data.SleepEdits +import org.openstrap.app.data.db.ActivityMinute +import org.openstrap.app.data.db.DailyEnergyRow +import org.openstrap.app.data.db.DailyPai +import org.openstrap.app.data.db.HeartRateSample +import org.openstrap.app.data.db.HrvSample +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.db.SleepStage +import org.openstrap.app.data.settings.UserProfile +import org.openstrap.app.testing.FixedClock +import org.openstrap.app.testing.LocaleRule +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.ui.components.ChartPoint +import org.openstrap.app.ui.components.StageSpan +import org.openstrap.app.ui.components.TrendPoint +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.TextStyle +import java.util.Locale + +/** The day view model only maps a [DayData] to what the screens draw (audit A3), and hands the Activity screens state (A11). */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DayViewModelUiStateTest { + @get:Rule val zone = ZoneRule() + @get:Rule val locale = LocaleRule() + + private val date = LocalDate.of(2026, 1, 15) + private fun at(h: Int, m: Int = 0) = date.atTime(h, m).toEpochSecond(ZoneOffset.UTC) + + @Before fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + @After fun tearDown() = Dispatchers.resetMain() + + @Test fun `an empty day maps to an empty loaded state`() { + val s = DayViewModel.toUiState(DayData(date)) + assertTrue(s.loaded) + assertEquals(date, s.date) + assertEquals(DayWindow.of(date).from, s.dayStart) + assertEquals(DayWindow.of(date).to, s.dayEnd) + assertNull(s.restingHeartRate) + assertFalse(s.restingEstimated) + assertNull(s.sleep) + assertNull(s.sleepBreakdown) + assertEquals(List(24) { 0f }, s.hourlySteps) + } + + @Test fun `steps come by the hour, and the shown total stays the repository's`() { + val minutes = (0 until 5).map { ActivityMinute(at(9) + it * 60L, 100, 40, 1, null) } + ActivityMinute(at(13), 50, 40, 1, null) + val s = DayViewModel.toUiState(DayData(date, minutes = minutes, steps = 900)) + assertEquals(900, s.steps) + assertEquals(550, s.minuteSteps) + assertEquals(500f, s.hourlySteps[9]) + assertEquals(50f, s.hourlySteps[13]) + } + + @Test fun `the trends carry their weekday labels, and today's resting value and flag come from the last day`() { + val days = (6L downTo 0L).map { date.minusDays(it) } + val data = DayData( + date, + restingTrend = days.map { DayValue(it, if (it == date) 52f else null) }, + restingEstimated = days.map { it == date }, + hrvTrend = days.map { DayValue(it, 40f) }, + ) + val s = DayViewModel.toUiState(data) + val label = { d: LocalDate -> d.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()).take(2) } + assertEquals(days.map { TrendPoint(label(it), if (it == date) 52f else null) }, s.restingTrend) + assertEquals("Th", s.restingTrend.last().label) + assertEquals(52, s.restingHeartRate) + assertTrue(s.restingEstimated) + assertEquals(days.map { TrendPoint(label(it), 40f) }, s.hrvTrend) + } + + @Test fun `the sleep's stages become spans, and only naps with stages get a breakdown`() { + val main = SleepSession(at(0), at(7), score = 80) + val stages = listOf(SleepStage(at(0), at(3), main.start, SleepStage.DEEP), SleepStage(at(3), at(7), main.start, SleepStage.AWAKE)) + val nap = SleepSession(at(14), at(14, 30), nap = true) + val bare = SleepSession(at(16), at(16, 20), nap = true) + val napStages = listOf(SleepStage(at(14), at(14, 30), nap.start, SleepStage.LIGHT)) + val deleted = SleepEdits.Resolved(main, null) + val data = DayData( + date, + sleep = SleepDay( + main = main, mainStages = stages, naps = listOf(nap, bare), napStages = mapOf(nap.start to napStages, bare.start to emptyList()), + previousScore = 70, mainAsDetected = main, strapStages = stages, deletedMain = deleted, neighbours = listOf(main, nap), + ), + ) + val s = DayViewModel.toUiState(data) + assertEquals(main, s.sleep) + assertEquals(70, s.previousSleepScore) + assertEquals(stages.map { StageSpan(it.start, it.end, it.stage) }, s.sleepStages) + assertEquals(s.sleepStages, s.strapSleepStages) + assertEquals(3 * 3600L, s.sleepBreakdown?.asleepSeconds) + assertEquals(setOf(nap.start, bare.start), s.napStages.keys) + assertEquals(setOf(nap.start), s.napBreakdowns.keys) + assertEquals(deleted, s.deletedSleep) + assertEquals(listOf(main, nap), s.sleepNeighbours) + } + + @Test fun `heart rate and the night's readings become charts`() { + val hr = (0 until 10).map { HeartRateSample(at(10) + it * 60L, 60 + it) } + val data = DayData(date, heartRate = hr, nightHeartRate = hr, sleepHrv = listOf(HrvSample(at(2), 55)), hrv = listOf(HrvSample(at(12), 45))) + val s = DayViewModel.toUiState(data) + assertEquals(69, s.latestHeartRate) + assertTrue(s.heartRate.isNotEmpty()) + assertEquals(s.heartRate, s.sleepHeartRate) + assertEquals(listOf(ChartPoint(at(2), 55f)), s.sleepHrv) + assertEquals(listOf(ChartPoint(at(12), 45f)), s.hrv) + } + + @Test fun `the Activity screens get the day's Heart points and energy as state`() = runTest { + val repo = FakeDays() + val clock = FixedClock(Instant.parse("2026-01-15T12:00:00Z")) + val vm = DayViewModel(repo, clock = { clock.clock }, work = UnconfinedTestDispatcher(testScheduler)) + val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.activityHistory.collect {} } + val h = vm.activityHistory.first { it.date == date } + assertEquals(date.minusDays(ActivityHistory.HEART_POINTS_DAYS - 1L) to date, repo.paiWindow) + assertEquals(date.minusDays(ActivityHistory.ENERGY_DAYS - 1L) to date, repo.energyWindow) + assertEquals(HeartPointsDay(12f, 80f), h.heartPointsOn(date)[date]) + assertEquals(emptyMap(), h.heartPointsOn(date.minusDays(1))) + assertEquals(EnergyDay(400f, 2400f, 150f, true), h.energyOn(date, date.minusDays(6))?.get(date)) + // A range only gets its own days, and another day's energy is still loading. + assertEquals(setOf(date), h.energyOn(date, date)?.keys) + assertNull(h.energyOn(date.minusDays(1), date.minusDays(7))) + collector.cancel() + vm.viewModelScope.coroutineContext[Job]?.cancel() + } + + /** A day repository with one day of Heart points and energy, noting the windows asked for. */ + private inner class FakeDays : DayRepository { + var paiWindow: Pair? = null + var energyWindow: Pair? = null + + override fun day(date: LocalDate, isToday: Boolean): Flow = flowOf(DayData(date, isToday)) + override fun daysWithData(): Flow> = flowOf(emptySet()) + override suspend fun hourlySteps(from: LocalDate, to: LocalDate): Map> = emptyMap() + override suspend fun recovery(date: LocalDate, profile: UserProfile): RecoveryResult = error("not read here") + override suspend fun healthspan(date: LocalDate, profile: UserProfile): Healthspan.Outcome = error("not read here") + override fun changes(): Flow = emptyFlow() + override fun deletedSleep(): Flow> = flowOf(emptyList()) + override suspend fun setSleepTimes(strap: SleepSession, start: Long, end: Long) = Unit + override suspend fun deleteSleep(strap: SleepSession) = Unit + override suspend fun restoreSleep(strap: SleepSession) = Unit + + override fun dailyPai(from: LocalDate, to: LocalDate): Flow> { + paiWindow = from to to + return flowOf(mapOf(date to DailyPai(date.toString(), 12f, 80f, at(9)))) + } + + override fun dailyEnergy(from: LocalDate, to: LocalDate): Flow> { + energyWindow = from to to + val row = DailyEnergyRow(DayWindow.of(date).from, at(12), 2400f, 400f, 1700f, 600, true, workoutKcal = 150f, walkingKcal = 100f, hourly = "") + return flowOf(mapOf(date to row, date.minusDays(10) to row)) + } + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/DeletedNightStateTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/DeletedNightStateTest.kt new file mode 100644 index 00000000..bbdfa6a8 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/DeletedNightStateTest.kt @@ -0,0 +1,72 @@ +package org.openstrap.app.ui.dashboard + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.HealthRepository +import org.openstrap.app.data.LiveSteps +import org.openstrap.app.data.RoomDayRepository +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.db.SleepSession +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.LocalDate +import java.time.ZoneId + +/** The day's state after a delete: the night leaves the nights (the strip's dots) and comes back on restore. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DeletedNightStateTest { + private lateinit var db: OpenStrapDatabase + + @Before fun open() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), OpenStrapDatabase::class.java).allowMainThreadQueries().build() + } + + @After fun close() { + db.close() + Dispatchers.resetMain() + } + + @Test fun `a deleted night leaves the nights, and a restored one comes back`() = runTest { + val zone = ZoneId.systemDefault() + val today = LocalDate.now(zone) + fun at(d: LocalDate, h: Int, m: Int) = d.atTime(h, m).atZone(zone).toEpochSecond() + val night = SleepSession(at(today.minusDays(1), 23, 20), at(today, 7, 21), score = 83) + val before = SleepSession(at(today.minusDays(2), 23, 0), at(today.minusDays(1), 7, 0), score = 80) + db.healthDao().insertSleepSessions(listOf(before, night)) + val repo = HealthRepository(db.healthDao()) + val vm = DayViewModel(RoomDayRepository(repo, LiveSteps())) + val collector = backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { vm.state.collect {} } + suspend fun settled(check: (DayUiState) -> Boolean) = vm.state.first { it.loaded && check(it) } + + settled { it.sleep != null }.let { s -> assertEquals(setOf(today.minusDays(1), today), s.nights.filter { it.session != null }.map { it.date }.toSet()) } + repo.deleteSleep(night) + settled { it.sleep == null }.let { s -> + assertNotNull(s.deletedSleep) + assertEquals(setOf(today.minusDays(1)), s.nights.filter { it.session != null }.map { it.date }.toSet()) + } + repo.restoreSleep(night) + settled { it.sleep != null }.let { s -> + assertNull(s.deletedSleep) + assertEquals(setOf(today.minusDays(1), today), s.nights.filter { it.session != null }.map { it.date }.toSet()) + } + collector.cancel() + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/FrameTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/FrameTest.kt index 570f78ad..23b91fc1 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/FrameTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/FrameTest.kt @@ -15,11 +15,15 @@ class FrameTest { private val today = LocalDate.of(2026, 10, 23) // a Friday private val uk = Locale.UK - @Test fun `title says today, yesterday, then the weekday`() { + @Test fun `title says today, yesterday, the weekday within the week, then the date`() { assertEquals("Today", dayTitle(today, today, uk)) assertEquals("Yesterday", dayTitle(today.minusDays(1), today, uk)) assertEquals("Wednesday", dayTitle(today.minusDays(2), today, uk)) - assertEquals("Friday", dayTitle(today.minusDays(7), today, uk)) + assertEquals("Saturday", dayTitle(today.minusDays(6), today, uk)) + // A week back would read as today's weekday, so it takes its date, with the year from an earlier one. + assertEquals("Fri 16 Oct", dayTitle(today.minusDays(7), today, uk)) + assertEquals("Wed 31 Dec 2025", dayTitle(LocalDate.of(2025, 12, 31), today, uk)) + assertEquals("Fri 16 Oct", dateLine(today.minusDays(7), uk)) } @Test fun `the day under the title is short and never has an interpunct`() { @@ -276,9 +280,9 @@ class FrameTest { @Test fun `evening dims, night is almost unlit, the rest is day`() { for (m in org.openstrap.app.data.Moment.entries) { val want = when (m) { - org.openstrap.app.data.Moment.Evening -> org.openstrap.app.ui.glow.GlowMood.Evening - org.openstrap.app.data.Moment.Night -> org.openstrap.app.ui.glow.GlowMood.Night - else -> org.openstrap.app.ui.glow.GlowMood.Day + org.openstrap.app.data.Moment.Evening -> org.openstrap.app.ui.glow.draw.GlowMood.Evening + org.openstrap.app.data.Moment.Night -> org.openstrap.app.ui.glow.draw.GlowMood.Night + else -> org.openstrap.app.ui.glow.draw.GlowMood.Day } assertEquals(m.name, want, momentMood(m)) } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/InfoCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/InfoCopyTest.kt index e20ea682..25082167 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/InfoCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/InfoCopyTest.kt @@ -8,9 +8,15 @@ import org.junit.Test import org.openstrap.app.data.History import org.openstrap.app.data.Recovery import org.openstrap.app.data.SleepRegularity +import org.junit.Rule +import org.openstrap.app.testing.LocaleRule +import java.util.Locale /** The info sheets (spec 6.2): one for every detail, in the copy rules of spec 7. */ class InfoCopyTest { + // The expected numbers are written the English way, whatever the machine's language. + @get:Rule val locale = LocaleRule(Locale.US) + private fun ready(capped: Boolean = false, fallbackHrv: Float? = null, weekNights: Int = 7) = Recovery.Readiness( score = 70, contributors = emptyList(), baselineNights = 30, strainAlert = false, alertSignals = emptyList(), capped = capped, fallbackHrv = fallbackHrv, hrvWeekNights = weekNights, diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/NarrativeTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/NarrativeTest.kt index e3d18214..7b93b623 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/NarrativeTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/NarrativeTest.kt @@ -74,4 +74,10 @@ class NarrativeTest { // Short nights still rate against need. assertEquals(Rating.LOW, total(5 * 60)) } + + @Test fun `the contributors write lengths of time as the rest of the app does`() { + val c = Narrative.sleepContributors(SleepSession(start = 0, end = 8 * 3600L), (7 * 60 + 40) * 60L, 480, 45) + assertEquals("7 h 40 of 8 h", c.first { it.label == "Total sleep" }.detail) + assertEquals("45 min awake after falling asleep", c.first { it.label == "Continuity" }.detail) + } } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/SleepCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/SleepCopyTest.kt index 5cbfe8d5..b51489ca 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/SleepCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/SleepCopyTest.kt @@ -151,4 +151,41 @@ class SleepCopyTest { listOf(InfoCopy.breathingLine(Recovery.BreathingDisturbances(2.4f, null, 3), true), InfoCopy.breathingLine(Recovery.BreathingDisturbances(2.4f, 2.1f, 9), false)) lines.forEach { l -> listOf("·", ";", "—").forEach { assertFalse("$l has $it", l.contains(it)) } } } + + @Test fun `the times sheet and the edited note say the times with to, never a dash`() { + assertEquals("Sleep times", SleepCopy.timesTitle(false)) + assertEquals("Nap times", SleepCopy.timesTitle(true)) + assertEquals("The strap saw 23:20 to 07:21.", SleepCopy.strapSaw("23:20", "07:21")) + assertEquals("Your times. The strap saw 23:20 to 07:21.", SleepCopy.yourTimes("23:20", "07:21")) + assertEquals("Asleep for 7 h 26.", SleepCopy.timesResult(false, 7 * 3600L + 26 * 60)) + assertEquals("A nap of 42 min.", SleepCopy.timesResult(true, 42 * 60L)) + assertEquals("Delete last night's sleep?", SleepCopy.deleteTitle(nap = false, today = true)) + assertEquals("Delete this nap?", SleepCopy.deleteTitle(nap = true, today = true)) + assertEquals("Last night's sleep deleted", SleepCopy.deleted(nap = false, today = true, start = "23:20")) + assertEquals("Nap at 10:40 deleted", SleepCopy.deleted(nap = true, today = true, start = "10:40")) + assertEquals( + "You deleted 23:20 to 07:21. Recovery this morning works without last night, as on a night the strap was off.", + SleepCopy.deletedNight("23:20", "07:21", today = true), + ) + assertEquals("Night of Wed 29 Oct", SleepCopy.deletedName(false, "Wed 29 Oct")) + assertEquals("Nap, Thu 30 Oct", SleepCopy.deletedName(true, "Thu 30 Oct")) + assertEquals("23:20 to 07:21\n7 days left", SleepCopy.deletedLine("23:20", "07:21", 7)) + assertEquals("10:40 to 11:05\n1 day left", SleepCopy.deletedLine("10:40", "11:05", 1)) + listOf( + SleepCopy.yourTimes("23:20", "07:21"), SleepCopy.span("14:05", "14:47"), SleepCopy.TIMES_HINT, SleepCopy.DELETE_WHAT, + SleepCopy.DELETE_RESTORE, SleepCopy.DELETED_INTRO, SleepCopy.DELETED_FOOT, SleepCopy.deletedNight("23:20", "07:21", false), + ).forEach { l -> + listOf("·", ";", "—", "–").forEach { assertFalse("$l has $it", l.contains(it)) } + } + } + + @Test fun `a deleted night says the user deleted it, never asks whether the strap was on`() { + assertEquals("You can bring it back from You, Your data, Deleted sleep for 7 days.", SleepCopy.DELETE_RESTORE) + assertEquals("Nothing deleted in the last 7 days.", SleepCopy.DELETED_NONE) + assertEquals("You deleted last night's sleep.", TabCopy.noNight(naps = true, isToday = true, deleted = true)) + assertEquals("You deleted that night's sleep.", TabCopy.noNight(naps = false, isToday = false, deleted = true)) + assertEquals(TodayCopy.NO_SLEEP, TabCopy.noNight(naps = false, isToday = true)) + assertEquals("You deleted last night's sleep.", TodayCopy.waking(null, null, false, null, firstNights = false, isToday = true, deleted = true)) + listOf(TabCopy.noNight(false, true, deleted = true), TabCopy.DELETED_SLEEP_SCORE).forEach { assertFalse(it, "strap on" in it) } + } } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabCopyTest.kt index aa29a216..0152de16 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabCopyTest.kt @@ -4,15 +4,23 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test import org.openstrap.app.data.Recovery import org.openstrap.app.data.Recovery.Contributor import org.openstrap.app.data.Recovery.Factor import org.openstrap.app.data.SleepRegularity +import org.openstrap.app.presentation.ClockSetting +import org.openstrap.app.testing.LocaleRule +import org.openstrap.app.testing.assertCleanCopy +import java.util.Locale /** The words on the Recovery, Sleep and Activity tabs (spec 5 and 7). */ class TabCopyTest { - private fun c(f: Factor, score: Int) = Contributor(f, score, "") + // The expected numbers are written the English way, and the German test below sets its own. + @get:Rule val locale = LocaleRule(Locale.US) + + private fun c(f: Factor, score: Int) = Contributor(f, score, org.openstrap.app.data.Recovery.Detail.Asleep(0)) @Test fun `recovery against yesterday`() { assertEquals("+3 vs yesterday", TabCopy.vsYesterday(78, 75)) @@ -188,8 +196,8 @@ class TabCopyTest { assertEquals(TabCopy.SportFact.Strength, TabCopy.sportFact(52)) assertEquals(TabCopy.SportFact.Other, TabCopy.sportFact(4)) assertEquals(TabCopy.SportFact.Other, TabCopy.sportFact(223)) - // 5.2 km in 28:05 of running: 5:24 a km. - assertEquals("5.2 km, 5:24 a km", TabCopy.workoutFact(TabCopy.SportFact.Run, 5200f, false, 1685, null, 152)) + // 5.2 km in 28:05 of running: 5:24 /km. + assertEquals("5.2 km, 5:24 /km", TabCopy.workoutFact(TabCopy.SportFact.Run, 5200f, false, 1685, null, 152)) assertEquals("3.4 km", TabCopy.workoutFact(TabCopy.SportFact.Walk, 3400f, false, 2530, null, 108)) assertEquals("14 sets", TabCopy.workoutFact(TabCopy.SportFact.Strength, null, false, 2100, 14, 121)) assertEquals("1 set", TabCopy.workoutFact(TabCopy.SportFact.Strength, null, false, 300, 1, 121)) @@ -199,8 +207,9 @@ class TabCopyTest { @Test fun `a workout's fact falls back to the next one it has`() { // Distance from steps is approximate and gets no pace. assertEquals("≈ 5.2 km", TabCopy.workoutFact(TabCopy.SportFact.Run, 5200f, true, 1685, null, 152)) - // Too short for a pace to mean much. - assertEquals("420 m", TabCopy.workoutFact(TabCopy.SportFact.Run, 420f, false, 150, null, 152)) + // Under 100 m, too short for a pace to mean much. From 100 m it has one, as on the live screen. + assertEquals("80 m", TabCopy.workoutFact(TabCopy.SportFact.Run, 80f, false, 30, null, 152)) + assertEquals("420 m, 5:57 /km", TabCopy.workoutFact(TabCopy.SportFact.Run, 420f, false, 150, null, 152)) // A strap run carries no distance: its heart rate. assertEquals("Average 152 bpm", TabCopy.workoutFact(TabCopy.SportFact.Run, null, false, 1685, null, 152)) assertEquals("Average 108 bpm", TabCopy.workoutFact(TabCopy.SportFact.Walk, null, false, 2530, null, 108)) @@ -214,8 +223,8 @@ class TabCopyTest { @Test fun `distance and pace`() { assertEquals("850 m", TabCopy.distanceText(850f, false)) assertEquals("≈ 1.0 km", TabCopy.distanceText(1000f, true)) - assertEquals("4:05 a km", TabCopy.paceText(245f)) - assertEquals("10:00 a km", TabCopy.paceText(600f)) + assertEquals("4:05 /km", TabCopy.paceText(245f)) + assertEquals("10:00 /km", TabCopy.paceText(600f)) assertNull(TabCopy.paceText(4000f)) assertNull(TabCopy.paceText(0f)) } @@ -225,12 +234,13 @@ class TabCopyTest { assertEquals("09:04", TabCopy.workoutLine(null, "09:04")) } - @Test fun `workout days read as today, yesterday, then the date`() { + @Test fun `workout days read as today, yesterday, the weekday, then the date`() { val today = java.time.LocalDate.of(2026, 10, 23) val uk = java.util.Locale.UK assertEquals("Today", TabCopy.workoutDay(today, today, uk)) assertEquals("Yesterday", TabCopy.workoutDay(today.minusDays(1), today, uk)) - assertEquals("Wed 21 Oct", TabCopy.workoutDay(today.minusDays(2), today, uk)) + assertEquals("Wednesday", TabCopy.workoutDay(today.minusDays(2), today, uk)) + assertEquals("Fri 16 Oct", TabCopy.workoutDay(today.minusDays(7), today, uk)) assertEquals("Sat 27 Dec 2025", TabCopy.workoutDay(java.time.LocalDate.of(2025, 12, 27), today, uk)) } @@ -246,4 +256,34 @@ class TabCopyTest { assertEquals("Your age for now", TabCopy.habitAgeForNow(null)) assertEquals("Your age for now. First estimate Sunday 1 Nov", TabCopy.habitAgeForNow("First estimate Sunday 1 Nov")) } + + @Test fun `numbers and pace follow the phone's language`() { + Locale.setDefault(Locale.GERMANY) + assertEquals("5,2 km, 5:24 /km", TabCopy.workoutFact(TabCopy.SportFact.Run, 5200f, false, 1685, null, 152)) + assertEquals("≈ 1,0 km", TabCopy.distanceText(1000f, true)) + assertEquals("Your usual is 2,1 an hour", TabCopy.breathing(Recovery.BreathingDisturbances(3.4f, 2.1f, 20))) + } + + @Test fun `the night's times and usual times follow the phone's clock`() { + val zone = java.time.ZoneOffset.UTC + val start = java.time.LocalDateTime.of(2026, 10, 22, 22, 58).toEpochSecond(zone) + val end = java.time.LocalDateTime.of(2026, 10, 23, 6, 56).toEpochSecond(zone) + val was = ClockSetting.use24Hour + try { + ClockSetting.use24Hour = true + assertEquals("22:58–06:56", TabCopy.nightTimes(start, end, zone)) + assertEquals("Usually asleep by 23:10, up at 06:50.", TabCopy.usualTimes(23 * 60 + 10, 6 * 60 + 50)) + ClockSetting.use24Hour = false + assertEquals("10:58 PM–6:56 AM", TabCopy.nightTimes(start, end, zone)) + assertEquals("Usually asleep by 11:10 PM, up at 6:50 AM.", TabCopy.usualTimes(23 * 60 + 10, 6 * 60 + 50)) + assertCleanCopy(TabCopy.usualTimes(23 * 60 + 10, 6 * 60 + 50)) + } finally { + ClockSetting.use24Hour = was + } + } + + @Test fun `the bands come from the shared definitions`() { + assertEquals(listOf("Poor", "Fair", "Good", "Great"), listOf(59, 60, 80, 90).map(TabCopy::sleepScoreWord)) + assertEquals(listOf("Holding you back", "Holding back a little", "Usual", "Helping"), listOf(10, Recovery.FAIR_FROM, 70, 85).map(TabCopy::contributorWord)) + } } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabPlansTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabPlansTest.kt index ef151c2b..db3e4a08 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabPlansTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TabPlansTest.kt @@ -4,7 +4,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.openstrap.app.ui.glow.CardSize -import org.openstrap.app.ui.glow.Edge +import org.openstrap.app.ui.glow.draw.Edge import org.openstrap.app.ui.glow.GridRow import org.openstrap.app.ui.glow.packGrid import org.openstrap.app.ui.glow.Pane diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayCopyTest.kt index 653727b6..5914d510 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayCopyTest.kt @@ -6,9 +6,9 @@ import java.time.ZoneId import java.time.ZonedDateTime import java.util.Locale import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Test +import org.openstrap.app.testing.assertCleanCopy import org.openstrap.app.ui.dashboard.TodayCopy.LoadStatus /** Today's hero sentences and small facts (spec 4.2, 4.3, 7). */ @@ -137,11 +137,38 @@ class TodayCopyTest { assertEquals("14" to "Hours left, ready tomorrow", TodayCopy.recoverText(14 * 3600L, at(18), zone)) } - @Test fun `your week is the last full monday to sunday`() { + @Test fun `your week is the last full week, monday to sunday where the week starts on monday`() { // 23 Oct 2026 is a Friday. - assertEquals(LocalDate.of(2026, 10, 12)..LocalDate.of(2026, 10, 18), TodayCopy.lastWeek(LocalDate.of(2026, 10, 23))) - assertEquals(LocalDate.of(2026, 10, 12)..LocalDate.of(2026, 10, 18), TodayCopy.lastWeek(LocalDate.of(2026, 10, 19))) - assertEquals(LocalDate.of(2026, 10, 12)..LocalDate.of(2026, 10, 18), TodayCopy.lastWeek(LocalDate.of(2026, 10, 25))) + val monday = java.time.DayOfWeek.MONDAY + assertEquals(LocalDate.of(2026, 10, 12)..LocalDate.of(2026, 10, 18), TodayCopy.lastWeek(LocalDate.of(2026, 10, 23), monday)) + assertEquals(LocalDate.of(2026, 10, 12)..LocalDate.of(2026, 10, 18), TodayCopy.lastWeek(LocalDate.of(2026, 10, 19), monday)) + assertEquals(LocalDate.of(2026, 10, 12)..LocalDate.of(2026, 10, 18), TodayCopy.lastWeek(LocalDate.of(2026, 10, 25), monday)) + } + + @Test fun `your week follows the locale's first day`() { + val was = Locale.getDefault() + try { + Locale.setDefault(Locale.US) + // In the US the week starts on Sunday: the week before Friday 23 Oct is Sunday 11 to Saturday 17. + assertEquals(LocalDate.of(2026, 10, 11)..LocalDate.of(2026, 10, 17), TodayCopy.lastWeek(LocalDate.of(2026, 10, 23))) + assertEquals("Week on week comparisons start next Sunday.", TodayCopy.WEEK_FIRST) + Locale.setDefault(Locale.UK) + assertEquals(LocalDate.of(2026, 10, 12)..LocalDate.of(2026, 10, 18), TodayCopy.lastWeek(LocalDate.of(2026, 10, 23))) + assertEquals("Week on week comparisons start next Monday.", TodayCopy.WEEK_FIRST) + } finally { + Locale.setDefault(was) + } + } + + @Test fun `ready by follows the phone's clock`() { + val was = org.openstrap.app.presentation.ClockSetting.use24Hour + try { + org.openstrap.app.presentation.ClockSetting.use24Hour = false + assertEquals("2" to "Hours left, ready by 8:30 PM", TodayCopy.recoverText(90 * 60L, at(19), zone, Locale.US)) + assertCleanCopy(TodayCopy.recoverText(90 * 60L, at(19), zone, Locale.US).second) + } finally { + org.openstrap.app.presentation.ClockSetting.use24Hour = was + } } @Test fun `steps change and last night line`() { @@ -159,7 +186,7 @@ class TodayCopyTest { TodayCopy.WEEK_FIRST, TodayCopy.recoverText(5000, at(10), zone).second, TodayCopy.charge(50, 30, true, 10), TodayCopy.window("run", 50), TodayCopy.after(80, 50), TodayCopy.evening(listOf("run"), 9000, 8000, LoadStatus.Usual, Locale.UK), ) - all.forEach { s -> listOf("·", ";", "—").forEach { assertFalse("$s has $it", it in s) } } + assertCleanCopy(all) } @Test fun `values read against your normal`() { @@ -194,7 +221,7 @@ class TodayCopyTest { TodayCopy.heartLineNote(true), TodayCopy.heartLineNote(false), TodayCopy.loadWord(null), TodayCopy.recoverShort(3600L * 30, 0, zone).second, TodayCopy.heartAt(false, "07:00"), ) - lines.forEach { l -> listOf("·", ";", "—").forEach { assertFalse("$l has $it", l.contains(it)) } } + assertCleanCopy(lines) } @Test diff --git a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayLooksTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayLooksTest.kt index 9f90dadd..cdd74ba0 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayLooksTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/dashboard/TodayLooksTest.kt @@ -16,7 +16,7 @@ import org.openstrap.app.ui.dashboard.TodayCard.Recovery import org.openstrap.app.ui.dashboard.TodayCard.RightNow import org.openstrap.app.ui.dashboard.TodayCard.Week import org.openstrap.app.ui.glow.CardSize -import org.openstrap.app.ui.glow.Edge +import org.openstrap.app.ui.glow.draw.Edge import org.openstrap.app.ui.glow.Pane /** No two Today cards next to each other look the same (spec 2.4), whatever the moment, the pins and the hides. */ diff --git a/app/src/test/kotlin/org/openstrap/app/ui/device/BackupCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/device/BackupCopyTest.kt index 8ea278cf..44ad3b1d 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/device/BackupCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/device/BackupCopyTest.kt @@ -21,6 +21,7 @@ class BackupCopyTest { assertEquals("Last backup today at 03:12", status(on.copy(lastDone = 1792195200L + 3 * 3600 + 12 * 60))) assertEquals("Last backup yesterday at 23:50", status(on.copy(lastDone = 1792195200L - 600))) assertEquals("Last backup on Wed 14 Oct", status(on.copy(lastDone = 1792195200L - 3 * 86_400L))) + assertEquals("Last backup on Fri 9 Oct", status(on.copy(lastDone = 1792195200L - 8 * 86_400L))) assertEquals( "Last backup failed. The folder is no longer available.", status(on.copy(lastDone = 1792195200L, failed = BackupFiles.Failure.FolderGone, failedAt = now)), @@ -43,10 +44,23 @@ class BackupCopyTest { } @Test fun `a restore says what came back in a toast's worth of words`() { - assertEquals("Restored 1,234 records and your settings.", BackupCopy.restored(1234, settings = true, skipped = 0)) + assertEquals("Restored 1,234 records and your settings.", BackupCopy.restored(1234, settings = true, skipped = 0, Locale.UK)) + assertEquals("Restored 1.234 records.", BackupCopy.restored(1234, settings = false, skipped = 0, Locale.GERMANY)) assertEquals("Restored 12 records.", BackupCopy.restored(12, settings = false, skipped = 0)) assertEquals("Restored your settings. Your records were already here.", BackupCopy.restored(0, settings = true, skipped = 0)) assertEquals("Nothing to restore. It's all already here.", BackupCopy.restored(0, settings = false, skipped = 0)) assertEquals("Restored 12 records. 3 rows couldn't be read.", BackupCopy.restored(12, settings = false, skipped = 3)) } + + @Test fun `a backup's time follows the phone's clock`() { + val was = org.openstrap.app.presentation.ClockSetting.use24Hour + try { + org.openstrap.app.presentation.ClockSetting.use24Hour = false + val line = status(on.copy(lastDone = 1792195200L + 3 * 3600 + 12 * 60)) + assertEquals("last backup today at 3:12 am", line.lowercase(Locale.ROOT)) + org.openstrap.app.testing.assertCleanCopy(line) + } finally { + org.openstrap.app.presentation.ClockSetting.use24Hour = was + } + } } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/device/DeviceViewModelTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/device/DeviceViewModelTest.kt new file mode 100644 index 00000000..80fd0146 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/device/DeviceViewModelTest.kt @@ -0,0 +1,150 @@ +package org.openstrap.app.ui.device + +import android.content.Context +import android.net.Uri +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.openstrap.app.data.BackupRepository +import org.openstrap.app.data.BackupResult +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.device.CompanionLink +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.StrapController +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.export.AutoBackup +import org.openstrap.app.health.HealthConnectAutoSync +import org.openstrap.app.health.HealthConnectExporter +import org.openstrap.app.testing.inMemoryDb +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.OutputStream +import java.lang.reflect.Proxy +import java.util.Collections +import java.util.zip.ZipException + +/** The Strap page drives the strap and the settings through its view model only, and backup through the repository (audit A2, A3). */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DeviceViewModelTest { + @get:Rule val folder = TemporaryFolder() + private val context: Context = ApplicationProvider.getApplicationContext() + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val settings by lazy { + SettingsStore.of(PreferenceDataStoreFactory.create(scope = scope) { folder.newFile("settings.preferences_pb").also { it.delete() } }) + } + private lateinit var db: OpenStrapDatabase + private val commands = Collections.synchronizedList(mutableListOf()) + private val backups = FakeBackups() + private lateinit var vm: DeviceViewModel + + @Before fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + db = inMemoryDb() + val strap = recordingStrap() + val exporter = HealthConnectExporter(context, db.healthDao()) + vm = DeviceViewModel( + settings, strap, CompanionLink(context, settings), { emptyFlow() }, backups, + AutoBackup(context, settings, flowOf(true), MutableStateFlow(WorkoutState.Idle), backups), + exporter, HealthConnectAutoSync(context, settings, exporter, strap, scope), sampleData = {}, + ) + } + + @After fun tearDown() { + vm.viewModelScope.coroutineContext[Job]?.cancel() + scope.cancel() + db.close() + Dispatchers.resetMain() + } + + @Test fun `the strap's buttons reach the strap through the view model`() { + vm.syncNow() + vm.connect() + vm.disconnect() + assertEquals(listOf("syncNow", "connect", "disconnect"), commands.toList()) + } + + @Test fun `the strap page's switches write the settings`() = runBlocking { + vm.setWearReminder(false).join() + vm.setAutoStartWorkouts(false).join() + assertEquals(false, settings.wearReminder.first()) + assertEquals(false, settings.autoStartWorkouts.first()) + } + + @Test fun `save a copy and restore say what the repository did`() = runBlocking { + val uri = Uri.parse("content://test/backup.zip") + backups.next = BackupResult.Saved + vm.exportCsv(uri).join() + assertEquals("Saved a copy.", vm.message.value) + + backups.next = BackupResult.Failed(SecurityException("denied")) + vm.exportCsv(uri).join() + assertEquals(ErrorCopy.saveCopy(SecurityException()), vm.message.value) + + backups.next = BackupResult.Restored(1200, settings = true, skipped = 0) + vm.importCsv(uri).join() + assertEquals(BackupCopy.restored(1200, true, 0), vm.message.value) + + backups.next = BackupResult.Failed(ZipException("bad")) + vm.importCsv(uri).join() + assertEquals("Couldn't restore the copy. That file isn't an OpenStrap copy.", vm.message.value) + } + + @Test fun `back up now with automatic backup off says so`() = runBlocking { + vm.backUpNow().join() + assertEquals(BackupCopy.outcome(AutoBackup.Outcome.Off), vm.message.value) + assertEquals(false, vm.backingUp.value) + } + + private class FakeBackups : BackupRepository { + var next: BackupResult = BackupResult.Saved + override suspend fun exportCsv(to: Uri) = next + override suspend fun importCsv(from: Uri) = next + override suspend fun exportSettings(to: Uri) = next + override suspend fun importSettings(from: Uri) = next + override suspend fun writeZip(out: OutputStream) = Unit + } + + /** A strap that is never connected and only records the commands it is given. */ + private fun recordingStrap(): StrapController { + val flows = mutableMapOf() + return Proxy.newProxyInstance(StrapController::class.java.classLoader, arrayOf(StrapController::class.java)) { proxy, method, args -> + when { + method.name == "hashCode" -> System.identityHashCode(proxy) + method.name == "equals" -> proxy === args?.firstOrNull() + method.name == "toString" -> "recording strap" + method.name == "getConnection" -> flows.getOrPut(method.name) { MutableStateFlow(ConnectionState.Disconnected) } + StateFlow::class.java.isAssignableFrom(method.returnType) -> flows.getOrPut(method.name) { MutableStateFlow(null) } + SharedFlow::class.java.isAssignableFrom(method.returnType) -> flows.getOrPut(method.name) { MutableSharedFlow() } + method.returnType == java.lang.Boolean.TYPE -> false + else -> { commands += method.name; Unit } + } + } as StrapController + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/device/ErrorCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/device/ErrorCopyTest.kt new file mode 100644 index 00000000..a3b24085 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/device/ErrorCopyTest.kt @@ -0,0 +1,92 @@ +package org.openstrap.app.ui.device + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.openstrap.app.ble.GattTimeoutException +import org.openstrap.app.device.StrapCommandException +import org.openstrap.app.firmware.FirmwareCloudException +import org.openstrap.app.testing.assertCleanCopy +import org.openstrap.protocol.firmware.DfuException +import java.io.FileNotFoundException +import java.io.IOException +import java.util.zip.ZipException + +/** Failures say plain words, never an exception's class or "null" (audit E6), and the address to pair is trimmed (E7). */ +class ErrorCopyTest { + private val raw = listOf( + NullPointerException(), + FileNotFoundException("open failed: ENOENT"), + IOException("write failed: ENOSPC (No space left on device)"), + SecurityException("Permission Denial: opening provider"), + ZipException("invalid END header"), + IllegalArgumentException("This isn't an OpenStrap export."), + IllegalStateException("Room cannot verify the data integrity"), + GattTimeoutException("op 0x12 timed out"), + IOException("Unable to resolve host"), + ) + + private val all: List = raw.flatMap { e -> + listOf( + ErrorCopy.strap(e, "Couldn't buzz the strap"), + ErrorCopy.saveCopy(e), + ErrorCopy.restoreCopy(e), + ErrorCopy.healthConnect("Couldn't write to Health Connect", e), + ErrorCopy.firmware(e, "Install failed"), + ) + } + + @Test fun `no exception text reaches the screen`() { + for (text in all) { + assertFalse(text, "Exception" in text) + assertFalse(text, "null" in text) + assertFalse(text, "ENOSPC" in text || "ENOENT" in text || "Permission Denial" in text || "END header" in text) + } + assertCleanCopy(all) + } + + @Test fun `a full disk and a lost permission say so`() { + assertEquals("Couldn't save the copy. There isn't enough space for it.", ErrorCopy.saveCopy(raw[2])) + assertEquals("Couldn't save the copy. OpenStrap wasn't allowed to write there. Choose another place.", ErrorCopy.saveCopy(raw[3])) + assertEquals("Couldn't save the copy. That place is no longer available. Choose another one.", ErrorCopy.saveCopy(raw[1])) + assertEquals("Couldn't save the copy. Try again, or choose another place.", ErrorCopy.saveCopy(raw[0])) + } + + @Test fun `a file that isn't a copy says so`() { + assertEquals("Couldn't restore the copy. That file isn't an OpenStrap copy.", ErrorCopy.restoreCopy(raw[4])) + assertEquals("Couldn't restore the copy. That file isn't an OpenStrap copy.", ErrorCopy.restoreCopy(raw[5])) + assertEquals("Couldn't restore the copy. OpenStrap can't open that file. Choose it again.", ErrorCopy.restoreCopy(raw[1])) + assertEquals("Couldn't restore the copy. Try again.", ErrorCopy.restoreCopy(raw[6])) + } + + @Test fun `health connect says permission or no answer`() { + assertEquals( + "Couldn't write to Health Connect. OpenStrap no longer has permission. Allow it in Health Connect.", + ErrorCopy.healthConnect("Couldn't write to Health Connect", raw[3]), + ) + assertEquals( + "Couldn't restore from Health Connect. Health Connect didn't answer. Try again.", + ErrorCopy.healthConnect("Couldn't restore from Health Connect", raw[8]), + ) + } + + @Test fun `the strap and the update server keep their own words`() { + assertEquals("The strap isn't connected", ErrorCopy.strap(StrapCommandException("The strap isn't connected"), "Couldn't buzz the strap")) + assertEquals("The strap didn't answer. Try again.", ErrorCopy.strap(raw[7], "Couldn't buzz the strap")) + assertEquals("Couldn't buzz the strap", ErrorCopy.strap(raw[0], "Couldn't buzz the strap")) + assertEquals("Download failed (HTTP 404)", ErrorCopy.firmware(FirmwareCloudException("Download failed (HTTP 404)"), "Download failed")) + assertEquals("Wait for the sync to finish first", ErrorCopy.firmware(DfuException(-1, "Wait for the sync to finish first"), "Install failed")) + assertEquals( + "Couldn't reach the update server. Check your internet connection and try again.", + ErrorCopy.firmware(raw[8], "Update check failed"), + ) + assertEquals("Install failed", ErrorCopy.firmware(raw[6], "Install failed")) + } + + @Test fun `a pasted address with spaces pairs, trimmed and upper case`() { + assertEquals("C8:0F:10:AB:CD:EF", DeviceViewModel.pairAddress(" c8:0f:10:ab:cd:ef \n")) + assertNull(DeviceViewModel.pairAddress("C8:0F:10:AB:CD")) + assertEquals(true, DeviceViewModel.isAddress(" C8:0F:10:AB:CD:EF ")) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/device/HealthConnectLineTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/device/HealthConnectLineTest.kt index ba474e11..ba45a8b6 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/device/HealthConnectLineTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/device/HealthConnectLineTest.kt @@ -25,7 +25,8 @@ class HealthConnectLineTest { @Test fun `on says when it last synced`() { assertEquals("On. Last synced 10:42", line(HealthConnectAuto(on = true, lastSynced = at(29, 10, 42)))) assertEquals("On. Last synced yesterday 22:10", line(HealthConnectAuto(on = true, lastSynced = at(28, 22, 10)))) - assertEquals("On. Last synced 12 Oct", line(HealthConnectAuto(on = true, lastSynced = at(12, 9, 0)))) + assertEquals("On. Last synced Tuesday 09:00", line(HealthConnectAuto(on = true, lastSynced = at(27, 9, 0)))) + assertEquals("On. Last synced Mon 12 Oct 09:00", line(HealthConnectAuto(on = true, lastSynced = at(12, 9, 0)))) } @Test fun `stopped says why`() { diff --git a/app/src/test/kotlin/org/openstrap/app/ui/device/StrapPageTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/device/StrapPageTest.kt index d2610182..c18e3f2a 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/device/StrapPageTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/device/StrapPageTest.kt @@ -5,6 +5,7 @@ import org.junit.Test import org.openstrap.app.data.settings.Goals import org.openstrap.app.device.ConnectionState import org.openstrap.app.device.SyncState +import java.util.Locale /** The Strap page's state line and the You page's values. */ class StrapPageTest { @@ -24,10 +25,11 @@ class StrapPageTest { } @Test fun `You page values`() { - assertEquals("7 h 30", hoursMinutes(450)) - assertEquals("8 h", hoursMinutes(480)) - assertEquals("7 h 05", hoursMinutes(425)) - assertEquals("8,000 steps, 450 active kcal, 8 active hours", goalsLine(Goals(steps = 8000, sleepMinutes = 450, activeHours = 8, calories = 450))) - assertEquals("7 h 30. Your sleep need is 8 h", sleepGoalLine(Goals(steps = 8000, sleepMinutes = 450, activeHours = 8), 480)) + val goals = Goals(steps = 8000, sleepMinutes = 450, activeHours = 8, calories = 450) + assertEquals("8,000 steps, 450 active kcal, 8 active hours", goalsLine(goals, Locale.UK)) + assertEquals("8.000 steps, 450 active kcal, 8 active hours", goalsLine(goals, Locale.GERMANY)) + // The sleep goal and need read as every other length of time does. + assertEquals("7 h 30. Your sleep need is 8 h", sleepGoalLine(goals, 480)) + assertEquals("7 h 05. Your sleep need is 8 h", sleepGoalLine(goals.copy(sleepMinutes = 425), 480)) } } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/firmware/FirmwareViewModelTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/firmware/FirmwareViewModelTest.kt new file mode 100644 index 00000000..1e686d87 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/firmware/FirmwareViewModelTest.kt @@ -0,0 +1,98 @@ +package org.openstrap.app.ui.firmware + +import android.net.Uri +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.StrapController +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.lang.reflect.Proxy + +/** The firmware view model stands on the strap, the settings, a cache folder and a file reader, not the whole app (audit A3). */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class FirmwareViewModelTest { + @get:Rule val folder = TemporaryFolder() + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val settings by lazy { + SettingsStore.of(PreferenceDataStoreFactory.create(scope = scope) { folder.newFile("settings.preferences_pb").also { it.delete() } }) + } + private val connection = MutableStateFlow(ConnectionState.Disconnected) + private val vms = mutableListOf() + + @Before fun setUp() = Dispatchers.setMain(UnconfinedTestDispatcher()) + + @After fun tearDown() { + vms.forEach { it.viewModelScope.coroutineContext[Job]?.cancel() } + scope.cancel() + Dispatchers.resetMain() + } + + private fun vm(openFile: (Uri) -> InputStream?) = + FirmwareViewModel(strap(), settings, folder.newFolder(), openFile).also { vms += it } + + private val picked = Uri.parse("content://test/firmware.bin") + + @Test fun `a picked file is read through the reader it was given`() = runBlocking { + val vm = vm { ByteArrayInputStream(ByteArray(10)) } + vm.loadFile(picked).join() + assertEquals(FirmwareStep.Failed("That file is 10 bytes, which isn't a plausible firmware image."), vm.step.value) + } + + @Test fun `a file that can't be opened asks for it again`() = runBlocking { + val vm = vm { null } + vm.loadFile(picked).join() + assertEquals(FirmwareStep.Failed("Couldn't read the file. Choose it again."), vm.step.value) + } + + @Test fun `install waits for a connected strap with enough battery`() { + val vm = vm { null } + assertEquals(listOf("Connect the strap."), vm.blockers()) + connection.value = ConnectionState.Connected(20, "3.25") + assertEquals(listOf("Charge the strap above 30% (now 20%)."), vm.blockers()) + assertEquals(connection.value, vm.connection.value) + } + + /** A strap whose link is [connection] and that does nothing else. */ + private fun strap(): StrapController { + val flows = mutableMapOf() + return Proxy.newProxyInstance(StrapController::class.java.classLoader, arrayOf(StrapController::class.java)) { proxy, method, args -> + when { + method.name == "hashCode" -> System.identityHashCode(proxy) + method.name == "equals" -> proxy === args?.firstOrNull() + method.name == "toString" -> "test strap" + method.name == "getConnection" -> connection + StateFlow::class.java.isAssignableFrom(method.returnType) -> flows.getOrPut(method.name) { MutableStateFlow(null) } + SharedFlow::class.java.isAssignableFrom(method.returnType) -> flows.getOrPut(method.name) { MutableSharedFlow() } + method.returnType == java.lang.Boolean.TYPE -> false + else -> Unit + } + } as StrapController + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/AllMetricsTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/AllMetricsTest.kt index 29b13b2a..0dc24c22 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/glow/AllMetricsTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/AllMetricsTest.kt @@ -2,7 +2,10 @@ package org.openstrap.app.ui.glow import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test +import org.openstrap.app.testing.LocaleRule +import org.openstrap.app.presentation.chargeShown import org.openstrap.app.ui.components.ChartPoint import org.openstrap.app.ui.dashboard.DayUiState import org.openstrap.app.ui.dashboard.Detail @@ -10,6 +13,9 @@ import org.openstrap.app.ui.dashboard.Metric /** The All metrics index is a map of what exists: every metric detail once, and the screens without one. */ class AllMetricsTest { + // The values are checked as an English phone writes them: a German one groups steps with a dot. + @get:Rule val locale = LocaleRule() + private val empty = DayUiState() @Test fun `every metric detail appears exactly once`() { diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/DashboardKeysTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/DashboardKeysTest.kt new file mode 100644 index 00000000..eaa1a936 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/DashboardKeysTest.kt @@ -0,0 +1,67 @@ +package org.openstrap.app.ui.glow + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.testing.ComposeRules +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Dashboard blocks are keyed on their cards: a card added above another (a pin in Edit Today) leaves the state saved + * inside the other with it, rather than handing it to whichever card now sits in its place (audit K5). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DashboardKeysTest { + @get:Rule val rules = ComposeRules() + private val compose get() = rules.compose + + /** A card that counts its taps in saved state, as a card's open info sheet is kept. */ + @Composable + private fun Counter(name: String) { + var taps by rememberSaveable { mutableIntStateOf(0) } + Text("$name $taps", Modifier.clickable { taps++ }) + } + + @Test fun `a card's saved state stays with it when a card is added above it`() { + var pinned by mutableStateOf(false) + compose.setContent { + GlowScreenList(PaddingValues(), rememberLazyListState()) { + add(Pane.Full, GlowBlocks.HEADER) { Text("Header") } + if (pinned) add(Pane.Full, GlowBlocks.cardsKey(listOf("Pinned"))) { Counter("Pinned") } + add(Pane.Full, GlowBlocks.cardsKey(listOf("Steps"))) { Counter("Steps") } + } + } + compose.onNodeWithText("Steps 0").performClick() + compose.onNodeWithText("Steps 1").assertExists() + + pinned = true + compose.waitForIdle() + + compose.onNodeWithText("Steps 1").assertExists() + compose.onNodeWithText("Pinned 0").assertExists() + } + + @Test fun `a repeated key is made unique rather than crash the list`() { + assertEquals(listOf("a", "b", "a#1", "a#2"), uniqueKeys(listOf("a", "b", "a", "a"))) + } + + @Test fun `a row's key is its cards in order`() { + assertEquals("Steps+Load", GlowBlocks.cardsKey(listOf("Steps", "Load"))) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/EdgeLightTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/EdgeLightTest.kt index 4f59d80c..8919988a 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/glow/EdgeLightTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/EdgeLightTest.kt @@ -4,6 +4,11 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import org.openstrap.app.ui.glow.draw.EdgeStatus +import org.openstrap.app.ui.glow.draw.EdgeKind +import org.openstrap.app.ui.glow.draw.EdgeLight +import org.openstrap.app.ui.glow.draw.edgeLight +import org.openstrap.app.ui.glow.draw.Edge /** Every row of the edge placement table (spec 2.4). */ class EdgeLightTest { diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/EditTodayRestoreTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/EditTodayRestoreTest.kt new file mode 100644 index 00000000..b36cb95e --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/EditTodayRestoreTest.kt @@ -0,0 +1,89 @@ +package org.openstrap.app.ui.glow + +import android.os.Bundle +import android.os.Parcel +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.key +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.test.isToggleable +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.testing.ComposeRules +import org.openstrap.app.ui.components.GlassHost +import org.openstrap.app.ui.components.LocalGlassHost +import org.openstrap.app.ui.dashboard.TodayCard +import org.openstrap.app.ui.dashboard.TodayEdits +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Edit Today's pins and hides, not yet saved, come back after the activity is recreated (a rotation or a fold), and + * through a parcelled bundle as after process death (audit K7). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class EditTodayRestoreTest { + @get:Rule val rules = ComposeRules() + private val compose get() = rules.compose + + private val editable = TodayCard.entries.filter { it.editable } + private val pinnable = editable.filter { it.pinnable } + + @Test fun `pins and hides made in the sheet are still there after a recreate, and Done saves them`() { + val host = GlassHost() + var saved: TodayEdits? = null + val tester = StateRestorationTester(compose) + tester.setContent { + CompositionLocalProvider(LocalGlassHost provides host) { + EditTodaySheet(TodayEdits(), onSave = { saved = it }, onDismiss = {}) + // The sheet draws in the app's overlay; the overlay's blur isn't needed to tap it. + host.layers.forEach { layer -> key(layer) { layer.content.value() } } + } + } + val hidden = editable.last { !it.pinnable } + compose.onAllNodesWithText("Pin")[0].performClick() + compose.onAllNodesWithText("Pin")[0].performClick() + compose.onAllNodes(isToggleable())[editable.indexOf(hidden)].performScrollTo().performClick() + val expected = TodayEdits().togglePin(pinnable[0]).togglePin(pinnable[1]).toggleHidden(hidden) + + tester.emulateSavedInstanceStateRestore() + + compose.onAllNodesWithText("Pinned").assertCountEquals(2) + // Done sits under the rows, scrolled to as a finger would. + compose.onNodeWithText("Done").performScrollTo().performClick() + assertEquals(expected, saved) + } + + @Test fun `the saved edits are plain values a bundle keeps through a parcel`() { + val edits = TodayEdits().togglePin(pinnable[1]).togglePin(pinnable[0]).toggleHidden(editable.last { !it.pinnable }) + val saved = with(TodayEditsSaver) { SaverScope { true }.save(edits) } + val bundle = Bundle().apply { putSerializable(KEY, saved as java.io.Serializable) } + val parcel = Parcel.obtain() + val back = try { + parcel.writeBundle(bundle) + parcel.setDataPosition(0) + parcel.readBundle(javaClass.classLoader)!! + } finally { + parcel.recycle() + } + @Suppress("DEPRECATION") + val restored = TodayEditsSaver.restore(back.getSerializable(KEY)!!) + assertEquals(edits, restored) + // Pins keep their order: the first pinned sits first under the hero. + assertEquals(listOf(pinnable[1], pinnable[0]), restored?.pinned) + } + + private fun androidx.compose.ui.test.SemanticsNodeInteractionCollection.assertCountEquals(n: Int) = + assertEquals(n, fetchSemanticsNodes().size) + + private companion object { + const val KEY = "edits" + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/GlowFormatTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/GlowFormatTest.kt new file mode 100644 index 00000000..97772493 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/GlowFormatTest.kt @@ -0,0 +1,171 @@ +package org.openstrap.app.ui.glow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.openstrap.app.data.Healthspan +import org.openstrap.app.data.Recovery +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.Workouts +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.presentation.ClockSetting +import org.openstrap.app.presentation.DayWords +import org.openstrap.app.presentation.TEST_LOCALES +import org.openstrap.app.presentation.Units +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.assertCleanCopy +import org.openstrap.app.testing.workout +import org.openstrap.app.ui.components.ChartPoint +import org.openstrap.app.ui.dashboard.Metric +import org.openstrap.app.ui.glow.draw.DotFont +import org.openstrap.app.ui.glow.live.speedText +import org.openstrap.app.ui.workout.PhoneSummary +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.Locale + +/** The glow screens write numbers, times and days through presentation/, and the dot font only gets what it can draw. */ +class GlowFormatTest { + @get:Rule val zone = ZoneRule(ZoneOffset.UTC) + + /** Runs [block] with the phone in [locale], then puts the default back. */ + private fun inLocale(locale: Locale, block: () -> T): T { + val saved = Locale.getDefault() + Locale.setDefault(locale) + return try { block() } finally { Locale.setDefault(saved) } + } + + private fun assertDrawable(text: String) = assertTrue("'$text' has a character the dot font can't draw", text.all(DotFont::has)) + + private val start = 1_792_000_000L + + private fun run(meters: Float, seconds: Long = 1800): PhoneSummary { + val w = AppWorkout(start, start + seconds, 1, distanceMeters = meters) + return PhoneSummary(WorkoutRecorder.analyse(w, emptyList(), 190), emptyList(), start, start + seconds, null, distanceMeters = meters) + } + + @Test fun `numbers drawn in dots keep ASCII digits and a dot in every language`() { + for (locale in TEST_LOCALES) inLocale(locale) { + listOf( + dotClock(3723), dotClock(754), clockDuration(27_600), Metric.SkinTemperature.format(36.55f, Units.DOT_LOCALE), Metric.Steps.format(13_860f, Units.DOT_LOCALE), + speedText(300.0, Units.DOT_LOCALE)!!, summaryHero(Workouts.Family.Distance, run(5210f), null).value, + ).forEach(::assertDrawable) + assertEquals("1:02:03", dotClock(3723)) + assertEquals("7:40", clockDuration(27_600)) + assertEquals("13,860", Metric.Steps.format(13_860f, Units.DOT_LOCALE)) + assertEquals("36.5", Metric.SkinTemperature.format(36.54f, Units.DOT_LOCALE)) + // Read in a row's text, the same figures take the phone's own marks. + assertEquals(String.format(locale, "%.1f", 36.54f), Metric.SkinTemperature.format(36.54f, locale)) + assertEquals(Units.count(13_860, locale), Metric.Steps.format(13_860f, locale)) + assertEquals("5.21", summaryHero(Workouts.Family.Distance, run(5210f), null).value) + } + } + + @Test fun `text figures follow the phone's language`() { + inLocale(Locale.GERMANY) { + assertEquals("12,0", speedText(300.0)) + assertEquals("12:34", workoutClock(754)) + } + inLocale(Locale.US) { assertEquals("12.0", speedText(300.0)) } + } + + @Test fun `a run under a kilometre leads with its metres`() { + val h = summaryHero(Workouts.Family.Distance, run(820f), null) + assertEquals("820", h.value) + assertEquals("m", h.unit) + } + + @Test fun `the summary and the share card write kcal and minutes the same way`() { + inLocale(Locale.GERMANY) { + val a = run(5210f).analysis.copy(activeKcal = 1312.4f) + assertEquals("1.312", kcalFigure(a)) + } + assertEquals("42" to "min", minutesFigure(42 * 60L + 10)) + assertEquals("45" to "s", minutesFigure(45)) + } + + @Test fun `a time of day follows the phone's 12 or 24 hour setting`() { + val t = LocalDate.of(2026, 10, 21).atTime(22, 30).toEpochSecond(ZoneOffset.UTC) + val saved = ClockSetting.use24Hour + try { + inLocale(Locale.US) { + ClockSetting.use24Hour = true + assertEquals("22:30", clock(t)) + ClockSetting.use24Hour = false + assertEquals("10:30 PM", clock(t)) + } + } finally { + ClockSetting.use24Hour = saved + } + } + + @Test fun `a heading names the day as DayWords does`() { + inLocale(Locale.UK) { + val today = LocalDate.now() + assertEquals("Today", dayName(today)) + assertEquals("Yesterday", dayName(today.minusDays(1))) + assertEquals(today.minusDays(3).dayOfWeek.getDisplayName(java.time.format.TextStyle.FULL, Locale.UK), dayName(today.minusDays(3))) + } + } + + @Test fun `chart labels are one weekday, day and month form`() { + inLocale(Locale.UK) { + val d = LocalDate.of(2026, 10, 21) + assertEquals("Wed", DayWords.weekdayShort(d)) + assertEquals("36,5", Metric.SkinTemperature.format(36.54f, Locale.GERMANY)) + assertEquals("13.860", Metric.Steps.format(13_860f, Locale.GERMANY)) + assertEquals("21 Oct", DayWords.dayMonth(d)) + assertEquals("Oct", DayWords.monthShort(d)) + assertEquals("21 Oct 2025", DayWords.dayMonthYear(d.minusYears(1))) + assertEquals("Oct 21", DayWords.monthDay(d)) + } + } + + @Test fun `Habit Age's sleep line writes a duration as everywhere else`() { + inLocale(Locale.US) { + val line = termDetail(Healthspan.Term(Healthspan.Habit.TimeAsleep, 1f, 340f, null), 40) + assertEquals("5 h 40, counts only below 6 h 30", line) + assertCleanCopy(line) + val steps = termDetail(Healthspan.Term(Healthspan.Habit.Steps, 0f, 6543f, 9000f), 40) + assertTrue(steps, steps.startsWith("6,543 a day, typical: 9,000")) + } + } + + private fun rated(end: Long, minutes: Int?): WorkoutAnalysis = + WorkoutAnalysis(workout(start = end - 1800, end = end), end, emptyList(), null, null, null, null, List(5) { 0 }, 190, recoveryMinutes = minutes) + + @Test fun `time to recover counts from the latest workout that has an estimate`() { + val older = rated(1000, 600) + val newer = rated(5000, 120) + val unrated = rated(9000, null) + assertEquals(newer, latestRecoveryEstimate(listOf(older, unrated, newer))) + assertNull(latestRecoveryEstimate(listOf(unrated))) + } + + @Test fun `heart rate and Charge share one bucketing`() { + val range = 0L..1199L + assertEquals(listOf(60f, 80f), bucket(listOf(ChartPoint(0, 50f), ChartPoint(60, 70f), ChartPoint(700, 80f)), range, 600)) + val charge = listOf(Recovery.ChargePoint(0, 50f, Recovery.State.Rest), Recovery.ChargePoint(700, 80f, Recovery.State.Rest)) + assertEquals(listOf(50f, 80f), bucket(charge, range, 600)) + } + + @Test fun `a year's chart groups weeks from the locale's first day`() { + val end = LocalDate.of(2026, 10, 21) + val start = end.minusDays(364) + val days = generateSequence(start) { it.plusDays(1) }.takeWhile { !it.isAfter(end) } + .map { org.openstrap.app.data.DayStat(it, 50f, 50f, 50f, 1) }.toList() + for ((locale, first) in listOf(Locale.US to java.time.DayOfWeek.SUNDAY, Locale.GERMANY to java.time.DayOfWeek.MONDAY)) inLocale(locale) { + val (values, _, dates) = series(org.openstrap.app.ui.dashboard.Range.Year, days, start, end) + assertTrue(dates.all { it.dayOfWeek == first }) + // Every week finds its average: the chart and History start weeks on the same day. + assertTrue(values.all { it == 50f }) + } + } + + @Test fun `the shared words are clean copy`() { + assertCleanCopy(listOf(NO_VALUE, NO_NIGHT_VALUE, NO_READING, AFTER_NEXT_SYNC, ADD_PROFILE)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/GlowGridTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/GlowGridTest.kt index a8c20741..430cd5d4 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/glow/GlowGridTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/GlowGridTest.kt @@ -57,8 +57,7 @@ class GlowGridTest { @Test fun `unit width on a phone`() { // (390 - 32 - 12) / 2, the mockups' 173. - assertEquals(173f, GlowGrid.unitWidth(390.dp).value, 1e-4f) - assertEquals(GlowGrid.unitWidth(390.dp).value, GlowGrid.unitWidthIn(358.dp).value, 1e-4f) + assertEquals(173f, GlowGrid.unitWidthIn(358.dp).value, 1e-4f) } @Test fun `heights line up across rows`() { diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/GlowLightTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/GlowLightTest.kt index bb83b882..95493ec4 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/glow/GlowLightTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/GlowLightTest.kt @@ -6,6 +6,21 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import org.openstrap.app.ui.glow.draw.LightFrom +import org.openstrap.app.ui.glow.draw.LightAngle +import org.openstrap.app.ui.glow.draw.GlowMood +import org.openstrap.app.ui.glow.draw.GlowStyle +import org.openstrap.app.ui.glow.draw.MIN_LIGHT_LEVEL +import org.openstrap.app.ui.glow.draw.glowLevel +import org.openstrap.app.ui.glow.draw.lightVector +import org.openstrap.app.ui.glow.draw.CoreBox +import org.openstrap.app.ui.glow.draw.angleCore +import org.openstrap.app.ui.glow.draw.levelEdge +import org.openstrap.app.ui.glow.draw.LEVEL_STEP +import org.openstrap.app.ui.glow.draw.LEVEL_FILL_REACH +import org.openstrap.app.ui.glow.draw.levelCore +import org.openstrap.app.ui.glow.draw.FillStop +import org.openstrap.app.ui.glow.draw.light class GlowLightTest { private val style = GlowStyle(color = Color(0xFF7CF2C9)) diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/ShareCardsTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/ShareCardsTest.kt new file mode 100644 index 00000000..7c0e6131 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/ShareCardsTest.kt @@ -0,0 +1,119 @@ +package org.openstrap.app.ui.glow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.data.Route +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.Workouts +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.AppWorkoutHeartRate +import org.openstrap.app.data.db.AppWorkoutSet +import org.openstrap.app.presentation.EarnedLine +import org.openstrap.app.ui.workout.PhoneSummary + +/** The share card's route hides its ends and fits its box, and each kind of card says what its board does. */ +class ShareCardsTest { + private val start = 1_792_000_000L + + /** A straight run north, a fix every [stepM] metres for [meters]. */ + private fun north(meters: Int, stepM: Int = 50): List = (0..meters / stepM).map { i -> + Route.Point(start + i * 10L, 51.5 + i * stepM / 111_195.0, -0.1, 5f) + } + + private fun length(points: List) = points.zipWithNext().sumOf { (a, b) -> Route.meters(a, b) } + + @Test fun `hiding the ends cuts 200 m from each`() { + val route = north(1000) + val trimmed = StoryRouteShape.trimEnds(route) + assertEquals(600.0, length(trimmed), 1.0) + assertEquals(200.0, Route.meters(route.first(), trimmed.first()), 1.0) + assertEquals(200.0, Route.meters(route.last(), trimmed.last()), 1.0) + } + + @Test fun `a route under 400 m has nothing left once its ends are hidden`() { + assertTrue(StoryRouteShape.trimEnds(north(350)).isEmpty()) + assertTrue(StoryRouteShape.trimEnds(north(0)).isEmpty()) + } + + @Test fun `the route fits its box to scale, centred`() { + val fitted = StoryRouteShape.fit(north(1000), 300f, 200f) + // A line north is all height: it runs top to bottom down the middle. + assertEquals(0f, fitted.minOf { it.y }, 0.01f) + assertEquals(200f, fitted.maxOf { it.y }, 0.01f) + fitted.forEach { assertEquals(150f, it.x, 0.01f) } + assertTrue("north is up", fitted.first().y > fitted.last().y) + } + + private fun summary(seconds: Long, sport: Int, bpm: Int? = null, sets: List = emptyList()): PhoneSummary { + val w = AppWorkout(start, start + seconds, sport, averageHeartRate = bpm) + val hr = if (bpm == null) emptyList() else (0L until seconds step 5).map { AppWorkoutHeartRate(start, start + it, bpm, false) } + return PhoneSummary(WorkoutRecorder.analyse(w, hr, 190), emptyList(), start, start + seconds, null, sets = sets) + } + + @Test fun `a strength card lists each exercise's sets and top weight, then the total`() { + val sets = listOf( + AppWorkoutSet(start, 0, start, start + 60, exercise = "Bench press", reps = 10, weightKg = 60f), + AppWorkoutSet(start, 1, start + 120, start + 180, exercise = "Bench press", reps = 10, weightKg = 65f), + AppWorkoutSet(start, 2, start + 240, start + 300, exercise = "Squat", reps = 8, weightKg = 90f), + AppWorkoutSet(start, 3, start + 360, start + 420, exercise = "squat ", reps = 6, weightKg = 80f), + ) + val s = summary(2040, 52, sets = sets) + val card = storyCard(Workouts.Family.Strength, s, summaryHero(Workouts.Family.Strength, s, null), emptyList(), null, start) as StoryCard.Lift + assertEquals("kg", card.unit) + assertEquals( + listOf("Bench press" to "2 × 10, top 65 kg", "Squat" to "2 sets, top 90 kg", "Total" to "34 min, 4 sets"), + card.rows, + ) + } + + @Test fun `a card leads with a best over the weekly goal`() { + val earned = listOf( + EarnedLine(EarnedLine.Kind.Goal, "Weekly goal", "3 of 4"), + EarnedLine(EarnedLine.Kind.Best, "Fastest 5 km", "26:41"), + ) + val s = summary(1925, 2, 141) + val indoor = storyCard(Workouts.Family.IndoorCardio, s, summaryHero(Workouts.Family.IndoorCardio, s, null), earned.take(1), null, start) as StoryCard.Indoor + assertEquals("Weekly goal, 3 of 4", indoor.chip?.text) + assertEquals("32:05", indoor.time) + assertEquals("141 avg bpm", indoor.line) + val run = storyCard(Workouts.Family.Distance, s, summaryHero(Workouts.Family.Distance, s, null), earned, null, start) as StoryCard.Run + assertEquals("Fastest 5 km", run.chip?.text) + } + + @Test fun `a calm card and a game card say what their summaries say, shorter`() { + // Half an hour coming down from 96 to 66. + val w = AppWorkout(start, start + 1800, 74, averageHeartRate = 80) + val hr = (0L..1800L step 5).map { AppWorkoutHeartRate(start, start + it, (96 - it * 30 / 1800).toInt(), false) } + val recorded = hr.map { org.openstrap.app.data.db.WorkoutHeartRate(start, it.timestamp, it.bpm) } + val calm = PhoneSummary(WorkoutRecorder.analyse(w, hr, 190), recorded, start, start + 1800, null) + val readings = recorded.map { it.timestamp to it.bpm } + assertEquals(readings, org.openstrap.app.ui.workout.FamilySummary.readings(calm)) + val (from, to) = org.openstrap.app.ui.workout.FamilySummary.settled(readings, start, start + 1800)!! + val card = storyCard(Workouts.Family.MindBody, calm, summaryHero(Workouts.Family.MindBody, calm, null), emptyList(), null, start) as StoryCard.Calm + assertEquals("Heart settled $from to $to", card.line) + assertEquals("Your heart settled from $from to $to.", summaryHero(Workouts.Family.MindBody, calm, null).sentence) + + val played = PhoneSummary(WorkoutRecorder.analyse(w.copy(epoc = 130f), hr, 190), recorded, start, start + 1800, null) + val game = storyCard(Workouts.Family.Games, played, summaryHero(Workouts.Family.Games, played, 100f), emptyList(), 100f, start) as StoryCard.Game + assertEquals("Load 130, hard", game.load) + assertEquals("Peak 96", game.peak) + } + + @Test fun `no card text uses an interpunct, semicolon or dash`() { + val s = summary(1800, 2, 120) + Workouts.Family.entries.forEach { f -> + val card = storyCard(f, s, summaryHero(f, s, null), emptyList(), null, start) + val texts = when (card) { + is StoryCard.Run -> card.stats.flatMap { listOfNotNull(it.value, it.unit, it.label) } + card.footer + is StoryCard.Lift -> card.rows.flatMap { it.toList() } + card.unit + is StoryCard.Indoor -> listOfNotNull(card.line, card.time) + is StoryCard.Rounds -> listOfNotNull(card.line, card.unit) + is StoryCard.Calm -> listOfNotNull(card.line, card.unit) + is StoryCard.Game -> listOfNotNull(card.load, card.peak, card.unit) + is StoryCard.Week -> listOf(card.range, card.goalLine) + card.totals.flatMap { listOfNotNull(it.value, it.unit, it.label) } + } + texts.forEach { t -> assertTrue("\"$t\"", t.none { it in "·;—–" }) } + } + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/SummaryHeroTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/SummaryHeroTest.kt new file mode 100644 index 00000000..d7f138a7 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/SummaryHeroTest.kt @@ -0,0 +1,61 @@ +package org.openstrap.app.ui.glow + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.Workouts +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.AppWorkoutHeartRate +import org.openstrap.app.ui.workout.PhoneSummary + +/** The summary's hero falls back to the time when its own number is missing, and a missing figure is a dash. */ +class SummaryHeroTest { + private val start = 1_792_000_000L + + private fun summary(seconds: Long, sport: Int, bpm: Int? = null): PhoneSummary { + val w = AppWorkout(start, start + seconds, sport, averageHeartRate = bpm) + val hr = if (bpm == null) emptyList() else (0L until seconds step 5).map { AppWorkoutHeartRate(start, start + it, bpm, false) } + return PhoneSummary(WorkoutRecorder.analyse(w, hr, 190), emptyList(), start, start + seconds, null) + } + + @Test + fun anIndoorWorkoutWithoutHeartRateLeadsWithItsTime() { + val h = summaryHero(Workouts.Family.IndoorCardio, summary(69, 2), null) + assertEquals("1", h.value) + assertEquals("min", h.unit) + assertEquals(listOf("1:09", null, null), h.figures.map { it.first }) + assertEquals(listOf("Time", "Active kcal", "Peak"), h.figures.map { it.second }) + } + + @Test + fun underAMinuteItReadsSeconds() { + val h = summaryHero(Workouts.Family.Games, summary(45, 26), null) + assertEquals("45", h.value) + assertEquals("s played", h.unit) + } + + @Test + fun withHeartRateTheKindsOwnNumberLeads() { + val h = summaryHero(Workouts.Family.IndoorCardio, summary(1800, 2, 141), null) + assertEquals("141", h.value) + assertEquals("avg bpm", h.unit) + } + + @Test + fun strengthAndIntervalsWithoutSetsOrRoundsFallBackToo() { + for (family in listOf(Workouts.Family.Strength, Workouts.Family.Intervals)) { + val h = summaryHero(family, summary(600, 52), null) + assertEquals("10", h.value) + assertEquals("min", h.unit) + assertNull(h.figures.last().first) + } + } + + @Test + fun noFigureEverReadsNone() { + Workouts.Family.entries.forEach { f -> + summaryHero(f, summary(69, 2), null).figures.forEach { (v, _) -> assertEquals(false, v == "None") } + } + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/TrimPanelRestoreTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/TrimPanelRestoreTest.kt new file mode 100644 index 00000000..912ec42e --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/TrimPanelRestoreTest.kt @@ -0,0 +1,72 @@ +package org.openstrap.app.ui.glow + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipe +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.openstrap.app.data.WorkoutRecorder +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.AppWorkoutHeartRate +import org.openstrap.app.data.db.WorkoutHeartRate +import org.openstrap.app.testing.ComposeRules +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.ui.workout.PhoneSummary +import org.openstrap.app.ui.workout.SummaryCopy +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** The trim handles keep where they were dragged while the panel stays open across a recreate (audit K7). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class TrimPanelRestoreTest { + private val compose = ComposeRules() + + @get:Rule val rules: RuleChain = RuleChain.outerRule(ZoneRule()).around(compose) + + private val start = 1_792_000_000L + private val seconds = 1800L + + private fun summary(): PhoneSummary { + val w = AppWorkout(start, start + seconds, 1, averageHeartRate = 130) + val hr = (0L..seconds step 5).map { AppWorkoutHeartRate(start, start + it, 110 + (it * 40 / seconds).toInt(), false) } + return PhoneSummary(WorkoutRecorder.analyse(w, hr, 190), hr.map { WorkoutHeartRate(start, it.timestamp, it.bpm) }, start, start + seconds, null) + } + + @Test fun `a dragged start comes back after a recreate, and Save saves it`() { + val s = summary() + var saved: Pair? = null + StateRestorationTester(compose.compose).let { tester -> + tester.setContent { TrimPanel(s, onSave = { f, t -> saved = f to t }, onCancel = {}) } + val opened = SummaryCopy.trimHeader(clock(s.analysis.shownStart), clock(s.analysis.end), s.analysis.end - s.analysis.shownStart) + compose.compose.onNodeWithText(opened).assertExists() + + // The strip sits between the panel's title and the clocks under it, as wide as they are. + val title = compose.compose.onNodeWithText("Trim").fetchSemanticsNode().boundsInRoot + val left = compose.compose.onNodeWithText(clock(s.recordedStart)).fetchSemanticsNode().boundsInRoot + val right = compose.compose.onNodeWithText(clock(s.recordedEnd)).fetchSemanticsNode().boundsInRoot + val y = (title.bottom + left.top) / 2 + val from = left.left + 2f + compose.compose.onRoot().performTouchInput { swipe(Offset(from, y), Offset((left.left + right.right) / 2, y), 400) } + compose.compose.waitForIdle() + // Dragged: the header no longer reads the range the panel opened on. + compose.compose.onNodeWithText(opened).assertDoesNotExist() + + tester.emulateSavedInstanceStateRestore() + + compose.compose.onNodeWithText(opened).assertDoesNotExist() + compose.compose.onNodeWithText("Save").performClick() + } + val (f, t) = saved!! + assertNotEquals(start, f) + assertEquals(start + seconds, t) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/DotSpecTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/DotSpecTest.kt index a2d37b60..1dce0ad8 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/DotSpecTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/DotSpecTest.kt @@ -4,8 +4,6 @@ import androidx.compose.ui.geometry.Offset import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import org.openstrap.app.ui.glow.DotSize - /** The dot numerals' shared geometry (widgets spec 6.2): what DotMatrixText and a widget's bitmap both draw. */ class DotSpecTest { @Test fun `8,432 in the second glow's dots`() { diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/LightSpecTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/LightSpecTest.kt index 6e26d06a..8c6af207 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/LightSpecTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/draw/LightSpecTest.kt @@ -6,19 +6,9 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -import org.openstrap.app.ui.glow.Edge -import org.openstrap.app.ui.glow.EdgeKind -import org.openstrap.app.ui.glow.EdgeStatus import org.openstrap.app.ui.glow.GlowLayout -import org.openstrap.app.ui.glow.GlowMood -import org.openstrap.app.ui.glow.GlowPalette -import org.openstrap.app.ui.glow.GlowStyle -import org.openstrap.app.ui.glow.LightAngle import org.openstrap.app.ui.glow.SoftGlow import org.openstrap.app.ui.glow.StrongGlow -import org.openstrap.app.ui.glow.edgeLight -import org.openstrap.app.ui.glow.level -import org.openstrap.app.ui.glow.levelEdge import org.openstrap.app.ui.theme.MetricColors /** The glow and edge light's shared spec (widgets spec 6.2): what the cards and a widget's background are drawn with. */ diff --git a/app/src/test/kotlin/org/openstrap/app/ui/glow/live/LiveRecompositionTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/glow/live/LiveRecompositionTest.kt new file mode 100644 index 00000000..2a5b6bd0 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/glow/live/LiveRecompositionTest.kt @@ -0,0 +1,149 @@ +package org.openstrap.app.ui.glow.live + +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ExperimentalComposeRuntimeApi +import androidx.compose.runtime.RecomposeScope +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.tooling.CompositionObserver +import androidx.compose.runtime.tooling.CompositionRegistrationObserver +import androidx.compose.runtime.tooling.ObservableComposition +import androidx.compose.runtime.tooling.observe +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.AndroidUiDispatcher +import java.time.Duration +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.db.AppWorkoutSet +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.testing.appWorkout +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowChoreographer +import org.robolectric.annotation.Config + +/** + * The live screens over simulated seconds, with the heart beating on the glow: once settled, the only scopes composed + * again are the clocks, one scope each (audit K3). A screen that read the time at its content level would add its own. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +@OptIn(ExperimentalComposeRuntimeApi::class) +class LiveRecompositionTest { + private val controls = LiveControls({}, {}, {}, {}, {}, {}) + private val connected = ConnectionState.Connected(batteryPercent = 80, firmware = null) + private val padding = PaddingValues() + + /** + * One test for both screens: Compose's main dispatcher is made once per process, for the first test's looper, so + * a second test method in this sandbox would never see a frame. + */ + @Test + fun `live screens compose only their clocks each second`() { + indoorCardio() + strengthSet() + } + + private fun indoorCardio() { + val now = System.currentTimeMillis() / 1000 + val r = WorkoutState.Recording(appWorkout(start = now - 600, end = null), maxHeartRate = 190) + val recent = (0 until 120).map { now - 120 + it to 140 } + val zones = IntArray(5) { 60 } + val ticks = recompositions { + LiveIndoorScreen(r, 140, recent, null, zones, connected, padding, controls, onLap = {}, kcal = null) + } + assertTrue("indoor: the clock never ticked: $ticks", ticks.entries >= 3) + // The large moving time is the only clock on this screen. + assertEquals("indoor: scopes composed again each second: ${ticks.why}", 1, ticks.scopes) + } + + private fun strengthSet() { + val now = System.currentTimeMillis() / 1000 + val start = now - 600 + val r = WorkoutState.Recording( + appWorkout(start = start, end = null, sportType = 50), maxHeartRate = 190, + sets = listOf(AppWorkoutSet(start, 0, now - 200, end = now - 140), AppWorkoutSet(start, 1, now - 30)), + ) + val history = (0 until 300).map { now - 300 + it to 120 } + val ticks = recompositions { + LiveStrengthScreen(r, 120, history, connected, padding, controls, emptyList(), {}, {}, {}, {}, { _, _, _, _ -> }) + } + assertTrue("strength: the clocks never ticked: $ticks", ticks.entries >= 3) + // The total, small on the top right, and the set's own clock in dots. + assertEquals("strength: scopes composed again each second: ${ticks.why}", 2, ticks.scopes) + } + + /** What composed again over the simulated seconds after the screen settled. */ + private data class Ticks(val entries: Int, val scopes: Int, val why: List) + + /** + * Composes [content] in an activity under a recomposer this test observes, lets it settle (the glow's layers + * land from a background thread), then counts the scopes composed again from the top of a pass over five seconds. + */ + private fun recompositions(content: @Composable () -> Unit): Ticks { + val roots = mutableListOf() + val reasons = mutableMapOf>() + var counting = false + var depth = 0 + val observer = object : CompositionObserver { + override fun onBeginComposition(composition: ObservableComposition) = Unit + override fun onScopeEnter(scope: RecomposeScope) { + if (counting && depth == 0) roots += scope + depth++ + } + override fun onReadInScope(scope: RecomposeScope, value: Any) = Unit + override fun onScopeExit(scope: RecomposeScope) { + depth-- + } + override fun onEndComposition(composition: ObservableComposition) = Unit + override fun onScopeInvalidated(scope: RecomposeScope, value: Any?) { + if (counting) reasons.getOrPut(scope) { mutableSetOf() } += value.toString() + } + override fun onScopeDisposed(scope: RecomposeScope) = Unit + } + // Frames on the fake vsync: unpaused, a beating glow asks for its next frame at the same instant forever. + ShadowChoreographer.setPaused(true) + val recomposer = Recomposer(AndroidUiDispatcher.Main) + val scope = CoroutineScope(AndroidUiDispatcher.Main) + scope.launch { recomposer.runRecomposeAndApplyChanges() } + val handle = recomposer.observe(object : CompositionRegistrationObserver { + override fun onCompositionRegistered(composition: ObservableComposition) { + composition.setObserver(observer) + } + override fun onCompositionUnregistered(composition: ObservableComposition) = Unit + }) + val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get() + val view = ComposeView(activity).apply { setParentCompositionContext(recomposer) } + activity.setContentView(view) + view.setContent(content) + repeat(SETTLE_S) { second() } + counting = true + repeat(COUNT_S) { second() } + counting = false + handle.dispose() + recomposer.cancel() + scope.cancel() + val distinct = roots.distinct() + return Ticks(roots.size, distinct.size, distinct.map { reasons[it].orEmpty().joinToString() }) + } + + /** One simulated second, waited in real time too: the clocks read the wall clock. */ + private fun second() { + Thread.sleep(1_000) + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(1)) + } + + private companion object { + const val SETTLE_S = 3 + const val COUNT_S = 5 + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/root/AppNavStateTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/root/AppNavStateTest.kt new file mode 100644 index 00000000..43dd928d --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/root/AppNavStateTest.kt @@ -0,0 +1,245 @@ +package org.openstrap.app.ui.root + +import androidx.compose.runtime.saveable.SaverScope +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openstrap.app.NavRequest +import org.openstrap.app.ui.dashboard.Detail +import org.openstrap.app.ui.dashboard.Metric +import org.openstrap.app.ui.dashboard.Reselect +import org.openstrap.app.ui.dashboard.Score +import org.openstrap.app.ui.dashboard.TabNav +import java.time.LocalDate + +/** Where the app is, and what back, the tabs and the requests do to it (audit A1 and A7). */ +class AppNavStateTest { + private val day = LocalDate.of(2026, 10, 23) + + /** + * Back as the screens' own handlers answer it before the split: each enabled handler in composition order, the + * last one composed wins. The root handler, then the tab's top detail, the pages, the goals and All metrics + * sheets, adding a workout, picking a stretch's sport, Choose, Ready and Your zones over it. + */ + private fun handlersBack(s: AppNavState.Snapshot, firmwareInstalling: Boolean, recording: Boolean): Pair { + val nav = AppNavState(s) + val current = nav.current + val ready = s.workout as? WorkoutOverlay.Ready + val closed: (() -> Unit) -> Pair = { close -> close(); nav.snapshot() to BackResult.Handled } + val handlers = listOf Pair>>( + true to { s.tab.back()?.let { s.copy(tab = it) to BackResult.Handled } ?: (s to BackResult.AtHome) }, + nav.stack(current).isNotEmpty() to { closed { nav.pop(current) } }, + s.pages.isNotEmpty() to { closed { if (!(s.pages.last() == Page.Firmware && firmwareInstalling)) nav.closePage() } }, + s.editGoals to { closed { nav.setEditGoals(false) } }, + s.allMetricsOpen to { closed { nav.setAllMetrics(false) } }, + (s.addingOn != null) to { closed { nav.setAddingOn(null) } }, + (s.savingStretch != null) to { closed { nav.setSavingStretch(null) } }, + (!recording && s.workout == WorkoutOverlay.Choose) to { closed(nav::closeChoose) }, + (!recording && ready != null) to { closed(nav::closeReady) }, + (!recording && ready?.zones == true) to { closed(nav::closeZones) }, + ) + return handlers.last { it.first }.second() + } + + private val overlays = listOf( + WorkoutOverlay.None, + WorkoutOverlay.Choose, + WorkoutOverlay.Ready(3), + WorkoutOverlay.Ready(3, overChoose = true), + WorkoutOverlay.Ready(3, picking = true, structure = "i:4x60", overChoose = true, zones = true), + ) + private val pageSets = listOf(emptyList(), listOf(Page.You), listOf(Page.Strap, Page.Firmware), listOf(Page.You, Page.Updates)) + private val stacks = listOf(emptyList(), listOf(Detail.Recovery), listOf(Detail.OfMetric(Metric.Hrv), Detail.OfScore(Score.Sleep))) + + @Test + fun `back gives the same answer as the handlers from every configuration`() { + var checked = 0 + for (current in Tab.entries) for (previous in listOf(null, Tab.Sleep.ordinal)) for (pages in pageSets) + for (installing in listOf(false, true)) for (stack in stacks) for (overlay in overlays) for (recording in listOf(false, true)) + for (flags in 0 until 16) { + val s = AppNavState.Snapshot( + tab = TabNav(current.ordinal, previous), + pages = pages, + stacks = Tab.entries.associateWith { if (it == current) stack else emptyList() }, + allMetricsOpen = flags and 1 != 0, + editGoals = flags and 2 != 0, + addingOn = day.takeIf { flags and 4 != 0 }, + savingStretch = (100L..200L).takeIf { flags and 8 != 0 }, + workout = overlay, + ) + val (expected, expectedResult) = handlersBack(s, installing, recording) + val nav = AppNavState(s) + val result = nav.back(installing, recording) + assertEquals("result from $s", expectedResult, result) + assertEquals("state from $s", expected, nav.snapshot()) + checked++ + } + assertEquals(30_720, checked) + } + + @Test + fun `back from a tab goes to the tab before, then Today, and Today's root is home`() { + val nav = AppNavState(AppNavState.Snapshot(tab = TabNav(Tab.Activity.ordinal, Tab.Sleep.ordinal))) + assertEquals(BackResult.Handled, nav.back(false)) + assertEquals(Tab.Sleep, nav.current) + assertEquals(BackResult.Handled, nav.back(false)) + assertEquals(Tab.Today, nav.current) + assertEquals(BackResult.AtHome, nav.back(false)) + } + + @Test + fun `Ready closes back to Choose when Choose was under it, and Your zones closes back to Ready`() { + val nav = AppNavState() + nav.openChoose() + nav.openReady(5) + nav.openZones() + assertEquals(WorkoutOverlay.Ready(5, overChoose = true, zones = true), nav.workout) + nav.back(false) + assertEquals(WorkoutOverlay.Ready(5, overChoose = true), nav.workout) + nav.back(false) + assertEquals(WorkoutOverlay.Choose, nav.workout) + nav.back(false) + assertEquals(WorkoutOverlay.None, nav.workout) + } + + @Test + fun `Choose asked for while Ready shows stays under it`() { + val nav = AppNavState() + nav.openReady(5) + nav.openChoose() + assertEquals(WorkoutOverlay.Ready(5, overChoose = true), nav.workout) + nav.closeReady() + assertEquals(WorkoutOverlay.Choose, nav.workout) + } + + @Test + fun `Ready's own sport list changes only the sport`() { + val nav = AppNavState() + nav.openReady(5, picking = true, structure = "s") + nav.readySport(7) + assertEquals(WorkoutOverlay.Ready(7, picking = true, structure = "s"), nav.workout) + } + + @Test + fun `a starting workout closes Choose and Ready`() { + val nav = AppNavState() + nav.openChoose() + nav.openReady(5) + nav.workoutStarted() + assertEquals(WorkoutOverlay.None, nav.workout) + } + + @Test + fun `an update installing keeps its pages open`() { + val nav = AppNavState(AppNavState.Snapshot(pages = listOf(Page.Strap, Page.Firmware))) + assertFalse(nav.closePages(firmwareInstalling = true)) + nav.openBeside(Page.Alarms, firmwareInstalling = true) + assertEquals(listOf(Page.Strap, Page.Firmware), nav.pages) + assertEquals(BackResult.Handled, nav.back(firmwareInstalling = true)) + assertEquals(listOf(Page.Strap, Page.Firmware), nav.pages) + assertTrue(nav.closePages(firmwareInstalling = false)) + assertEquals(emptyList(), nav.pages) + } + + @Test + fun `a tap on the tab showing scrolls, then pops to the root`() { + val nav = AppNavState(AppNavState.Snapshot(stacks = mapOf(Tab.Today to listOf(Detail.Recovery)))) + assertEquals(Reselect.ScrollToTop, nav.reselect(Tab.Today)) + assertEquals(Reselect.PopToRoot, nav.reselect(Tab.Today)) + assertEquals(emptyList(), nav.stack(Tab.Today)) + } + + @Test + fun `a detail already on top isn't opened twice, and a switcher pick takes its place`() { + val nav = AppNavState() + nav.push(Tab.Sleep, Detail.Sleep) + nav.push(Tab.Sleep, Detail.Sleep) + assertEquals(listOf(Detail.Sleep), nav.stack(Tab.Sleep)) + nav.replaceTop(Tab.Sleep, Detail.OfMetric(Metric.Hrv)) + assertEquals(listOf(Detail.OfMetric(Metric.Hrv)), nav.stack(Tab.Sleep)) + } + + @Test + fun `a request for Ready or Choose waits while a workout records`() { + val nav = AppNavState() + nav.apply(NavRequest.OpenReady(4), recording = true, firmwareInstalling = false) + nav.apply(NavRequest.OpenChoose, recording = true, firmwareInstalling = false) + assertEquals(WorkoutOverlay.None, nav.workout) + nav.apply(NavRequest.OpenChoose, recording = false, firmwareInstalling = false) + nav.apply(NavRequest.OpenReady(4), recording = false, firmwareInstalling = false) + assertEquals(WorkoutOverlay.Ready(4, overChoose = true), nav.workout) + } + + @Test + fun `the Moment widget opens Today at its root over whatever showed`() { + val nav = AppNavState( + AppNavState.Snapshot( + tab = TabNav(Tab.Sleep.ordinal, 0), pages = listOf(Page.You), allMetricsOpen = true, + stacks = mapOf(Tab.Today to listOf(Detail.Week)), workout = WorkoutOverlay.Choose, + ), + ) + nav.apply(NavRequest.OpenToday, recording = false, firmwareInstalling = false) + assertEquals(Tab.Today, nav.current) + assertEquals(emptyList(), nav.pages) + assertEquals(emptyList(), nav.stack(Tab.Today)) + assertFalse(nav.allMetricsOpen) + assertEquals(WorkoutOverlay.None, nav.workout) + } + + @Test + fun `a notification opens its workout on the Activity tab and its metric on the tab showing`() { + val nav = AppNavState(AppNavState.Snapshot(tab = TabNav(Tab.Recovery.ordinal, 0), pages = listOf(Page.Strap))) + nav.apply(NavRequest.OpenDetail("Hrv", day), recording = false, firmwareInstalling = false) + assertEquals(listOf(Detail.OfMetric(Metric.Hrv)), nav.stack(Tab.Recovery)) + assertEquals(emptyList(), nav.pages) + nav.apply(NavRequest.OpenDetail("nothing", day), recording = false, firmwareInstalling = false) + assertEquals(listOf(Detail.OfMetric(Metric.Hrv)), nav.stack(Tab.Recovery)) + nav.apply(NavRequest.OpenWorkout(42), recording = false, firmwareInstalling = false) + assertEquals(Tab.Activity, nav.current) + assertEquals(listOf(Detail.Workout(42)), nav.stack(Tab.Activity)) + } + + @Test + fun `the strap, recap and updates requests open their pages unless an update installs`() { + val nav = AppNavState(AppNavState.Snapshot(pages = listOf(Page.Strap, Page.Firmware))) + nav.apply(NavRequest.OpenRecap, recording = false, firmwareInstalling = true) + assertEquals(listOf(Page.Strap, Page.Firmware), nav.pages) + nav.apply(NavRequest.OpenRecap, recording = false, firmwareInstalling = false) + assertEquals(listOf(Page.You, Page.GoalsAndBests, Page.WeeklyRecap), nav.pages) + nav.apply(NavRequest.OpenUpdates, recording = false, firmwareInstalling = false) + assertEquals(listOf(Page.You, Page.Updates), nav.pages) + nav.apply(NavRequest.OpenStrap, recording = false, firmwareInstalling = false) + assertEquals(listOf(Page.Strap), nav.pages) + } + + private val scope = object : SaverScope { + override fun canBeSaved(value: Any) = true + } + + @Test + fun `the place survives process death`() { + val s = AppNavState.Snapshot( + tab = TabNav(Tab.Sleep.ordinal, Tab.Activity.ordinal), + pages = listOf(Page.Strap, Page.Alarms), + stacks = Tab.entries.associateWith { if (it == Tab.Sleep) listOf(Detail.OfMetric(Metric.Hrv, night = true), Detail.Workout(9)) else emptyList() }, + allMetricsOpen = true, + editGoals = true, + addingOn = day, + savingStretch = 100L..900L, + workout = WorkoutOverlay.Ready(12, picking = true, structure = "i:4x60", overChoose = true, zones = true), + ) + val saved = with(AppNavState.Saver) { scope.save(AppNavState(s)) }!! + assertEquals(s, AppNavState.Saver.restore(saved)!!.snapshot()) + for (w in overlays) { + val again = with(AppNavState.Saver) { scope.save(AppNavState(AppNavState.Snapshot(workout = w))) }!! + assertEquals(w, AppNavState.Saver.restore(again)!!.workout) + } + } + + @Test + fun `nothing saved restores as the start`() { + val empty = with(AppNavState.Saver) { scope.save(AppNavState()) }!! + assertEquals(AppNavState().snapshot(), AppNavState.Saver.restore(empty)!!.snapshot()) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/root/AppRootViewModelTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/root/AppRootViewModelTest.kt new file mode 100644 index 00000000..da320673 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/root/AppRootViewModelTest.kt @@ -0,0 +1,169 @@ +package org.openstrap.app.ui.root + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.HealthRepository +import org.openstrap.app.data.HeartZones +import org.openstrap.app.data.MaxHeartRates +import org.openstrap.app.data.RewardsKeeper +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.db.StrapReadinessRow +import org.openstrap.app.data.settings.Goals +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.StrapController +import org.openstrap.app.device.WorkoutSession +import org.openstrap.app.health.HealthConnectExporter +import org.openstrap.app.testing.FixedClock +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.app.testing.profile +import org.openstrap.app.update.AppUpdater +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.lang.reflect.Proxy +import java.time.LocalDate +import java.time.ZoneId + +/** The app root's reads and writes, out of the composition (audit A1). */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AppRootViewModelTest { + private val app = ApplicationProvider.getApplicationContext() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val connection = MutableStateFlow(ConnectionState.Disconnected) + private lateinit var db: OpenStrapDatabase + private lateinit var settings: SettingsStore + private lateinit var vm: AppRootViewModel + + /** A strap whose connection the test moves. Every other flow stays empty and every command returns at once. */ + private fun strap(): StrapController { + val flows = mutableMapOf() + return Proxy.newProxyInstance(StrapController::class.java.classLoader, arrayOf(StrapController::class.java)) { proxy, method, args -> + when { + method.name == "hashCode" -> System.identityHashCode(proxy) + method.name == "equals" -> proxy === args?.firstOrNull() + method.name == "toString" -> "test strap" + method.name == "getConnection" -> connection + method.name == "getSync" -> flows.getOrPut(method.name) { MutableStateFlow(org.openstrap.app.device.SyncState.Idle) } + method.name == "getSyncRecord" -> flows.getOrPut(method.name) { MutableStateFlow(org.openstrap.app.device.SyncRecord()) } + method.returnType == java.lang.Boolean.TYPE -> false + StateFlow::class.java.isAssignableFrom(method.returnType) -> flows.getOrPut(method.name) { MutableStateFlow(emptyValue(method.genericReturnType)) } + SharedFlow::class.java.isAssignableFrom(method.returnType) -> flows.getOrPut(method.name) { MutableSharedFlow() } + else -> Unit + } + } as StrapController + } + + private fun emptyValue(type: java.lang.reflect.Type): Any? = + when ((type as? java.lang.reflect.ParameterizedType)?.actualTypeArguments?.firstOrNull()?.typeName?.substringBefore('<')) { + "java.lang.Boolean" -> false + "java.util.List" -> emptyList() + else -> null + } + + @Before fun setUp() { + val main = UnconfinedTestDispatcher() + Dispatchers.setMain(main) + db = inMemoryDb() + settings = SettingsStore(app) + val dao = db.healthDao() + val strap = strap() + val maxHeartRates = MaxHeartRates(dao, flowOf(profile())) + val clock = FixedClock() + vm = AppRootViewModel( + settings, HealthRepository(dao), dao, strap, + WorkoutSession(app, dao, settings, strap, maxHeartRates, clock = { clock.now }, scope = scope), + RewardsKeeper(dao, settings, maxHeartRates), HealthConnectExporter(app, dao), + AppUpdater(app, settings, recording = { false }, scope = scope), + MutableStateFlow(emptyList()), + // Its work on the test's own dispatcher: nothing left on another thread finishes into the next test. + work = main, + ) + } + + @After fun tearDown() { + HeartZones.use(HeartZones.STANDARD) + runBlocking { + vm.viewModelScope.coroutineContext[kotlinx.coroutines.Job]?.cancelAndJoin() + scope.coroutineContext[kotlinx.coroutines.Job]?.cancelAndJoin() + } + db.close() + Dispatchers.resetMain() + } + + private fun await(flow: kotlinx.coroutines.flow.Flow, what: (T) -> Boolean): T = + runBlocking { withTimeout(WAIT_MS) { flow.first(what) } } + + @Test fun `the first score's day waits for the store, then keeps the day marked`() { + assertEquals(FirstScoreDay.Loading, vm.firstScoreDay.value) + val day = LocalDate.of(2026, 10, 20) + vm.markFirstScore(day) + assertEquals(FirstScoreDay.Day(day), await(vm.firstScoreDay) { it is FirstScoreDay.Day }) + } + + @Test fun `the day's strap score and check-in follow the day shown`() { + val day = LocalDate.of(2026, 10, 20) + val start = day.atStartOfDay(ZoneId.systemDefault()).toEpochSecond() + runBlocking { db.healthDao().insertStrapReadiness(listOf(StrapReadinessRow(start, start, 71, null, null, null, null, null, null, "1"))) } + vm.saveCheckIn(day, 4) + vm.dayShown(day, start) + val extras = await(vm.dayExtras) { it.checkIn != null && it.strapReadiness != null } + assertEquals(4, extras.checkIn) + assertEquals(71, extras.strapReadiness?.score) + val next = day.plusDays(1) + vm.dayShown(next, next.atStartOfDay(ZoneId.systemDefault()).toEpochSecond()) + assertEquals(DayExtras(), await(vm.dayExtras) { it.checkIn == null && it.strapReadiness == null }) + } + + @Test fun `the strap facts follow the connection`() { + val connected = ConnectionState.Connected(80, "3.25", false) + connection.value = connected + assertEquals(connected, await(vm.strap) { it.connection == connected }.connection) + } + + @Test fun `saved goals reach the store`() { + val goals = Goals(steps = 12_345) + vm.saveGoals(goals) + assertEquals(12_345, await(settings.goals) { it.steps == 12_345 }.steps) + assertEquals(12_345, await(vm.goals) { it.steps == 12_345 }.steps) + } + + @Test fun `moved heart zones are in use before they're stored`() { + val zones = HeartZones(listOf(50f, 61f, 71f, 81f, 91f)) + vm.setHeartZones(zones) + assertEquals(zones, HeartZones.current.value) + } + + @Test fun `no history badges while none wait`() { + runBlocking { settings.historyBadgesShown() } + assertEquals(null, await(vm.historyBadges) { it == null }) + } + + private companion object { + const val WAIT_MS = 10_000L + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/FamilySummaryTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/workout/FamilySummaryTest.kt new file mode 100644 index 00000000..f11bf887 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/workout/FamilySummaryTest.kt @@ -0,0 +1,219 @@ +package org.openstrap.app.ui.workout + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.openstrap.app.data.Structure +import org.openstrap.app.data.Workouts +import org.openstrap.app.data.db.AppWorkoutRound +import org.openstrap.app.data.db.AppWorkoutSet + +class FamilySummaryTest { + // English figures: the German and Arabic ones are WorkoutWordsTest's and the presentation tests'. + @get:org.junit.Rule val localeRule = org.openstrap.app.testing.LocaleRule() + + private val utc = ZoneOffset.UTC + + @Test + fun whenLineNamesThePartOfTheDay() { + val day = LocalDate.of(2026, 10, 30).atStartOfDay(utc).toEpochSecond() + val now = day + 20 * 3600 + assertEquals("This morning, 07:12", FamilySummary.whenLine(day + 7 * 3600 + 12 * 60, now, utc, Locale.UK)) + assertEquals("Today, 14:00", FamilySummary.whenLine(day + 14 * 3600, now, utc, Locale.UK)) + assertEquals("This evening, 19:05", FamilySummary.whenLine(day + 19 * 3600 + 5 * 60, now, utc, Locale.UK)) + assertEquals("Yesterday, 10:00", FamilySummary.whenLine(day - 14 * 3600, now, utc, Locale.UK)) + // Further back the day words' rule, and the time follows the 12 or 24 hour setting. + assertEquals("Tuesday 10:00", FamilySummary.whenLine(day - 3 * 86_400 + 10 * 3600, now, utc, Locale.UK, use24Hour = true)) + assertEquals("Mon 19 Oct 10:00", FamilySummary.whenLine(day - 11 * 86_400 + 10 * 3600, now, utc, Locale.UK, use24Hour = true)) + assertEquals("This evening, 7:05 pm", FamilySummary.whenLine(day + 19 * 3600 + 5 * 60, now, utc, Locale.UK, use24Hour = false)) + } + + @Test + fun fastestSplitIsTheQuickestWholeKilometre() { + val rows = listOf( + SplitRow("Km 1", "5:31", null, 331, SplitRow.Kind.Whole), SplitRow("Km 2", "5:10", null, 310, SplitRow.Kind.Whole), + SplitRow("Km 3", "5:19", null, 319, SplitRow.Kind.Whole), SplitRow("Last 210 m", "0:58", null, 58, SplitRow.Kind.Last), + ) + assertEquals(1, FamilySummary.fastestSplit(rows)) + // It reads the kind and the seconds, not the words: renamed rows still mark the same one. + assertEquals(1, FamilySummary.fastestSplit(rows.map { it.copy(name = "Kilometre", time = "") })) + assertNull(FamilySummary.fastestSplit(rows.take(1))) + } + + @Test + fun setsGroupByExerciseWithTheirPeaks() { + val readings = (0L..400L).map { it to (100 + it / 4).toInt() } + val sets = listOf( + AppWorkoutSet(0, 0, 0, 40, 100, "Bench press", 10, 60f), + AppWorkoutSet(0, 1, 100, 140, 200, "Squat", 8, 80f), + AppWorkoutSet(0, 2, 200, 240, 300, "bench press", 10, 62.5f), + AppWorkoutSet(0, 3, 300, null), + ) + val groups = FamilySummary.exerciseSets(sets, readings) + assertEquals(listOf("Bench press", "Squat"), groups.map { it.name }) + assertEquals(listOf("10 × 60 kg", "10 × 62.5 kg"), groups[0].sets.map { it.text }) + assertEquals(listOf(1, 2), groups[0].sets.map { it.number }) + assertEquals(110, groups[0].sets[0].peak) + assertEquals(3, FamilySummary.setsDone(sets)) + assertEquals(60L, FamilySummary.averageRest(sets)) + } + + @Test + fun restRecoveryIsTheTimeToFallUnderTheLevel() { + // After each set the heart rate falls 1 bpm a second from 150. + val sets = listOf(AppWorkoutSet(0, 0, 0, 100, 200), AppWorkoutSet(0, 1, 200, 300, 400)) + val readings = (0L..400L).map { t -> + val since = if (t >= 300) t - 300 else if (t in 100..199) t - 100 else null + t to (if (since == null) 150 else 150 - since.toInt()) + } + assertEquals(41L, FamilySummary.restRecovery(sets, readings, 110)) + assertNull(FamilySummary.restRecovery(sets, readings, null)) + assertEquals("Your heart rate dropped under 110 in 41 s on average, 9 s faster than last time.", FamilySummary.restLine(41, 110, 50)) + assertEquals("Your heart rate dropped under 110 in 41 s on average, about the same as last time.", FamilySummary.restLine(41, 110, 43)) + assertEquals("Your heart rate dropped under 110 in 1:05 on average.", FamilySummary.restLine(65, 110, null)) + } + + @Test + fun roundsShowPeakAndDropAndTheHardest() { + val readings = (0L..90L).map { t -> t to listOf(150, 170, 160, 175, 150, 165)[(t / 15).toInt().coerceAtMost(5)] } + val rounds = listOf( + AppWorkoutRound(0, 0, 0, 15, 30, 170), + AppWorkoutRound(0, 1, 30, 45, 60, 175), + AppWorkoutRound(0, 2, 60, 75, 90, 168), + AppWorkoutRound(0, 3, 90), + ) + val lines = FamilySummary.rounds(rounds, readings) + assertEquals(3, lines.size) + assertEquals(listOf(10, 25, 3), lines.map { it.drop }) + assertEquals("You recovered 13 bpm in 10 s on average. Round 2 was your hardest.", FamilySummary.roundsLine(lines, 10)) + assertEquals(45L, FamilySummary.workSeconds(rounds)) + } + + @Test + fun theHardestRoundIsTheHighestPeakFromThreeRounds() { + val lines = listOf(RoundLine(1, 158, 19), RoundLine(2, 176, 17), RoundLine(3, null, null), RoundLine(4, 176, 22)) + assertEquals(1, FamilySummary.hardestRound(lines)) + assertNull(FamilySummary.hardestRound(lines.take(2))) + } + + @Test + fun peakBarsAreFullForTheHardestAndShorterBelowIt() { + val shares = FamilySummary.peakShares(listOf(167, 141, null, 100)) + assertEquals(1f, shares[0]!!, 1e-4f) + // 141 sits 24 above the floor of 116.9, out of 50.1. + assertEquals(0.48f, shares[1]!!, 0.01f) + assertNull(shares[2]) + assertEquals(FamilySummary.PEAK_MIN_SHARE, shares[3]!!, 1e-4f) + assertEquals(listOf(null, null), FamilySummary.peakShares(listOf(null, null))) + assertEquals(listOf(1f, 1f), FamilySummary.peakShares(listOf(150, 150))) + } + + @Test + fun zoneMinutesRoundAndKeepAMinuteForAShortStay() { + assertEquals(listOf(6, 0, 1, 31, 17), FamilySummary.zoneMinutes(intArrayOf(360, 0, 20, 1850, 1020))) + } + + @Test + fun theHeartLineAveragesAndSmooths() { + val flat = FamilySummary.heartLine((0L..600L step 5).map { it to 80 }, 30, window = 5) + assertEquals(30, flat.size) + flat.forEach { assertEquals(80f, it!!, 1e-4f) } + // One high stretch is softened by its neighbours. + val spike = (0L..600L step 5).map { t -> t to if (t in 300L..310L) 160 else 80 } + val raw = FamilySummary.heartLine(spike, 60).filterNotNull().max() + val smooth = FamilySummary.heartLine(spike, 60, window = 5).filterNotNull().max() + assertEquals(true, smooth < raw) + assertEquals(listOf(null, null), FamilySummary.heartLine(emptyList(), 2)) + } + + @Test + fun theWeekLineCountsTowardTheGoal() { + assertEquals("3 workouts this week, 1 more for your goal.", FamilySummary.weekGoalLine(3, 4)) + assertEquals("1 workout this week, 3 more for your goal.", FamilySummary.weekGoalLine(1, 4)) + assertEquals("4 workouts this week, goal met.", FamilySummary.weekGoalLine(4, 4)) + } + + @Test + fun aKcalTargetIsReachedByTimeOrByTheEnd() { + assertEquals(true, FamilySummary.targetReached("k:300", 1800, null)) + assertEquals(true, FamilySummary.targetReached("k:300", null, 310)) + assertEquals(false, FamilySummary.targetReached("k:300", null, 212)) + assertEquals(false, FamilySummary.targetReached("t:1800", 1800, 400)) + } + + @Test + fun theHeartSettlesFromTheFirstMinutesToTheLast() { + val readings = (0L..1800L step 5).map { t -> t to (96 - (t * 30 / 1800)).toInt() } + val (from, to) = FamilySummary.settled(readings, 0, 1800)!! + assertEquals(true, from in 94..96) + assertEquals(true, to in 66..68) + assertEquals("Your heart settled from 96 to 66.", FamilySummary.settledLine(96, 66)) + assertEquals("Your heart held near 72.", FamilySummary.settledLine(73, 72)) + assertNull(FamilySummary.settled(readings, 0, 300)) + // The share card's short form, by the same 3 bpm rule. + assertEquals("Heart settled 96 to 66", FamilySummary.settledShort(96, 66)) + assertEquals("Heart settled 75 to 72", FamilySummary.settledShort(75, 72)) + assertEquals("Heart held near 72", FamilySummary.settledShort(74, 72)) + assertEquals(3, FamilySummary.settledDrop(75, 72)) + assertNull(FamilySummary.settledDrop(74, 72)) + } + + @Test + fun aGamesLoadReadsTheSameOnTheSummaryAndTheCard() { + assertEquals("Load, hard", FamilySummary.loadLabel(130, 100f)) + assertEquals("Load 130, hard", FamilySummary.loadLine(130, 100f)) + assertEquals("Load, light", FamilySummary.loadLabel(60, 100f)) + assertEquals("Load 60, light", FamilySummary.loadLine(60, 100f)) + assertEquals("Load", FamilySummary.loadLabel(100, 100f)) + assertEquals("Load 100", FamilySummary.loadLine(100, 100f)) + assertEquals("Load 80", FamilySummary.loadLine(80, null)) + } + + @Test + fun aGameReadsAFadeOrAStrongFinish() { + val end = 92 * 60L + val faded = (0L..end step 10).map { t -> t to if (t < 70 * 60) 160 else 140 } + assertEquals("You faded a little after 70 min.", FamilySummary.gameRead(faded, 0, end)) + val steady = (0L..end step 10).map { t -> t to 155 } + assertEquals("You kept your effort up to the end.", FamilySummary.gameRead(steady, 0, end)) + val strong = (0L..end step 10).map { t -> t to if (t > 80 * 60) 175 else 150 } + assertEquals("You finished stronger than you started.", FamilySummary.gameRead(strong, 0, end)) + assertNull(FamilySummary.gameRead(steady, 0, 20 * 60)) + } + + @Test + fun periodsAreHalvesOrQuarters() { + val readings = (0L..6000L step 5).map { it to 150 } + val halves = listOf(AppWorkoutRound(0, 0, 0, 2400, 3000), AppWorkoutRound(0, 1, 3000, 5400, 5400)) + assertEquals(listOf("1st half", "2nd half"), FamilySummary.periods(halves, readings, 190, 2).map { it.name }) + assertEquals(150, FamilySummary.periods(halves, readings, 190, 2)[0].averageBpm) + } + + @Test + fun aQuartersGameEndedEarlyKeepsItsQuarters() { + val readings = (0L..6000L step 5).map { it to 150 } + val two = listOf(AppWorkoutRound(0, 0, 0, 900, 1000), AppWorkoutRound(0, 1, 1000, 1900, null)) + assertEquals(listOf("1st quarter", "2nd quarter"), FamilySummary.periods(two, readings, 190, 4).map { it.name }) + } + + @Test + fun weekDaysStartOnTheGivenDay() { + val thursday = LocalDate.of(2026, 10, 29) + val tuesday = LocalDate.of(2026, 10, 27).atTime(18, 0).toEpochSecond(utc) + val days = FamilySummary.weekDays(listOf(tuesday), thursday, DayOfWeek.MONDAY, utc) + assertEquals(LocalDate.of(2026, 10, 26), days.first().first) + assertEquals(listOf(false, true, false, false, false, false, false), days.map { it.second }) + assertEquals(LocalDate.of(2026, 10, 25), FamilySummary.weekDays(emptyList(), thursday, DayOfWeek.SUNDAY, utc).first().first) + } + + @Test + fun roundsMakeAnIntervalsSummaryAndPeriodsAGameOne() { + assertEquals(Workouts.Family.Distance, FamilySummary.familyOf(1, null)) + assertEquals(Workouts.Family.Intervals, FamilySummary.familyOf(1, Structure.parse("r:400m/90x6"))) + assertEquals(Workouts.Family.Games, FamilySummary.familyOf(1, Structure.HALVES)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/LiveGlanceTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/workout/LiveGlanceTest.kt new file mode 100644 index 00000000..29806286 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/workout/LiveGlanceTest.kt @@ -0,0 +1,196 @@ +package org.openstrap.app.ui.workout + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.openstrap.app.data.Route +import org.openstrap.app.data.WorkoutTarget + +class LiveGlanceTest { + // English figures: the German and Arabic ones are WorkoutWordsTest's and the presentation tests'. + @get:org.junit.Rule val localeRule = org.openstrap.app.testing.LocaleRule() + + private val now = 1_000_000L + + /** A run 24:51 in, at 4.32 km and 5:42 /km, 142 bpm (zone 3 of 190). */ + private fun run(target: WorkoutTarget? = WorkoutTarget.Distance(5000), pausedAt: Long? = null, stoppedSince: Long? = null) = GlanceInput( + sportType = 1, now = now, activeSeconds = 24 * 60 + 51L, pausedAt = pausedAt, bpm = 142, connected = true, maxHr = 190, + gps = true, distanceMeters = 4320f, pace = 342.0, averagePace = 345.0, target = target, activeKcal = 312f, + stoppedSince = stoppedSince, + ) + + @Test fun `a run with a goal shows its distance, pace and what's left`() { + val g = Glance.of(run()) + assertEquals("4.32", g.figure) + assertEquals("km", g.unit) + assertEquals("4.32 km, 5:42 /km", g.line1) + assertEquals("24:51, 680 m to go", g.line2) + assertEquals(0.864f, g.progress!!.fraction, 1e-3f) + assertFalse(g.progress!!.drain) + assertEquals(GlanceAction.Pause, g.action) + assertEquals(142, g.bpm) + assertEquals(GlanceTint.Zone, g.tint) + assertEquals(2, g.zone) + assertEquals("Goal 5 km", g.goal) + // 680 m at 5:42 /km is 3.9 min: about 4. + assertEquals("About 4 min to go", g.toGo) + assertEquals(listOf("5:42" to "Pace /km", "142" to "bpm, zone 3", "312" to "Active kcal"), g.figures) + assertEquals("24:51", g.clock) + } + + @Test fun `without a goal there's no bar and the zone takes the place of what's left`() { + val g = Glance.of(run(target = null)) + assertNull(g.progress) + assertEquals("24:51, zone 3", g.line2) + assertNull(g.goal) + } + + @Test fun `a time goal counts the minutes down, and a met goal says so`() { + val g = Glance.of(run(target = WorkoutTarget.Time(30 * 60))) + assertEquals("24:51, 6 min to go", g.line2) + val met = Glance.of(run(target = WorkoutTarget.Distance(4000))) + assertEquals("Reached", met.toGo) + assertEquals("24:51, zone 3", met.line2) + } + + @Test fun `indoor cardio leads with the heart rate`() { + val g = Glance.of(run(target = null).copy(sportType = 2, gps = false, distanceMeters = 2140f, distanceFromSteps = true, averageBpm = 128)) + assertEquals("142", g.figure) + assertEquals("bpm", g.unit) + assertEquals("Treadmill, 24:51", g.line1) + assertEquals("Zone 3, tempo", g.line2) + assertEquals(listOf("2.14" to "km from steps", "128" to "Avg bpm", "312" to "Active kcal"), g.figures) + } + + @Test fun `paused shows how long, what's done, and Resume`() { + val g = Glance.of(run(pausedAt = now - 72)) + assertEquals("Paused for 1:12", g.line1) + assertEquals("Outdoor run, 4.32 km so far", g.line2) + assertEquals(GlanceAction.Resume, g.action) + assertEquals(GlanceTint.Paused, g.tint) + assertNull(g.bpm) + } + + @Test fun `stopped moving asks, with End beside Keep going`() { + val g = Glance.of(run(stoppedSince = now - 200)) + assertEquals("Still going?", g.line1) + assertEquals("No movement for 3 min", g.line2) + assertEquals(GlanceAction.KeepGoing, g.action) + assertEquals(GlanceAction.End, g.second) + assertEquals("End", g.secondLabel) + assertEquals(GlanceTint.Asking, g.tint) + } + + @Test fun `the strap away says so instead of a zone`() { + val g = Glance.of(run(target = null).copy(sportType = 2, gps = false, connected = false)) + // The same words as every live screen and the notification. + assertEquals("Waiting for the strap", g.line2) + assertEquals(WorkoutCopy.noHeartRate(paused = false, connected = false), g.line2) + assertNull(g.figure) + } + + @Test fun `the hero's split strip names the last kilometre and the fastest`() { + val g = Glance.of(run().copy(lastSplit = Route.Split(3, now - 30, 355, 1000.0), lastSplitBpm = 146, fastestSplit = 2)) + assertEquals("Km 4 in 5:55" to "Avg 146 bpm. Fastest so far was km 3.", g.split) + assertNull(Glance.of(run()).split) + } + + private fun lift(m: StrengthMoment) = run(target = null).copy(sportType = 52, gps = false, distanceMeters = null, strength = m) + + @Test fun `strength in a set offers Set done`() { + val g = Glance.of(lift(StrengthMoment(inSet = true, setNumber = 3, setsTarget = 5, done = 2, setSeconds = 42, exercise = "Bench press"))) + assertEquals("Set 3 of 5, 0:42", g.line1) + assertEquals("Bench press, 142 bpm", g.line2) + assertEquals(GlanceAction.SetDone, g.action) + assertEquals("Set done", g.actionLabel) + assertEquals("0:42", g.figure) + assertNull(g.progress) + } + + @Test fun `strength resting drains in green with Start set and 30 s more`() { + val g = Glance.of(lift(StrengthMoment( + inSet = false, setNumber = 4, setsTarget = 5, done = 3, restLeft = 58, restTotal = 90, restBelow = 110, drop = 28, + exercise = "Bench press", nextLoad = "10 × 60 kg", + )).copy(bpm = 118)) + assertEquals("0:58 rest left", g.line1) + assertEquals("118 bpm, down 28, set 4 next", g.line2) + assertEquals(GlanceTint.Rest, g.tint) + assertEquals(58f / 90f, g.progress!!.fraction, 1e-4f) + assertEquals(true, g.progress!!.drain) + assertEquals(GlanceAction.StartSet, g.action) + assertEquals("Start set 4", g.actionLabel) + assertEquals("Set 4", g.shortLabel) + assertEquals(GlanceAction.AddRest, g.second) + assertEquals("+30 s", g.secondLabel) + assertEquals("118 bpm, down 28. Ends sooner once you stay under 110.", g.goal) + assertEquals("Set 4, bench press 10 × 60 kg", g.next) + } + + @Test fun `strength before the first set offers Start set 1`() { + val g = Glance.of(lift(StrengthMoment(inSet = false, setNumber = 1, setsTarget = null, done = 0))) + assertEquals("Ready for set 1", g.line1) + assertEquals("Set 1", g.shortLabel) + } + + private fun hiit(m: IntervalMoment) = run(target = null).copy(sportType = 49, gps = false, distanceMeters = null, interval = m) + + @Test fun `intervals work in red with the seconds left, rest in green with Skip rest`() { + val work = Glance.of(hiit(IntervalMoment(org.openstrap.app.data.Phase.Work(2, now - 6, 26), 3, 8, left = 14, nextRest = 10))) + assertEquals("14", work.figure) + assertEquals("Work, round 3 of 8", work.line1) + assertEquals("Then 10 s rest, 142 bpm", work.line2) + assertEquals(GlanceTint.Work, work.tint) + assertEquals(GlanceAction.Pause, work.action) + val rest = Glance.of(hiit(IntervalMoment(org.openstrap.app.data.Phase.Rest(2, now - 3, 36), 4, 8, left = 7, drop = 19))) + assertEquals("Rest, round 4 next", rest.line1) + assertEquals("142 bpm, down 19", rest.line2) + assertEquals(GlanceTint.Rest, rest.tint) + assertEquals(GlanceAction.Skip, rest.action) + assertEquals("Skip rest", rest.actionLabel) + assertEquals("+10 s", rest.secondLabel) + val emom = Glance.of(hiit(IntervalMoment(org.openstrap.app.data.Phase.Work(0, now, null), 1, 12, left = 40, everyMinute = true))) + assertEquals("Done", emom.actionLabel) + val warm = Glance.of(hiit(IntervalMoment(org.openstrap.app.data.Phase.WarmUp, 1, 8))) + assertEquals("Start rounds", warm.actionLabel) + } + + @Test fun `mind and body is one quiet line in violet`() { + val g = Glance.of(run(target = WorkoutTarget.Time(45 * 60)).copy(sportType = 60, gps = false, distanceMeters = null, activeSeconds = 23 * 60 + 40L)) + assertEquals("Yoga, 23:40 of 45 min", g.line1) + assertNull(g.line2) + assertEquals(GlanceTint.Mind, g.tint) + assertNull(g.bpm) + assertEquals("23", g.figure) + } + + @Test fun `a game shows its period, zone and effort, and Half time`() { + val m = IntervalMoment(org.openstrap.app.data.Phase.Work(0, now - 1930, 2700), 1, 2, left = 770, periodLength = 2700) + val g = Glance.of(run(target = null).copy(sportType = 18, gps = false, distanceMeters = null, interval = m, effort = 3)) + assertEquals("1st half, 32:10", g.line1) + assertEquals("Zone 3, effort 3", g.line2) + assertEquals(GlanceAction.Skip, g.action) + assertEquals("Half time", g.actionLabel) + val ht = Glance.of(run(target = null).copy(sportType = 18, gps = false, distanceMeters = null, effort = 3, + interval = IntervalMoment(org.openstrap.app.data.Phase.Rest(0, now - 100, 3300), 2, 2, left = 492, periodLength = 2700))) + assertEquals("Half time, 8:12 left", ht.line1) + assertEquals("Start 2nd half", ht.actionLabel) + val free = Glance.of(run(target = null).copy(sportType = 17, gps = false, distanceMeters = null)) + assertEquals("Tennis, 24:51", free.line1) + } + + @Test fun `paces read as clocks`() { + assertEquals("5:42", Glance.paceClock(342.0)) + assertNull(Glance.paceClock(null)) + assertNull(Glance.paceClock(4000.0)) + assertEquals("680 m", Glance.kmWithUnit(680f, java.util.Locale.UK)) + assertEquals("4.32 km", Glance.kmWithUnit(4320f, java.util.Locale.UK)) + } + + @Test fun `no glance line uses banned punctuation`() { + val all = listOf(run(), run(target = null), run(pausedAt = now - 5), run(stoppedSince = now - 60)).map(Glance::of) + all.flatMap { listOfNotNull(it.line1, it.line2, it.goal, it.toGo, it.actionLabel) }.forEach { s -> + assertFalse(s, s.any { it in "·;—–" }) + } + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/LiveGlanceThreadTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/workout/LiveGlanceThreadTest.kt new file mode 100644 index 00000000..49ebbff2 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/workout/LiveGlanceThreadTest.kt @@ -0,0 +1,99 @@ +package org.openstrap.app.ui.workout + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.MaxHeartRates +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.device.ConnectionState +import org.openstrap.app.device.StrapController +import org.openstrap.app.device.WorkoutSession +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.testing.FixedClock +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.app.testing.profile +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.lang.reflect.Proxy +import java.util.Collections + +/** Each second's glance, which walks the whole route, is worked out off the main thread (audit C7). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class LiveGlanceThreadTest { + private val context = ApplicationProvider.getApplicationContext() + private val clock = FixedClock() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private lateinit var db: OpenStrapDatabase + + @Before fun setUp() { + db = inMemoryDb() + } + + @After fun tearDown() { + scope.cancel() + db.close() + } + + /** A strap that is never connected and does nothing: each flow stays empty, each command returns at once. */ + private fun idleStrap(): StrapController { + val flows = mutableMapOf() + return Proxy.newProxyInstance(StrapController::class.java.classLoader, arrayOf(StrapController::class.java)) { proxy, method, args -> + when { + method.name == "hashCode" -> System.identityHashCode(proxy) + method.name == "equals" -> proxy === args?.firstOrNull() + method.name == "toString" -> "idle strap" + method.returnType == java.lang.Boolean.TYPE -> false + method.name == "getConnection" -> flows.getOrPut(method.name) { MutableStateFlow(ConnectionState.Disconnected) } + StateFlow::class.java.isAssignableFrom(method.returnType) -> flows.getOrPut(method.name) { MutableStateFlow(emptyValue(method.genericReturnType)) } + SharedFlow::class.java.isAssignableFrom(method.returnType) -> flows.getOrPut(method.name) { MutableSharedFlow() } + else -> Unit + } + } as StrapController + } + + /** What a flow of [type] starts at: false, an empty list or null. */ + private fun emptyValue(type: java.lang.reflect.Type): Any? = + when ((type as? java.lang.reflect.ParameterizedType)?.actualTypeArguments?.firstOrNull()?.typeName?.substringBefore('<')) { + "java.lang.Boolean" -> false + "java.util.List" -> emptyList() + else -> null + } + + @Test fun `the glance is worked out off the main thread`() { + val dao = db.healthDao() + val session = WorkoutSession(context, dao, SettingsStore(context), idleStrap(), MaxHeartRates(dao, flowOf(profile())), clock = { clock.now }, scope = scope) + session.start(sportType = 1) + runBlocking { withTimeout(WAIT_MS) { session.state.first { it is WorkoutState.Recording } } } + val threads = Collections.synchronizedList(mutableListOf()) + // The live heart rate is read inside the glance's flow, so where it's read is where the glance is worked out. + val bpm = flow { + threads += Thread.currentThread().name + emit(120) + } + runBlocking { withTimeout(WAIT_MS) { liveGlance(session, bpm, flowOf(ConnectionState.Disconnected)).first { it != null } } } + assertTrue("read on $threads", threads.isNotEmpty() && threads.all { it.startsWith("DefaultDispatcher") }) + } + + private companion object { + const val WAIT_MS = 10_000L + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/StartCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/workout/StartCopyTest.kt new file mode 100644 index 00000000..5dabf19a --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/workout/StartCopyTest.kt @@ -0,0 +1,292 @@ +package org.openstrap.app.ui.workout + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test +import org.openstrap.app.data.Strength +import org.openstrap.app.data.WorkoutAnalysis +import org.openstrap.app.data.WorkoutTarget +import org.openstrap.app.data.Workouts +import org.openstrap.app.data.db.Workout +import org.openstrap.app.device.ConnectionState +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.Locale + +class StartCopyTest { + // English figures: the German and Arabic ones are WorkoutWordsTest's and the presentation tests'. + @get:org.junit.Rule val localeRule = org.openstrap.app.testing.LocaleRule() + + private val today = LocalDate.of(2026, 10, 30) + + @Test fun `the strap line says it's ready, or what's wrong`() { + assertEquals("Strap ready, 72 bpm", StartCopy.strapLine(ConnectionState.Connected(80, null), 72)) + assertEquals("Strap ready", StartCopy.strapLine(ConnectionState.Connected(80, null), null)) + assertEquals("Strap not connected. You can start anyway.", StartCopy.strapLine(ConnectionState.Disconnected, null)) + } + + @Test fun `no goal is named for the sport`() { + assertEquals("Just run", StartCopy.noGoal(1)) + assertEquals("Just run", StartCopy.noGoal(2)) + assertEquals("Just walk", StartCopy.noGoal(15)) + assertEquals("Just ride", StartCopy.noGoal(8)) + assertEquals("Just go", StartCopy.noGoal(9)) + } + + @Test fun `each family's plan card has its label and sheet`() { + assertEquals("Goal", StartCopy.planTitle(Workouts.Family.Distance)) + assertEquals("Plan", StartCopy.planTitle(Workouts.Family.Strength)) + assertEquals("Plan", StartCopy.planTitle(Workouts.Family.Intervals)) + assertEquals("Length", StartCopy.planTitle(Workouts.Family.MindBody)) + assertEquals("Match", StartCopy.planTitle(Workouts.Family.Games)) + assertEquals("Choose a goal", StartCopy.planSheet(Workouts.Family.IndoorCardio)) + assertEquals("Choose a length", StartCopy.planSheet(Workouts.Family.MindBody)) + assertEquals("Free play or periods", StartCopy.planSheet(Workouts.Family.Games)) + assertEquals("Change the plan", StartCopy.planHint(Workouts.Family.Strength)) + } + + @Test fun `strength's sets and rest parse within their ranges`() { + assertEquals(5, StartCopy.parseSets("5")) + assertEquals(10, StartCopy.parseSets(" 10 ")) + assertEquals(null, StartCopy.parseSets("0")) + assertEquals(null, StartCopy.parseSets("11")) + assertEquals(null, StartCopy.parseSets("")) + assertEquals(90L, StartCopy.parseSetRest("90")) + assertEquals(300L, StartCopy.parseSetRest("300")) + assertEquals(null, StartCopy.parseSetRest("20")) + assertEquals(null, StartCopy.parseSetRest("301")) + assertEquals("Once you're under 110 bpm", StartCopy.endRestNote(110)) + assertEquals(listOf("3 sets", "4 sets", "5 sets", "6 sets"), StartCopy.SET_PRESETS.map { it.second }) + // End rest early turns on at a level the recorder knows. + assertEquals(true, StartCopy.DEFAULT_BELOW in Strength.Rest.LEVELS) + assertEquals(true, StartCopy.DEFAULT_SETS in Strength.SETS) + assertEquals("2 min 15 s", StartCopy.restText(135)) + } + + @Test fun `every HIIT preset carries its own icon, and only Free takes the sport's`() { + val icons = org.openstrap.app.ui.theme.AppIcons + assertEquals(listOf(icons.Timer, icons.Hourglass, icons.Alarm, null), StartCopy.PRESETS.map { it.icon }) + // Free is the one without rounds, and the last. + assertEquals(listOf(false, false, false, true), StartCopy.PRESETS.map { it.rounds == null }) + } + + @Test fun `HIIT's pill names a preset, else what the rounds are, and typed numbers change only what reads`() { + val tabata = org.openstrap.app.data.Structure.TABATA + assertEquals("Tabata", StartCopy.hiitName(tabata.warm(true))) + assertEquals("30 / 15", StartCopy.hiitName(tabata.copy(work = 30, rest = 15))) + assertEquals("20 / 10", StartCopy.hiitName(tabata.copy(rounds = 10))) + assertEquals("Every minute", StartCopy.hiitName(org.openstrap.app.data.Structure.EVERY_MINUTE)) + assertEquals("Every minute", StartCopy.hiitName(org.openstrap.app.data.Structure.EveryMinute(60, 10))) + assertEquals("Every 90 s", StartCopy.hiitName(org.openstrap.app.data.Structure.EveryMinute(90, 10))) + assertEquals("3:50 in all", StartCopy.totalLine(tabata)) + assertEquals("12:00 in all", StartCopy.totalLine(org.openstrap.app.data.Structure.EVERY_MINUTE)) + assertEquals(tabata.copy(work = 30, rest = 15, rounds = 6), StartCopy.typedTimed(tabata, listOf("30", "15", "6"))) + assertEquals(tabata.copy(rest = 0), StartCopy.typedTimed(tabata, listOf("", "0", "99"))) + assertEquals(tabata.warm(true).copy(work = 45), StartCopy.typedTimed(tabata.warm(true), listOf("45", "10", "8"))) + val every = org.openstrap.app.data.Structure.EVERY_MINUTE + assertEquals(every.copy(period = 90, rounds = 10), StartCopy.typedEveryMinute(every, listOf("90", "10"))) + assertEquals(every, StartCopy.typedEveryMinute(every, listOf("5", "0"))) + } + + @Test fun `a game's note says what the strap does, and typed periods are in minutes`() { + assertEquals("Phone in your bag? That's fine.", StartCopy.gamesNote(null)) + assertEquals("Phone in your bag? That's fine. The strap buzzes at the end of each half and break.", StartCopy.gamesNote(org.openstrap.app.data.Structure.HALVES)) + assertEquals( + "Phone in your bag? That's fine. The strap buzzes at the end of each quarter.", + StartCopy.gamesNote(org.openstrap.app.data.Structure.QUARTERS.copy(rest = 0)), + ) + val halves = org.openstrap.app.data.Structure.HALVES + assertEquals(halves.copy(length = 45 * 60, rest = 15 * 60), StartCopy.typedPeriods(halves, listOf("2", "45", "15"))) + assertEquals(org.openstrap.app.data.Structure.Periods(3, 30 * 60, 0), StartCopy.typedPeriods(halves, listOf("3", "30", "0"))) + assertEquals(halves, StartCopy.typedPeriods(halves, listOf("1", "0", "31"))) + assertEquals(listOf("15 min", "30 min", "45 min", "1 h"), StartCopy.MIND_PRESETS.map { it.second }) + } + + @Test fun `last time says what and when`() { + val tuesday = LocalDate.of(2026, 10, 27).atTime(18, 0).toEpochSecond(ZoneOffset.UTC) + val run = WorkoutAnalysis(Workout(tuesday, tuesday + 1800, 1, 1800, null, null, null, null, null, null, null, null, null), + tuesday + 1800, emptyList(), null, null, null, null, List(5) { 0 }, 190, fromPhone = true, distanceMeters = 5210f) + assertEquals("Last time 5.21 km, Tuesday", StartCopy.lastTime(run, today, ZoneOffset.UTC, Locale.UK)) + // The decimal mark is the language's. + assertEquals("Last time 5,21 km, Dienstag", StartCopy.lastTime(run, today, ZoneOffset.UTC, Locale.GERMANY)) + val lift = run.copy(workout = run.workout.copy(sportType = 52), distanceMeters = null, sets = 5) + assertEquals("Last time 5 sets, Tuesday", StartCopy.lastTime(lift, today, ZoneOffset.UTC, Locale.UK)) + val yoga = run.copy(workout = run.workout.copy(sportType = 60), distanceMeters = null) + assertEquals("Last time 30 min, Tuesday", StartCopy.lastTime(yoga, today, ZoneOffset.UTC, Locale.UK)) + } + + @Test fun `days are today, yesterday, a weekday, then a date`() { + fun on(day: LocalDate) = WorkoutAnalysis( + Workout(day.atTime(18, 0).toEpochSecond(ZoneOffset.UTC), 0, 52, 1800, null, null, null, null, null, null, null, null, null), + 0, emptyList(), null, null, null, null, List(5) { 0 }, 190, fromPhone = true, sets = 1, + ) + assertEquals("Last time 1 set, today", StartCopy.lastTime(on(today), today, ZoneOffset.UTC, Locale.UK)) + assertEquals("Last time 1 set, yesterday", StartCopy.lastTime(on(today.minusDays(1)), today, ZoneOffset.UTC, Locale.UK)) + assertEquals("Last time 1 set, Saturday", StartCopy.lastTime(on(today.minusDays(6)), today, ZoneOffset.UTC, Locale.UK)) + // A week back, the date with its weekday, as every day word reads. + assertEquals("Last time 1 set, Fri 23 Oct", StartCopy.lastTime(on(today.minusDays(7)), today, ZoneOffset.UTC, Locale.UK)) + } + + @Test fun `rounds are named by their preset, else by what they are`() { + assertEquals("Tabata", StartCopy.structureName(org.openstrap.app.data.Structure.TABATA)) + assertEquals("Tabata", StartCopy.structureName(org.openstrap.app.data.Structure.TABATA.warm(true))) + assertEquals("40 / 20", StartCopy.structureName(org.openstrap.app.data.Structure.FORTY_TWENTY)) + assertEquals("6 × 400 m", StartCopy.structureName(org.openstrap.app.data.Structure.Reps(400, null, 90, 6))) + assertEquals("8 × 2 min", StartCopy.structureName(org.openstrap.app.data.Structure.Reps(null, 120, 60, 8))) + // As the rep field under it says it, not "1500 m". + assertEquals("6 × 1.5 km", StartCopy.structureName(org.openstrap.app.data.Structure.Reps(1500, null, 90, 6))) + assertEquals(230L, StartCopy.totalTime(org.openstrap.app.data.Structure.TABATA)) + // Reps fit a run, rounds fit HIIT, never the other way round. + assertEquals(true, org.openstrap.app.data.Structure.fits(org.openstrap.app.data.Structure.Reps(400, null, 90, 6), 2)) + assertEquals(false, org.openstrap.app.data.Structure.fits(org.openstrap.app.data.Structure.TABATA, 1)) + assertEquals(true, org.openstrap.app.data.Structure.fits(org.openstrap.app.data.Structure.TABATA, 49)) + } + + @Test fun `GPS says how close the fix is`() { + assertEquals("Finding GPS", StartCopy.gpsStatus(null)) + assertEquals("Finding GPS, ±48 m", StartCopy.gpsStatus(48f)) + assertEquals("GPS ready, 8 m", StartCopy.gpsStatus(8f)) + assertEquals("GPS ready, 6 m", StartCopy.gpsStatus(6.4f)) + } + + @Test fun `what the strap will do, without banned punctuation`() { + val reps = org.openstrap.app.data.Structure.Reps(400, null, 90, 6) + val all = listOf( + StartCopy.STRENGTH_STATUS, StartCopy.GAMES_STATUS, StartCopy.noGoalNote(true), StartCopy.distanceNote(7500), + StartCopy.endsNear(java.time.LocalTime.NOON, 1800, true), StartCopy.ZONE_NOTE, StartCopy.intervalsNote(reps), + StartCopy.zoneRangeLine(WorkoutTarget.Zones(1, 2), org.openstrap.app.data.HeartZones.STANDARD, 190), + StartCopy.INTERVALS_TYPING, StartCopy.ASK_TO_END_NOTE, StartCopy.SAVE_NOTE, StartCopy.indoorNote(8), + HeartZonesCopy.NOTE, HeartZonesCopy.SET_NOTE, HeartZonesCopy.edgesNote(0), HeartZonesCopy.edgesNote(4), + HeartZonesCopy.source(org.openstrap.app.data.MaxHeartRate(190, org.openstrap.app.data.MaxHeartRate.Source.Guess)), + StartCopy.OPEN_SETS_KIND_NOTE, StartCopy.PLAN_KIND_NOTE, StartCopy.OPEN_SETS_NOTE, StartCopy.STRENGTH_TYPING, + StartCopy.endRestNote(110), StartCopy.LOG_SETS_NOTE, StartCopy.INTERVALS_STATUS, StartCopy.HIIT_FREE_NOTE, StartCopy.HIIT_TYPING, + StartCopy.EVERY_TYPING, StartCopy.WARM_UP_NOTE, StartCopy.MIND_STATUS, StartCopy.MIND_OPEN_NOTE, StartCopy.MIND_OPEN_KIND_NOTE, + StartCopy.MIND_TIME_KIND_NOTE, StartCopy.FREE_PLAY_KIND_NOTE, StartCopy.HALVES_KIND_NOTE, StartCopy.QUARTERS_KIND_NOTE, + StartCopy.GAMES_TYPING, StartCopy.gamesNote(org.openstrap.app.data.Structure.QUARTERS), StartCopy.planSheet(org.openstrap.app.data.Workouts.Family.Games), + ) + StartCopy.PRESETS.flatMap { listOf(it.name, it.note) } + all.forEach { s -> assertFalse(s, s.any { it in "·;—–" }) } + } + + @Test fun `Ready's where row names the side you're on and the other one`() { + val run = org.openstrap.app.data.Workouts.sidesOf(1)!! + assertEquals("Outside", StartCopy.place(run, indoor = false)) + assertEquals("On a treadmill", StartCopy.place(run, indoor = true)) + assertEquals("Treadmill", StartCopy.otherPlace(run, indoor = false)) + assertEquals("Outside", StartCopy.otherPlace(run, indoor = true)) + val ride = org.openstrap.app.data.Workouts.sidesOf(4)!! + assertEquals("Indoors", StartCopy.place(ride, indoor = true)) + assertEquals("Indoors", StartCopy.otherPlace(ride, indoor = false)) + val swim = org.openstrap.app.data.Workouts.sidesOf(6)!! + assertEquals("In a pool", StartCopy.place(swim, indoor = true)) + assertEquals("Open water", StartCopy.otherPlace(swim, indoor = true)) + assertEquals("Pace from your steps", StartCopy.indoorNote(2)) + assertEquals("Distance from your steps", StartCopy.indoorNote(40)) + assertEquals("Time and your heart, no distance", StartCopy.indoorNote(8)) + assertEquals("Start treadmill run", StartCopy.startLabel(2)) + assertEquals("Start run", StartCopy.startLabel(1)) + } + + @Test fun `a distance reads as typed, steps by half a km and parses what's typed`() { + assertEquals("5", StartCopy.distanceValue(5000)) + assertEquals("7.5", StartCopy.distanceValue(7500)) + assertEquals("12.45", StartCopy.distanceValue(12_450)) + assertEquals("21.1", StartCopy.distanceValue(StartCopy.HALF_MARATHON)) + assertEquals("The strap buzzes at halfway and at 7.5 km.", StartCopy.distanceNote(7500)) + assertEquals(8000, StartCopy.stepDistance(7500, up = true)) + assertEquals(7000, StartCopy.stepDistance(7500, up = false)) + assertEquals(12_500, StartCopy.stepDistance(12_400, up = true)) + assertEquals(12_000, StartCopy.stepDistance(12_400, up = false)) + assertEquals(500, StartCopy.stepDistance(500, up = false)) + assertEquals(12_400, StartCopy.parseDistance("12.4")) + assertEquals(12_400, StartCopy.parseDistance("12,4")) + assertEquals(10_000, StartCopy.parseDistance("10")) + assertEquals(null, StartCopy.parseDistance("")) + assertEquals(null, StartCopy.parseDistance("0")) + assertEquals(null, StartCopy.parseDistance("1000")) + assertEquals(listOf("3", "5", "10", "Half"), StartCopy.DISTANCE_PRESETS.map { it.second }) + } + + @Test fun `a time reads in hours and minutes, steps by 5 minutes and parses two fields`() { + assertEquals("1 h 15", StartCopy.timeValue(4500)) + assertEquals("45 min", StartCopy.timeValue(2700)) + assertEquals("1 h", StartCopy.timeValue(3600)) + assertEquals(listOf("1" to "h", "15" to "min"), StartCopy.timeParts(4500)) + assertEquals(4800L, StartCopy.stepTime(4500, up = true)) + assertEquals(4200L, StartCopy.stepTime(4500, up = false)) + assertEquals(4500L, StartCopy.stepTime(4380, up = true)) + assertEquals(300L, StartCopy.stepTime(300, up = false)) + assertEquals(4500L, StartCopy.parseTime("1", "15")) + assertEquals(2700L, StartCopy.parseTime("", "45")) + assertEquals(5400L, StartCopy.parseTime("0", "90")) + assertEquals(7200L, StartCopy.parseTime("2", "")) + assertEquals(null, StartCopy.parseTime("", "")) + assertEquals(null, StartCopy.parseTime("11", "0")) + assertEquals(null, StartCopy.parseTime("1", "x")) + assertEquals(listOf("30 min", "45 min", "1 h", "1 h 30"), StartCopy.TIME_PRESETS.map { it.second }) + assertEquals("Ends near 19:15", StartCopy.endsNear(java.time.LocalTime.of(18, 0), 4500, is24Hour = true)) + assertEquals("Type hours, then minutes. Ends near 19:15", StartCopy.timeTypingNote("Ends near 19:15")) + } + + @Test fun `a zone goal reads its heart rates and names from your zones`() { + val zones = org.openstrap.app.data.HeartZones.STANDARD.withStart(1, 115, 192).withStart(2, 134, 192).withStart(3, 153, 192) + assertEquals("115 to 152 bpm, aerobic to tempo", StartCopy.zoneRangeLine(WorkoutTarget.Zones(1, 2), zones, 192)) + assertEquals("115 to 133 bpm, aerobic", StartCopy.zoneRangeLine(WorkoutTarget.Zones(1, 1), zones, 192)) + assertEquals("173 bpm and up, max", StartCopy.zoneRangeLine(WorkoutTarget.Zones(4, 4), zones, 192)) + assertEquals("153 bpm and up, threshold to max", StartCopy.zoneRangeLine(WorkoutTarget.Zones(3, 4), zones, 192)) + } + + @Test fun `run intervals' presets, parts, note and typing`() { + assertEquals(listOf("8 × 200 m", "6 × 400 m", "5 × 1 km", "4 × 4 min"), StartCopy.INTERVAL_PRESETS.map { StartCopy.structureName(it) }) + val reps = org.openstrap.app.data.Structure.Reps(400, null, 90, 6) + assertEquals("400" to "m", StartCopy.repParts(reps)) + assertEquals("1.5" to "km", StartCopy.repParts(reps.copy(meters = 1500))) + assertEquals("4" to "min", StartCopy.repParts(reps.copy(meters = null, seconds = 240))) + assertEquals("90" to "s", StartCopy.repParts(reps.copy(meters = null, seconds = 90))) + assertEquals("90" to "s", StartCopy.restParts(90)) + assertEquals("2" to "min", StartCopy.restParts(120)) + assertEquals("150" to "s", StartCopy.restParts(150)) + assertEquals("The strap buzzes at the end of each 400 m and when rest is up. 2.4 km in all.", StartCopy.intervalsNote(reps)) + assertEquals("The strap buzzes at the end of each 200 m and when rest is up. 800 m in all.", StartCopy.intervalsNote(reps.copy(meters = 200, rounds = 4))) + assertEquals( + "The strap buzzes at the end of each 4 min rep and when rest is up. 16 min of reps in all.", + StartCopy.intervalsNote(org.openstrap.app.data.Structure.Reps(null, 240, 180, 4)), + ) + assertEquals(8, StartCopy.parseCount("8")) + assertEquals(null, StartCopy.parseCount("0")) + assertEquals(null, StartCopy.parseCount("31")) + assertEquals(reps.copy(meters = 800), StartCopy.parseRep("800", StartCopy.RepUnit.Meters, reps)) + assertEquals(reps.copy(meters = 1500), StartCopy.parseRep("1.5", StartCopy.RepUnit.Kilometers, reps)) + assertEquals(reps.copy(meters = null, seconds = 150), StartCopy.parseRep("2.5", StartCopy.RepUnit.Minutes, reps)) + assertEquals(null, StartCopy.parseRep("", StartCopy.RepUnit.Meters, reps)) + assertEquals(null, StartCopy.parseRep("10", StartCopy.RepUnit.Meters, reps)) + assertEquals(StartCopy.RepUnit.Kilometers, StartCopy.repUnit(reps.copy(meters = 1000))) + assertEquals(StartCopy.RepUnit.Minutes, StartCopy.repUnit(reps.copy(meters = null, seconds = 240))) + assertEquals(90L, StartCopy.parseRest("90")) + assertEquals(0L, StartCopy.parseRest("0")) + assertEquals(null, StartCopy.parseRest("1000")) + } + + @Test fun `no goal says what's recorded`() { + assertEquals("No goal. Time, distance and your heart.", StartCopy.noGoalNote(hasDistance = true)) + assertEquals("No goal. Time and your heart.", StartCopy.noGoalNote(hasDistance = false)) + } + + @Test fun `the zones page says where your max comes from and each zone's heart rates`() { + val d = LocalDate.of(2026, 10, 3) + assertEquals("Learned from your hardest efforts, 3 Oct", HeartZonesCopy.source(org.openstrap.app.data.MaxHeartRate(192, org.openstrap.app.data.MaxHeartRate.Source.Learned, d), Locale.UK)) + assertEquals("Set by you", HeartZonesCopy.source(org.openstrap.app.data.MaxHeartRate(185, org.openstrap.app.data.MaxHeartRate.Source.Set))) + val zones = org.openstrap.app.data.HeartZones.STANDARD + assertEquals("96 to 115", HeartZonesCopy.zoneBpm(zones, 0, 192)) + assertEquals("173 and up", HeartZonesCopy.zoneBpm(zones, 4, 192)) + assertEquals("Zone 2", HeartZonesCopy.zoneName(1)) + assertEquals(185, HeartZonesCopy.parseMax("185")) + assertEquals(null, HeartZonesCopy.parseMax("100")) + } + + @Test + fun theRouteSwitchIsNamedForTheSport() { + assertEquals("Save a map of my run", StartCopy.keepRoute(1)) + assertEquals("Save a map of my walk", StartCopy.keepRoute(3)) + assertEquals("Save a map of my ride", StartCopy.keepRoute(4)) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/SummaryCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/workout/SummaryCopyTest.kt index 7ebc72c8..ea71133a 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/workout/SummaryCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/workout/SummaryCopyTest.kt @@ -1,7 +1,5 @@ package org.openstrap.app.ui.workout -import java.time.LocalDate -import java.time.ZoneOffset import java.util.Locale import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -12,6 +10,9 @@ import org.openstrap.app.data.Route /** The summary right after a workout: the heart rate drop, Load, splits and a personal best (spec 6.3). */ class SummaryCopyTest { + // English figures: the German and Arabic ones are WorkoutWordsTest's and the presentation tests'. + @get:org.junit.Rule val localeRule = org.openstrap.app.testing.LocaleRule() + private val end = 1_000_000L private val maxHr = 190 @@ -97,6 +98,9 @@ class SummaryCopyTest { val rows = SummaryCopy.splitRows(splits, hr, firstT = 100L) assertEquals(listOf("Km 1", "Km 2", "Last 430 m"), rows.map { it.name }) assertEquals("5:42", rows[0].time) + // The seconds and the kind travel with each row, for the fastest mark and the bars. + assertEquals(listOf(342L, 358L, 150L), rows.map { it.seconds }) + assertEquals(listOf(SplitRow.Kind.Whole, SplitRow.Kind.Whole, SplitRow.Kind.Last), rows.map { it.kind }) assertEquals(150, rows[0].averageBpm) assertEquals(160, rows[1].averageBpm) // A last bit under 100 m is left out. @@ -104,41 +108,6 @@ class SummaryCopyTest { assertNull(SummaryCopy.splitRows(splits, emptyList(), 100L)[0].averageBpm) } - private fun day(y: Int, m: Int, d: Int) = LocalDate.of(y, m, d).atStartOfDay(ZoneOffset.UTC).toEpochSecond() - private fun run(at: Long, km: Float?, minutes: Long = 30) = BestCandidate(at, 1, minutes * 60, km?.times(1000f)) - private fun best(w: BestCandidate, others: List) = SummaryCopy.personalBest(w, others, ZoneOffset.UTC, Locale.UK) - - @Test fun `personal best, farthest since the last farther one`() { - val now = day(2026, 10, 20) - val others = listOf(run(day(2026, 3, 5), 8f), run(day(2026, 6, 1), 5f), run(day(2026, 8, 1), 6f), run(day(2026, 10, 1), 5f)) - assertEquals("Farthest run since March.", best(run(now, 7f), others)) - // Beating only two since then is too little to call. - assertNull(best(run(now, 7f, minutes = 20), others.filter { it.start != day(2026, 8, 1) })) - // A farther one under a month ago: nothing to say. - assertNull(best(run(now, 4.9f, minutes = 20), others + run(day(2026, 10, 10), 6f, minutes = 25))) - // From another year, the year shows. - val lastYear = listOf(run(day(2025, 3, 5), 8f)) + List(3) { run(day(2025, 11, 1 + it), 5f) } - assertEquals("Farthest run since March 2025.", best(run(day(2026, 1, 20), 7f), lastYear)) - } - - @Test fun `personal best yet only with enough before`() { - val now = day(2026, 10, 20) - val few = List(3) { run(day(2026, 9, 1 + it), 5f) } - assertNull(best(run(now, 7f), few)) - val many = List(5) { run(day(2026, 9, 1 + it), 5f) } - assertEquals("Your farthest run yet.", best(run(now, 7f), many)) - } - - @Test fun `personal best falls back to the longest by time`() { - val now = day(2026, 10, 20) - val walks = listOf(BestCandidate(day(2026, 2, 1), 3, 90 * 60, null)) + List(3) { BestCandidate(day(2026, 9, 1 + 7 * it), 3, 40 * 60, null) } - assertEquals("Longest walk since February.", best(BestCandidate(now, 3, 60 * 60, null), walks)) - // Other sports never count. - assertNull(best(BestCandidate(now, 3, 60 * 60, null), walks.map { it.copy(sportType = 1) })) - // Too short to be a best. - assertNull(best(BestCandidate(now, 3, 5 * 60, null), emptyList())) - } - @Test fun `the last part is what the workout's distance leaves after the whole kilometres`() { // The raw route runs to 2185 m, the workout's smoothed distance is 2176 m. @@ -147,13 +116,6 @@ class SummaryCopyTest { assertEquals("Last 185 m", SummaryCopy.splitRows(splits, emptyList(), 0L).last().name) } - @Test fun `the workout card says its calories, active first`() { - assertEquals("486 active kcal, total 1,612", SummaryCopy.kcalLine(486, 1612, Locale.UK)) - assertEquals("612 kcal", SummaryCopy.kcalLine(null, 612, Locale.UK)) - assertNull(SummaryCopy.kcalLine(null, null)) - assertNull(SummaryCopy.kcalLine(0, 0)) - } - @Test fun `the workout card says whether its kcal target was reached`() { assertEquals("Target 400 kcal, reached at 38:10", SummaryCopy.targetLine("k:400", 2290, 431, Locale.UK)) assertEquals("Target not reached, 212 of 300 kcal", SummaryCopy.targetLine("k:300", null, 212, Locale.UK)) diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutCopyTest.kt index 99702d3b..64c84e02 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutCopyTest.kt @@ -11,6 +11,9 @@ import org.openstrap.app.device.ConnectionState /** The Ready and live workout screens' words and rules (spec 6.3). */ class WorkoutCopyTest { + // English figures: the German and Arabic ones are WorkoutWordsTest's and the presentation tests'. + @get:org.junit.Rule val localeRule = org.openstrap.app.testing.LocaleRule() + private val connected = ConnectionState.Connected(batteryPercent = 80, firmware = null) @Test fun `the ready hero is the strap in every state`() { @@ -18,6 +21,10 @@ class WorkoutCopyTest { assertEquals(live, WorkoutCopy.readyHero(connected, 72)) assertEquals(live.copy(line = "Waiting for heart rate."), WorkoutCopy.readyHero(connected, null)) assertEquals(live.copy(line = "Live from your strap. Battery 15%."), WorkoutCopy.readyHero(connected.copy(batteryPercent = 15), 72)) + // Ready warns at 20%, earlier than the 15% everywhere else, before a workout that may run long. + assertEquals(live.copy(line = "Live from your strap. Battery 20%."), WorkoutCopy.readyHero(connected.copy(batteryPercent = 20), 72)) + assertEquals(live, WorkoutCopy.readyHero(connected.copy(batteryPercent = 21), 72)) + assertTrue(WorkoutCopy.READY_LOW_BATTERY > org.openstrap.app.presentation.StrapBands.LOW_BATTERY) // Charging, a low battery is on its way up: not worth a word. assertEquals(live, WorkoutCopy.readyHero(connected.copy(batteryPercent = 15, charging = true), 72)) val away = WorkoutCopy.ReadyHero("Strap not connected", "You can start anyway.", live = false) @@ -29,12 +36,7 @@ class WorkoutCopyTest { } @Test fun `gps and route notes`() { - assertEquals("Off. No distance without GPS.", WorkoutCopy.gpsNote(false, 4)) - assertEquals("Off. Distance from the strap's steps, roughly.", WorkoutCopy.gpsNote(false, 1)) - assertEquals("On. Distance, pace and the map.", WorkoutCopy.gpsNote(true, 1)) assertEquals("Off. Only the totals are kept.", WorkoutCopy.routeNote(false)) - assertEquals(1, WorkoutCopy.readyRows(outdoor = false).size) - assertEquals(listOf("Heart rate", "GPS", "Keep the route"), WorkoutCopy.readyRows(outdoor = true).map { it.first }) } @Test fun `zone name and time in it`() { @@ -47,9 +49,8 @@ class WorkoutCopyTest { val strings = listOf( WorkoutCopy.readyHero(connected.copy(batteryPercent = 5), 60).line, WorkoutCopy.readyHero(ConnectionState.NotPaired, null).line, WorkoutCopy.LOCATION_OFF, WorkoutCopy.LOCATION_OFF_NOTE, WorkoutCopy.startLabel(1), - WorkoutCopy.gpsNote(true, 1), WorkoutCopy.gpsNote(false, 1), WorkoutCopy.gpsNote(false, 4), - WorkoutCopy.routeNote(true), WorkoutCopy.routeNote(false), WorkoutCopy.READY_MEANING, WorkoutCopy.zoneTime(600), - ) + WorkoutCopy.readyRows(true).flatMap { listOf(it.first, it.second) } + (0..4).flatMap { WorkoutCopy.zoneTitle(it).toList() } + WorkoutCopy.routeNote(true), WorkoutCopy.routeNote(false), WorkoutCopy.zoneTime(600), + ) + (0..4).flatMap { WorkoutCopy.zoneTitle(it).toList() } strings.forEach { s -> assertFalse("\"$s\"", s.any { it == '·' || it == ';' || it == '—' }) } } @@ -119,35 +120,15 @@ class WorkoutCopyTest { assertEquals(levels.sorted(), levels) } - /* ------------------------------------------ Side figures ------------------------------------------ */ - - private val indoor = WorkoutCopy.sideFigures(kcal = true, distance = false, gps = false) - private val steps = WorkoutCopy.sideFigures(kcal = true, distance = true, gps = false) - private val gps = WorkoutCopy.sideFigures(kcal = true, distance = true, gps = true) - - @Test fun `every figure the workout has shows at once`() { - val heart = listOf(SideFigure.Avg, SideFigure.Max) - assertEquals(LiveFigures(heart, listOf(SideFigure.Kcal)), WorkoutCopy.liveFigures(indoor)) - assertEquals(LiveFigures(heart, listOf(SideFigure.Kcal, SideFigure.Distance)), WorkoutCopy.liveFigures(steps)) - assertEquals(LiveFigures(heart, listOf(SideFigure.Kcal, SideFigure.Distance, SideFigure.Pace)), WorkoutCopy.liveFigures(gps)) - // Without a profile there's no kcal, and indoors nothing goes under the time. - assertEquals(LiveFigures(heart, emptyList()), WorkoutCopy.liveFigures(WorkoutCopy.sideFigures(kcal = false, distance = false, gps = false))) - // Every available figure shows exactly once. - for (a in listOf(indoor, steps, gps)) WorkoutCopy.liveFigures(a).let { assertEquals(a, it.sides + it.below) } - } - @Test fun `kcal reads as whole active kcal`() { assertEquals("312 kcal", WorkoutCopy.kcalText(312.9f)) assertNull(WorkoutCopy.kcalText(null)) } - @Test fun `a kcal target says when the strap buzzes and how far the workout is`() { - assertEquals("Target. The strap buzzes at 300 active kcal.", WorkoutCopy.targetHint(WorkoutTarget.Kcal(300))) - assertEquals("Target. The strap buzzes if you drift out.", WorkoutCopy.targetHint(WorkoutTarget.Zones(1, 2))) - assertEquals("Target. The strap buzzes if you drift out.", WorkoutCopy.targetHint(null)) + @Test fun `a kcal target says how far the workout is`() { assertEquals("212 of 300 kcal", WorkoutCopy.kcalProgress(212.8f, WorkoutTarget.Kcal(300))) assertEquals("0 of 500 kcal", WorkoutCopy.kcalProgress(null, WorkoutTarget.Kcal(500))) - listOf(WorkoutCopy.targetHint(WorkoutTarget.Kcal(800)), WorkoutCopy.kcalProgress(1f, WorkoutTarget.Kcal(800))) + listOf(WorkoutCopy.kcalProgress(1f, WorkoutTarget.Kcal(800))) .forEach { l -> listOf("·", ";", "—").forEach { b -> assertFalse("$l has $b", l.contains(b)) } } } } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutDetailCopyTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutDetailCopyTest.kt index 0cd1a6f6..2176f500 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutDetailCopyTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutDetailCopyTest.kt @@ -12,6 +12,9 @@ import org.openstrap.app.ui.workout.WorkoutDetailCopy.Effort /** The page of a past workout: its sentence, facts, heart rate drop and the (i) on Load (spec 6.3). */ class WorkoutDetailCopyTest { + // English figures: the German and Arabic ones are WorkoutWordsTest's and the presentation tests'. + @get:org.junit.Rule val localeRule = org.openstrap.app.testing.LocaleRule() + private val run = 1 private val walk = 3 private val strength = 52 @@ -89,13 +92,19 @@ class WorkoutDetailCopyTest { fun at(d: LocalDate) = d.atTime(18, 0).toEpochSecond(ZoneOffset.UTC) assertEquals("Today", WorkoutDetailCopy.day(at(today), today, ZoneOffset.UTC, Locale.UK)) assertEquals("Yesterday", WorkoutDetailCopy.day(at(today.minusDays(1)), today, ZoneOffset.UTC, Locale.UK)) - assertEquals("Wed 21 Oct", WorkoutDetailCopy.day(at(LocalDate.of(2026, 10, 21)), today, ZoneOffset.UTC, Locale.UK)) + // Within the week the weekday, as every day word reads, then the date. + assertEquals("Wednesday", WorkoutDetailCopy.day(at(LocalDate.of(2026, 10, 21)), today, ZoneOffset.UTC, Locale.UK)) + assertEquals("Fri 16 Oct", WorkoutDetailCopy.day(at(LocalDate.of(2026, 10, 16)), today, ZoneOffset.UTC, Locale.UK)) assertEquals("Fri 24 Oct 2025", WorkoutDetailCopy.day(at(LocalDate.of(2025, 10, 24)), today, ZoneOffset.UTC, Locale.UK)) } @Test fun `facts leave out what there's nothing to say about`() { val all = WorkoutDetailCopy.facts(5210f, false, 4 * 60L + 12, 6201, 412, locale = Locale.UK) assertEquals(listOf("Distance", "Paused", "Steps", "Calories"), all.map { it.name }) + // Colours and icons go by the kind, not the words. + assertEquals( + listOf(WorkoutFact.Kind.Distance, WorkoutFact.Kind.Paused, WorkoutFact.Kind.Steps, WorkoutFact.Kind.Calories), all.map { it.kind }, + ) assertEquals("5.21", all[0].value) assertEquals("Kilometres", all[0].status) assertEquals("4:12", all[1].value) @@ -201,9 +210,9 @@ class WorkoutDetailCopyTest { } @Test fun `a kcal target is a fact, reached at its time or not reached with how far it got`() { - assertEquals(WorkoutFact("Target", "300 kcal", "Reached at 24:40"), WorkoutDetailCopy.targetFact("k:300", 1480, 340, Locale.UK)) - assertEquals(WorkoutFact("Target", "300 kcal", "Not reached, 212 of 300"), WorkoutDetailCopy.targetFact("k:300", null, 212, Locale.UK)) - assertEquals(WorkoutFact("Target", "1,200 kcal", "Reached"), WorkoutDetailCopy.targetFact("k:1200", null, 1250, Locale.UK)) + assertEquals(WorkoutFact("Target", "300 kcal", "Reached at 24:40", WorkoutFact.Kind.Target), WorkoutDetailCopy.targetFact("k:300", 1480, 340, Locale.UK)) + assertEquals(WorkoutFact("Target", "300 kcal", "Not reached, 212 of 300", WorkoutFact.Kind.Target), WorkoutDetailCopy.targetFact("k:300", null, 212, Locale.UK)) + assertEquals(WorkoutFact("Target", "1,200 kcal", "Reached", WorkoutFact.Kind.Target), WorkoutDetailCopy.targetFact("k:1200", null, 1250, Locale.UK)) assertEquals("Not reached", WorkoutDetailCopy.targetFact("k:300", null, null, Locale.UK)!!.status) // Other targets say nothing here. assertNull(WorkoutDetailCopy.targetFact("t:1800", 1800, 400)) diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutListZonesTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutListZonesTest.kt index 9e16d9de..e2a74b46 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutListZonesTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutListZonesTest.kt @@ -69,7 +69,8 @@ class WorkoutListZonesTest { dao.insertAppWorkouts(listOf(phone)) dao.insertAppWorkoutHeartRate(phoneHr) - val vm = WorkoutsViewModel(dao, flowOf(UserProfile(maxHeartRate = maxHr))) + val profile = flowOf(UserProfile(maxHeartRate = maxHr)) + val vm = WorkoutsViewModel(org.openstrap.app.data.RoomWorkoutRepository(dao, profile, org.openstrap.app.data.MaxHeartRates(dao, profile))) val list = withTimeout(10_000) { vm.workouts.first { l -> l.size == 3 && l.count { it.zoneSeconds.sum() > 0 } == 2 } } val strapRow = list.single { !it.fromPhone && it.workout.start == hard.start } diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutRulesTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutRulesTest.kt index d70dd100..f8cac4d1 100644 --- a/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutRulesTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutRulesTest.kt @@ -119,25 +119,24 @@ class WorkoutRulesTest { assertEquals(emptyList(), Workouts.recentSports(listOf(Workouts.GENERIC_ACTIVITY))) } - @Test fun `Start names the sport and the end page says it was saved`() { + @Test fun `Start names the sport`() { assertEquals("Start run", WorkoutCopy.startLabel(2)) assertEquals("Start HIIT", WorkoutCopy.startLabel(49)) assertEquals("Start workout", WorkoutCopy.startLabel(5)) assertEquals("Start tennis", WorkoutCopy.startLabel(17)) - assertEquals("Run saved", SummaryCopy.savedTitle(1)) - assertEquals("Run saved", SummaryCopy.savedTitle(2)) - assertEquals("Workout saved", SummaryCopy.savedTitle(null)) } @Test fun `no workout text uses an interpunct, semicolon or em dash`() { val strings = Workouts.SIDES.flatMap { listOf(it.outdoorName, it.indoorName) } + Workouts.EVERYDAY.map(Workouts::shortName) + - SummaryCopy.DROP_MEANING + WorkoutCopy.READY_MEANING + WorkoutCopy.readyRows(true).flatMap { listOf(it.first, it.second) } + SummaryCopy.DROP_MEANING strings.forEach { s -> assertFalse("\"$s\"", s.any { it == '·' || it == ';' || it == '—' }) } } @Test fun `the workout pages use the type roles and no Material parts`() { val root = listOf(File("src/main"), File("app/src/main")).first { it.isDirectory } - val files = listOf("GlowWorkoutMode.kt", "GlowWorkoutSummary.kt", "GlowWorkoutScreen.kt").map { File(root, "kotlin/org/openstrap/app/ui/glow/$it") } + val glow = File(root, "kotlin/org/openstrap/app/ui/glow") + val files = listOf("GlowWorkoutMode.kt", "ChooseScreen.kt", "ReadyScreen.kt", "WorkoutCapsule.kt", "GlowWorkoutSummary.kt", "GlowWorkoutScreen.kt").map { File(glow, it) } + + File(glow, "live").listFiles().orEmpty().filter { it.extension == "kt" }.sortedBy { it.name } val banned = Regex("""MaterialTheme\.typography|GlowType\.(headline|title|label)\b|OutlinedTextField|\bIconButton\b|headline\.fontSize""") val found = files.flatMap { f -> f.readLines().mapIndexedNotNull { i, line -> diff --git a/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutsViewModelThreadsTest.kt b/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutsViewModelThreadsTest.kt new file mode 100644 index 00000000..2adc9f90 --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/ui/workout/WorkoutsViewModelThreadsTest.kt @@ -0,0 +1,164 @@ +package org.openstrap.app.ui.workout + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.DayWindow +import org.openstrap.app.data.MaxHeartRates +import org.openstrap.app.data.RoomWorkoutRepository +import org.openstrap.app.data.db.AppWorkout +import org.openstrap.app.data.db.AppWorkoutHeartRate +import org.openstrap.app.data.db.HealthDao +import org.openstrap.app.data.db.HeartRateSample +import org.openstrap.app.data.db.OpenStrapDatabase +import org.openstrap.app.data.db.WorkoutHeartRate +import org.openstrap.app.data.settings.UserProfile +import org.openstrap.app.testing.FixedClock +import org.openstrap.app.testing.ZoneRule +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.app.testing.profile +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Duration +import java.time.Instant +import java.time.LocalDate +import java.util.Collections + +/** + * The workouts list is worked out off the main thread (audit C7), the suggestions' window moves on at midnight + * (C10), and a phone workout's effect backfill runs beside its summary, not inside the summary's read. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class WorkoutsViewModelThreadsTest { + @get:Rule val zone = ZoneRule() + + private val scheduler = TestCoroutineScheduler() + private val clock = FixedClock(Instant.parse("2026-01-15T23:59:30Z")) + private lateinit var db: OpenStrapDatabase + private val collectors = mutableListOf() + private val vms = mutableListOf() + + /** Where the list's last heart rates were read, and each window the suggestions read heart rate from. */ + private val listThreads = Collections.synchronizedList(mutableListOf()) + private val heartRateFrom = Collections.synchronizedList(mutableListOf()) + + private val dao: HealthDao by lazy { + val real = db.healthDao() + object : HealthDao by real { + override fun lastWorkoutHeartRates(): Flow> = + real.lastWorkoutHeartRates().onEach { listThreads += Thread.currentThread().name } + + override fun heartRate(from: Long, to: Long): Flow> { + heartRateFrom += from + return real.heartRate(from, to) + } + } + } + + @Before fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher(scheduler)) + db = inMemoryDb() + } + + @After fun tearDown() { + collectors.forEach { it.cancel() } + vms.forEach { it.viewModelScope.coroutineContext[Job]?.cancel() } + db.close() + Dispatchers.resetMain() + } + + private fun vm(profile: Flow = flowOf(profile()), onTestTime: Boolean = false): WorkoutsViewModel { + val work = if (onTestTime) UnconfinedTestDispatcher(scheduler) else Dispatchers.Default + return WorkoutsViewModel(RoomWorkoutRepository(dao, profile, MaxHeartRates(dao, flowOf(profile()))), clock = { clock.clock }, work = work).also { vms += it } + } + + private fun Flow.watched() { + collectors += CoroutineScope(Dispatchers.Main).launch { collect {} } + } + + private fun until(what: String, check: suspend () -> Boolean) = runBlocking { + try { + withTimeout(WAIT_MS) { while (!check()) delay(POLL_MS) } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + throw AssertionError("$what never came", e) + } + } + + /** A phone workout of half an hour at 150, finished before its effect was worked out. */ + private fun oldPhoneWorkout(): Long = runBlocking { + val start = clock.now - 86_400 + db.healthDao().insertAppWorkouts(listOf(AppWorkout(start, start + 1800, 1))) + db.healthDao().insertAppWorkoutHeartRate((0L until 1800L).map { AppWorkoutHeartRate(start, start + it, 150, false) }) + start + } + + @Test fun `the list is worked out off the main thread`() { + val vm = vm() + vm.workouts.watched() + until("the list's heart rates") { listThreads.isNotEmpty() } + assertTrue("read on $listThreads", listThreads.all { it.startsWith("DefaultDispatcher") }) + } + + @Test fun `the suggestions read from the new yesterday after midnight`() { + val vm = vm(onTestTime = true) + vm.suggestions.watched() + val jan15 = LocalDate.of(2026, 1, 15) + until("yesterday's window") { DayWindow.of(jan15.minusDays(1)).from in heartRateFrom } + clock.advance(Duration.ofMinutes(1)) + scheduler.advanceTimeBy(Duration.ofMinutes(1).toMillis()) + scheduler.runCurrent() + until("the new yesterday's window") { DayWindow.of(jan15).from in heartRateFrom } + } + + @Test fun `the summary shows without waiting for the effect backfill`() { + val start = oldPhoneWorkout() + // A profile that never comes: the backfill waits on it for good, and the summary mustn't. + val vm = vm(profile = MutableSharedFlow()) + val summary = runBlocking { withTimeout(WAIT_MS) { vm.phoneSummary(start).first { it != null } } } + assertNotNull(summary) + } + + @Test fun `opening the summary still backfills the effect, and a preview writes nothing`() { + val preview = oldPhoneWorkout() + val opened = preview - 7200 + runBlocking { + db.healthDao().insertAppWorkouts(listOf(AppWorkout(opened, opened + 1800, 1))) + db.healthDao().insertAppWorkoutHeartRate((0L until 1800L).map { AppWorkoutHeartRate(opened, opened + it, 150, false) }) + } + val vm = vm() + runBlocking { withTimeout(WAIT_MS) { vm.phoneSummary(preview, backfill = false).first { it != null } } } + vm.phoneSummary(opened).watched() + until("the backfilled effect") { db.healthDao().appWorkoutOnce(opened)?.epoc != null } + assertNull(runBlocking { db.healthDao().appWorkoutOnce(preview)?.epoc }) + } + + private companion object { + const val WAIT_MS = 10_000L + const val POLL_MS = 20L + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/update/AppUpdaterTest.kt b/app/src/test/kotlin/org/openstrap/app/update/AppUpdaterTest.kt new file mode 100644 index 00000000..11994d9b --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/update/AppUpdaterTest.kt @@ -0,0 +1,89 @@ +package org.openstrap.app.update + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.settings.SettingsStore +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.io.FileNotFoundException + +/** + * Audit test 7: an update is installed only from HTTPS, matching GitHub's checksum, as OpenStrap signed with this + * copy's key, and never while a workout records. A session that fails before its commit is abandoned (audit E12). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class AppUpdaterTest { + private val app: Application = ApplicationProvider.getApplicationContext() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val sha = "a".repeat(64) + + @After fun tearDown() = scope.cancel() + + private fun updater(recording: Boolean) = AppUpdater(app, SettingsStore(app), recording = { recording }, scope = scope) + + private fun release(url: String) = Release( + tag = "v9.0.0", version = listOf(9, 0, 0), notes = "", publishedAt = null, page = "https://github.com/x/y", + apk = ReleaseAsset("OpenStrap-9.0.0.apk", url, 1000, sha), sums = null, + ) + + private fun failureAfterInstall(updater: AppUpdater, release: Release): InstallFailure = runBlocking { + updater.install(release) + withTimeout(10_000) { (updater.state.first { it.install is Install.Failed }.install as Install.Failed).failure } + } + + @Test fun `only an https address with a host is fetched`() { + assertNull(AppUpdater.urlFailure("https://github.com/openstrap/releases/download/v9/OpenStrap.apk")) + assertEquals(InstallFailure.NotOpenStrap, AppUpdater.urlFailure("http://github.com/OpenStrap.apk")) + assertEquals(InstallFailure.NotOpenStrap, AppUpdater.urlFailure("file:///sdcard/OpenStrap.apk")) + assertEquals(InstallFailure.NotOpenStrap, AppUpdater.urlFailure("https:///OpenStrap.apk")) + assertEquals(InstallFailure.NotOpenStrap, AppUpdater.urlFailure("not a url at all")) + } + + @Test fun `a bad url stops the install before any download`() { + assertEquals(InstallFailure.NotOpenStrap, failureAfterInstall(updater(recording = false), release("http://example.com/OpenStrap.apk"))) + } + + @Test fun `a checksum that doesn't match is refused`() { + assertNull(AppUpdater.checksumFailure(sha, sha)) + assertNull(AppUpdater.checksumFailure(sha.uppercase(), sha)) + assertEquals(InstallFailure.Checksum, AppUpdater.checksumFailure(sha, "b".repeat(64))) + assertEquals(InstallFailure.Checksum, AppUpdater.checksumFailure("", "")) + } + + @Test fun `another package or another signer is refused`() { + val mine = setOf("key1") + assertNull(AppUpdater.archiveFailure("org.openstrap.app", "org.openstrap.app", mine, mine)) + assertEquals(InstallFailure.NotOpenStrap, AppUpdater.archiveFailure("com.example.other", "org.openstrap.app", mine, mine)) + assertEquals(InstallFailure.NotOpenStrap, AppUpdater.archiveFailure(null, "org.openstrap.app", mine, mine)) + assertEquals(InstallFailure.Signature, AppUpdater.archiveFailure("org.openstrap.app", "org.openstrap.app", setOf("key2"), mine)) + assertEquals(InstallFailure.Signature, AppUpdater.archiveFailure("org.openstrap.app", "org.openstrap.app", emptySet(), mine)) + assertEquals(InstallFailure.Signature, AppUpdater.archiveFailure("org.openstrap.app", "org.openstrap.app", mine + "key2", mine)) + } + + @Test fun `nothing installs while a workout records`() { + assertEquals(InstallFailure.Recording, failureAfterInstall(updater(recording = true), release("https://github.com/OpenStrap.apk"))) + } + + @Test fun `a session that fails before its commit is abandoned`() = runBlocking { + val installer = app.packageManager.packageInstaller + val missing = File(app.cacheDir, "missing.apk").apply { delete() } + val thrown = runCatching { updater(recording = false).commit(missing) }.exceptionOrNull() + assertTrue(thrown.toString(), thrown is FileNotFoundException) + assertEquals(emptyList(), installer.allSessions) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/update/ReleasesTest.kt b/app/src/test/kotlin/org/openstrap/app/update/ReleasesTest.kt index 8f82f84e..98e69e97 100644 --- a/app/src/test/kotlin/org/openstrap/app/update/ReleasesTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/update/ReleasesTest.kt @@ -75,6 +75,27 @@ class ReleasesTest { assert(!Releases.waiting(emptyList(), "2.1.0", seen = null)) } + @Test fun `a release that asks for a newer Android than the phone's is skipped, one at or below it kept`() { + // The JSON escape for a newline, as GitHub sends the body. + val nl = "\\n" + val json = "[${release("v3.1.0", body = "Notes$nl")}, ${release("v3.0.0", body = "Notes$nl$nl")}, " + + "${release("v2.2.2", body = "minSdk: 35")}, ${release("v2.2.1")}]" + val onSixteen = Releases.parse(json, sdk = 36) + assertEquals(listOf("v3.0.0", "v2.2.2", "v2.2.1"), onSixteen.map { it.tag }) + assertEquals(36, onSixteen.first().minSdk) + assertNull(onSixteen.last().minSdk) + assertEquals(listOf("v3.1.0", "v3.0.0", "v2.2.2", "v2.2.1"), Releases.parse(json, sdk = 37).map { it.tag }) + // So the Updates page never offers what the installer would turn down. + assertEquals(listOf("v3.0.0"), Releases.newer(onSixteen, "2.2.2").map { it.tag }) + } + + @Test fun `the minimum SDK marker reads as a hidden comment or a plain line, and nowhere else`() { + assertEquals(36, Releases.minSdk("Fixes\n\n\n")) + assertEquals(37, Releases.minSdk("minSdk: 37")) + assertNull(Releases.minSdk("Needs minSdk: 36 or newer, said in a sentence.")) + assertNull(Releases.minSdk("Android 16 or newer.")) + } + @Test fun `a SHA256SUMS line gives the APK's hash`() { val sums = "CFB8B129 OpenStrap-v2.1.0.apk\nffff other.apk\n" assertEquals("cfb8b129", Releases.sumFor(sums, "OpenStrap-v2.1.0.apk")) diff --git a/app/src/test/kotlin/org/openstrap/app/widget/MomentWidgetModelTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/MomentWidgetModelTest.kt index 07f40071..acf33b7b 100644 --- a/app/src/test/kotlin/org/openstrap/app/widget/MomentWidgetModelTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/widget/MomentWidgetModelTest.kt @@ -15,18 +15,18 @@ import org.openstrap.app.data.Moment import org.openstrap.app.data.MomentInputs import org.openstrap.app.data.MomentLead import org.openstrap.app.data.Recovery +import org.openstrap.app.data.WidgetKind import org.openstrap.app.data.db.SleepSession import org.openstrap.app.data.db.SleepStage -import org.openstrap.app.device.WidgetUpdater import org.openstrap.app.ui.components.ChartPoint import org.openstrap.app.ui.dashboard.AFTER_FIRST_NIGHT import org.openstrap.app.ui.dashboard.TabCopy import org.openstrap.app.ui.dashboard.TodayCopy -import org.openstrap.app.ui.glow.AFTER_RIM -import org.openstrap.app.ui.glow.EVENING_BODY -import org.openstrap.app.ui.glow.GlowMood -import org.openstrap.app.ui.glow.LightAngle -import org.openstrap.app.ui.glow.WINDOW_RIM +import org.openstrap.app.ui.glow.draw.AFTER_RIM +import org.openstrap.app.ui.glow.draw.EVENING_BODY +import org.openstrap.app.ui.glow.draw.GlowMood +import org.openstrap.app.ui.glow.draw.LightAngle +import org.openstrap.app.ui.glow.draw.WINDOW_RIM import org.openstrap.app.ui.theme.MetricColors /** diff --git a/app/src/test/kotlin/org/openstrap/app/widget/RecoveryWidgetModelTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/RecoveryWidgetModelTest.kt index bed35bec..35e3078c 100644 --- a/app/src/test/kotlin/org/openstrap/app/widget/RecoveryWidgetModelTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/widget/RecoveryWidgetModelTest.kt @@ -104,7 +104,7 @@ class RecoveryWidgetModelTest { hrvSamples = 40, hrMinutes = 400, ) val ready = Recovery.Readiness( - 78, listOf(Recovery.Contributor(Recovery.Factor.Sleep, 82, "7 h 30 asleep")), 30, false, emptyList(), false, + 78, listOf(Recovery.Contributor(Recovery.Factor.Sleep, 82, Recovery.Detail.Asleep(450))), 30, false, emptyList(), false, ) val r = RecoveryResult( today, ready, Recovery.ChargeDay(emptyList(), null, null, 0f, 0f, 0f, 0f, null, null, anchored = true), week, diff --git a/app/src/test/kotlin/org/openstrap/app/widget/StartWidgetModelTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/StartWidgetModelTest.kt index be3964fc..cdb02c21 100644 --- a/app/src/test/kotlin/org/openstrap/app/widget/StartWidgetModelTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/widget/StartWidgetModelTest.kt @@ -109,6 +109,15 @@ class StartWidgetModelTest { assertEquals("No workouts yet", StartWidgetModel.weekLine(0, 0L)) } + @Test fun `this week starts on the locale's first day`() { + // In the US the week began on Sunday the 18th, so that day's strength session counts too. + val us = StartWidgetModel.from(history, emptyMap(), true, null, now, zone, Locale.US) + assertEquals("3 workouts, 2 h 15", us.weekLine) + // The last run's distance in the locale's own decimal mark. + val german = StartWidgetModel.from(history.filter { it.workout.sportType != 2 }, emptyMap(), true, null, now, zone, Locale.GERMANY) + assertEquals("Dienstag, 5,2 km", german.lastLine) + } + @Test fun `the usual sport's last, by its tile, with its distance, sets or length`() { // The treadmill on Friday is a run too. assertEquals("Last run", model().lastLabel) @@ -122,7 +131,7 @@ class StartWidgetModelTest { assertEquals("Last workout", StartWidgetModel.lastLabel(5)) assertEquals("Last walk", StartWidgetModel.lastLabel(40)) assertEquals("Today, 35 min", "${recentDay(now.atZone(zone).toLocalDate(), now.atZone(zone).toLocalDate())}, 35 min") - assertEquals("10 Oct", recentDay(java.time.LocalDate.of(2026, 10, 10), java.time.LocalDate.of(2026, 10, 24), Locale.UK)) + assertEquals("Sat 10 Oct", recentDay(java.time.LocalDate.of(2026, 10, 10), java.time.LocalDate.of(2026, 10, 24), Locale.UK)) } @Test fun `while recording, the sport, since when and the active time`() { diff --git a/app/src/test/kotlin/org/openstrap/app/widget/StepsWidgetModelTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/StepsWidgetModelTest.kt index cb16e409..0f21d4fc 100644 --- a/app/src/test/kotlin/org/openstrap/app/widget/StepsWidgetModelTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/widget/StepsWidgetModelTest.kt @@ -7,14 +7,19 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test import org.openstrap.app.data.Summaries +import org.openstrap.app.testing.LocaleRule import org.openstrap.app.ui.glow.draw.WeekBarsLook import org.openstrap.app.ui.glow.draw.weekBars import org.openstrap.app.ui.glow.draw.weekStub /** Widgets spec 3.3: what the Steps widget shows in each state, from the app's own sums and copy, and how it fits. */ class StepsWidgetModelTest { + // TalkBack's words follow the phone's language, and these tests read them in English. + @get:Rule val phone = LocaleRule(Locale.UK) + private val today = LocalDate.of(2026, 10, 24) // a Saturday private val monday = LocalDate.of(2026, 10, 19) @@ -41,6 +46,23 @@ class StepsWidgetModelTest { assertEquals(today, m.date) } + @Test fun `the dots keep the dot font's marks, the words follow the phone's language`() { + Locale.setDefault(Locale.GERMANY) + val m = StepsWidgetModel.from(today, 8_432, hourly, 14, 10_000, past, Locale.GERMANY) + assertEquals("8,432", m.stepsText) + assertEquals("Goal 10.000", m.goalNote) + assertEquals("1.568 to go", m.toGo) + assertEquals("8.432 steps today, 84% of your goal", m.description) + } + + @Test fun `the week starts on the locale's first day`() { + // Saturday the 24th: Monday the 19th first in the UK, Sunday the 18th in the US. + assertEquals(monday, model().week.first().date) + val us = StepsWidgetModel.from(today, 8_432, hourly, 14, 10_000, past, Locale.US) + assertEquals(LocalDate.of(2026, 10, 18), us.week.first().date) + assertEquals(today, us.week.last().date) + } + @Test fun `past the goal, Goal met replaces what's to go and the bar stays full`() { val m = model(steps = 12_345) assertTrue(m.goalMet) diff --git a/app/src/test/kotlin/org/openstrap/app/widget/StrapStatusTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/StrapStatusTest.kt index dac8ce60..fa7dbaf2 100644 --- a/app/src/test/kotlin/org/openstrap/app/widget/StrapStatusTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/widget/StrapStatusTest.kt @@ -59,7 +59,9 @@ class StrapStatusTest { assertEquals("Synced 09:10", line(StrapStatus.StaleSync(now - 2 * 3600 - 50 * 60))) assertEquals("Synced yesterday", line(StrapStatus.StaleSync(now - 86_400))) assertEquals("Synced Tuesday", line(StrapStatus.StaleSync(now - 3 * 86_400))) - assertEquals("Synced 13 Oct", line(StrapStatus.StaleSync(now - 10 * 86_400))) + assertEquals("Synced Tue 13 Oct", line(StrapStatus.StaleSync(now - 10 * 86_400))) + // A week back would repeat today's weekday, so it takes the date. + assertEquals("Synced Fri 16 Oct", line(StrapStatus.StaleSync(now - 7 * 86_400))) } @Test fun `the widgets hear of pairing, the low edge and charging, not every percent`() { diff --git a/app/src/test/kotlin/org/openstrap/app/device/UpdateThrottleTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/UpdateThrottleTest.kt similarity index 96% rename from app/src/test/kotlin/org/openstrap/app/device/UpdateThrottleTest.kt rename to app/src/test/kotlin/org/openstrap/app/widget/UpdateThrottleTest.kt index c7a05611..a5e8b9dd 100644 --- a/app/src/test/kotlin/org/openstrap/app/device/UpdateThrottleTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/widget/UpdateThrottleTest.kt @@ -1,4 +1,4 @@ -package org.openstrap.app.device +package org.openstrap.app.widget import org.junit.Assert.assertEquals import org.junit.Assert.assertNull diff --git a/app/src/test/kotlin/org/openstrap/app/widget/WidgetSnapshotTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/WidgetSnapshotTest.kt new file mode 100644 index 00000000..712968bf --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/widget/WidgetSnapshotTest.kt @@ -0,0 +1,94 @@ +package org.openstrap.app.widget + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.openstrap.app.data.HealthRepository +import org.openstrap.app.data.LiveSteps +import org.openstrap.app.data.WidgetKind +import org.openstrap.app.data.db.ActivityMinute +import org.openstrap.app.data.db.SleepSession +import org.openstrap.app.data.settings.SettingsStore +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.testing.FixedClock +import org.openstrap.app.testing.inMemoryDb +import org.openstrap.protocol.services.StepTotals +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Instant + +/** + * The widgets' one read per refresh (audit A4, A5): only the kinds asked for are read, the live steps come from the + * app's one [LiveSteps], and the moment, the Moment widget and the Sleep widget pick the same main night. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class WidgetSnapshotTest { + private val context = ApplicationProvider.getApplicationContext() + private val db = inMemoryDb() + private val dao = db.healthDao() + private val clock = FixedClock() + private val liveSteps = LiveSteps() + private var live: StepTotals? = null + private val snapshot = WidgetSnapshot( + HealthRepository(dao), dao, SettingsStore(context), liveSteps, + syncing = { false }, todaySteps = { live }, workoutState = { WorkoutState.Idle }, location = { false }, placed = { emptySet() }, + ) + private val now = clock.instant + private val zone = clock.zone + private val midnight = now.atZone(zone).toLocalDate().atStartOfDay(zone).toEpochSecond() + + @After fun tearDown() = db.close() + + @Test fun `a refresh reads only the kinds it is for`() = runBlocking { + val none = snapshot.gather(emptySet(), now, zone) + assertNull(none.moment) + assertNull(none.steps) + assertNull(none.sleep) + val steps = snapshot.gather(setOf(WidgetKind.Steps), now, zone) + assertNotNull(steps.steps) + // Steps is lit in the moment's mood, so the moment is read, and nothing else is. + assertNotNull(steps.moment?.getOrNull()) + assertNull(steps.sleep) + assertNull(steps.recovery) + assertNull(steps.start) + assertNull(steps.momentWidget) + } + + @Test fun `live steps carry on from the app's one counter, as Today's do`() = runBlocking { + dao.insertActivity(listOf(ActivityMinute(midnight + 3600, 2456, 0, 0, null))) + // Firmware 3.25's counter with its offset: implausible, so counted from the history on. + live = StepTotals(11368, 1836, 151) + assertEquals(2456, snapshot.gather(setOf(WidgetKind.Steps), now, zone).steps?.day?.steps) + live = StepTotals(11391, 1853, 152) + // A fresh counter per read would show 2,456 again (A5): the shared one adds the 23 steps since. + assertEquals(2479, snapshot.gather(setOf(WidgetKind.Steps), now, zone).steps?.day?.steps) + } + + @Test fun `the moment, Moment and Sleep pick one main night`() = runBlocking { + val main = SleepSession(start = midnight - 3 * 3600, end = midnight + 7 * 3600) + val shorter = SleepSession(start = midnight + 8 * 3600, end = midnight + 8 * 3600 + 1800) + dao.insertSleepSessions(listOf(main, shorter)) + val data = snapshot.gather(setOf(WidgetKind.Sleep, WidgetKind.Moment), now, zone) + assertEquals(main.end, data.moment?.getOrThrow()?.sleepEnd) + assertEquals(main.start, data.sleep?.getOrThrow()?.tonight?.session?.start) + assertEquals(main.start, data.momentWidget?.getOrThrow()?.night?.session?.start) + } + + @Test fun `the Sleep widget reads as many nights as the Sleep tab`() { + assertEquals(org.openstrap.app.ui.dashboard.SLEEP_NIGHTS, WidgetSnapshot.SLEEP_NIGHTS) + } + + @Test fun `each widget adds its own bedtime to the moment`() = runBlocking { + val data = snapshot.gather(setOf(WidgetKind.Steps), Instant.parse("2026-01-15T21:00:00Z"), zone) + val bed = java.time.LocalTime.of(22, 30) + assertEquals(bed, data.momentInputs(bed)?.usualBedtime) + assertNull(data.moment?.getOrThrow()?.usualBedtime) + } +} diff --git a/app/src/test/kotlin/org/openstrap/app/widget/WidgetStatusLineTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/WidgetStatusLineTest.kt index ffa61ca4..f0628830 100644 --- a/app/src/test/kotlin/org/openstrap/app/widget/WidgetStatusLineTest.kt +++ b/app/src/test/kotlin/org/openstrap/app/widget/WidgetStatusLineTest.kt @@ -11,7 +11,6 @@ import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -import org.openstrap.app.device.WidgetUpdater /** * Widgets spec 5: the strap status line's wordings, its button and words in the title row, and that every widget @@ -59,7 +58,7 @@ class WidgetStatusLineTest { assertEquals(listOf("Synced 08:59", "08:59"), line(StrapStatus.StaleSync(now - 3 * 3600 - 60)).options) assertEquals(listOf("Synced yesterday", "Yesterday"), line(StrapStatus.StaleSync(now - 86_400)).options) assertEquals(listOf("Synced Wednesday", "Synced Wed", "Wednesday", "Wed"), line(StrapStatus.StaleSync(now - 3 * 86_400)).options) - assertEquals(listOf("Synced 13 Oct", "13 Oct"), line(StrapStatus.StaleSync(now - 11 * 86_400)).options) + assertEquals(listOf("Synced Tue 13 Oct", "Tue 13 Oct", "13 Oct"), line(StrapStatus.StaleSync(now - 11 * 86_400)).options) lines.forEach { l -> assertEquals(strapStatusLine(l.status, now, zone, Locale.UK), l.full) l.options.forEach { assertFalse(it, it.any { c -> c in "·;—–" }) } @@ -120,11 +119,11 @@ class WidgetStatusLineTest { } @Test fun `a stale line has an alarm of its own, only while widgets are placed`() { - assertEquals(now + 1, WidgetUpdater.staleAt(true, now - SYNC_STALE_SECONDS, now - 10)) + assertEquals(now + 1, WidgetUpdater.staleAt(true, now - org.openstrap.app.presentation.StrapBands.STALE_AFTER_S, now - 10)) assertNull(WidgetUpdater.staleAt(false, now, now)) assertNull(WidgetUpdater.staleAt(true, null, now)) // Already stale: the line shows now, no alarm needed. - assertNull(WidgetUpdater.staleAt(true, now - SYNC_STALE_SECONDS - 5, now)) + assertNull(WidgetUpdater.staleAt(true, now - org.openstrap.app.presentation.StrapBands.STALE_AFTER_S - 5, now)) } @Test fun `the gaps share the spare height, half above and below`() { diff --git a/app/src/test/kotlin/org/openstrap/app/widget/WidgetTriggersTest.kt b/app/src/test/kotlin/org/openstrap/app/widget/WidgetTriggersTest.kt new file mode 100644 index 00000000..5597a14d --- /dev/null +++ b/app/src/test/kotlin/org/openstrap/app/widget/WidgetTriggersTest.kt @@ -0,0 +1,120 @@ +package org.openstrap.app.widget + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openstrap.app.data.WidgetKind +import org.openstrap.app.data.settings.StrapSnapshot +import org.openstrap.app.device.SyncState +import org.openstrap.app.device.WorkoutState +import org.openstrap.app.presentation.StrapBands +import org.openstrap.app.testing.appWorkout +import org.openstrap.protocol.services.StepTotals + +/** + * What refreshes which widgets now they watch the data (wave 4, Decisions 5): the workout's state changes as + * [workoutKinds] reads them, and that a process start, where every flow gives its first value, refreshes nothing. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WidgetTriggersTest { + private val both = setOf(WidgetKind.Moment, WidgetKind.Start) + private val all = WidgetKind.entries.toSet() + private val recording = WorkoutState.Recording(appWorkout(start = NOW - 600), maxHeartRate = 190) + + @Test fun `nothing before the first state`() { + assertEquals(emptySet(), workoutKinds(null, recording)) + } + + @Test fun `starting and ending a workout refresh Moment and Start`() { + assertEquals(both, workoutKinds(WorkoutState.Idle, recording)) + assertEquals(both, workoutKinds(recording, WorkoutState.Idle)) + } + + @Test fun `a pause or a resume refreshes Start alone`() { + val paused = recording.copy(workout = recording.workout.copy(pausedAt = NOW)) + assertEquals(setOf(WidgetKind.Start), workoutKinds(recording, paused)) + assertEquals(setOf(WidgetKind.Start), workoutKinds(paused, recording)) + } + + @Test fun `the same state, or a new reading while recording, refreshes nothing`() { + assertEquals(emptySet(), workoutKinds(WorkoutState.Idle, WorkoutState.Idle)) + assertEquals(emptySet(), workoutKinds(recording, recording.copy(restExtra = 10))) + } + + // The watching, on flows the test moves. + private val scope = TestScope(UnconfinedTestDispatcher()) + private val goals = MutableStateFlow(1) + private val sides = MutableStateFlow(mapOf(1 to true)) + private val snapshot = MutableStateFlow(StrapSnapshot(paired = true, battery = 80, charging = false, lastSyncDone = NOW - 60)) + private val sync = MutableStateFlow(SyncState.Idle) + private val steps = MutableStateFlow(null) + private val ended = MutableSharedFlow(extraBufferCapacity = 1) + private val workout = MutableStateFlow(WorkoutState.Idle) + private val changed = mutableListOf>() + private var stepPushes = 0 + + private fun watch() { + val t = WidgetTriggers(goals, sides, snapshot, sync, steps, ended, workout) { NOW } + scope.watchWidgetTriggers(t, { changed += it }, { stepPushes++ }) + scope.runCurrent() + } + + @After fun tearDown() = scope.cancel() + + @Test fun `a process start refreshes no widget`() { + watch() + assertEquals(emptyList>(), changed) + assertEquals(0, stepPushes) + } + + @Test fun `the goals and the workout sides refresh their widgets`() { + watch() + goals.value = 2 + sides.value = mapOf(1 to false) + scope.runCurrent() + assertEquals(listOf(setOf(WidgetKind.Steps, WidgetKind.Moment), setOf(WidgetKind.Start)), changed) + } + + @Test fun `a sync starting refreshes all only while the last one is stale`() { + watch() + sync.value = SyncState.Running("x") + scope.runCurrent() + assertEquals(emptyList>(), changed) + sync.value = SyncState.Idle + snapshot.value = snapshot.value.copy(lastSyncDone = NOW - StrapBands.STALE_AFTER_S - 1) + sync.value = SyncState.Running("y") + scope.runCurrent() + assertEquals(listOf(all), changed) + } + + @Test fun `the strap's state refreshes all only when the line could change`() { + watch() + snapshot.value = snapshot.value.copy(battery = 79) + scope.runCurrent() + assertEquals(emptyList>(), changed) + snapshot.value = snapshot.value.copy(charging = true) + scope.runCurrent() + assertEquals(listOf(all), changed) + } + + @Test fun `a sync ending, live steps and a workout reach the widgets`() { + watch() + ended.tryEmit(Unit) + steps.value = StepTotals(1200, 900, 40) + workout.value = recording + scope.runCurrent() + assertEquals(listOf(all, both), changed) + assertEquals(1, stepPushes) + } + + private companion object { + const val NOW = 1_768_478_400L + } +} diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 00000000..3f67ea5a --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=35 diff --git a/build.gradle.kts b/build.gradle.kts index b8a84ad3..f6063d39 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,6 @@ plugins { alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.detekt) apply false + alias(libs.plugins.ktlint) apply false } diff --git a/config/detekt.yml b/config/detekt.yml new file mode 100644 index 00000000..0c98d92d --- /dev/null +++ b/config/detekt.yml @@ -0,0 +1,104 @@ +# detekt rules for OpenStrap, on top of detekt's defaults (buildUponDefaultConfig). +# Each rule here guards a finding from docs/audits/2026-11-01-code-quality.md section 10. +# Old findings live in each module's detekt baseline. Add nothing to a baseline, fix the code instead. + +complexity: + LargeClass: + active: true + allowedLines: 600 + LongMethod: + active: true + allowedLines: 80 + # Composables are long by nature. Tighten once the screens are split (audit section 2). + ignoreAnnotated: ['Composable'] + LongParameterList: + active: true + allowedFunctionParameters: 8 + allowedConstructorParameters: 8 + ignoreAnnotated: ['Composable'] + CyclomaticComplexMethod: + active: true + allowedComplexity: 20 + +coroutines: + # C1, C2, C6: runCatching or a broad catch in suspend code eats CancellationException. + SuspendFunSwallowedCancellation: + active: true + # C3: scopes nobody owns. + GlobalCoroutineUsage: + active: true + # C7: hardcoded dispatchers. + InjectDispatcher: + active: true + +empty-blocks: + # E9: silent catches. + EmptyCatchBlock: + active: true + +potential-bugs: + # D1, D4, E8: String.format and "...".format without a Locale follow the phone's locale. This is detekt's + # own rule for kotlin.text.format without a locale, so no ForbiddenMethodCall entry is needed for it. + ImplicitDefaultLocale: + active: true + +exceptions: + TooGenericExceptionCaught: + active: true + SwallowedException: + active: true + +naming: + FunctionNaming: + ignoreAnnotated: ['Composable'] + +style: + # D9, D10: magic thresholds and sport ids. Named constants and properties are the fix, so they are allowed. + MagicNumber: + active: true + ignorePropertyDeclaration: true + ignoreCompanionObjectPropertyDeclaration: true + ignoreConstantDeclaration: true + # Matches .editorconfig, so detekt and ktlint agree on the limit. + MaxLineLength: + maxLineLength: 140 + # K12: collectAsState ignores the lifecycle. Use collectAsStateWithLifecycle. Glance widgets have no lifecycle owner. + ForbiddenMethodCall/collectAsState: + active: true + excludes: ['**/widget/**'] + methods: + - reason: 'Keeps collecting in the background. Use collectAsStateWithLifecycle.' + value: 'androidx.compose.runtime.collectAsState' + # C10: the clock read once in composition freezes time on screen. Take the time from the view model or a ticker. + ForbiddenMethodCall/uiClock: + active: true + includes: ['**/ui/**'] + methods: + - reason: 'Time read in composition goes stale. Take it from the view model.' + value: 'java.time.LocalDate.now' + - reason: 'Time read in composition goes stale. Take it from the view model.' + value: 'java.time.Instant.now' + - reason: 'Time read in composition goes stale. Take it from the view model.' + value: 'java.lang.System.currentTimeMillis' + UnusedParameter: + active: true + UnusedPrivateFunction: + active: true + UnusedPrivateProperty: + active: true + +# K3, K6, K8, K10, K11: the Compose rules (io.nlopez.compose.rules), all on, unstable collections included. +Compose: + active: true + ComposableParamOrder: + active: true + CompositionLocalAllowlist: + active: true + ModifierMissing: + active: true + MutableParams: + active: true + RememberMissing: + active: true + UnstableCollections: + active: true diff --git a/docs/design/requests/spec.md b/docs/design/requests/spec.md new file mode 100644 index 00000000..c2814816 --- /dev/null +++ b/docs/design/requests/spec.md @@ -0,0 +1,325 @@ +# User requests, late 2026: spec + +Status: draft, 2026-10-30. No branch yet. Follows the v2 design system ([../v2/spec.md](../v2/spec.md) section 2) and +its copy rules. Each feature needs mockups before code, as v2 and widgets did. + +## 0. The requests + +| # | Feature | Group | Size | +|---|---|---|---| +| 1 | Delete or adjust sleep and naps | Data | Small | +| 2 | Custom heart rate zones | Workouts | Small | +| 3 | Stop the call buzz from the strap | Strap | Unknown, hardware first | +| 4 | Workout sharing: card, photo, route, map | Gamification | Medium | +| 5 | Workout sharing: video and route replay | Gamification | Large | +| 6 | Daily streaks | Gamification | Medium | +| 7 | Badges and medals | Gamification | Medium | + +Sharing came up twice and is one feature here, split into 4 and 5 because video is much more work. Build order is in +section 8. + +## 1. Delete or adjust sleep and naps + +Now specced in full in [../sleep-edit/spec.md](../sleep-edit/spec.md), with mockups. That spec wins where they differ. + +### 1.1 Goal + +Let people remove a sleep or nap that is wrong (a false nap while sitting still, a "sleep" while the strap lay on a +table) and fix its times. Main sleep and naps use **the same logic and the same controls**. + +### 1.2 Today + +- Naps already have "Adjust times" (`SleepDetail.kt`). The user's times live in `nap_edit` (`NapEdit`), keyed by the + strap's start for the nap, because a sync deletes and rewrites the day's sessions (`HistorySync.kt` + `deleteSleepSessionsForDay`). +- Main sleep has no edit and nothing can be deleted. + +### 1.3 Design: one edit record for both + +Replace `nap_edit` with `sleep_edit`, used for main sleep and naps alike: + +| Column | Meaning | +|---|---| +| `strapStart` (key) | The strap's start for the session, stable across syncs | +| `start`, `end` | The user's times, null when not changed | +| `deleted` | True when the user removed it | +| `editedAt` | When, for the restore list | + +- A migration copies every `nap_edit` row in with `deleted = false`. +- One function applies edits to sessions (today's nap logic, made general). Every reader goes through it: the Sleep + tab, Today, widgets, sleep score and need, recovery, calories, Health Connect export, CSV export. Nothing reads + `sleep_session` directly for display. +- A sync never undoes an edit, since the key is the strap's start. +- Deleting keeps the raw rows (sessions, stages). Only the edit hides them, so restore is exact. + +### 1.4 What the user sees + +- In the sleep detail, for main sleep and every nap: **Adjust times** and **Delete**, both `PageButton` with an icon + (pencil, trash). +- Delete asks once: "Delete this nap?" / "Delete last night's sleep?" with "It won't count toward totals, scores or + Health Connect." Then a snackbar with **Undo** for 8 s. +- You > Data gets a **Deleted sleep** row listing the last 30 days of deleted sessions, each with Restore. +- Adjust times on main sleep works like the nap one: start and end within the strap's own window plus 2 h either side. + +### 1.5 Effects of a delete + +- Totals, stages, sleep score, sleep debt and the sleep goal for that day leave the session out. +- Deleting the main sleep: the day shows the "no sleep recorded" state. Recovery for that morning falls back to what + it does with no sleep today. +- Health Connect: the next export deletes and rewrites sleep for that range (`HealthConnectExporter` already does + this), so it disappears there too. +- Heart rate, SpO2 and other samples in that window stay. Only the sleep session is gone. +- CSV export and backup include `sleep_edit`, so a restore keeps the user's edits. + +### 1.6 Done when + +- Deleting and adjusting work the same on main sleep and naps, and survive a full resync. +- Every screen and the widgets agree on totals after a delete. +- Unit tests for the apply function: edited, deleted, restored, edited then deleted, migration from `nap_edit`. + +## 2. Custom heart rate zones + +### 2.1 Today + +Five zones from max HR (`Workouts.zoneOf`): 50 to 60, 60 to 70, 70 to 80, 80 to 90 and 90+ % of max, named Easy, +Aerobic, Tempo, Threshold and Max (`WorkoutStyle.kt`). Max HR comes from the profile or a default of 190. + +### 2.2 Design + +You > Profile gets a **Heart rate zones** page: + +- **Mode:** "From max heart rate" (today's zones, the default) or "Custom". +- **Custom:** five rows, one per zone, each with its colour and name and a lower bpm bound. The top of each zone is the + next zone's lower bound, Max has no top. Editing one bound moves only that boundary. +- Switching to Custom fills the bounds from the current max HR, so the user starts from what they had. +- Rules: bounds strictly rise, at least 3 bpm apart, between 60 and 230. Invalid input shows inline and doesn't save. +- **Reset** returns to the computed zones. +- Changing max HR in Custom mode doesn't move the bounds. The page says so. + +### 2.3 Where zones apply + +Live workout screen, workout notification, workout summary, zone bars in history, and the share card (4). One +`HrZones` value replaces the bare `maxHr` passed around today (`zoneOf(bpm, zones)`). + +**Decision needed:** zone minutes for past workouts. If they're computed when shown, old workouts change to the new +zones. Recommendation: follow the current zones everywhere, which is simpler and what most apps do. The alternative +stores the zones used with each workout. + +### 2.4 Done when + +- A custom zone set shows the same zone for the same bpm on every screen. +- Tests for `zoneOf` with custom bounds, edges included, and for the validation rules. + +## 3. Stop the call buzz from the strap + +### 3.1 The problem + +A ringing call starts the strap's find buzz (`FindDevice.BUZZ`), which runs until the call is answered or ends +(`PhoneAlerts.kt`). The user wants a double tap on the strap to stop it. + +### 3.2 What we know + +- The strap has no screen and no known tap or gesture event. +- Tested on fw 3.25: **tapping the strap did not stop a BUZZ** (`FindDevice.kt`). +- The strap does send `07` (FOUND) on the find endpoint when buzzing stops on the strap, and the app already listens + for it (`StrapManager.watchFindDevice`). What makes the strap send it is not known. + +So a double tap may not be possible. Step 1 is a hardware test, and the fallbacks ship either way. + +### 3.3 Step 1: hardware test (adb, logging every endpoint) + +During a BUZZ, try: one firm tap, double tap, triple tap, a wrist flick, covering the sensor, taking it off. For each, +record any packet on any endpoint, especially `07` on find. Also search the Zepp app for a strap gesture or "tap to +stop" setting on this model. Write results to `TODO.md` like the other strap tests. + +- If any action makes the strap send an event: map it to "stop the call buzz", send `STOP`, and keep the buzz off + until the next call. +- If nothing does: no double tap. Say so to the user who asked, and ship 3.4. + +### 3.4 Fallbacks (ship regardless) + +- **Buzz length** setting in Phone alerts: "Until answered" (today), "30 s", "15 s", "Three pulses". A limited buzz + sends `STOP` when time is up. +- **Silencing the phone stops the strap.** When the user presses a volume key or power to silence the ringer, stop the + buzz too. Needs testing: which signal Android gives for a silenced ring (audio mode, ringer volume, call state) on + the Fold. Only ship if it is reliable. +- **"Stop strap buzz"** action on a short notification during the call, for when the phone is in a pocket and the + user is not answering. + +### 3.5 Done when + +The hardware result is recorded, and at least the buzz length setting ships. + +## 4. Workout sharing: card, photo, route, map + +### 4.1 Goal + +Share a workout as an image: a plain stats card, or the user's photo with stats and the route on top. + +### 4.2 Entry points + +- **Share** button on the workout summary right after a workout, and on any workout in history. A `PageButton` with + the share icon. + +### 4.3 The share editor + +One screen with a live preview and these controls: + +- **Background:** Card (the app's black and glow), Photo (camera or gallery), or later Video (5). +- **Stats:** up to four, picked from duration, distance, pace or speed, average HR, max HR, calories, elevation, and + the zone bar. Defaults by sport: running shows distance, pace, duration, average HR. +- **Route:** Off, Line (the route alone, no map), or Map (route on map tiles). Hidden for workouts with no GPS. +- **Layout:** stats at the top or bottom, light or dark text over a photo. +- **Size:** Story 1080 × 1920 or Post 1080 × 1350. +- **Hide start and end:** on by default whenever a route shows. Trims the first and last 200 m so home isn't on the + image. +- Buttons: **Share** (Android share sheet) and **Save** (to the gallery through MediaStore, no permission needed). + +### 4.4 Photo + +- Camera: `ActivityResultContracts.TakePicture` into a cache file through a FileProvider. +- Gallery: the system Photo Picker, no storage permission. +- Crop to the chosen size by dragging and pinching the photo inside the frame. +- Add a soft dark gradient behind the stats so they read on any photo. + +### 4.5 Route and map + +- Points come from `AppWorkoutRoutePoint`, already saved for phone GPS workouts. +- **Line:** drawn by the app on a clear layer, coloured by HR zone like the in-app map (`ZONE_COLORS`). No network. +- **Map:** MapLibre's `MapSnapshotter` with the OpenFreeMap dark style the app already uses (`WorkoutMap.kt`), + route drawn on the snapshot. The map attribution ("© OpenStreetMap") is drawn on the image, as the tiles' terms + require. Needs network. Offline it falls back to Line with a note. + +### 4.6 Rendering + +- The image is drawn with the same Compose code as the preview, captured to a bitmap off screen, so preview and + output always match. +- Fonts and the brand mark: the app's wordmark small in a corner, can be turned off. +- Files go to the cache and are cleared after a day. + +### 4.7 Attach media to a workout + +- **Keep with workout** toggle (on by default when a photo is used). The photo is copied into app storage, since + gallery links can disappear. +- New table `workout_media`: workout start, file name, type (photo or video), created at. +- Workout detail shows kept media as a strip. Tapping opens it full screen. +- Deleting a workout deletes its media. Media is left out of the backup by default because of size, and the backup + page says so. + +### 4.8 Done when + +Card, photo, route line and map each export at both sizes, match the preview, and open correctly in at least one +messaging app and Instagram stories. + +## 5. Workout sharing: video and route replay + +### 5.1 Video with stats overlay + +- Pick or record a clip (Photo Picker video, or `CaptureVideo`). Capped at 15 s, with a simple trim slider. +- Same editor as 4 with Video as the background. Stats and route are burned in. +- Encoding with Media3 Transformer and an overlay effect. Output 1080p H.264 MP4. A progress bar and Cancel, and the + screen stays on while encoding. + +### 5.2 Route replay + +An animated clip where the route draws itself over the workout's time, with a moving dot and live stats (distance, +pace, HR) ticking beside it. + +- Length 8 s at 30 fps, whatever the workout's length. +- Background: black, the map snapshot from 4.5 (fixed camera, no fly over in the first version), or the user's video. +- One code path: the replay is a time based overlay. On black or a map it is the whole clip, on a video it is drawn + over the video in the same Transformer run. +- Stats come from the route timestamps and the workout's HR series, so they line up with where the dot is. +- Needs GPS. Indoor workouts can still replay the HR curve with ticking stats, no route. + +### 5.3 Done when + +A 15 s clip and a replay each encode in under 30 s on the Fold, play in the gallery, and share to Instagram stories. + +## 6. Daily streaks + +### 6.1 Base: the day record + +Streaks and badges both need to know, per day, what happened and whether each goal was met. New table `day_result`: + +| Column | Meaning | +|---|---| +| `day` | Local midnight | +| `steps`, `activeHours`, `calories`, `sleepMinutes`, `workouts`, `wornHours` | The day's values | +| `stepsGoal`, `activeGoal`, `caloriesGoal`, `sleepGoal` | The goals as they were that day | +| `final` | True once the day has ended and synced | + +- Goals are stored per day, so changing a goal later doesn't rewrite past streaks. +- Today's row updates live. A past row is final once a sync covers the whole day. +- On first launch the table is filled from all history, with the current goals. + +### 6.2 Streak kinds + +| Streak | A day counts when | +|---|---| +| Steps | Steps goal met | +| Active | Active hours goal met | +| Sleep | Sleep goal met (uses section 1's edits) | +| Workout | At least one workout | +| Worn | Strap worn for at least 20 h | + +### 6.3 Rules + +- Today never breaks a streak. It only adds once met. +- A day with no data yet (not synced) doesn't break a streak. It waits. A day that is final and not met ends it. +- **Rest days:** Workout streaks allow one missed day in any 7 without breaking. **Decision needed:** keep this, or + make the Workout streak weekly (a goal of N workouts per week) instead. +- Each streak keeps its current and best length. + +### 6.4 What the user sees + +- A **Streaks** card on Today when any streak is 3 days or more. Shows the longest active one and "Goal met" when + today counts. The card's look must differ from its neighbours (no two cards in a row look alike). +- A Streaks page from You: every streak with current and best, and a calendar of met days. +- A quiet notification when a streak is about to break in the evening is **off** by default. Optional, decided later. + +## 7. Badges and medals + +### 7.1 The set (first version) + +| Group | Badges | +|---|---| +| Firsts | First workout, first 10k steps day, first night with the sleep goal met | +| Milestones | 10, 50, 100, 500 workouts. 100, 500, 1000 km in total. 1 million steps in total | +| Streaks | 7, 30, 100, 365 days, for each streak kind in 6.2 | +| Personal bests | Most steps in a day, longest workout, longest run, fastest 5 km and 10 km (from the route) | + +Personal bests are records, not one time badges: a new best replaces the old one and keeps its date. + +### 7.2 Rules + +- Computed from `day_result` and workouts, so they are earned for past history on first launch. The first launch + shows them all at once on one screen, not one by one. +- Each earned badge stores: id, earned date, and the workout or day that earned it. +- Deleted workouts or sleep (section 1) don't take back a badge already earned. **Decision needed** if that should + change. + +### 7.3 What the user sees + +- **Earned now:** shown on the workout summary or as a short card on Today, never as a pop up over other screens. +- A **Badges** page from You: earned badges in colour with dates, locked ones dim with what they need ("3,240 km to + go"). +- Tapping an earned badge opens it with **Share**, using the renderer from 4. +- Badge art: the v2 language (glow, dots, edge light), one shape per group. Needs mockups. + +## 8. Build order + +1. Section 1, sleep and naps. Small, and fixes data people see as wrong. +2. Section 2, HR zones. Small, self contained. +3. Section 3 step 1, the hardware test. Short, and decides the rest of 3. +4. Section 4, card and photo with route line, then map. +5. Section 6 base table, then streaks. +6. Section 7, badges. +7. Section 5, video, then replay. + +## 9. Open decisions + +- 2.3: past workouts follow new zones, or keep their own? +- 3.3: double tap only if the strap sends an event. Confirm the fallbacks are acceptable if it doesn't. +- 6.3: Workout streak with rest days, or weekly? +- 7.2: do deletes take badges back? diff --git a/docs/design/sleep-edit/spec.md b/docs/design/sleep-edit/spec.md new file mode 100644 index 00000000..dd114891 --- /dev/null +++ b/docs/design/sleep-edit/spec.md @@ -0,0 +1,176 @@ +# Adjust or delete sleep and naps: spec + +Status: draft, 2026-10-30. No branch yet. Mockups: the +[sleep and nap edits canvas](https://claude.ai/artifact/GyxZkFcptwfTVj95XDF5Bn). Expands section 1 of +[../requests/spec.md](../requests/spec.md) and replaces it where they differ. Follows the v2 design system +([../v2/spec.md](../v2/spec.md) section 2) and its copy rules. + +## 0. Goal + +Let people fix a sleep or nap the strap got wrong: move its start or end, or remove it (a "nap" while sitting still +at a desk, a "sleep" while the strap lay on the bedside table). **Main sleep and naps share one logic, one table, one +sheet and the same two buttons.** + +## 1. Today [SleepNow, NapsNow, NapTimesNow] + +- Naps have **Adjust times** (`NapsDetail` and `NapTimesSheet` in `SleepDetail.kt`). The user's times are in + `nap_edit` (`NapEdit`: strapStart, start, end), applied by `NapTimes.apply`, which ignores anything that isn't a + nap. +- Main sleep has no edit. Nothing can be deleted. +- Edits are applied in `DayViewModel`, `RecoveryEngine` (twice), `HealthConnectExporter` and the change feed in + `HealthRepository`. Other readers of sleep sessions don't apply them: the widgets (`SleepWidgetModel`, + `MomentWidget`, `WidgetMoment`), `HealthspanEngine`, `SleepHistory`, `CsvTables` and `WorkoutsViewModel`. With + naps only, that gap was small. With main sleep and deletes it would show different totals on different screens. +- A sync deletes and rewrites a day's sessions (`HistorySync.kt`, `deleteSleepSessionsForDay`), which is why edits + live in their own table keyed by the strap's start. + +## 2. Data: one edit record + +Replace `nap_edit` with `sleep_edit`: + +| Column | Meaning | +|---|---| +| `strapStart` (key) | The strap's start for the session | +| `nap` | Which kind it was, so a match never crosses kinds | +| `start`, `end` | The user's times, null when not moved | +| `deleted` | True when removed | +| `editedAt` | When, for the Deleted sleep list and the 7 day clean up | + +- The migration (Room 24 to 25, or whichever is next) copies every `nap_edit` row with `nap = true`, + `deleted = false`, `editedAt` = migration time, then drops `nap_edit`. +- A row with no times and `deleted = false` is removed, so "Use the strap's times" leaves no trace (as + `NapTimes.edit` does today). +- **Matching a session to its edit**: the exact `strapStart` first. If none, the session of the same kind whose + start is within 15 minutes, so a resync that moves the strap's start a little keeps the edit. Unit tested. +- Raw rows (sessions and stages) are never changed by an edit, so Undo and Restore are exact. A delete removes them + only after 7 days (4.4). + +## 3. Logic: `SleepEdits` + +`NapTimes` becomes `SleepEdits`, one pure object for both kinds: + +- `apply(sessions, edits)`: drops deleted sessions, moves edited ones. The only way any code reads sleep for display + or maths. +- `clip(stages, start, end)`: as today, stages outside the user's times go. +- `problem(strap, start, end, now)`: why times can't be saved, by kind: + +| Rule | Nap | Main sleep | +|---|---|---| +| Woke after fell asleep | yes | yes | +| Shortest | 5 min | 1 h | +| Longest | 4 h | 16 h | +| Furthest either end may move from the strap's | 3 h | 3 h | +| Not after now | yes | yes | +| Doesn't overlap another sleep or nap as shown | yes | yes | + +Messages, one sentence each: "Waking up has to come after falling asleep.", "A nap needs at least 5 minutes.", +"Sleep needs at least an hour.", "A nap can be 4 hours at most.", "Sleep can be 16 hours at most.", "Keep it within 3 +hours of what the strap saw.", "That's later than now.", "That runs into your nap at 14:05." + +A repository wrapper (`HealthRepository.sleepSessions`) returns sessions already through `apply`. Every reader in +section 1 moves to it, and `dao.sleepSessions*` is called only from there and the sync. A lint style test greps for +direct DAO sleep reads outside those two places. + +## 4. Screens + +### 4.1 Actions [SleepEdit, NapsEdit] + +- In the Sleep score detail, under Fell asleep, Woke up and Asleep: **Adjust times** (pencil) and **Delete** (trash), + side by side, both `PageButton` with an icon. Delete has the warn text colour. +- In the Naps detail, the same pair under each nap. +- Not on the Sleep tab hero or Today. Editing is a detail page action. + +### 4.2 Adjust times [TimesSheet, SleepEdited] + +`NapTimesSheet` becomes `SleepTimesSheet`, one sheet for both kinds: + +- Title "Sleep times" or "Nap times". Under it: "The strap saw 23:20 to 07:21." and one line on how to read the chart. +- The heart rate from 2 h before to 2 h after the strap's session, with the chosen span shaded and its two edges + drawn, scrubbable as today. +- Segments "Fell asleep 23:55" and "Woke up 07:21", and the alarm style time picker (`AlarmClockColors`) as today. +- One line with the result: "Asleep for 7 h 26." or "A nap of 42 min.", or the problem from section 3 in warn colour. +- Cancel and Save. "Use the strap's times" only when the times differ. +- After saving: the detail shows a note "Your times. The strap saw 23:20 to 07:21." with a pencil, the stage chart + dims what's outside, and the score, totals and sentence are worked out again from the user's span. + +### 4.3 Delete [DeleteSleep, SleepDeleted, NapDeleted] + +- Delete opens a sheet once: "Delete last night's sleep?" or "Delete this nap?", the times, then "It won't count + toward your totals, scores or Health Connect. Heart rate and the other readings stay." and "You can bring it back + from You, Your data, Deleted sleep for 7 days." Cancel and a red Delete with a trash icon. +- After it: a snackbar "Last night's sleep deleted" or "Nap at 10:40 deleted" with **Undo** for 8 s. +- Main sleep deleted: the detail shows "No score" and a card "No sleep recorded" that says which times were deleted + and that recovery works without last night, with **Restore**. The day strip loses its dot. +- A nap deleted: it leaves the list, the day's nap total and sentence change. + +### 4.4 Deleted sleep [DeletedList] + +- You, Your data, a row **Deleted sleep** (shown only when there is something in it). +- Each entry: night or nap, its day, its times, how long until it's gone ("6 days left"), and **Restore**. +- **Deleted for good after 7 days.** A daily job (the existing sync worker) removes the session and its stages for + every `deleted` edit older than 7 days, and the entry leaves the list. The `sleep_edit` row stays as a marker with + `start` and `end` cleared, so a later resync of that night from the strap is dropped again instead of coming back. + Heart rate and the other samples in the window still stay. +- No manual "delete now" in this release. + +### 4.5 A hint for false naps [NapsEdit] + +When a nap's average heart rate is within 5 bpm of the hour before it and it has no deep sleep, the nap shows one +line: "Heart rate stayed at 72, as when you sit still. Might not be a nap." The buttons are the same. Nothing is +removed automatically. + +## 5. What a delete or move changes + +- Totals, stages, sleep score, sleep need and debt, regularity, naps total and the sleep goal for that day use the + shown sessions only. +- Main sleep deleted: recovery for that morning does what it does on a night with no sleep. The morning hero and + widgets show their no sleep state. +- Moved: the stages are clipped (`clip`). When both ends moved less than 15 minutes in all, the strap's own score + stays. A bigger move keeps the strap's score as the anchor and moves it by what OpenStrap's own model says the + change does: shown = strap score + (own score of the user's span less own score of the strap's span), 0 to 100. A + shorter night never scores above the strap's, nor a longer one below it. The score's info sheet then says "The + strap's score, adjusted for your times." (Changed 2026-10-30 after phone testing: replacing the strap's score + outright made a night 35 minutes shorter score 90 against the strap's 83.) +- Health Connect: the exporter already deletes and rewrites sleep for its range, so the next export removes or + moves it there too. The export runs right after an edit, not only on the next sync. +- Heart rate, SpO2, HRV, skin temperature and breathing samples in the window stay. +- CSV export includes `sleep_edit`. Backup and restore keep it. +- Badges and streaks already earned don't change (requests spec 7.2). + +## 6. Copy + +- Buttons: "Adjust times", "Delete", "Restore", "Undo", "Use the strap's times". +- Times read "23:20 to 07:21" in new text. The existing nap row title "14:05–14:47" changes to "14:05 to 14:47". +- No ·, ; or dashes in UI text. + +## 7. Done when + +- Adjust and Delete work the same on main sleep and naps, and survive a full resync, including one that shifts the + strap's start by a few minutes. +- After a delete or a move, the Sleep tab, Today, Recovery, the widgets, Healthspan, Activity's calories and Health + Connect all show the same totals. +- Undo and Restore bring back exactly what was there, until day 7. After it the session and stages are gone and a + resync doesn't bring them back. +- Unit tests for `SleepEdits`: moved, deleted, restored, moved then deleted, kinds never cross, fuzzy match within + 15 min, overlap check, every `problem` message, migration from `nap_edit`, the 7 day clean up and its marker, the + 15 minute rule for keeping the strap's score. + +## 8. Build order + +1. `sleep_edit`, the migration and `SleepEdits` with tests (no visible change, naps keep working). +2. Route every reader through the repository wrapper. +3. Main sleep Adjust times with the shared sheet. +4. Delete, Undo, the no sleep state, then Deleted sleep in You, Your data. +5. The false nap hint. + +## 9. Decisions + +- Deleted sleep is removed for good after 7 days (decided 2026-10-30). +- A move under 15 minutes keeps the strap's score (decided 2026-10-30). + +## 10. Open decisions + +- None. "I was awake" apart from "the strap was off": recommended no. Every delete leaves the night out of averages, + since deletes are nearly always the strap misreading (on the table, sitting still), and a zero night would drag + sleep debt, regularity and the weekly average down for a night that was partly slept. If it's needed later, it + goes on the "No sleep recorded" card as an optional "I didn't sleep" tag, not in the delete sheet. diff --git a/docs/design/workouts/spec.md b/docs/design/workouts/spec.md new file mode 100644 index 00000000..b1a02938 --- /dev/null +++ b/docs/design/workouts/spec.md @@ -0,0 +1,369 @@ +# Workouts, first release: spec + +Status: draft, 2026-10-30. No branch yet. Mockups: the +[simple canvas](https://claude.ai/artifact/Tt8TBJwvpzLX2kb1x1Jwpb). The fuller flows kept for later are on the +[advanced canvas](https://claude.ai/artifact/JqHesboNdm9TDvrQ3AJnHE). Follows the v2 design system +([../v2/spec.md](../v2/spec.md) section 2) and its copy rules. Sharing, badges and heart rate zones build on +[../requests/spec.md](../requests/spec.md), which this spec changes in two places (section 9). + +## 0. What this release is + +One flow for every workout: **Choose, Ready, Live, Summary, Share**, plus one weekly goal with bests and badges. + +The aim is that most workouts start in two taps, and that each kind of workout shows what matters for it (pace for a +run, the set and the rest for strength, the timer for HIIT, calm for yoga), without the user ever choosing a "kind". + +| In this release (simple canvas) | Kept for later (advanced canvas) | +|---|---| +| Choose: your usual, two recent, one saved, All sports | Choose with every sport on the first screen | +| Ready: live heart rate, one status line, goal chips, Start | The Structure row and the options list on Ready | +| Run: two views, Pace and Map | The Big numbers view and the always on glance | +| Strength: sets, rest, optional reps and weight | Supersets and circuits | +| HIIT: timer presets. Run: distance or time reps | Interval builder for any sport, warm up and cool down blocks | +| One weekly goal, personal bests, badges | Daily streaks, rest days, streak at risk nudge, several streaks | +| Share as card or photo, with route or map | Video and route replay (requests spec section 5) | + +Nothing from the right column comes back without asking. + +## 1. The model (internal only) + +Every workout is a **sport** plus a **structure** plus a **setting**. These words and the family names never appear +in the app. The user picks a sport and, at most, a goal chip. The app works out the rest. + +| Family | Sports (Zepp type ids where they exist) | Structure | Setting | Live screen | +|---|---|---|---|---| +| Distance | Run, walk, hike, cycling, outdoor sides of each | Open or goal, intervals for run | Outdoor GPS, or the indoor side | Pace and Map (2.3) | +| Indoor cardio | Treadmill, elliptical, rower, indoor bike, stairs | Open or goal | Indoor | Heart (2.3) | +| Strength | `Strength.SPORTS` (24, 50, 52, 222, 226) | Sets | In place | Set, Log, Rest (2.4) | +| Intervals | HIIT (49), run intervals | Rounds | In place, or outdoor GPS | Timer (2.5) | +| Mind and body | Yoga, pilates, stretching, breathing, tai chi | Open or length | In place | Calm (2.6) | +| Games and sports | Football, basketball, tennis, padel and the rest | Free play, halves, quarters | Phone in a bag | Game (2.7) | + +A new `Workouts.family(type)` maps every sport id to one family. Sports not listed fall into Indoor cardio if they +have an indoor side, else Games and sports. Unit tests cover every id in `SPORT_NAMES`. + +## 2. Screens + +Board names in brackets are the canvas boards. + +### 2.1 Choose [ChooseV1] + +Replaces the sport picker as the first screen when you start a workout (`GlowSportPickerScreen` stays behind +All sports). + +- Top: "Start a workout", and one status line for the strap ("Strap ready, 72 bpm", or what's wrong and a button). +- **Your usual**: one glow card for the sport you started most in the last 28 days, with what you did last time + ("Last time 5.2 km, Tuesday") and a **Start** button inside it. Start goes straight to Live with last time's + settings, skipping Ready. Tapping the card elsewhere opens Ready. +- **Recent**: the next two sports from `Workouts.recentSports`, as small cards with last time's settings in one line. + Tapping opens Ready. +- **Saved**: the most recent saved interval workout (2.5), if any, as one row. +- **All sports**: a button at the bottom that opens today's picker with search. +- New user with no workouts: no usual card. Recent shows Run, Walk and Strength, and All sports. + +The tile and the widget keep starting the sport they start today (no Choose step). + +### 2.2 Ready [ReadyRunV1, ReadyIndoorV1, ReadyMindV1, ReadyStrength, HiitReady, ReadyGame] + +Same frame for every family: close, the sport name as a pill (tap to change sport), a settings icon, the big live +heart rate from the strap, one status line, the family's one choice, and a Start button named for the sport +("Start run"). + +Everything is filled from last time for that sport. Ready never asks a question it can answer itself. + +| Family | One status line | The choice on Ready | Behind the settings icon | +|---|---|---|---| +| Distance | GPS: "Finding GPS, ±18 m" then "GPS ready" | Outdoor or the indoor side, then goal chips: Just run, 5 km, 30 min, More | Keep the route, audio cues, auto pause, ask to end | +| Indoor cardio | What the strap will do ("buzzes at the halfway mark and at your goal") | Goal chips: Just go, 20 min, 30 min, More | Ask to end, zone target | +| Strength | "The strap buzzes when rest is over" | Sets and rest steppers, end rest sooner below, Log reps and weight | Ask to end | +| Intervals (HIIT) | "The strap buzzes at every switch" | Presets: Tabata, 40 / 20, Every minute, Free. Warm up first | Countdown length | +| Mind and body | "Quiet strap and a dim screen. One soft buzz at the end." | Length chips: Open, 15, 30, 45, 60 min | None in this release | +| Games and sports | "Phone in your bag? That's fine." | Free play, Halves, Quarters, with length and break steppers | Ask to end when I stop | + +**More** opens a sheet with the goal kinds: distance, time, heart rate zone, active kcal, and for run, intervals +(2.5). The chips shown are the two most used goals for that sport, so they learn. + +GPS: Start is never blocked by GPS. Starting before a fix shows "Finding GPS" on Live and the route starts at the +first fix under 20 m. + +Fix on the canvas: ReadyGame still says "Structure" as a label. It becomes no label, just the three chips. + +### 2.3 Live: distance and indoor cardio [Run, Map, Main, Paused] + +Common to every live screen: minimize, the sport, strap battery, the total time large, and the controls row +**Lap or Skip, Pause, Hold to end** at the bottom in the same place. Pause is the biggest. Ending always needs a hold. + +- **Run, view 1, Pace**: moving time, current pace large, avg pace, a split bar per kilometre with the one under + way, heart rate with its zone, then distance, climb and active kcal. +- **Run, view 2, Map**: the map with the route coloured by heart rate zone, a heart rate pill on top, GPS accuracy, + route only and follow buttons, and below it distance, moving time, pace and the last split. +- Two views only, swiped. The page dots read "View 1 of 2". +- **Indoor cardio**: one view. Heart rate is the hero with the zone arc and the time in each zone, the target zone if + one is set, then distance from steps, avg bpm and active kcal. +- **Paused**: time stops, "Paused for", heart rate and how far it dropped, avg pace and avg bpm, End workout and + Resume. Ending opens the summary with Undo for a few seconds. + +### 2.4 Live: strength [StrengthSet, StrengthLog, StrengthRest] + +Builds on `Strength.kt` (sets, `Rest`, `restOver`). + +- **In a set**: set N of M, the exercise name, time in this set, heart rate and zone, the sets done below with reps, + weight, time and peak. One big button: **Set done**. +- **Log** (only when "Log reps and weight" is on): a card after Set done with reps and kg steppers, filled from the + last set of that exercise. Skip or Save. Rest is already running underneath. +- **Rest**: time left, heart rate and how far it dropped, a falling heart line with the "end sooner" level, what's + next, +30 s, and **Start set N**. The strap buzzes when rest ends (time up, or under the level for the hold time). +- Exercise name: picked once per exercise from a short list or typed, remembered per user. "Next exercise" is on the + log card. Without logging, the name is optional and sets are just numbered. + +### 2.5 Live: intervals [HiitWork, HiitRest, RunIntervals] + +New. HIIT today is an open workout. + +- **Presets**: Tabata (20 s on, 10 off, 8 rounds), 40 / 20 (10 rounds), Every minute (12 rounds, rest is what's left + of the minute, the user taps Done), Free (no timer, heart rate only, laps mark rounds). +- **Work**: round N of M, what's next, the seconds left in Doto large on a work coloured card, heart rate and zone. + Skip moves to rest. +- **Rest**: a different card (rest colour), the seconds left, heart rate and the drop since work ended, peak per round + so far, +10 s, Skip rest. +- **Run intervals**: reps of a distance (400 m, 800 m, 1 km) or a time, with rest by time. Shows metres to go, rep + pace against the goal pace, and past reps. Needs GPS, or the treadmill side counts time reps only. +- **Saved**: a run interval set up in More can be saved with a name ("Tuesday intervals"). Saved workouts appear on + Choose (2.1) and in More. Up to 10. +- The strap buzzes at every switch: one buzz for rest, two for work, three for the end. Countdown beeps from the + phone in the last 3 s if sound is on. + +### 2.6 Live: mind and body [LiveMind] + +Dark by design: time against the length, a soft heart line that shows it settling, Pause and Hold to end. The screen +dims to near black after 10 s and lights on a tap. No zone colours, no kcal on screen. One soft buzz at the end of +the length, and the workout keeps recording until you end it. + +### 2.7 Live: games and sports [LiveGame] + +Made for the phone in a bag. Period and time in it, heart rate and zone, an effort bar (1 to 5 from time in zones so +far), active kcal, peak bpm and hard efforts (spells of 10 s or more in zone 5). A **Half time** button starts the +break. The strap buzzes at the end of each period and break, so nothing needs the phone. + +### 2.8 Summary [SummaryRun, SummaryIndoor, SummaryStrength, SummaryHiit, SummaryMind, SummaryGame] + +Same top for every family: sport, when, a hero card with the one number that matters, three figures, then the +family's own section, then **Earned** (new bests, badges, the weekly goal line), then Share and Done. + +| Family | Hero number | Own section | +|---|---|---| +| Distance | km | Route coloured by zone, climb, splits with the fastest marked | +| Indoor cardio | avg bpm | Time in zones, heart rate line, "vs last time" at the same effort | +| Strength | kg lifted (sum of reps × kg, only logged sets) | Sets per exercise with peaks, rest recovery against last week | +| Intervals | rounds done | Peak per round, drop in each rest, hardest round | +| Mind and body | minutes | Heart settled from X to Y, this week's days | +| Games and sports | minutes played | Each period, time in zones, a one line read ("You faded a little after 70 min") | + +Strength without logging: the hero becomes sets, and kg lifted is not shown. + +The weekly goal line is always last in Earned: "Weekly goal, 3 of 4", or "Weekly goal met, 4 of 4" on the workout +that meets it. + +### 2.9 Share [ShareRun, ShareIndoor, ShareStrength, ShareHiit, ShareMind, ShareGame] + +As the requests spec section 4: background Card or Photo (Video is shown disabled with "Coming later", or hidden, +decision in section 11), route Off, Line or Map for GPS workouts, size Story or Post, hide start and end, Save and +Share. Each family's card uses its hero number and its own small chart (splits, zones, rounds, the settling line). + +### 2.10 While it runs, around the app [NowWhileLive, HomeLiveRun, HomeLiveRest, CapsuleStates] + +When the live screen is minimized, the workout shows in two places: the Today hero (`DuringHero` in +`GlowTodayScreen.kt`) and the floating bar above the tab bar (`WorkoutCapsule` in `GlowWorkoutMode.kt`). + +**Today, as built.** Both say the same thing, sport, time and bpm, one under the other. Both look the same for every +family, so a runner can't see distance or the goal, and someone resting between sets can't see the countdown. The +hero's one button (`WorkoutShortcut`) only opens the live screen. + +**One source for both.** A new pure `LiveGlance` is worked out from the recording, the phase (2.4, 2.5) and the +goal. It holds what both places show, so they never disagree: + +| Field | Meaning | +|---|---| +| `figure` | The one number that matters now: km, rest left, work seconds, minutes | +| `line1`, `line2` | Two short lines (see the table below) | +| `progress` | 0 to 1 and its kind: Goal (fills), Drain (empties, for rest), or none | +| `tint` | Zone colour, work red, rest green, yoga purple, paused grey, asking amber | +| `action` | The next thing you'd do: Pause, Set done, Start set N, Resume, Keep going | +| `second` | An optional second action: +30 s on rest, End when asking | + +**The Today hero** (HomeLiveRun, HomeLiveRest) + +- The glow card in `tint`, lit from below. Top line: sport and total time. +- `figure` in Doto, the only Doto number on the card. +- The progress bar with one line under it: "Goal 5 km, about 4 min to go", or for rest "118 bpm, down 28. Ends + sooner once you stay under 110." +- Run: pace, bpm with its zone, active kcal as three figures. Strength rest: a "Next" box with the next set. +- Buttons: `second` or Pause as a dark round button, `action` as the white one ("Open run" while running, "Start set + 4" on rest). Tapping the card elsewhere opens the live screen. +- Below it, for a run, the last split as a small card in a different look (the no repeated look rule). +- Workouts without a goal show no bar. Mind and body shows minutes of the length and the settling line instead. + +**The floating bar** (CapsuleStates) + +Same size and place as today (64 dp, above the tab bar). It shows on every tab, and on Today only once the hero has +scrolled out of view, so the two are never on screen together. + +| Moment | Line 1 | Line 2 | Right side | +|---|---|---|---| +| Run or distance, with a goal | "4.32 km, 5:42 /km" | "24:51, 0.68 km to go" | bpm in dots, Pause. Thin goal bar on the bottom edge | +| Indoor cardio | "Treadmill, 24:51" | zone name | bpm in dots, Pause | +| Strength, in a set | "Set 3 of 5, 0:42" | exercise and bpm | **Set done** | +| Strength, resting | "0:58 rest left" | bpm and the next set | **Set N**. The bar's fill empties in green | +| HIIT, work | seconds left in Doto, "Work, round 3 of 8" | what's next and bpm | Pause. The whole bar is work red | +| HIIT, rest | the same in rest green | | Skip rest | +| Mind and body | "Yoga, 23:40 of 45 min" | none | Pause, smaller. No glow, 56 dp, the settling line | +| Games | "1st half, 32:10" | zone and effort | Half time | +| Paused | "Paused for 1:12" | what's done so far | **Resume** | +| Stopped moving (`endPrompt`) | "Still going?" | "No movement for 3 min" | End and **Keep going** | + +End from the bar opens the same confirm as the live screen. Where the bar shows Pause, it stays one tap, as today. +Where it shows another action, Pause is on the live screen and the Today hero. + +The ongoing notification (`WorkoutNotification.kt`) can take the same `LiveGlance` lines later. Not in this release. + +## 3. Rewards [GoalMoment, BadgeUnlocked, WeeklyRecap, AchievementsV1] + +Replaces the daily streaks of requests spec section 6 for this release. + +### 3.1 Weekly goal + +- **Goal**: workouts per week, 1 to 14, default 3. Set in Goals (`Goals.workoutsPerWeek`) and from the pencil on + the Goals and bests page. +- **What counts**: a workout of at least 10 minutes (`WorkoutSuggestions.MIN_MINUTES`) that you started, or an auto + detected one you kept. One a day counts at most twice. +- **Week**: Monday to Sunday, or the locale's first day. +- **Weeks in a row**: consecutive weeks with the goal met. Missing days never matter, only the week. +- Changing the goal mid week applies to that week. Past weeks keep the goal they had. + +### 3.2 Personal bests + +Records with a date and the workout that set them. A new one replaces the old. + +- Run (GPS only, never distance from steps): fastest 1 km, 5 km, 10 km and half marathon as the fastest stretch of + that distance inside any run, longest run. +- Any workout: longest workout. +- Strength (logged sets only): heaviest set per exercise. +- Day: most steps. + +### 3.3 Badges + +The set from requests spec 7.1, with the streak row replaced by weekly goal badges: first goal met, then 4, 12, 26 +and 52 weeks in a row. Plus one "first" badge per family (first Tabata, first strength session with logging and so +on). About 24 in all. Earned for past history on first launch, all shown on one screen once. + +### 3.4 Celebrations + +- The full screen **goal moment** shows once a week, after the summary of the workout that meets the goal. +- A **best or badge** shows on the summary under Earned. A full screen badge card shows only for a personal best, + never for milestones. +- **Weekly recap** on Monday morning as one notification that opens the recap page. Off with one switch. +- No other notifications, no nudges in this release. +- **Celebrations settings**: weekly goal moment on or off, weekly recap on or off. + +### 3.5 Goals and bests page + +From You: this week against the goal with weeks in a row and the best run of weeks, personal bests, badges earned +(5 of 24) with the next locked one showing what it needs, and All badges. + +## 4. Data + +Room version 24 today. One migration for all of this. + +| Change | Where | Why | +|---|---|---| +| `structure` text, null for open | `AppWorkout` | Intervals and game periods ("t:20/10x8", "r:400m/90x6", "p:2x2700/900") | +| `goal` widens `target` with distance ("d:5000") | `WorkoutTarget` | The 5 km chip | +| `exercise`, `reps`, `weightKg` nullable | `AppWorkoutSet` | Strength log | +| `rounds` table: workoutStart, index, workStart, workEnd, restEnd, peakBpm | new | Intervals and run reps | +| `saved_workout` table: id, name, sport, structure, goal, createdAt | new | Saved intervals | +| `exercise` table: name, lastReps, lastKg, usedAt | new | Exercise list and filling the log card | +| `personal_best` table: kind, value, date, workoutStart | new | Bests | +| `badge` table: id, earnedAt, workoutStart or day | new | Badges (requests spec 7.2) | +| `week_result` table: weekStart, goal, count, met | new | Weekly goal and weeks in a row | +| `Goals.workoutsPerWeek` | settings | The goal | +| Last settings per sport | settings, one JSON per sport | Ready filled from last time | + +`week_result` and bests are recomputed for a week when a workout in it is added, trimmed or deleted. Badges already +earned stay (requests spec 7.2 decision applies). + +Health Connect: unchanged, workouts still write as exercise sessions. Laps and rounds go as segments where Health +Connect has a matching type. + +## 5. Strap + +All buzzes go through one `WorkoutBuzz` with named patterns so the families stay consistent: + +| Event | Pattern | +|---|---| +| Rest over, work starts | Two short | +| Work over, rest starts | One short | +| Halfway, period over | One long | +| Goal reached, workout length over | Three short | +| Mind and body end | One soft (shortest the strap does) | + +Needs a hardware check that the patterns can be told apart on the wrist, with the phone in a bag. Same adb session +as the call buzz test (requests spec 3.3). + +## 6. Code shape + +- `GlowWorkoutMode.kt` is 1,771 lines. Split before adding: `ChooseScreen.kt`, `ReadyScreen.kt`, and one file per + live family under `ui/glow/live/`, sharing `LiveFrame` (top bar, time, controls row). +- `WorkoutSession.kt` gains a small state machine for structured workouts (`Phase.Work`, `Phase.Rest`, + `Phase.Period`, `Phase.Break`) driven by time, heart rate (`Strength.restOver`) and taps. Pure Kotlin, unit tested, + so the same rules run with the screen off. +- Summary sections become one composable per family behind the shared summary frame in `GlowWorkoutSummary.kt`. +- Rewards are worked out in `data/Rewards.kt`, pure, from workouts and days, so they can be tested and rebuilt. + +## 7. Copy + +- The family, structure and setting words are internal. The UI says the sport, the goal and what will happen. +- Status lines say what the strap or phone will do, in one sentence. +- Numbers on a card use one Doto figure only (v2 rule). Two cards in a row never share a look. +- No ·, ; or dashes in UI text. Ranges read "130 to 148". + +## 8. Done when + +- Starting your usual workout from Choose takes two taps and uses last time's settings. +- Each family's live screen works with the screen off, with the right buzzes, checked on the strap. +- Strength rest ends on time or on heart rate, the log card fills from last time, and kg lifted adds up. +- Tabata runs 8 rounds of 20 and 10 with the strap buzzing each switch, and the summary shows per round peaks. +- A 6 × 400 m run counts reps from GPS and saves as "Tuesday intervals". +- The weekly goal counts right across a week boundary, a delete and a trim, and the goal moment shows once. +- Bests and badges are earned for past history on first launch. +- Share works for every family as card or photo. +- With the live screen minimized, the Today hero and the floating bar show the same `LiveGlance`, are never on + screen together, and the bar's button is the next action for each moment in 2.10. + +## 9. Changes to the requests spec + +- Section 6 (daily streaks) moves to Later. The weekly goal here replaces it. `day_result` is not built now. +- Section 7: the streak badges become weekly goal badges (3.3). + +## 10. Build order + +1. Split `GlowWorkoutMode.kt` and add `LiveFrame` (no visible change). +2. `Workouts.family`, last settings per sport, Choose and the new Ready. +3. Run Pace and Map views, Paused, and `LiveGlance` with the new Today hero and floating bar for distance and indoor + cardio. +4. Strength log and exercises, with their bar and hero moments. +5. Interval state machine, HIIT, then run intervals and saved workouts, with their bar and hero moments. +6. Mind and body, games. +7. Summaries per family. +8. Weekly goal, bests, badges, celebrations, Goals and bests page. +9. Share for every family (requests spec section 4). + +Each step ships on its own behind nothing: the old screen stays for families not yet done. + +## 11. Open decisions + +- Does Start on the usual card skip Ready entirely, or show Ready for one second with a cancel? +- Default weekly goal: 3 or 4? +- Do auto detected workouts count toward the goal without being kept? +- Video on the share screen before section 5 lands: hidden, or shown as "Coming later"? +- Exercise list: ship a built in list of common lifts, or only what the user types? +- Floating bar during a strength set: Set done (as drawn), or keep Pause there and put Set done on the hero only? diff --git a/docs/privacy.md b/docs/privacy.md index 4bc4c8e9..2b628d45 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -17,7 +17,7 @@ You can skip the sign-in and paste the key and token by hand instead. ## Permissions -- **Nearby devices (Bluetooth)** to find and connect to the strap. On Android 11 and older, Android calls this Location. +- **Nearby devices (Bluetooth)** to find and connect to the strap. - **Location** only if you turn on GPS for an outdoor workout, and only while that workout records. Never in the background otherwise. The route is used for distance, pace and the map, then discarded unless you choose **Keep route on this phone**. Without location, distance comes from the strap's steps (shown with ≈). - **Notifications** for the "Helio Strap" connection notification, health alerts, the off-body reminder and new versions of OpenStrap. - **Health Connect** read and write, only if you export to or restore from it. diff --git a/docs/testing/background-connection.md b/docs/testing/background-connection.md index 240a8461..2ae1941d 100644 --- a/docs/testing/background-connection.md +++ b/docs/testing/background-connection.md @@ -23,7 +23,6 @@ The companion device association (sayed0am/openstrap#7) was tested on one phone: | The strap leaving range and coming back | Needs the strap moved away from the phone | Walk away with the phone for a few minutes, come back, and check the strap reconnects without opening the app. | | Android 13–15 (`onDeviceAppeared(AssociationInfo)`, presence by address) | No phone on those versions | Associate, SIGKILL the process, and check `companion: strap appeared` and `connecting to` in `adb logcat -s OpenStrap`. | | Android 12 (`onDeviceAppeared(String)`, no association ids) | No phone on that version | As above. Also check that Forget removes the association by address. | -| Android 9–11 (association, no presence callbacks) | No phone on those versions | Associate, then check the whitelist as above. Nothing restarts the process after a kill on these versions; a notification or opening the app reconnects. | | Phones without companion device support | No such phone | The row should offer the battery-optimization prompt straight away. | | A day of normal use on battery | Not run | Leave the strap connected all day with the app closed. Check that syncs, call buzzes and notification buzzes keep working, and look at OpenStrap's battery use in Settings. | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 33dc03a2..07c09012 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,13 @@ glance = "1.2.0" # Newer than the 2.7.1 Glance 1.2.0 asks for (built on a 2021 Room); named so the app can set WorkManager up itself (widget/WidgetWork.kt). work = "2.12.0" documentfile = "1.1.0" +# Static analysis. detekt 2 is the line that runs on Kotlin 2.4 (alpha.6 is built on Kotlin 2.4.10), and compose +# rules 0.6.8 is built against that exact detekt release. +detekt = "2.0.0-alpha.6" +composeRulesDetekt = "0.6.8" +slackComposeLint = "1.6.1" +ktlintGradle = "14.2.0" +ktlint = "1.8.0" [libraries] compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -30,6 +37,7 @@ compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } @@ -37,6 +45,7 @@ lifecycle-service = { group = "androidx.lifecycle", name = "lifecycle-service", navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } +room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } health-connect = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" } @@ -54,6 +63,8 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } org-json = { group = "org.json", name = "json", version.ref = "orgJson" } robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } androidx-test-core = { group = "androidx.test", name = "core-ktx", version.ref = "androidxTestCore" } +compose-rules-detekt = { group = "io.nlopez.compose.rules", name = "detekt", version.ref = "composeRulesDetekt" } +slack-compose-lint = { group = "com.slack.lint.compose", name = "compose-lint-checks", version.ref = "slackComposeLint" } haze-blur = { group = "dev.chrisbanes.haze", name = "haze-blur", version.ref = "haze" } [plugins] @@ -61,3 +72,5 @@ android-application = { id = "com.android.application", version.ref = "agp" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +detekt = { id = "dev.detekt", version.ref = "detekt" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } diff --git a/licenses/OFL-MuseoModerno.txt b/licenses/OFL-MuseoModerno.txt deleted file mode 100644 index 292cb458..00000000 --- a/licenses/OFL-MuseoModerno.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2020 The MuseoModerno Project Authors (https://github.com/Omnibus-Type/MuseoModerno) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/protocol/build.gradle.kts b/protocol/build.gradle.kts index 3b293104..c8e86686 100644 --- a/protocol/build.gradle.kts +++ b/protocol/build.gradle.kts @@ -2,6 +2,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.detekt) + alias(libs.plugins.ktlint) } java { @@ -12,7 +14,24 @@ java { kotlin { jvmToolchain(21) // Bytecode level matches the Android app module. - compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + // The module is small and clean, so a new warning fails the build here (audit section 10, step 5). + allWarningsAsErrors.set(true) + } +} + +// Same rules as :app (config/detekt.yml). Old findings live in detekt-baseline-main.xml. +detekt { + buildUponDefaultConfig = true + config.setFrom(rootProject.file("config/detekt.yml")) + baseline = file("detekt-baseline.xml") +} + +// Formatting only, from .editorconfig. Old violations live in the baseline, so nothing is reformatted. +ktlint { + version = libs.versions.ktlint.get() + baseline = file("ktlint-baseline.xml") } dependencies { @@ -21,4 +40,6 @@ dependencies { testImplementation(libs.coroutines.test) // Only used to cross-check our B-163 implementation in tests; not shipped. testImplementation(libs.bouncycastle) + + detektPlugins(libs.compose.rules.detekt) } diff --git a/protocol/detekt-baseline-main.xml b/protocol/detekt-baseline-main.xml new file mode 100644 index 00000000..cd73d930 --- /dev/null +++ b/protocol/detekt-baseline-main.xml @@ -0,0 +1,276 @@ + + + + + AbstractClassCanBeInterface:FetchAssembler.kt:FetchAssembler.Error$Error + AbstractClassCanBeInterface:FetchAssembler.kt:FetchAssembler.Result$Result + AbstractClassCanBeInterface:FetchAssembler.kt:FetchAssembler.State$State + AbstractClassCanBeInterface:FetchCodec.kt:GdspMessage$GdspMessage + AbstractClassCanBeInterface:RecordParsers.kt:ParseResult$ParseResult + ComplexCondition:SportSession.kt:SportSession$reply.size < 4 || reply.u8(0) != REPLY || reply.u8(1) != VERSION || reply.u8(2) != 1 + CyclomaticComplexMethod:Settings.kt:SettingsCodec$fun parse: SettingsGroup? + EmptyElseBlock:Services.kt:RealtimeSteps$if (r.remaining < 4) return StepTotals(steps, null, null) + EmptyWhileBlock:GdspClient.kt:GdspClient${} + EmptyWhileBlock:Settings.kt:SettingsCodec${} + ImplicitDefaultLocale:Auth.kt:Auth$"status 0x%02x".format(status) + ImplicitDefaultLocale:Bytes.kt:"%02x".format(it) + ImplicitDefaultLocale:ChannelSession.kt:ChannelSession$"%04x".format(endpoint) + ImplicitDefaultLocale:ChannelSession.kt:ChannelSession$"%04x%s".format(it.key, if (it.value) "*" else "") + ImplicitDefaultLocale:DfuSession.kt:DfuSession$"No reply to %02X — the strap may be rebooting; reconnect before retrying".format(command) + ImplicitDefaultLocale:DfuSession.kt:DfuSession$"Step %02X failed: %s".format(cmd, DfuCode.describe(reply.code)) + ImplicitDefaultLocale:DfuSession.kt:DfuSession$"Step %02X failed: %s".format(command, DfuCode.describe(r.code)) + ImplicitDefaultLocale:DfuSession.kt:DfuSession$"Step %02X took too long".format(command) + ImplicitDefaultLocale:DfuSession.kt:DfuSession$"Unexpected reply to %02X".format(cmd) + ImplicitDefaultLocale:GdspClient.kt:GdspClient$"%02x %s".format(it.opcode, it.status) + ImplicitDefaultLocale:GdspClient.kt:GdspTimeoutException$"no reply to GDSP command %02x".format(op) + MagicNumber:ActivityLayout.kt:ActivityLayout$0xFF + MagicNumber:ActivityLayout.kt:ActivityLayout$4 + MagicNumber:ActivityLayout.kt:ActivityLayout$8 + MagicNumber:ActivityLayout.kt:ActivityLayout$9 + MagicNumber:Alarms.kt:Alarm$0x7F + MagicNumber:Alarms.kt:Alarm$23 + MagicNumber:Alarms.kt:Alarm$59 + MagicNumber:Alarms.kt:AlarmCodec$3 + MagicNumber:Alarms.kt:AlarmCodec$4 + MagicNumber:Auth.kt:Auth$0x10 + MagicNumber:Auth.kt:Auth$3 + MagicNumber:Auth.kt:AuthHandshake$16 + MagicNumber:Bytes.kt:0xFF + MagicNumber:Bytes.kt:16 + MagicNumber:Bytes.kt:8 + MagicNumber:Bytes.kt:ByteReader$4 + MagicNumber:Bytes.kt:ByteWriter$0xFF + MagicNumber:Bytes.kt:ByteWriter$0xFFFF + MagicNumber:Bytes.kt:ByteWriter$0xFFFFFFFFL + MagicNumber:Bytes.kt:ByteWriter$16 + MagicNumber:Bytes.kt:ByteWriter$32 + MagicNumber:Bytes.kt:ByteWriter$8 + MagicNumber:ChannelCrypto.kt:Aes$16 + MagicNumber:ChannelCrypto.kt:ChannelCipher$16 + MagicNumber:ChannelCrypto.kt:ChannelCipher$24 + MagicNumber:ChannelCrypto.kt:ChannelCipher$4 + MagicNumber:ChannelCrypto.kt:ChannelCipher.Companion$15 + MagicNumber:ChannelCrypto.kt:ChannelCipher.Companion$16 + MagicNumber:ChannelCrypto.kt:ChannelCipher.Companion$8 + MagicNumber:ChannelSession.kt:ChannelSession$0x04 + MagicNumber:ChannelSession.kt:ChannelSession$0xFF + MagicNumber:Chunks.kt:0xFF + MagicNumber:Chunks.kt:3 + MagicNumber:Chunks.kt:4 + MagicNumber:Chunks.kt:Reassembler$0xFF + MagicNumber:Chunks.kt:Reassembler$5 + MagicNumber:Chunks.kt:Reassembler$9 + MagicNumber:DataTypes.kt:HistoryDataType.AF_ACC$30 + MagicNumber:DataTypes.kt:HistoryDataType.AF_PPG$11 + MagicNumber:DataTypes.kt:HistoryDataType.AF_RESULT$17 + MagicNumber:DataTypes.kt:HistoryDataType.BLOOD_PRESSURE_52$52 + MagicNumber:DataTypes.kt:HistoryDataType.BLOOD_PRESSURE_53$53 + MagicNumber:DataTypes.kt:HistoryDataType.BLOOD_PRESSURE_54$54 + MagicNumber:DataTypes.kt:HistoryDataType.BLOOD_PRESSURE_55$55 + MagicNumber:DataTypes.kt:HistoryDataType.BLOOD_PRESSURE_62$62 + MagicNumber:DataTypes.kt:HistoryDataType.BODY_TEMPERATURE_80S$48 + MagicNumber:DataTypes.kt:HistoryDataType.BODY_TEMPERATURE_ALL_DAY$46 + MagicNumber:DataTypes.kt:HistoryDataType.BODY_TEMPERATURE_SINGLE$47 + MagicNumber:DataTypes.kt:HistoryDataType.DAILY_MAX_EXERCISE_HR$61 + MagicNumber:DataTypes.kt:HistoryDataType.DEVICE_STATISTIC$44 + MagicNumber:DataTypes.kt:HistoryDataType.ECG$3 + MagicNumber:DataTypes.kt:HistoryDataType.EDA_RAW_DATA$84 + MagicNumber:DataTypes.kt:HistoryDataType.HLD$71 + MagicNumber:DataTypes.kt:HistoryDataType.HLD_COMPOSITION$82 + MagicNumber:DataTypes.kt:HistoryDataType.HRV_RMSSD$73 + MagicNumber:DataTypes.kt:HistoryDataType.HRV_SDNN$78 + MagicNumber:DataTypes.kt:HistoryDataType.JS_WEIGHT_V1$75 + MagicNumber:DataTypes.kt:HistoryDataType.JS_WEIGHT_V2$77 + MagicNumber:DataTypes.kt:HistoryDataType.LIFELOAD_AUDIO$96 + MagicNumber:DataTypes.kt:HistoryDataType.LOG$7 + MagicNumber:DataTypes.kt:HistoryDataType.OSA_RESULT$74 + MagicNumber:DataTypes.kt:HistoryDataType.PAI$13 + MagicNumber:DataTypes.kt:HistoryDataType.PPG_RR$8 + MagicNumber:DataTypes.kt:HistoryDataType.RECOVERY_HEART_RATE$70 + MagicNumber:DataTypes.kt:HistoryDataType.RESTING_HEART_RATE$58 + MagicNumber:DataTypes.kt:HistoryDataType.REST_HR$86 + MagicNumber:DataTypes.kt:HistoryDataType.SEC_HR$76 + MagicNumber:DataTypes.kt:HistoryDataType.SEC_HR_V2$87 + MagicNumber:DataTypes.kt:HistoryDataType.SLEEP_BREATH_RATE$56 + MagicNumber:DataTypes.kt:HistoryDataType.SLEEP_PLAN$66 + MagicNumber:DataTypes.kt:HistoryDataType.SLEEP_RESPIRATORY_RATE_EVENT$57 + MagicNumber:DataTypes.kt:HistoryDataType.SLEEP_RESULT$72 + MagicNumber:DataTypes.kt:HistoryDataType.SOLAR_INTENSITY$89 + MagicNumber:DataTypes.kt:HistoryDataType.SPO2_OSA$38 + MagicNumber:DataTypes.kt:HistoryDataType.SPO2_OSA_EVENT$39 + MagicNumber:DataTypes.kt:HistoryDataType.SPO2_OSA_EVENT_ALT$43 + MagicNumber:DataTypes.kt:HistoryDataType.SPO2_SINGLE$37 + MagicNumber:DataTypes.kt:HistoryDataType.SPORT_DETAIL$6 + MagicNumber:DataTypes.kt:HistoryDataType.SPORT_EFFECT$59 + MagicNumber:DataTypes.kt:HistoryDataType.SPORT_SUMMARY$5 + MagicNumber:DataTypes.kt:HistoryDataType.SPORT_SUMMARY_ALT$14 + MagicNumber:DataTypes.kt:HistoryDataType.SPORT_VOICE_RECORD$79 + MagicNumber:DataTypes.kt:HistoryDataType.STRESS_ALL_DAY$19 + MagicNumber:DataTypes.kt:HistoryDataType.STRESS_SINGLE$18 + MagicNumber:DataTypes.kt:HistoryDataType.TEMPERATURE_LEGACY$4 + MagicNumber:DataTypes.kt:HistoryDataType.TODAY_ACTIVITY_EXTENDED$88 + MagicNumber:DeviceFeatures.kt:DeviceFeatures.Companion$8 + MagicNumber:DfuSession.kt:DfuSession$10 + MagicNumber:DfuSession.kt:DfuSession$1_000 + MagicNumber:DfuSession.kt:DfuSession$2_000 + MagicNumber:DfuSession.kt:DfuSession$4 + MagicNumber:DfuSession.kt:DfuSession$5 + MagicNumber:DfuSession.kt:DfuSession$6 + MagicNumber:DfuSession.kt:DfuSession$600 + MagicNumber:FetchAssembler.kt:FetchAssembler$0xFF + MagicNumber:FetchCodec.kt:FetchCodec$0xFF + MagicNumber:FetchCodec.kt:FetchCodec$3 + MagicNumber:FetchCodec.kt:FetchCodec$4 + MagicNumber:FetchCodec.kt:GdspMessage.VersionReply$4 + MagicNumber:FetchCodec.kt:GdspStatus.BUSY$7 + MagicNumber:FetchCodec.kt:GdspStatus.CANCELLED_BY_APP$9 + MagicNumber:FetchCodec.kt:GdspStatus.COMPRESS_FAILED$12 + MagicNumber:FetchCodec.kt:GdspStatus.CONTENT_PARSE_ERROR$10 + MagicNumber:FetchCodec.kt:GdspStatus.CRC_MISMATCH$8 + MagicNumber:FetchCodec.kt:GdspStatus.DATA_SIZE_MISMATCH$14 + MagicNumber:FetchCodec.kt:GdspStatus.DECOMPRESS_FAILED$13 + MagicNumber:FetchCodec.kt:GdspStatus.FAST_RECHARGE_MODE$51 + MagicNumber:FetchCodec.kt:GdspStatus.INVALID_PARAMETER$3 + MagicNumber:FetchCodec.kt:GdspStatus.INVALID_STATE$5 + MagicNumber:FetchCodec.kt:GdspStatus.MUSIC_RUNNING$48 + MagicNumber:FetchCodec.kt:GdspStatus.OPERATION_FAILED$4 + MagicNumber:FetchCodec.kt:GdspStatus.PACKET_INDEX_ERROR$6 + MagicNumber:FetchCodec.kt:GdspStatus.SPEECH_RUNNING$49 + MagicNumber:FetchCodec.kt:GdspStatus.TIMEOUT$50 + MagicNumber:FetchCodec.kt:GdspStatus.UNAUTHORIZED$11 + MagicNumber:FetchCodec.kt:GdspTime$900L + MagicNumber:FetchCodec.kt:GdspTime.Companion$900 + MagicNumber:FindDevice.kt:FindDevice$0x04 + MagicNumber:FindDevice.kt:MotorVibrate$0xFFFF + MagicNumber:Firmware.kt:DfuCommands$0xFF + MagicNumber:Firmware.kt:DfuReply.Companion$0x10 + MagicNumber:Firmware.kt:DfuReply.Companion$3 + MagicNumber:Firmware.kt:DfuReply.Companion$8 + MagicNumber:Firmware.kt:HmfwTrailer.Companion$96 + MagicNumber:Firmware.kt:UihhContainer$0x0004 + MagicNumber:Firmware.kt:UihhContainer$5 + MagicNumber:Firmware.kt:UihhContainer$6 + MagicNumber:GdspClient.kt:0xFF + MagicNumber:GdspClient.kt:256 + MagicNumber:GdspClient.kt:31 + MagicNumber:GdspClient.kt:8 + MagicNumber:GdspClient.kt:GdspClient$0xFF + MagicNumber:GdspClient.kt:GdspClient$300 + MagicNumber:Readiness.kt:ReadinessCodec$100 + MagicNumber:Readiness.kt:ReadinessCodec$22 + MagicNumber:Readiness.kt:ReadinessCodec$220 + MagicNumber:Readiness.kt:ReadinessCodec$25 + MagicNumber:Readiness.kt:ReadinessCodec$254 + MagicNumber:Readiness.kt:ReadinessCodec$3 + MagicNumber:RecordParsers.kt:0xFF + MagicNumber:RecordParsers.kt:5 + MagicNumber:Records.kt:SleepResult$1440 + MagicNumber:Records.kt:SleepResult$3600 + MagicNumber:Records.kt:SleepResult$60L + MagicNumber:Services.kt:BatteryInfo.Companion$0x04 + MagicNumber:Services.kt:BatteryInfo.Companion$0x05 + MagicNumber:Services.kt:BatteryInfo.Companion$3 + MagicNumber:Services.kt:ChannelVersion.Companion$3 + MagicNumber:Services.kt:HealthAlert.Companion$10 + MagicNumber:Services.kt:HealthAlert.Type.HIGH_STRESS$3 + MagicNumber:Services.kt:HealthAlert.Type.IRREGULAR_RHYTHM$5 + MagicNumber:Services.kt:HealthAlert.Type.LOW_SPO2$4 + MagicNumber:Services.kt:LiveHeartRate$0x05 + MagicNumber:Services.kt:LiveHeartRate$250 + MagicNumber:Services.kt:LiveHeartRate$3 + MagicNumber:Services.kt:LiveHeartRate$8 + MagicNumber:Services.kt:RealtimeSteps$0x04 + MagicNumber:Services.kt:RealtimeSteps$0x06 + MagicNumber:Services.kt:RealtimeSteps$0x0C + MagicNumber:Services.kt:RealtimeSteps$4 + MagicNumber:Services.kt:RealtimeSteps$5 + MagicNumber:Services.kt:RealtimeSteps$8 + MagicNumber:Services.kt:ServiceList$4 + MagicNumber:Services.kt:ServiceList.Companion$0x04 + MagicNumber:Services.kt:TimeSync$0x05 + MagicNumber:Services.kt:TimeSync$0x08 + MagicNumber:Services.kt:TimeSync$0xFFFF + MagicNumber:Services.kt:TimeSync$15 + MagicNumber:Services.kt:TimeSync$1_000_000 + MagicNumber:Services.kt:TimeSync$4 + MagicNumber:Services.kt:TimeSync$60 + MagicNumber:Services.kt:TimeSync$7 + MagicNumber:Services.kt:UserProfile$0x04 + MagicNumber:Services.kt:UserProfile$0x08 + MagicNumber:Services.kt:UserProfile$0x4F + MagicNumber:Settings.kt:SettingsCodec$10 + MagicNumber:Settings.kt:SettingsCodec$3 + MagicNumber:Settings.kt:SettingsCodec$4 + MagicNumber:Settings.kt:SettingsCodec$6 + MagicNumber:Settings.kt:SettingsCodec$8 + MagicNumber:SportSession.kt:SportSession$240 + MagicNumber:SportSession.kt:SportSession$255 + MagicNumber:SportSession.kt:SportSession$3 + MagicNumber:SportSession.kt:SportSession$30 + MagicNumber:SportSession.kt:SportSession$4 + MagicNumber:SportSession.kt:SportSession$5 + MagicNumber:SportSession.kt:SportSession$6 + MagicNumber:SportSession.kt:SportSession.State.ABANDON$5 + MagicNumber:SportSession.kt:SportSession.State.RESUME$3 + MagicNumber:SportSession.kt:SportSession.State.STOP$4 + MagicNumber:Workouts.kt:Proto$0x7F + MagicNumber:Workouts.kt:Proto$0x80 + MagicNumber:Workouts.kt:Proto$4 + MagicNumber:Workouts.kt:Proto$5 + MagicNumber:Workouts.kt:Proto$64 + MagicNumber:Workouts.kt:Proto$7 + MagicNumber:Workouts.kt:Proto$8 + MaxLineLength:Alarms.kt:Alarm$* + MaxLineLength:ChannelSession.kt:ChannelSession$if (v.version != 2 || v.encryptEnable == null) throw ProtocolException("Unsupported channel (v${v.version}, encryption ${v.encryptEnable})") + MaxLineLength:ChannelSession.kt:ChannelSession$services = ServiceList.parse(request(Endpoints.SERVICES, ServiceList.REQUEST, timeoutMs = 5_000, wantReply = false) { it.u8(0) == 0x04 }) + MaxLineLength:DeviceFeatures.kt:DeviceFeatures$listOf(version, mask, algorithm, group1?.contentHashCode(), group2?.contentHashCode(), group4?.contentHashCode(), group16?.contentHashCode()).hashCode() + MaxLineLength:DfuSession.kt:DfuSession$r == null -> throw DfuException(-6, "No reply to %02X — the strap may be rebooting; reconnect before retrying".format(command)) + MaxLineLength:FindDevice.kt:FindDevice$/** Zepp's continuous find. Not answered, and on fw 3.25 the strap doesn't vibrate for it (retested alone 2026-10-21); OpenStrap uses [BUZZ]. */ + MaxLineLength:Firmware.kt:DfuReply$Protocol : DfuReply + MaxLineLength:GdspClient.kt:GdspClient$* One workout (04 §3.16, `xyk.java:60-150`): START(5, cursor) for the summary, then START(6, same cursor) for its detail. The next cursor is the summary's time + 1 s. + MaxLineLength:GdspClient.kt:WorkoutFetch.Workout$data + MaxLineLength:Readiness.kt:ReadinessCodec$val score = r.u8() + MaxLineLength:Settings.kt:Setting$if (type == SettingsCodec.TYPE_STRING || type == SettingsCodec.TYPE_STRING_ENUM) raw.toString(Charsets.UTF_8).substringBefore('\u0000') else null + MaxLineLength:Settings.kt:SettingsCodec$/** GENERAL key 45 `resetSwitch`: writing true restarts the strap. Zepp offers Restart only when the strap reports it (07 §0, 03 §5.2). */ + MaxLineLength:SportSession.kt:SportSession$DEVICE_STATE -> p.takeIf { it.size >= 2 }?.let { s -> State.entries.firstOrNull { it.code == s.u8(1) }?.let(Push::StateChanged) } + MaxLineLength:SportSession.kt:SportSession.Capabilities$data + MaxLineLength:Workouts.kt:WorkoutParsers$TAG_HEART_RATE -> if (len >= 3) base?.let { b -> val off = v.u16(); HeartRateSentinel.valid(v.u8())?.let { hr += (b + off) to it } } + NoNameShadowing:Alarms.kt:AlarmCodec${ if (it and 0xFF == Alarm.WORKDAYS) Alarm.WORKDAYS else it and 0x7F } + NoNameShadowing:Readiness.kt:ReadinessCodec${ it in 1..254 } + NoNameShadowing:Readiness.kt:ReadinessCodec${ it in 25..220 } + ReturnCount:B163.kt:B163$fun isOnCurve: Boolean + ReturnCount:B163.kt:B163$fun pointAdd: Point + ReturnCount:Chunks.kt:Reassembler$fun accept: ByteArray? + ReturnCount:FetchAssembler.kt:FetchAssembler$fun accept: State + ReturnCount:FetchCodec.kt:GdspMessage.VersionReply$fun supportedTypes: Set<Int>? + ReturnCount:Firmware.kt:DfuReply.Companion$fun parse: DfuReply? + ReturnCount:Firmware.kt:HmfwTrailer.Companion$fun read: HmfwTrailer? + ReturnCount:GdspClient.kt:GdspClient$suspend fun fetch: GdspFetch + ReturnCount:GdspClient.kt:GdspClient$suspend fun fetchWorkout: WorkoutFetch + ReturnCount:GdspClient.kt:GdspClient$suspend fun transfer: Pair<ByteArray, Long?>? + ReturnCount:Readiness.kt:ReadinessCodec$fun parseScores: List<StrapReadiness>? + ReturnCount:RecordParsers.kt:private fun isSpo2Versioned: Boolean + ReturnCount:Services.kt:HealthAlert.Companion$fun parse: HealthAlert? + ReturnCount:Services.kt:RealtimeSteps$fun parseRecord: StepTotals? + ReturnCount:Settings.kt:SettingsCodec$fun calorieTargetFor: ByteArray? + ReturnCount:Settings.kt:SettingsCodec$fun parse: SettingsGroup? + ReturnCount:Settings.kt:SettingsCodec$fun parseVersion: SettingsProtocol? + ReturnCount:Settings.kt:SettingsCodec$fun versionFlag: Pair<Int, Int>? + ReturnCount:SportSession.kt:SportSession$fun parseVersion: Capabilities? + SuspendFunSwallowedCancellation:ChannelSession.kt:ChannelSession$runCatching + SuspendFunSwallowedCancellation:GdspClient.kt:GdspClient$runCatching + SwallowedException:GdspClient.kt:GdspClient$e: GdspTimeoutException + ThrowingExceptionsWithoutMessageOrCause:DeviceFeatures.kt:DeviceFeatures.Companion$IndexOutOfBoundsException() + ThrowsCount:ChannelCrypto.kt:ChannelCipher$fun decrypt: ByteArray + ThrowsCount:ChannelSession.kt:ChannelSession$suspend fun handshake + ThrowsCount:DfuSession.kt:DfuSession$private suspend fun awaitFinal + TooGenericExceptionCaught:Chunks.kt:Reassembler$e: Exception + TooGenericExceptionCaught:RecordParsers.kt:e: IndexOutOfBoundsException + TooGenericExceptionCaught:Workouts.kt:WorkoutParsers$e: IndexOutOfBoundsException + TooManyFunctions:B163.kt:B163 + TooManyFunctions:Settings.kt:SettingsCodec + UnsafeCallOnNullableType:ChannelSession.kt:ChannelSession$Auth.stripReply(command, reply)!! + UnsafeCallOnNullableType:Services.kt:UserProfile$weightUnits!! + UseOrEmpty:Alarms.kt:AlarmCodec$a.title ?: "" + + diff --git a/protocol/ktlint-baseline.xml b/protocol/ktlint-baseline.xml new file mode 100644 index 00000000..dae89027 --- /dev/null +++ b/protocol/ktlint-baseline.xml @@ -0,0 +1,726 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/protocol/src/main/kotlin/org/openstrap/protocol/ChannelSession.kt b/protocol/src/main/kotlin/org/openstrap/protocol/ChannelSession.kt index d7a9f045..7da1a159 100644 --- a/protocol/src/main/kotlin/org/openstrap/protocol/ChannelSession.kt +++ b/protocol/src/main/kotlin/org/openstrap/protocol/ChannelSession.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import org.openstrap.protocol.auth.Auth import org.openstrap.protocol.auth.AuthException @@ -54,6 +53,12 @@ interface StrapLink { class ProtocolException(message: String) : Exception(message) +/** + * The strap didn't reply in time. A plain exception, not `withTimeout`'s cancellation: a caller that rethrows every + * cancellation would otherwise take a silent strap for a cancelled job and never report the failure (audit C1). + */ +class ProtocolTimeoutException(message: String) : Exception(message) + /** * One authenticated channel connection (docs/protocol/01 §1.4 steps 6-10). * Call [start] once the link's notifications are enabled, then [handshake]. @@ -132,11 +137,10 @@ class ChannelSession( /** Version query, services list, ECDH auth. Throws [AuthException] on a rejected key. */ suspend fun handshake(authKey: ByteArray) { - val v = withTimeout(5_000) { - val versionReplies = link.controlNotifications.filter { it.isNotEmpty() && it.u8(0) == 0x02 } - val reply = launchAndAwait(versionReplies) { link.writeControl(ChannelVersion.REQUEST) } - ChannelVersion.parse(reply) ?: throw ProtocolException("Unexpected version reply ${reply.toHex()}") - } + val versionReplies = link.controlNotifications.filter { it.isNotEmpty() && it.u8(0) == 0x02 } + val reply = withTimeoutOrNull(VERSION_TIMEOUT_MS) { launchAndAwait(versionReplies) { link.writeControl(ChannelVersion.REQUEST) } } + ?: throw ProtocolTimeoutException("No reply to the channel version query") + val v = ChannelVersion.parse(reply) ?: throw ProtocolException("Unexpected version reply ${reply.toHex()}") version = v log("channel v${v.version}, encryptEnable=${v.encryptEnable}") if (v.version != 2 || v.encryptEnable == null) throw ProtocolException("Unsupported channel (v${v.version}, encryption ${v.encryptEnable})") @@ -210,15 +214,16 @@ class ChannelSession( timeoutMs: Long = 10_000, wantReply: Boolean = true, matches: (ByteArray) -> Boolean = { true }, - ): ByteArray = withTimeout(timeoutMs) { + ): ByteArray = withTimeoutOrNull(timeoutMs) { launchAndAwait(messagesFor(endpoint).filter(matches)) { send(endpoint, payload, wantReply) } - } + } ?: throw ProtocolTimeoutException("No reply on ${"%04x".format(endpoint)} in $timeoutMs ms") - /** Subscribes to [flow] before running [action], so a fast reply can't be missed. */ private companion object { const val ENCRYPTED_ACK_TIMEOUT_MS = 2_000L + const val VERSION_TIMEOUT_MS = 5_000L } + /** Subscribes to [flow] before running [action], so a fast reply can't be missed. */ private suspend fun launchAndAwait(flow: Flow, action: suspend () -> Unit): T = coroutineScope { val waiter = async(start = CoroutineStart.UNDISPATCHED) { flow.first() } diff --git a/protocol/src/test/kotlin/org/openstrap/protocol/ChannelSessionTimeoutTest.kt b/protocol/src/test/kotlin/org/openstrap/protocol/ChannelSessionTimeoutTest.kt new file mode 100644 index 00000000..61e1fc35 --- /dev/null +++ b/protocol/src/test/kotlin/org/openstrap/protocol/ChannelSessionTimeoutTest.kt @@ -0,0 +1,40 @@ +package org.openstrap.protocol + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test + +/** A link whose strap takes every write and never answers. */ +private class SilentLink : StrapLink { + override val payloadMtu = 20 + override suspend fun writeControl(data: ByteArray) = Unit + override suspend fun writeData(data: ByteArray) = Unit + override val controlNotifications: Flow = MutableSharedFlow() + override val dataNotifications: Flow = MutableSharedFlow() +} + +/** + * A silent strap ends in a plain [ProtocolTimeoutException], not `withTimeout`'s cancellation, so a caller that + * rethrows cancellations still reports the failure (audit C1). + */ +class ChannelSessionTimeoutTest { + @Test + fun requestTimeoutIsAPlainException() = runTest { + val session = ChannelSession(SilentLink()) + session.start(backgroundScope) + val e = runCatching { session.request(0x0001, byteArrayOf(1), timeoutMs = 1_000) }.exceptionOrNull() + assertTrue("got $e", e is ProtocolTimeoutException) + assertTrue("got $e", e !is CancellationException) + } + + @Test + fun handshakeTimeoutIsAPlainException() = runTest { + val session = ChannelSession(SilentLink()) + session.start(backgroundScope) + val e = runCatching { session.handshake(ByteArray(16)) }.exceptionOrNull() + assertTrue("got $e", e is ProtocolTimeoutException) + } +}