From f40fbd42ce4a63a123764d8b036a0cdf33714586 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Fri, 25 Sep 2026 21:02:28 +0200 Subject: [PATCH 1/5] =?UTF-8?q?docs:=20Phase=209=20design=20=E2=80=94=20pe?= =?UTF-8?q?riods,=20per-app=20breakdown,=20daily=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- .../format/{GoalInput.kt => LimitInput.kt} | 0 .../{HistorySeries.kt => DaySeries.kt} | 0 .../com/scrollmeter/app/insights/TopApps.kt | 35 ---- .../com/scrollmeter/app/ui/apps/AppsScreen.kt | 157 ------------------ .../app/ui/history/HistoryScreen.kt | 132 --------------- app/src/main/res/drawable/ic_nav_apps.xml | 12 -- app/src/main/res/drawable/ic_nav_history.xml | 12 -- app/src/main/res/drawable/ic_nav_overview.xml | 12 -- .../{GoalInputTest.kt => LimitInputTest.kt} | 0 ...{HistorySeriesTest.kt => DaySeriesTest.kt} | 0 .../scrollmeter/app/insights/TopAppsTest.kt | 42 ----- docs/plans/phase-9-ux-limit.md | 61 +++++++ 12 files changed, 61 insertions(+), 402 deletions(-) rename app/src/main/java/com/scrollmeter/app/format/{GoalInput.kt => LimitInput.kt} (100%) rename app/src/main/java/com/scrollmeter/app/insights/{HistorySeries.kt => DaySeries.kt} (100%) delete mode 100644 app/src/main/java/com/scrollmeter/app/insights/TopApps.kt delete mode 100644 app/src/main/java/com/scrollmeter/app/ui/apps/AppsScreen.kt delete mode 100644 app/src/main/java/com/scrollmeter/app/ui/history/HistoryScreen.kt delete mode 100644 app/src/main/res/drawable/ic_nav_apps.xml delete mode 100644 app/src/main/res/drawable/ic_nav_history.xml delete mode 100644 app/src/main/res/drawable/ic_nav_overview.xml rename app/src/test/java/com/scrollmeter/app/format/{GoalInputTest.kt => LimitInputTest.kt} (100%) rename app/src/test/java/com/scrollmeter/app/insights/{HistorySeriesTest.kt => DaySeriesTest.kt} (100%) delete mode 100644 app/src/test/java/com/scrollmeter/app/insights/TopAppsTest.kt create mode 100644 docs/plans/phase-9-ux-limit.md diff --git a/app/src/main/java/com/scrollmeter/app/format/GoalInput.kt b/app/src/main/java/com/scrollmeter/app/format/LimitInput.kt similarity index 100% rename from app/src/main/java/com/scrollmeter/app/format/GoalInput.kt rename to app/src/main/java/com/scrollmeter/app/format/LimitInput.kt diff --git a/app/src/main/java/com/scrollmeter/app/insights/HistorySeries.kt b/app/src/main/java/com/scrollmeter/app/insights/DaySeries.kt similarity index 100% rename from app/src/main/java/com/scrollmeter/app/insights/HistorySeries.kt rename to app/src/main/java/com/scrollmeter/app/insights/DaySeries.kt diff --git a/app/src/main/java/com/scrollmeter/app/insights/TopApps.kt b/app/src/main/java/com/scrollmeter/app/insights/TopApps.kt deleted file mode 100644 index d479c20..0000000 --- a/app/src/main/java/com/scrollmeter/app/insights/TopApps.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.scrollmeter.app.insights - -import com.scrollmeter.app.data.model.AppSummary - -/** The dashboard's "Top aplikace dnes" (spec §21): a few apps by distance, the rest as one "Ostatní" row. */ -data class TopApps(val apps: List, val other: AppSummary?) { - val isEmpty: Boolean get() = apps.isEmpty() - - companion object { - const val OTHER = "other" - const val LIMIT = 4 - - /** - * Apps with scrolled distance, longest first. More than [limit] + 1 → the tail becomes one - * "Ostatní" row (distance and times summed; a time stays unknown only if every app's is); - * exactly [limit] + 1 → the last app is shown by name instead of a one-app "Ostatní". - */ - fun of(apps: List, limit: Int = LIMIT): TopApps { - val scrolled = apps.filter { (it.distanceMm ?: 0.0) > 0 }.sortedByDescending { it.distanceMm } - if (scrolled.size <= limit + 1) return TopApps(scrolled, null) - val tail = scrolled.drop(limit) - val other = AppSummary( - packageName = OTHER, - distanceMm = tail.sumOf { it.distanceMm ?: 0.0 }, - activeScrollMs = tail.mapNotNull { it.activeScrollMs }.takeIf { it.isNotEmpty() }?.sum(), - foregroundMs = tail.mapNotNull { it.foregroundMs }.takeIf { it.isNotEmpty() }?.sum(), - measuredEventCount = null, - fallbackEventCount = null, - unmeasurableEventCount = null, - rejectedOutlierCount = null, - ) - return TopApps(scrolled.take(limit), other) - } - } -} diff --git a/app/src/main/java/com/scrollmeter/app/ui/apps/AppsScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/apps/AppsScreen.kt deleted file mode 100644 index 6eebe7f..0000000 --- a/app/src/main/java/com/scrollmeter/app/ui/apps/AppsScreen.kt +++ /dev/null @@ -1,157 +0,0 @@ -package com.scrollmeter.app.ui.apps - -import androidx.compose.foundation.clickable -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.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.FilterChip -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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 -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.scrollmeter.app.AppGraph -import com.scrollmeter.app.R -import com.scrollmeter.app.data.model.DateRange -import com.scrollmeter.app.format.DistanceFormatter -import com.scrollmeter.app.format.TimeFormatter -import com.scrollmeter.app.insights.AppRanking -import com.scrollmeter.app.insights.AppSort -import com.scrollmeter.app.insights.AppsPeriod -import com.scrollmeter.app.insights.RankedApp -import com.scrollmeter.app.measurement.MeasurementQuality -import com.scrollmeter.app.settings.Settings -import com.scrollmeter.app.settings.UnitPreference -import com.scrollmeter.app.ui.components.AppIcon -import com.scrollmeter.app.ui.components.PeriodSelector -import com.scrollmeter.app.ui.components.appLocale -import com.scrollmeter.app.ui.components.rememberAppInfo -import com.scrollmeter.app.ui.components.rememberCalibrationConfidence -import com.scrollmeter.app.ui.components.rememberToday -import com.scrollmeter.app.ui.components.rememberUsageGranted -import java.util.Locale - -/** - * Aplikace (spec §24, D19): the apps of Dnes | 7 dní | 30 dní | Celkem, by distance or by time, with - * their share of the distance and a quality word (ADR-029) — never a made-up accuracy percentage. - * An app with time but no scroll data (YouTube) shows "—" and says so. A row opens the detail. - */ -@Composable -fun AppsScreen(graph: AppGraph, onOpenApp: (String) -> Unit) { - val locale = appLocale() - val today = rememberToday() - val usageGranted = rememberUsageGranted(graph) - val calibration = rememberCalibrationConfidence(graph) - var period by rememberSaveable { mutableStateOf(AppsPeriod.TODAY) } - var sort by rememberSaveable { mutableStateOf(AppSort.DISTANCE) } - val settings by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = Settings()) - val range = period.range(today) - val apps by remember(range) { graph.scrollRepository.apps(range) }.collectAsStateWithLifecycle(initialValue = null) - // Quality describes the app, not the period: rated on all-time counters (ADR-029). - val allTime by remember { graph.scrollRepository.apps(DateRange.ALL) }.collectAsStateWithLifecycle(initialValue = null) - // Ranked only once both are read, so a row never flashes the period's own quality first. - val ranked = remember(apps, allTime, sort, usageGranted, calibration) { - val period = apps ?: return@remember null - val basis = allTime ?: return@remember null - AppRanking.rank(period, sort, usageGranted, calibration, basis.associateBy { it.packageName }) - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - item { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(stringResource(R.string.apps_title), style = MaterialTheme.typography.titleLarge) - PeriodSelector(AppsPeriod.entries, period, { stringResource(appsPeriodLabel(it)) }, { period = it }) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - AppSort.entries.forEach { option -> - FilterChip( - selected = sort == option, - onClick = { sort = option }, - label = { Text(stringResource(if (option == AppSort.DISTANCE) R.string.apps_sort_distance else R.string.apps_sort_time)) }, - ) - } - } - } - } - if (ranked?.isEmpty() == true) { - item { Text(stringResource(R.string.apps_empty), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } - } - items(ranked.orEmpty(), key = { it.app.packageName }) { row -> - AppListRow(graph, row, usageGranted, settings.unitPreference, locale) { onOpenApp(row.app.packageName) } - } - } -} - -@Composable -private fun AppListRow(graph: AppGraph, row: RankedApp, usageGranted: Boolean, unit: UnitPreference, locale: Locale, onClick: () -> Unit) { - val app = row.app - val info by rememberAppInfo(graph.appInfoProvider, app.packageName, 36.dp) - val label = info?.label ?: app.packageName - val time = if (usageGranted) app.foregroundMs else app.activeScrollMs - val detail = when { - row.quality == null -> stringResource(R.string.apps_no_scroll_data) - row.sharePercent != null -> stringResource(R.string.apps_share_quality, decimal(row.sharePercent, locale), stringResource(qualityLabel(row.quality))) - else -> stringResource(qualityLabel(row.quality)) - } - Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { - AppIcon(info, label) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text(label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2) - } - Spacer(Modifier.width(12.dp)) - Column(horizontalAlignment = Alignment.End) { - Text(app.distanceMm?.let { DistanceFormatter.format(it, unit, locale) } ?: TimeFormatter.UNKNOWN, style = MaterialTheme.typography.titleMedium) - Text(TimeFormatter.duration(time), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } -} - -fun appsPeriodLabel(period: AppsPeriod): Int = when (period) { - AppsPeriod.TODAY -> R.string.period_today - AppsPeriod.DAYS_7 -> R.string.period_7_days - AppsPeriod.DAYS_30 -> R.string.period_30_days - AppsPeriod.LIFETIME -> R.string.period_lifetime -} - -fun qualityLabel(quality: MeasurementQuality): Int = when (quality) { - MeasurementQuality.HIGH -> R.string.quality_high - MeasurementQuality.MEDIUM -> R.string.quality_medium - MeasurementQuality.LOW -> R.string.quality_low - MeasurementQuality.UNKNOWN -> R.string.quality_unknown -} - -fun qualityInfo(quality: MeasurementQuality): Int = when (quality) { - MeasurementQuality.HIGH -> R.string.quality_high_info - MeasurementQuality.MEDIUM -> R.string.quality_medium_info - MeasurementQuality.LOW -> R.string.quality_low_info - MeasurementQuality.UNKNOWN -> R.string.quality_unknown_info -} - -/** A share with one decimal ("43,7"). */ -internal fun decimal(value: Double, locale: Locale): String = - java.text.NumberFormat.getNumberInstance(locale).apply { - minimumFractionDigits = 1 - maximumFractionDigits = 1 - }.format(value) diff --git a/app/src/main/java/com/scrollmeter/app/ui/history/HistoryScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/history/HistoryScreen.kt deleted file mode 100644 index 9a2c9d8..0000000 --- a/app/src/main/java/com/scrollmeter/app/ui/history/HistoryScreen.kt +++ /dev/null @@ -1,132 +0,0 @@ -package com.scrollmeter.app.ui.history - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Card -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -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 -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.scrollmeter.app.AppGraph -import com.scrollmeter.app.R -import com.scrollmeter.app.format.DistanceFormatter -import com.scrollmeter.app.insights.HistoryPeriod -import com.scrollmeter.app.insights.HistorySeriesBuilder -import com.scrollmeter.app.settings.Settings -import com.scrollmeter.app.ui.components.BarChart -import com.scrollmeter.app.ui.components.ChartLabels -import com.scrollmeter.app.ui.components.PeriodSelector -import com.scrollmeter.app.ui.components.appLocale -import com.scrollmeter.app.ui.components.rememberToday - -/** - * Historie (spec §23): distance per day for 7 or 30 days, per month for 12 months, and below it - * Průměr / den, Nejvyšší den, Nejnižší den, Celkem — per day in every view, counted from the first - * measured day (ADR-029). Tapping a bar shows its value. - */ -@Composable -fun HistoryScreen(graph: AppGraph) { - val locale = appLocale() - val labels = remember(locale) { ChartLabels(locale) } - val today = rememberToday() - var period by rememberSaveable { mutableStateOf(HistoryPeriod.DAYS_7) } - var selected by rememberSaveable(period, today) { mutableStateOf(null) } - val settings by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = Settings()) - val repository = graph.scrollRepository - val range = HistorySeriesBuilder.range(period, today) - val days by remember(range) { repository.days(range) }.collectAsStateWithLifecycle(initialValue = emptyList()) - val first by remember { repository.firstMeasuredDay() }.collectAsStateWithLifecycle(initialValue = null) - val series = remember(period, today, days, first) { HistorySeriesBuilder.build(period, today, days, first) } - val unit = settings.unitPreference - - fun distance(mm: Double) = DistanceFormatter.format(mm, unit, locale) - - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text(stringResource(R.string.history_title), style = MaterialTheme.typography.titleLarge) - PeriodSelector( - options = HistoryPeriod.entries, - selected = period, - label = { stringResource(periodLabel(it)) }, - onSelect = { period = it }, - ) - val scale = remember(series) { ChartLabels.distanceScale(series.bars.maxOfOrNull { it.value } ?: 0.0) } - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - val bar = selected?.let(series.bars::getOrNull) - Text( - text = if (bar != null) { - stringResource(R.string.history_day_value, labels.selected(period, bar), distance(bar.value)) - } else { - stringResource(R.string.history_day_value, stringResource(R.string.history_total), distance(series.bars.sumOf { it.value })) - }, - style = MaterialTheme.typography.titleMedium, - ) - BarChart( - values = series.bars.map { it.value }, - scale = scale, - axisLabel = remember(scale, unit, locale) { ChartLabels.distanceAxis(scale, unit, locale) }, - barLabel = { labels.bar(period, series.bars[it]) }, - contentDescription = stringResource( - R.string.history_chart_description, - distance(series.bars.sumOf { it.value }), - series.stats?.let { distance(it.maxDay.value) } ?: "—", - ), - selected = selected, - onSelect = { selected = it }, - ) - } - } - val stats = series.stats - if (stats == null) { - Text(stringResource(R.string.history_empty), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - } else { - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - StatRow(stringResource(R.string.history_average), distance(stats.averagePerDay)) - HorizontalDivider() - StatRow(stringResource(R.string.history_max), distance(stats.maxDay.value), labels.day(stats.maxDay.date)) - HorizontalDivider() - StatRow(stringResource(R.string.history_min), distance(stats.minDay.value), labels.day(stats.minDay.date)) - HorizontalDivider() - StatRow(stringResource(R.string.history_total), distance(stats.total)) - } - } - } - } -} - -fun periodLabel(period: HistoryPeriod): Int = when (period) { - HistoryPeriod.DAYS_7 -> R.string.period_7_days - HistoryPeriod.DAYS_30 -> R.string.period_30_days - HistoryPeriod.MONTHS_12 -> R.string.period_12_months -} - -@Composable -private fun StatRow(label: String, value: String, date: String? = null) { - Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f)) { - Text(label, style = MaterialTheme.typography.bodyLarge) - if (date != null) Text(date, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Text(value, style = MaterialTheme.typography.titleMedium) - } -} diff --git a/app/src/main/res/drawable/ic_nav_apps.xml b/app/src/main/res/drawable/ic_nav_apps.xml deleted file mode 100644 index e4b708e..0000000 --- a/app/src/main/res/drawable/ic_nav_apps.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_nav_history.xml b/app/src/main/res/drawable/ic_nav_history.xml deleted file mode 100644 index 2498ee0..0000000 --- a/app/src/main/res/drawable/ic_nav_history.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_nav_overview.xml b/app/src/main/res/drawable/ic_nav_overview.xml deleted file mode 100644 index c3c38a9..0000000 --- a/app/src/main/res/drawable/ic_nav_overview.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/app/src/test/java/com/scrollmeter/app/format/GoalInputTest.kt b/app/src/test/java/com/scrollmeter/app/format/LimitInputTest.kt similarity index 100% rename from app/src/test/java/com/scrollmeter/app/format/GoalInputTest.kt rename to app/src/test/java/com/scrollmeter/app/format/LimitInputTest.kt diff --git a/app/src/test/java/com/scrollmeter/app/insights/HistorySeriesTest.kt b/app/src/test/java/com/scrollmeter/app/insights/DaySeriesTest.kt similarity index 100% rename from app/src/test/java/com/scrollmeter/app/insights/HistorySeriesTest.kt rename to app/src/test/java/com/scrollmeter/app/insights/DaySeriesTest.kt diff --git a/app/src/test/java/com/scrollmeter/app/insights/TopAppsTest.kt b/app/src/test/java/com/scrollmeter/app/insights/TopAppsTest.kt deleted file mode 100644 index bc91f38..0000000 --- a/app/src/test/java/com/scrollmeter/app/insights/TopAppsTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.scrollmeter.app.insights - -import com.google.common.truth.Truth.assertThat -import com.scrollmeter.app.data.model.AppSummary -import org.junit.Test - -/** Spec §21: top apps today, the rest as "Ostatní". */ -class TopAppsTest { - private fun app(pkg: String, mm: Double?, fg: Long? = null, active: Long? = null) = - AppSummary(pkg, mm, active, fg, null, null, null, null) - - @Test - fun appsWithoutDistanceAreLeftOutAndTheRestIsSorted() { - val top = TopApps.of(listOf(app("video", null, fg = 900_000), app("b", 5.0), app("a", 50.0), app("zero", 0.0))) - assertThat(top.apps.map { it.packageName }).containsExactly("a", "b").inOrder() - assertThat(top.other).isNull() - } - - @Test - fun theTailBecomesOneOtherRow() { - val apps = (1..7).map { app("p$it", it * 10.0, fg = if (it == 1) null else 60_000, active = 1_000) } - val top = TopApps.of(apps) - assertThat(top.apps.map { it.packageName }).containsExactly("p7", "p6", "p5", "p4").inOrder() - assertThat(top.other!!.distanceMm).isEqualTo(60.0) // p3 + p2 + p1 - assertThat(top.other!!.foregroundMs).isEqualTo(120_000) - assertThat(top.other!!.activeScrollMs).isEqualTo(3_000) - } - - @Test - fun oneAppMoreThanTheLimitIsShownByName() { - val top = TopApps.of((1..5).map { app("p$it", it * 10.0) }) - assertThat(top.apps).hasSize(5) - assertThat(top.other).isNull() - } - - @Test - fun unknownTimesStayUnknownInOther() { - val top = TopApps.of((1..6).map { app("p$it", it * 10.0) }) - assertThat(top.other!!.foregroundMs).isNull() - assertThat(top.other!!.activeScrollMs).isNull() - } -} diff --git a/docs/plans/phase-9-ux-limit.md b/docs/plans/phase-9-ux-limit.md new file mode 100644 index 0000000..3263c56 --- /dev/null +++ b/docs/plans/phase-9-ux-limit.md @@ -0,0 +1,61 @@ +# Phase 9 — UX: periods + daily limit (v0.2.0) + +Honza's request (2026-09-25): statistics for today / this week / this month with per-day values; tapping a period +opens its history with a per-app breakdown (metres, sorted, time in app on the same row); durations in min → h → +d; the simplest possible UX; **not a competition** — the daily goal becomes a **limit** not to exceed, the UI turns +green → orange → red. Design by Fable 5.1 (plan agent), refined here; decisions go to ADR-036. + +## Screens + +- **Přehled** (start, only top-level screen; no bottom bar — Nastavení under a gear in the header): + service banner · **Dnes card** (background = limit level; distance, "zbývá X do limitu Y" / "o X přes limit Y", + "V aplikacích 1 h 12 min", comparison sentence when enabled; tap → Období Den today) · rows **Tento týden** / + **Tento měsíc** (level dot, total, avg/day; tap → Období) · **Nejvíc dnes**: top 3 apps + "Všechny aplikace ›" + (→ Období Den) · usage-access card · accuracy row. +- **Období** (`PeriodRoute(kind, anchor)`): segmented Den / Týden / Měsíc; ‹ label › (calendar day, week Mon–Sun, + month; › disabled at the current period, ‹ disabled before the period containing the first measured day); + header card (background = level; total, time in apps, Den: remaining/over sentence; Týden/Měsíc: "Průměr X / den" + + remaining/over vs the limit); Týden/Měsíc: bar chart per day, each bar in its own level colour, dashed limit + line, tap a bar → Den of that date; app list sorted by metres, row = icon · name · "1,21 km (3 h 40 min)", + time-only apps "— (2 h 15 min)" last; tap → App detail for this period. Empty: "Za toto období nic naměřeno." +- **App detail** (`AppDetailRoute(pkg, kind, anchor)`): header "Instagram · Tento týden · 21.–27. 9.", no period + selector; summary + "Podíl na vzdálenosti"; quality card and the two 30-day charts unchanged. +- **Nastavení**: pushed route with Back; "Denní limit · 500 m / Bez limitu"; switches "Překročení limitu" and + "Shrnutí včerejška" (record gone). **O aplikaci** gains "Celkem naměřeno X · od ". +- Deleted: History and Apps tabs/screens, rolling 7/30/12 periods, sort chips, share %/quality word in rows, + TopApps "Ostatní", Nejvyšší/Nejnižší den, lifetime tile on Přehled, the goal ring. + +## Limit + +- `LimitLevel { NONE, UNDER, NEAR, OVER }`, ratio = distance / limit: UNDER < 0.7 ≤ NEAR < 1.0 ≤ OVER; NONE when the + limit is 0 ("Bez limitu"). `MeasurementConfig.LIMIT_WARN_RATIO = 0.7`. +- Week / month: average per day vs the daily limit; counted days = range ∩ [first measured day, today]. +- Colour on the Dnes card, Období header, bars, week/month dots — never the whole screen, never alone (text always). +- Fixed palette (not dynamic), light / dark per level: container / onContainer / bar — + UNDER #C8E6C9/#1B5E20/#43A047 · #1E4620/#C8E6C9/#66BB6A; NEAR #FFE0B2/#7A3E00/#FB8C00 · #4E3200/#FFE0B2/#FFA726; + OVER #FFCDD2/#8E1B1B/#E53935 · #5B1A1A/#FFCDD2/#EF5350; NONE = surfaceContainerHigh/onSurface/primary. +- Stored in the same DataStore key `daily_goal_mm` (0.0 = off), default 500 m; code renames goal → limit. +- Notifications: LIMIT ("Denní limit překročen — Dnes jsi přes svůj limit 500 m.", keys `notify_goal` / + `notified_goal_date` kept), SUMMARY unchanged; RECORD removed with its constants, facts and query; channel id + `goals` kept, name "Limit a shrnutí". No "approaching" notification. + +## Duration format + +null / < 0 → "—"; < 60 s → "< 1 min"; floored minutes; < 60 min "N min"; < 24 h "H h" / "H h M min"; ≥ 24 h +"D d" + non-zero "H h" / "M min" (e.g. 24 h → "1 d", 25 h 1 min → "1 d 1 h 1 min", 24 h 5 min → "1 d 5 min"). +Units identical in cs / en. + +## Implementation order + +1. Pure helpers + tests: `format/TimeFormatter` (days), `insights/Period.kt`, `insights/DailyLimit.kt`, + `insights/HistorySeries.kt` → day bars for any range, `insights/AppRanking.kt` trimmed to quality/share, + delete `insights/TopApps.kt`; `MeasurementConfig` (+ LIMIT_WARN_RATIO, − RECORD_*). +2. Settings / notifications: renames, 0 = off, no record (rules, facts, DAO `daysBefore`, `PriorDays`). +3. Theme `LimitColors`, `BarChart` (bar colour per bar, dashed reference line), `ChartLabels` (period labels). +4. Screens: `LimitCard`, `AppList`, Dashboard rewrite, new `ui/period/PeriodScreen.kt` (replaces History + Apps), + App detail takes the period, NavHost (routes, no bottom bar, gear), Settings header + limit dialog, About total. +5. Strings cs + en together (TranslationsTest parity). +6. `versionCode 2`, `versionName 0.2.0`. +7. Docs: ADR-036, SPEC deviation note, README UI section, architecture.md, PLAN.md Phase 9, handoff.md. +8. Gates, `/topshit`, Stage 2, emulator screenshots (cs/en, light/dark, UNDER/NEAR/OVER, Bez limitu, upgrade from + v0.1.0), PR, merge, tag `v0.9`, release `v0.2.0`, install on the OnePlus + Samsung as an update. From fa55d2800df3e66028e7d702e0d8e26818b87747 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Fri, 25 Sep 2026 21:02:28 +0200 Subject: [PATCH 2/5] feat: Statistiky per day/week/month with the app breakdown; daily limit in colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Přehled shows today against a daily limit (green / orange from 70 % / red), this week and this month with their average per day, and the three apps scrolled most today. Tapping a period opens Statistiky: Den / Týden / Měsíc with ‹ ›, a bar per day in its limit colour with the limit as a dashed line, and every app sorted by distance with its time in app on the same row. It replaces the Historie and Aplikace tabs; the bottom bar is gone and Nastavení sits behind a gear. The goal becomes a limit (same DataStore key, 0 = no limit); the record notice is removed. Durations roll over to days (1 d 1 h 1 min). versionCode 2, versionName 0.2.0. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/build.gradle.kts | 4 +- .../main/java/com/scrollmeter/app/AppGraph.kt | 2 +- .../scrollmeter/app/data/local/ScrollDao.kt | 13 -- .../scrollmeter/app/data/model/Summaries.kt | 3 - .../app/data/repository/ScrollRepository.kt | 20 +- .../com/scrollmeter/app/format/LimitInput.kt | 12 +- .../scrollmeter/app/format/TimeFormatter.kt | 23 +- .../scrollmeter/app/insights/AppRanking.kt | 69 ++---- .../scrollmeter/app/insights/DailyLimit.kt | 52 +++++ .../com/scrollmeter/app/insights/DaySeries.kt | 72 +----- .../com/scrollmeter/app/insights/Period.kt | 68 ++++++ .../app/measurement/MeasurementConfig.kt | 7 +- .../AndroidNotificationPoster.kt | 8 +- .../app/notifications/NotificationRules.kt | 37 ++- .../com/scrollmeter/app/settings/Settings.kt | 19 +- .../app/settings/SettingsRepository.kt | 28 ++- .../app/ui/apps/AppDetailScreen.kt | 99 +++++--- .../scrollmeter/app/ui/components/AppList.kt | 42 ++++ .../scrollmeter/app/ui/components/BarChart.kt | 20 +- .../app/ui/components/ChartLabels.kt | 39 ++-- .../app/ui/components/LimitCard.kt | 66 ++++++ .../app/ui/dashboard/DashboardScreen.kt | 212 +++++++++--------- .../app/ui/navigation/ScrollMeterNavHost.kt | 108 ++++----- .../scrollmeter/app/ui/period/PeriodScreen.kt | 183 +++++++++++++++ .../app/ui/settings/PrivacyAboutScreens.kt | 22 ++ .../app/ui/settings/SettingsDialogs.kt | 38 ++-- .../app/ui/settings/SettingsScreen.kt | 35 +-- .../com/scrollmeter/app/ui/theme/Theme.kt | 35 ++- app/src/main/res/drawable/ic_chevron_left.xml | 12 + app/src/main/res/values-en/strings.xml | 100 ++++----- app/src/main/res/values/strings.xml | 103 ++++----- .../app/data/ScrollRepositoryTest.kt | 7 +- .../scrollmeter/app/format/LimitInputTest.kt | 24 +- .../app/format/TimeFormatterTest.kt | 15 ++ .../app/insights/AppRankingTest.kt | 46 ++-- .../app/insights/DailyLimitTest.kt | 54 +++++ .../scrollmeter/app/insights/DaySeriesTest.kt | 73 ++---- .../scrollmeter/app/insights/PeriodTest.kt | 71 ++++++ .../AndroidNotificationPosterTest.kt | 18 +- .../notifications/NotificationRulesTest.kt | 41 ++-- .../app/settings/SettingsRepositoryTest.kt | 34 ++- .../app/ui/components/ChartLabelsTest.kt | 26 ++- 42 files changed, 1262 insertions(+), 698 deletions(-) create mode 100644 app/src/main/java/com/scrollmeter/app/insights/DailyLimit.kt create mode 100644 app/src/main/java/com/scrollmeter/app/insights/Period.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/components/AppList.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/components/LimitCard.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/period/PeriodScreen.kt create mode 100644 app/src/main/res/drawable/ic_chevron_left.xml create mode 100644 app/src/test/java/com/scrollmeter/app/insights/DailyLimitTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/insights/PeriodTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 44ad156..f32a4f2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,8 +47,8 @@ android { // events of the "active" window alone, which it hardly ever updates for such a service (ADR-033). minSdk = 29 targetSdk = 36 - versionCode = 1 - versionName = "0.1.0" + versionCode = 2 + versionName = "0.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/scrollmeter/app/AppGraph.kt b/app/src/main/java/com/scrollmeter/app/AppGraph.kt index faefda2..01b5694 100644 --- a/app/src/main/java/com/scrollmeter/app/AppGraph.kt +++ b/app/src/main/java/com/scrollmeter/app/AppGraph.kt @@ -85,7 +85,7 @@ class AppGraph(context: Context) { val notificationWatcher by lazy { NotificationWatcher( settings = { settingsRepository.stored.first() }, - facts = { today, settings -> scrollRepository.notificationFacts(today, settings.dailyGoalMm) }, + facts = { today, settings -> scrollRepository.notificationFacts(today, settings.dailyLimitMm) }, state = settingsRepository, poster = notificationPoster, epoch = monitor.dataEpoch::get, diff --git a/app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt b/app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt index 42200d4..bcdcfe0 100644 --- a/app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt +++ b/app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt @@ -9,7 +9,6 @@ import com.scrollmeter.app.data.model.AppSummary import com.scrollmeter.app.data.model.DaySummary import com.scrollmeter.app.data.model.ExportAppDay import com.scrollmeter.app.data.model.ExportDay -import com.scrollmeter.app.data.model.PriorDays import kotlinx.coroutines.flow.Flow /** @@ -170,18 +169,6 @@ abstract class ScrollDao { @Query("SELECT packageName FROM daily_app_aggregate UNION SELECT packageName FROM daily_app_usage ORDER BY packageName") abstract fun seenPackages(): Flow> - /** The best earlier day and how many earlier days had a distance — "Nový rekord" (ADR-031). */ - @Query( - """ - SELECT COALESCE(MAX(total), 0) AS bestMm, COUNT(*) AS days FROM ( - SELECT SUM(distanceMm) AS total FROM daily_app_aggregate - WHERE date < :date AND packageName NOT IN (:excluded) - GROUP BY date HAVING total > 0 - ) - """, - ) - abstract suspend fun daysBefore(date: String, excluded: List): PriorDays - /** `per_app.csv` (spec §28, ADR-021): one row per day and app, scroll and time side by side. */ @Query( """ diff --git a/app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt b/app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt index ab8a2ac..e12d141 100644 --- a/app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt +++ b/app/src/main/java/com/scrollmeter/app/data/model/Summaries.kt @@ -23,9 +23,6 @@ data class DaySummary( val foregroundMs: Long?, ) -/** Days before a date: the best total and how many had a distance (ADR-031). */ -data class PriorDays(val bestMm: Double, val days: Int) - /** One `per_app.csv` row (spec §28 + ADR-021 columns); null = no data of that kind. */ data class ExportAppDay( val date: String, diff --git a/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt b/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt index 71aaed3..b0c331e 100644 --- a/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt +++ b/app/src/main/java/com/scrollmeter/app/data/repository/ScrollRepository.kt @@ -114,19 +114,13 @@ class ScrollRepository( /** Every package with stored data, excluded ones included (the exclusion list). */ fun seenPackages(): Flow> = dao.seenPackages() - /** Facts for the notification check (ADR-031): today live, earlier days stored. */ - suspend fun notificationFacts(today: LocalDate, goalMm: Double): NotificationFacts { - val ex = excluded.first().toList() - val prior = dao.daysBefore(today.toString(), ex) - return NotificationFacts( - today = today, - todayMm = distance(DateRange.day(today)).first(), - goalMm = goalMm, - previousBestMm = prior.bestMm, - priorMeasuredDays = prior.days, - yesterdayMm = distance(DateRange.day(today.minusDays(1))).first(), - ) - } + /** Facts for the notification check (ADR-031, ADR-036): today live, yesterday stored. */ + suspend fun notificationFacts(today: LocalDate, limitMm: Double): NotificationFacts = NotificationFacts( + today = today, + todayMm = distance(DateRange.day(today)).first(), + limitMm = limitMm, + yesterdayMm = distance(DateRange.day(today.minusDays(1))).first(), + ) suspend fun exportAppDays(): List = dao.exportAppDays(exportExcluded().toList()) diff --git a/app/src/main/java/com/scrollmeter/app/format/LimitInput.kt b/app/src/main/java/com/scrollmeter/app/format/LimitInput.kt index ca17d47..c97b268 100644 --- a/app/src/main/java/com/scrollmeter/app/format/LimitInput.kt +++ b/app/src/main/java/com/scrollmeter/app/format/LimitInput.kt @@ -6,20 +6,20 @@ import java.text.ParsePosition import java.util.Locale /** - * A custom daily goal typed by the user (spec §25): a number in metres or kilometres, with the - * locale's decimal separator or a dot. Null when it is not a number or falls outside - * [Settings.GOAL_RANGE_MM] — never silently clamped to something the user did not type. Pure Kotlin. + * A custom daily limit typed by the user (spec §25, ADR-036): a number in metres or kilometres, with + * the locale's decimal separator or a dot. Null when it is not a number or falls outside + * [Settings.LIMIT_RANGE_MM] — never silently clamped to something the user did not type. Pure Kotlin. */ -object GoalInput { +object LimitInput { fun parseMm(text: String, kilometres: Boolean, locale: Locale): Double? { val trimmed = text.trim().replace(' ', ' ').replace(" ", "") if (trimmed.isEmpty()) return null val value = parse(trimmed, locale) ?: parse(trimmed.replace(',', '.'), Locale.ROOT) ?: return null val mm = value * if (kilometres) 1_000_000.0 else 1_000.0 - return mm.takeIf { it.isFinite() && it in Settings.GOAL_RANGE_MM } + return mm.takeIf { it.isFinite() && it in Settings.LIMIT_RANGE_MM } } - /** The whole text must be the number: "5 km" or "1,2,3" is not a goal. */ + /** The whole text must be the number: "5 km" or "1,2,3" is not a limit. */ private fun parse(text: String, locale: Locale): Double? { val position = ParsePosition(0) val number = NumberFormat.getNumberInstance(locale).apply { isGroupingUsed = false }.parse(text, position) diff --git a/app/src/main/java/com/scrollmeter/app/format/TimeFormatter.kt b/app/src/main/java/com/scrollmeter/app/format/TimeFormatter.kt index df623e8..bedb814 100644 --- a/app/src/main/java/com/scrollmeter/app/format/TimeFormatter.kt +++ b/app/src/main/java/com/scrollmeter/app/format/TimeFormatter.kt @@ -11,18 +11,23 @@ object TimeFormatter { const val UNKNOWN = "—" private const val MINUTE_MS = 60_000L - /** "< 1 min", "12 min", "1 h 5 min", "2 h" — minutes are floored, so 59,9 min never reads "60 min". */ + /** + * "< 1 min", "12 min", "1 h 5 min", "2 h", "1 d 3 h 5 min" (ADR-036) — minutes are floored, so 59,9 min + * never reads "60 min"; from 24 h on the days come first, and parts that are zero are left out + * ("1 d 5 min"). The units read the same in Czech and English. + */ fun duration(ms: Long?): String { if (ms == null || ms < 0) return UNKNOWN if (ms < MINUTE_MS) return "< 1 min" - val minutes = ms / MINUTE_MS - val hours = minutes / 60 - val rest = minutes % 60 - return when { - hours == 0L -> "$minutes min" - rest == 0L -> "$hours h" - else -> "$hours h $rest min" - } + val totalMinutes = ms / MINUTE_MS + val days = totalMinutes / (24 * 60) + val hours = totalMinutes / 60 % 24 + val minutes = totalMinutes % 60 + return listOfNotNull( + days.takeIf { it > 0 }?.let { "$it d" }, + hours.takeIf { it > 0 }?.let { "$it h" }, + minutes.takeIf { it > 0 }?.let { "$it min" }, + ).joinToString(" ") } /** diff --git a/app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt b/app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt index 2084765..600bc91 100644 --- a/app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt +++ b/app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt @@ -2,60 +2,37 @@ package com.scrollmeter.app.insights import com.scrollmeter.app.calibration.CalibrationConfidence import com.scrollmeter.app.data.model.AppSummary -import com.scrollmeter.app.data.model.DateRange import com.scrollmeter.app.measurement.MeasurementQuality import com.scrollmeter.app.measurement.MeasurementQualityRater -import java.time.LocalDate - -enum class AppSort { DISTANCE, TIME } - -/** Spec §24: Dnes | 7 dní | 30 dní | Celkem — the Aplikace list and the app detail. */ -enum class AppsPeriod { - TODAY, DAYS_7, DAYS_30, LIFETIME; - - fun range(today: LocalDate): DateRange = when (this) { - TODAY -> DateRange.day(today) - DAYS_7 -> DateRange(today.minusDays(6), today) - DAYS_30 -> DateRange(today.minusDays(29), today) - LIFETIME -> DateRange.ALL - } -} - -/** One row of Aplikace (spec §24): share of the period's distance, quality; null = no scroll data. */ -data class RankedApp(val app: AppSummary, val sharePercent: Double?, val quality: MeasurementQuality?) /** - * Orders the apps of a period (spec §24, D19). Quality is rated on [qualityBasis] — the app's - * all-time counters — when given: it describes how the app reports scrolling, so it should not - * flip to "málo dat" every morning just because today has few events (ADR-029). By distance: longest first. By time: time in app - * when Usage access is granted, scroll time otherwise. Apps with only time in app (YouTube) have no - * share and no quality and sort after every app with data of the chosen kind. Pure Kotlin. + * The app list of a period (ADR-036) and the app detail's share and quality. Pure Kotlin. */ object AppRanking { - fun rank( - apps: List, - sort: AppSort, - usageGranted: Boolean, - calibration: CalibrationConfidence, - qualityBasis: Map = emptyMap(), - ): List { - val total = apps.sumOf { it.distanceMm ?: 0.0 } - val ranked = apps.map { app -> - val scrolled = app.distanceMm != null - RankedApp( - app = app, - sharePercent = if (scrolled && total > 0) app.distanceMm / total * 100 else null, - quality = if (scrolled) quality(qualityBasis[app.packageName] ?: app, calibration) else null, - ) - } - val key: (RankedApp) -> Double? = when (sort) { - AppSort.DISTANCE -> { r -> r.app.distanceMm } - AppSort.TIME -> { r -> (if (usageGranted) r.app.foregroundMs else r.app.activeScrollMs)?.toDouble() } - } - return ranked.sortedWith(compareBy { key(it) == null }.thenByDescending { key(it) ?: 0.0 }.thenBy { it.app.packageName }) + /** + * By distance, longest first; apps with only time in app (YouTube, D19) after every app with a + * distance, longest time first; rows with nothing to show are left out. + */ + fun list(apps: List): List = apps + .filter { (it.distanceMm ?: 0.0) > 0.0 || (it.foregroundMs ?: 0L) > 0L || (it.activeScrollMs ?: 0L) > 0L } + .sortedWith( + compareBy { it.distanceMm == null } + .thenByDescending { it.distanceMm ?: 0.0 } + .thenByDescending { it.foregroundMs ?: 0L } + .thenBy { it.packageName }, + ) + + /** An app's share of the period's distance in percent; null without scroll data or in an empty period. */ + fun sharePercent(appMm: Double?, totalMm: Double?): Double? { + val distance = appMm ?: return null + if (totalMm == null || totalMm <= 0.0) return null + return distance / totalMm * 100 } - /** The quality word for one app's counters (spec §20, ADR-029). */ + /** + * The quality word for one app's counters (spec §20, ADR-029) — rated on the all-time counters, so + * it does not flip to "málo dat" every morning just because today has few events. + */ fun quality(app: AppSummary, calibration: CalibrationConfidence): MeasurementQuality = MeasurementQualityRater.rate( app.measuredEventCount ?: 0, app.fallbackEventCount ?: 0, app.unmeasurableEventCount ?: 0, app.rejectedOutlierCount ?: 0, calibration, ) diff --git a/app/src/main/java/com/scrollmeter/app/insights/DailyLimit.kt b/app/src/main/java/com/scrollmeter/app/insights/DailyLimit.kt new file mode 100644 index 0000000..3de081a --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/insights/DailyLimit.kt @@ -0,0 +1,52 @@ +package com.scrollmeter.app.insights + +import com.scrollmeter.app.data.model.DateRange +import com.scrollmeter.app.measurement.MeasurementConfig +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +/** + * How close a distance is to the daily limit (ADR-036). The app is not a competition: the limit is + * what the user does not want to go over, and the colour says how close they are. NONE = no limit set. + */ +enum class LimitLevel { NONE, UNDER, NEAR, OVER } + +/** [remainingMm] until the limit (0 when over), [overMm] past it (0 when under). */ +data class LimitStatus(val level: LimitLevel, val limitMm: Double, val remainingMm: Double, val overMm: Double) + +/** Pure Kotlin. */ +object DailyLimit { + /** UNDER below [MeasurementConfig.LIMIT_WARN_RATIO] of the limit, NEAR from there, OVER from the limit on. */ + fun level(distanceMm: Double?, limitMm: Double): LimitLevel { + if (limitMm <= 0.0) return LimitLevel.NONE + val ratio = (distanceMm ?: 0.0) / limitMm + return when { + ratio >= 1.0 -> LimitLevel.OVER + ratio >= MeasurementConfig.LIMIT_WARN_RATIO -> LimitLevel.NEAR + else -> LimitLevel.UNDER + } + } + + fun status(distanceMm: Double?, limitMm: Double): LimitStatus { + val value = distanceMm ?: 0.0 + return LimitStatus( + level = level(value, limitMm), + limitMm = limitMm, + remainingMm = (limitMm - value).coerceAtLeast(0.0), + overMm = (value - limitMm).coerceAtLeast(0.0), + ) + } + + /** + * A week's or a month's distance per day, to hold against the daily limit. Only days from the + * first measured day up to today count (as the history statistics did, ADR-029): the unmeasured + * days before installing, or the rest of the current week, would dilute it. Null when no day counts. + */ + fun averagePerDay(totalMm: Double?, range: DateRange, firstMeasuredDay: LocalDate?, today: LocalDate): Double? { + if (firstMeasuredDay == null) return null + val from = maxOf(range.from, minOf(firstMeasuredDay, today)) + val to = minOf(range.to, today) + if (to.isBefore(from)) return null + return (totalMm ?: 0.0) / (ChronoUnit.DAYS.between(from, to) + 1) + } +} diff --git a/app/src/main/java/com/scrollmeter/app/insights/DaySeries.kt b/app/src/main/java/com/scrollmeter/app/insights/DaySeries.kt index 8186cc1..f01241e 100644 --- a/app/src/main/java/com/scrollmeter/app/insights/DaySeries.kt +++ b/app/src/main/java/com/scrollmeter/app/insights/DaySeries.kt @@ -3,75 +3,25 @@ package com.scrollmeter.app.insights import com.scrollmeter.app.data.model.DateRange import com.scrollmeter.app.data.model.DaySummary import java.time.LocalDate -import java.time.temporal.ChronoUnit -/** Spec §23: the three history views. */ -enum class HistoryPeriod { DAYS_7, DAYS_30, MONTHS_12 } - -/** One bar: a day, or a month starting at [start]; [value] in the unit of the series (mm, ms). */ -data class HistoryBar(val start: LocalDate, val value: Double) - -data class DayValue(val date: LocalDate, val value: Double) - -/** - * Průměr / den, Nejvyšší den, Nejnižší den, Celkem (spec §23) — always per **day**, also in the - * 12-month view. Only days since the first measured day count: before that nothing was measured, - * and averaging those days in would understate everything for a new user. - */ -data class HistoryStats(val averagePerDay: Double, val maxDay: DayValue, val minDay: DayValue, val total: Double) - -data class HistorySeries(val period: HistoryPeriod, val range: DateRange, val bars: List, val stats: HistoryStats?) +/** One bar of a day chart; [value] in the unit of the series (mm, ms). */ +data class DayBar(val date: LocalDate, val value: Double) /** - * Pure Kotlin: stored day summaries → bars (missing days are 0) and daily statistics. The series is - * distance by default; [build]'s `value` picks another column (time in app, scroll time), null = 0. + * Pure Kotlin: stored day summaries → one bar per day of a range, missing days 0 — a calendar week or + * month (ADR-036) or the app detail's last 30 days. The series is distance by default; `value` picks + * another column (time in app), null = 0. */ -object HistorySeriesBuilder { - fun range(period: HistoryPeriod, today: LocalDate): DateRange = when (period) { - HistoryPeriod.DAYS_7 -> DateRange(today.minusDays(6), today) - HistoryPeriod.DAYS_30 -> DateRange(today.minusDays(29), today) - HistoryPeriod.MONTHS_12 -> DateRange(today.withDayOfMonth(1).minusMonths(11), today) - } - - /** [firstMeasuredDay] = the earliest stored day of all time, or null when nothing was ever measured. */ - fun build( - period: HistoryPeriod, - today: LocalDate, - days: List, - firstMeasuredDay: LocalDate?, - value: (DaySummary) -> Double? = { it.distanceMm }, - ): HistorySeries { - val range = range(period, today) +object DaySeries { + fun bars(range: DateRange, days: List, value: (DaySummary) -> Double? = { it.distanceMm }): List { val byDate = HashMap() days.forEach { d -> val date = runCatching { LocalDate.parse(d.date) }.getOrNull() ?: return@forEach if (!date.isBefore(range.from) && !date.isAfter(range.to)) byDate.merge(date, value(d) ?: 0.0, Double::plus) } - val allDays = generateSequence(range.from) { it.plusDays(1) }.takeWhile { !it.isAfter(range.to) }.toList() - val bars = when (period) { - HistoryPeriod.MONTHS_12 -> allDays.groupBy { it.withDayOfMonth(1) } - .map { (month, ds) -> HistoryBar(month, ds.sumOf { byDate[it] ?: 0.0 }) } - else -> allDays.map { HistoryBar(it, byDate[it] ?: 0.0) } - } - return HistorySeries(period, range, bars, stats(allDays, byDate, firstMeasuredDay, today)) + return generateSequence(range.from) { it.plusDays(1) } + .takeWhile { !it.isAfter(range.to) } + .map { DayBar(it, byDate[it] ?: 0.0) } + .toList() } - - private fun stats(allDays: List, byDate: Map, first: LocalDate?, today: LocalDate): HistoryStats? { - if (first == null) return null - // A first day "in the future" (the clock was moved back) still means something was measured. - val from = minOf(first, today) - val counted = allDays.filter { !it.isBefore(from) }.map { DayValue(it, byDate[it] ?: 0.0) } - if (counted.isEmpty()) return null - val total = counted.sumOf { it.value } - return HistoryStats( - averagePerDay = total / counted.size, - // Ties go to the most recent day — the one the user remembers. - maxDay = counted.maxWith(compareBy { it.value }.thenBy { it.date }), - minDay = counted.minWith(compareBy { it.value }.thenByDescending { it.date }), - total = total, - ) - } - - /** Days between two dates inclusive — for labels such as "za 12 dní". */ - fun daysInclusive(range: DateRange): Long = ChronoUnit.DAYS.between(range.from, range.to) + 1 } diff --git a/app/src/main/java/com/scrollmeter/app/insights/Period.kt b/app/src/main/java/com/scrollmeter/app/insights/Period.kt new file mode 100644 index 0000000..c12f398 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/insights/Period.kt @@ -0,0 +1,68 @@ +package com.scrollmeter.app.insights + +import com.scrollmeter.app.data.model.DateRange +import java.time.LocalDate + +/** Den / Týden / Měsíc (ADR-036): calendar periods only — a week runs Monday–Sunday (spec §19). */ +enum class PeriodKind { DAY, WEEK, MONTH } + +/** How a period relates to today, for its name: "Dnes", "Minulý týden", or just its dates. */ +enum class PeriodRelation { CURRENT, PREVIOUS, OTHER } + +/** + * One calendar period, identified by any day inside it ([anchor]); switching Den / Týden / Měsíc + * keeps the anchor, so a day opened from a week's bar switches back to that week. Routes and saved + * state carry it as [key], e.g. "WEEK:2026-09-21". Pure Kotlin. + */ +data class Period(val kind: PeriodKind, val anchor: LocalDate) { + val range: DateRange + get() = when (kind) { + PeriodKind.DAY -> DateRange.day(anchor) + PeriodKind.WEEK -> DateRange.week(anchor) + PeriodKind.MONTH -> DateRange.month(anchor) + } + + val key: String get() = "${kind.name}:$anchor" + + fun previous(): Period = shift(-1) + + fun next(): Period = shift(1) + + fun withKind(kind: PeriodKind): Period = copy(kind = kind) + + /** Forward only while the next period has begun — never into the future. */ + fun canGoForward(today: LocalDate): Boolean = range.to.isBefore(today) + + /** Back only while the previous period reaches the first measured day; with nothing measured, never. */ + fun canGoBack(firstMeasuredDay: LocalDate?): Boolean = + firstMeasuredDay != null && !previous().range.to.isBefore(firstMeasuredDay) + + fun relation(today: LocalDate): PeriodRelation = when { + today in this -> PeriodRelation.CURRENT + today in next() -> PeriodRelation.PREVIOUS + else -> PeriodRelation.OTHER + } + + operator fun contains(date: LocalDate): Boolean = !date.isBefore(range.from) && !date.isAfter(range.to) + + private fun shift(by: Long): Period = copy( + anchor = when (kind) { + PeriodKind.DAY -> anchor.plusDays(by) + PeriodKind.WEEK -> anchor.plusWeeks(by) + PeriodKind.MONTH -> anchor.plusMonths(by) + }, + ) + + companion object { + fun today(kind: PeriodKind, today: LocalDate) = Period(kind, today) + + /** From route arguments; null for anything that is not a kind and an ISO date. */ + fun parse(kind: String, anchor: String): Period? { + val k = PeriodKind.entries.firstOrNull { it.name == kind } ?: return null + val date = runCatching { LocalDate.parse(anchor) }.getOrNull() ?: return null + return Period(k, date) + } + + fun fromKey(key: String): Period? = key.split(':', limit = 2).takeIf { it.size == 2 }?.let { parse(it[0], it[1]) } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt index e9c3158..9ff3c81 100644 --- a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt +++ b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt @@ -84,11 +84,10 @@ object MeasurementConfig { const val SESSION_RETENTION_DAYS = 90L /** - * "Nový rekord" (spec §26, ADR-031): only after this many earlier measured days — the second day - * of use is not a record worth a notification — and only above [RECORD_MIN_MM]. + * Daily limit (ADR-036): from this share of the limit the colour turns from green to orange (red from + * the limit itself). 70 % leaves a warning before the limit rather than at it. */ - const val RECORD_MIN_PRIOR_DAYS = 3 - const val RECORD_MIN_MM = 10_000.0 + const val LIMIT_WARN_RATIO = 0.7 /** * Measurement quality per app (spec §20, ADR-029). Under [QUALITY_MIN_EVENTS] stored events there is diff --git a/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt b/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt index 9bf7b26..43718f1 100644 --- a/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt +++ b/app/src/main/java/com/scrollmeter/app/notifications/AndroidNotificationPoster.kt @@ -19,7 +19,7 @@ import com.scrollmeter.app.settings.Settings import java.util.Locale /** - * Posts the three notices (spec §26, ADR-030/031) on one channel, "Cíle a rekordy", in the app's + * Posts the two notices (spec §26, ADR-030/031/036) on one channel, "Limit a shrnutí", in the app's * language (the application context alone would use the phone's — ADR-035). Tapping one * opens the app — an explicit, immutable intent without extras. Without POST_NOTIFICATIONS * (API 33+) or with the app's notifications off, [canPost] is false and nothing is attempted. @@ -33,7 +33,7 @@ class AndroidNotificationPoster(private val context: Context) : NotificationPost return permitted && manager.areNotificationsEnabled() } - /** "Smazat všechna data": a posted "Nový rekord 24 m" is measured data too. */ + /** "Smazat všechna data": a posted "Včera to dohromady bylo 24 m" is measured data too. */ fun clearShown() = manager.cancelAll() override fun post(notice: Notice, settings: Settings) { @@ -43,8 +43,7 @@ class AndroidNotificationPoster(private val context: Context) : NotificationPost val locale = Locale.forLanguageTag(strings.getString(R.string.number_locale)) val distance = DistanceFormatter.format(notice.distanceMm, settings.unitPreference, locale) val (title, text) = when (notice.kind) { - NotificationKind.GOAL -> strings.getString(R.string.notification_goal_title) to strings.getString(R.string.notification_goal_text, distance) - NotificationKind.RECORD -> strings.getString(R.string.notification_record_title) to strings.getString(R.string.notification_record_text, distance) + NotificationKind.LIMIT -> strings.getString(R.string.notification_limit_title) to strings.getString(R.string.notification_limit_text, distance) NotificationKind.SUMMARY -> strings.getString(R.string.notification_summary_title) to strings.getString(R.string.notification_summary_text, distance) } val open = PendingIntent.getActivity( @@ -76,6 +75,7 @@ class AndroidNotificationPoster(private val context: Context) : NotificationPost } private companion object { + /** Kept from the goal days (ADR-036): a new id would leave a dead channel in the system settings. */ const val CHANNEL_ID = "goals" } } diff --git a/app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt b/app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt index fb9ba3f..296a2e2 100644 --- a/app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt +++ b/app/src/main/java/com/scrollmeter/app/notifications/NotificationRules.kt @@ -1,11 +1,13 @@ package com.scrollmeter.app.notifications -import com.scrollmeter.app.measurement.MeasurementConfig import com.scrollmeter.app.settings.Settings import java.time.LocalDate -/** Spec §26, §44: the three optional notifications. */ -enum class NotificationKind { GOAL, RECORD, SUMMARY } +/** + * Spec §26, §44, ADR-036: the two optional notifications. Nothing celebrates scrolling more — the + * "record" notification is gone and the goal became a limit. + */ +enum class NotificationKind { LIMIT, SUMMARY } /** The day each kind was last posted — at most one per kind and day (spec §26, ADR-031). */ interface NotificationState { @@ -16,44 +18,31 @@ interface NotificationState { /** One notification to post; [distanceMm] is the number it shows. */ data class Notice(val kind: NotificationKind, val distanceMm: Double) -/** - * What a check knows. [previousBestMm] and [priorMeasuredDays] cover the days before [today] with - * a distance above 0; [yesterdayMm] is yesterday's total. Excluded apps are left out everywhere. - */ +/** What a check knows: today's and yesterday's totals, excluded apps left out; [limitMm] 0 = no limit. */ data class NotificationFacts( val today: LocalDate, val todayMm: Double, - val goalMm: Double, - val previousBestMm: Double, - val priorMeasuredDays: Int, + val limitMm: Double, val yesterdayMm: Double, ) /** - * Pure Kotlin (ADR-031). Never per event: the watcher asks after a flush, and each kind is posted - * at most once a day. - * - GOAL: today reached the daily goal. - * - RECORD: today passed the best earlier day — only after [MeasurementConfig.RECORD_MIN_PRIOR_DAYS] measured days - * (the second day of use is not a record worth a notification) and above [MeasurementConfig.RECORD_MIN_MM]. + * Pure Kotlin (ADR-031, ADR-036). Never per event: the watcher asks after a flush, and each kind is + * posted at most once a day. + * - LIMIT: today went over the daily limit (none without a limit). * - SUMMARY: yesterday's total, with the first check of a new day (i.e. the first scroll — never at * midnight, no scheduled job); nothing when yesterday was empty. */ object NotificationRules { fun enabled(settings: Settings): Set = buildSet { - if (settings.notifyGoal) add(NotificationKind.GOAL) - if (settings.notifyRecord) add(NotificationKind.RECORD) + if (settings.notifyLimit) add(NotificationKind.LIMIT) if (settings.notifySummary) add(NotificationKind.SUMMARY) } /** [due] = the enabled kinds not yet posted today. */ fun decide(facts: NotificationFacts, due: Set): List = buildList { - if (NotificationKind.GOAL in due && facts.goalMm > 0 && facts.todayMm >= facts.goalMm) { - add(Notice(NotificationKind.GOAL, facts.goalMm)) - } - if (NotificationKind.RECORD in due && facts.priorMeasuredDays >= MeasurementConfig.RECORD_MIN_PRIOR_DAYS && - facts.todayMm > facts.previousBestMm && facts.todayMm >= MeasurementConfig.RECORD_MIN_MM - ) { - add(Notice(NotificationKind.RECORD, facts.todayMm)) + if (NotificationKind.LIMIT in due && facts.limitMm > 0 && facts.todayMm >= facts.limitMm) { + add(Notice(NotificationKind.LIMIT, facts.limitMm)) } if (NotificationKind.SUMMARY in due && facts.yesterdayMm > 0) { add(Notice(NotificationKind.SUMMARY, facts.yesterdayMm)) diff --git a/app/src/main/java/com/scrollmeter/app/settings/Settings.kt b/app/src/main/java/com/scrollmeter/app/settings/Settings.kt index e8660da..c4850e6 100644 --- a/app/src/main/java/com/scrollmeter/app/settings/Settings.kt +++ b/app/src/main/java/com/scrollmeter/app/settings/Settings.kt @@ -10,7 +10,8 @@ enum class ThemePreference { SYSTEM, LIGHT, DARK } * bookkeeping is in [SettingsRepository] but not here — it is state, not a setting. */ data class Settings( - val dailyGoalMm: Double = DEFAULT_DAILY_GOAL_MM, + /** ADR-036: a limit the user does not want to go over — not a goal; 0 = no limit. */ + val dailyLimitMm: Double = DEFAULT_DAILY_LIMIT_MM, val excludedPackages: Set = emptySet(), val unitPreference: UnitPreference = UnitPreference.AUTOMATIC, val theme: ThemePreference = ThemePreference.SYSTEM, @@ -19,16 +20,18 @@ data class Settings( val privacyDisclosureAccepted: Boolean = false, val usageTimeCardDismissed: Boolean = false, /** Spec §25, §26: every notification is opt-in; none is needed for measuring. */ - val notifyGoal: Boolean = false, - val notifyRecord: Boolean = false, + val notifyLimit: Boolean = false, val notifySummary: Boolean = false, ) { companion object { - /** Spec §25 options; 500 m is the spec's own example. */ - const val DEFAULT_DAILY_GOAL_MM = 500_000.0 - val GOAL_PRESETS_MM = listOf(100_000.0, 250_000.0, 500_000.0, 1_000_000.0, 2_000_000.0, 5_000_000.0) + /** Spec §25 options (ADR-036: as a limit); 500 m is the spec's own example. */ + const val DEFAULT_DAILY_LIMIT_MM = 500_000.0 + val LIMIT_PRESETS_MM = listOf(100_000.0, 250_000.0, 500_000.0, 1_000_000.0, 2_000_000.0, 5_000_000.0) - /** Custom goals (spec §25, ADR-031): the dialog rejects anything outside; never clamped silently. */ - val GOAL_RANGE_MM = 10_000.0..100_000_000.0 + /** "Bez limitu": no colour, no limit notification (ADR-036). */ + const val NO_LIMIT = 0.0 + + /** Custom limits (spec §25, ADR-031): the dialog rejects anything outside; never clamped silently. */ + val LIMIT_RANGE_MM = 10_000.0..100_000_000.0 } } diff --git a/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt b/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt index ee7dba4..1af89e6 100644 --- a/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt +++ b/app/src/main/java/com/scrollmeter/app/settings/SettingsRepository.kt @@ -50,7 +50,10 @@ class SettingsRepository(private val dataStore: DataStore) : UsageS .map(::toSettings) .distinctUntilChanged() - suspend fun setDailyGoalMm(mm: Double) = edit { it[DAILY_GOAL_MM] = mm.coerceIn(Settings.GOAL_RANGE_MM) } + /** 0 or less = "Bez limitu"; anything else is kept inside [Settings.LIMIT_RANGE_MM]. */ + suspend fun setDailyLimitMm(mm: Double) = edit { + it[DAILY_LIMIT_MM] = if (mm <= Settings.NO_LIMIT) Settings.NO_LIMIT else mm.coerceIn(Settings.LIMIT_RANGE_MM) + } suspend fun setExcluded(packageName: String, excluded: Boolean) = edit { val current = it[EXCLUDED_PACKAGES].orEmpty() @@ -93,7 +96,8 @@ class SettingsRepository(private val dataStore: DataStore) : UsageS } internal companion object { - val DAILY_GOAL_MM = doublePreferencesKey("daily_goal_mm") + /** The key of the former daily goal: its value carries over as the limit (ADR-036). */ + val DAILY_LIMIT_MM = doublePreferencesKey("daily_goal_mm") val EXCLUDED_PACKAGES = stringSetPreferencesKey("excluded_packages") val UNIT = stringPreferencesKey("unit_preference") val THEME = stringPreferencesKey("theme") @@ -103,23 +107,28 @@ class SettingsRepository(private val dataStore: DataStore) : UsageS val USAGE_TIME_CARD_DISMISSED = booleanPreferencesKey("usage_time_card_dismissed") val USAGE_SYNC_LAST_MS = longPreferencesKey("usage_sync_last_ms") val DATA_FLOOR_MS = longPreferencesKey("data_floor_ms") - val NOTIFY_GOAL = booleanPreferencesKey("notify_goal") - val NOTIFY_RECORD = booleanPreferencesKey("notify_record") + /** The former goal notification's keys, so its switch and today's "posted" mark carry over (ADR-036). */ + val NOTIFY_LIMIT = booleanPreferencesKey("notify_goal") val NOTIFY_SUMMARY = booleanPreferencesKey("notify_summary") fun notifyKey(kind: NotificationKind) = when (kind) { - NotificationKind.GOAL -> NOTIFY_GOAL - NotificationKind.RECORD -> NOTIFY_RECORD + NotificationKind.LIMIT -> NOTIFY_LIMIT NotificationKind.SUMMARY -> NOTIFY_SUMMARY } - fun postedKey(kind: NotificationKind) = stringPreferencesKey("notified_${kind.name.lowercase()}_date") + fun postedKey(kind: NotificationKind) = stringPreferencesKey( + when (kind) { + NotificationKind.LIMIT -> "notified_goal_date" + NotificationKind.SUMMARY -> "notified_summary_date" + }, + ) fun toSettings(prefs: Preferences): Settings { val defaults = Settings() fun read(block: () -> T?): T? = runCatching(block).getOrNull() return Settings( - dailyGoalMm = read { prefs[DAILY_GOAL_MM] }?.takeIf { it in Settings.GOAL_RANGE_MM } ?: defaults.dailyGoalMm, + dailyLimitMm = read { prefs[DAILY_LIMIT_MM] }?.takeIf { it == Settings.NO_LIMIT || it in Settings.LIMIT_RANGE_MM } + ?: defaults.dailyLimitMm, excludedPackages = read { prefs[EXCLUDED_PACKAGES] } ?: defaults.excludedPackages, unitPreference = read { prefs[UNIT]?.let(UnitPreference::valueOf) } ?: defaults.unitPreference, theme = read { prefs[THEME]?.let(ThemePreference::valueOf) } ?: defaults.theme, @@ -127,8 +136,7 @@ class SettingsRepository(private val dataStore: DataStore) : UsageS onboardingCompleted = read { prefs[ONBOARDING_COMPLETED] } ?: defaults.onboardingCompleted, privacyDisclosureAccepted = read { prefs[PRIVACY_DISCLOSURE_ACCEPTED] } ?: defaults.privacyDisclosureAccepted, usageTimeCardDismissed = read { prefs[USAGE_TIME_CARD_DISMISSED] } ?: defaults.usageTimeCardDismissed, - notifyGoal = read { prefs[NOTIFY_GOAL] } ?: defaults.notifyGoal, - notifyRecord = read { prefs[NOTIFY_RECORD] } ?: defaults.notifyRecord, + notifyLimit = read { prefs[NOTIFY_LIMIT] } ?: defaults.notifyLimit, notifySummary = read { prefs[NOTIFY_SUMMARY] } ?: defaults.notifySummary, ) } diff --git a/app/src/main/java/com/scrollmeter/app/ui/apps/AppDetailScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/apps/AppDetailScreen.kt index 1bb0834..9d0d656 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/apps/AppDetailScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/apps/AppDetailScreen.kt @@ -36,31 +36,32 @@ import com.scrollmeter.app.format.DistanceFormatter import com.scrollmeter.app.format.TimeFormatter import com.scrollmeter.app.insights.AppPeriodTotals import com.scrollmeter.app.insights.AppRanking -import com.scrollmeter.app.insights.AppsPeriod import com.scrollmeter.app.insights.ChartScale -import com.scrollmeter.app.insights.HistoryPeriod -import com.scrollmeter.app.insights.HistorySeriesBuilder +import com.scrollmeter.app.insights.DaySeries +import com.scrollmeter.app.insights.Period import com.scrollmeter.app.insights.ScrollShare +import com.scrollmeter.app.measurement.MeasurementQuality import com.scrollmeter.app.settings.Settings import com.scrollmeter.app.ui.components.AppIcon import com.scrollmeter.app.ui.components.BarChart import com.scrollmeter.app.ui.components.ChartLabels -import com.scrollmeter.app.ui.components.PeriodSelector import com.scrollmeter.app.ui.components.appLocale import com.scrollmeter.app.ui.components.rememberAppInfo import com.scrollmeter.app.ui.components.rememberCalibrationConfidence import com.scrollmeter.app.ui.components.rememberToday import com.scrollmeter.app.ui.components.rememberUsageGranted +import com.scrollmeter.app.ui.period.periodName import java.util.Locale import kotlinx.coroutines.flow.map /** - * One app (spec §24, D19): Dnes | 7 dní | 30 dní | Celkem with distance, time in app, scroll time, - * the share of the time in app spent scrolling and the quality word with its explanation; below, - * the last 30 days of distance and of time (in app with Usage access, scrolling otherwise). + * One app over one period (spec §24, D19, ADR-036) — the period it was opened from, named in the + * header: distance, its share of the period's distance, time in app, scroll time, pace, the share of + * the time in app spent scrolling, and the quality word with its explanation; below, the last 30 days + * of distance and of time (in app with Usage access, scrolling otherwise). */ @Composable -fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { +fun AppDetailScreen(graph: AppGraph, packageName: String, period: Period, onBack: () -> Unit) { val locale = appLocale() val labels = remember(locale) { ChartLabels(locale) } val today = rememberToday() @@ -68,14 +69,14 @@ fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { val calibration = rememberCalibrationConfidence(graph) val info by rememberAppInfo(graph.appInfoProvider, packageName, 48.dp) val label = info?.label ?: packageName - var period by rememberSaveable { mutableStateOf(AppsPeriod.TODAY) } val settings by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = Settings()) val unit = settings.unitPreference - val range = period.range(today) + val range = period.range val periodDays by remember(range, packageName) { graph.scrollRepository.appDays(packageName, range) } .collectAsStateWithLifecycle(initialValue = null) val totals = remember(periodDays) { periodDays?.let(AppPeriodTotals::of) } - val chartRange = HistorySeriesBuilder.range(HistoryPeriod.DAYS_30, today) + val periodMm by remember(range) { graph.scrollRepository.distance(range) }.collectAsStateWithLifecycle(initialValue = null) + val chartRange = DateRange(today.minusDays(CHART_DAYS - 1L), today) val days by remember(chartRange, packageName) { graph.scrollRepository.appDays(packageName, chartRange) } .collectAsStateWithLifecycle(initialValue = emptyList()) // Quality describes the app, not the period: rated on its all-time counters (ADR-029). @@ -94,10 +95,18 @@ fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { Spacer(Modifier.width(4.dp)) AppIcon(info, label, 40.dp) Spacer(Modifier.width(12.dp)) - Text(label, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + val name = periodName(period, today, labels) + val dates = labels.dates(period) + Text( + if (name == dates) dates else stringResource(R.string.app_detail_period, name, dates), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - PeriodSelector(AppsPeriod.entries, period, { stringResource(appsPeriodLabel(it)) }, { period = it }) - PeriodSummary(totals, usageGranted, ::distance, locale) + PeriodSummary(totals, AppRanking.sharePercent(totals?.distanceMm, periodMm), usageGranted, ::distance, locale) val basis = allTime if (basis?.distanceMm != null) { @@ -110,48 +119,50 @@ fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { } } - // Charts only: no statistics, so no first measured day. - val distanceSeries = remember(days, today) { HistorySeriesBuilder.build(HistoryPeriod.DAYS_30, today, days, firstMeasuredDay = null) } - val distanceScale = remember(distanceSeries) { ChartLabels.distanceScale(distanceSeries.bars.maxOf { it.value }) } + val distanceBars = remember(days, chartRange) { DaySeries.bars(chartRange, days) } + val distanceScale = remember(distanceBars) { ChartLabels.distanceScale(distanceBars.maxOf { it.value }) } ChartCard( title = stringResource(R.string.app_detail_distance_chart), - values = distanceSeries.bars.map { it.value }, + values = distanceBars.map { it.value }, scale = distanceScale, axisLabel = remember(distanceScale, unit, locale) { ChartLabels.distanceAxis(distanceScale, unit, locale) }, - barLabel = { labels.bar(HistoryPeriod.DAYS_30, distanceSeries.bars[it]) }, - firstBar = distanceSeries.range.from.toString(), - selectedText = { i -> stringResource(R.string.history_day_value, labels.day(distanceSeries.bars[i].start), distance(distanceSeries.bars[i].value)) }, + barLabel = { labels.bar(distanceBars[it], distanceBars.size) }, + firstBar = chartRange.fromKey, + selectedText = { i -> stringResource(R.string.chart_day_value, labels.day(distanceBars[i].date), distance(distanceBars[i].value)) }, ) - val timeSeries = remember(days, today, usageGranted) { - HistorySeriesBuilder.build(HistoryPeriod.DAYS_30, today, days, firstMeasuredDay = null) { - (if (usageGranted) it.foregroundMs else it.activeScrollMs)?.toDouble() - } + val timeBars = remember(days, chartRange, usageGranted) { + DaySeries.bars(chartRange, days) { (if (usageGranted) it.foregroundMs else it.activeScrollMs)?.toDouble() } } ChartCard( title = stringResource(if (usageGranted) R.string.app_detail_time_chart else R.string.app_detail_scroll_chart), // Bars and axis in minutes, so the grid steps read as clock time. - values = timeSeries.bars.map { it.value / 60_000.0 }, - scale = remember(timeSeries) { ChartLabels.minutesScale(timeSeries.bars.maxOf { it.value }) }, + values = timeBars.map { it.value / 60_000.0 }, + scale = remember(timeBars) { ChartLabels.minutesScale(timeBars.maxOf { it.value }) }, axisLabel = ChartLabels::minutesAxis, - barLabel = { labels.bar(HistoryPeriod.DAYS_30, timeSeries.bars[it]) }, - firstBar = timeSeries.range.from.toString(), + barLabel = { labels.bar(timeBars[it], timeBars.size) }, + firstBar = chartRange.fromKey, selectedText = { i -> - stringResource(R.string.history_day_value, labels.day(timeSeries.bars[i].start), TimeFormatter.duration(timeSeries.bars[i].value.toLong())) + stringResource(R.string.chart_day_value, labels.day(timeBars[i].date), TimeFormatter.duration(timeBars[i].value.toLong())) }, ) } } /** - * Distance, time in app and pace (with Usage access, ADR-022), scroll time; the scroll-share sentence + * Distance and its share of the period's distance, time in app and pace (with Usage access, ADR-022), + * scroll time; the scroll-share sentence * when both times are known, or a note that the app reports no scrolling. Pace and share use only * the days that have a time in app ([AppPeriodTotals]). */ @Composable -private fun PeriodSummary(app: AppPeriodTotals?, usageGranted: Boolean, distance: (Double) -> String, locale: Locale) { +private fun PeriodSummary(app: AppPeriodTotals?, sharePercent: Double?, usageGranted: Boolean, distance: (Double) -> String, locale: Locale) { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { SummaryRow(stringResource(R.string.app_detail_distance), app?.distanceMm?.let(distance) ?: TimeFormatter.UNKNOWN) + if (sharePercent != null) { + HorizontalDivider() + SummaryRow(stringResource(R.string.app_detail_share), stringResource(R.string.percent, decimal(sharePercent, locale))) + } if (usageGranted) { HorizontalDivider() SummaryRow(stringResource(R.string.app_detail_time_in_app), TimeFormatter.duration(app?.foregroundMs)) @@ -208,3 +219,27 @@ private fun ChartCard( } } } + +/** The last 30 days, whatever the period: the charts show the app's trend, the summary the period. */ +private const val CHART_DAYS = 30 + +private fun qualityLabel(quality: MeasurementQuality): Int = when (quality) { + MeasurementQuality.HIGH -> R.string.quality_high + MeasurementQuality.MEDIUM -> R.string.quality_medium + MeasurementQuality.LOW -> R.string.quality_low + MeasurementQuality.UNKNOWN -> R.string.quality_unknown +} + +private fun qualityInfo(quality: MeasurementQuality): Int = when (quality) { + MeasurementQuality.HIGH -> R.string.quality_high_info + MeasurementQuality.MEDIUM -> R.string.quality_medium_info + MeasurementQuality.LOW -> R.string.quality_low_info + MeasurementQuality.UNKNOWN -> R.string.quality_unknown_info +} + +/** A share with one decimal ("43,7"). */ +private fun decimal(value: Double, locale: Locale): String = + java.text.NumberFormat.getNumberInstance(locale).apply { + minimumFractionDigits = 1 + maximumFractionDigits = 1 + }.format(value) diff --git a/app/src/main/java/com/scrollmeter/app/ui/components/AppList.kt b/app/src/main/java/com/scrollmeter/app/ui/components/AppList.kt new file mode 100644 index 0000000..8e93b2b --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/components/AppList.kt @@ -0,0 +1,42 @@ +package com.scrollmeter.app.ui.components + +import androidx.compose.foundation.clickable +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.width +import androidx.compose.material3.MaterialTheme +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.scrollmeter.app.AppGraph +import com.scrollmeter.app.data.model.AppSummary +import com.scrollmeter.app.format.TimeFormatter + +/** + * One app of a period (ADR-036): icon, name, and on the same line the distance with the time in the + * app in brackets — "1,21 km (3 h 40 min)"; an app with only time (YouTube, D19) "— (2 h 15 min)". + * Without Usage access there is no time in app, so no brackets — never a 0. + */ +@Composable +fun AppRow(graph: AppGraph, app: AppSummary, usageGranted: Boolean, distance: (Double) -> String, onClick: () -> Unit) { + val info by rememberAppInfo(graph.appInfoProvider, app.packageName, 36.dp) + val label = info?.label ?: app.packageName + val time = app.foregroundMs?.takeIf { usageGranted && it > 0 }?.let(TimeFormatter::duration) + Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { + AppIcon(info, label, 36.dp) + Spacer(Modifier.width(12.dp)) + Text(label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + Spacer(Modifier.width(12.dp)) + Text(app.distanceMm?.let(distance) ?: TimeFormatter.UNKNOWN, style = MaterialTheme.typography.titleMedium, maxLines = 1) + if (time != null) { + Spacer(Modifier.width(6.dp)) + Text("($time)", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/components/BarChart.kt b/app/src/main/java/com/scrollmeter/app/ui/components/BarChart.kt index b046f7c..8d275a3 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/components/BarChart.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/components/BarChart.kt @@ -13,6 +13,8 @@ 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.Color +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription @@ -30,6 +32,8 @@ import kotlin.math.max * one bar per value, x labels from [barLabel] thinned out so they never overlap — counted from the * newest bar, which is always labelled. Tapping a bar selects it ([onSelect] null = deselect); the * caller shows the selected value. TalkBack reads [contentDescription] instead of the bars. + * [barColor] colours each bar on its own (the daily limit, ADR-036); [referenceLine] draws a dashed + * line at that value, in [referenceColor], when it lies inside the scale. */ @Composable fun BarChart( @@ -42,6 +46,9 @@ fun BarChart( onSelect: (Int?) -> Unit, modifier: Modifier = Modifier, height: Dp = 200.dp, + barColor: ((Int) -> Color)? = null, + referenceLine: Double? = null, + referenceColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, ) { val measurer = rememberTextMeasurer() // The tap handler outlives recompositions; it must see the current selection and callback. @@ -50,7 +57,6 @@ fun BarChart( val style = MaterialTheme.typography.labelSmall.copy(color = MaterialTheme.colorScheme.onSurfaceVariant) val grid = MaterialTheme.colorScheme.outlineVariant val bar = MaterialTheme.colorScheme.primary - val dimmed = bar.copy(alpha = 0.35f) val density = LocalDensity.current val gapPx = with(density) { 8.dp.toPx() } val axisLabels = remember(scale, axisLabel, style) { scale.gridLines.map { measurer.measure(axisLabel(it), style) } } @@ -93,14 +99,24 @@ fun BarChart( val h = scale.fraction(value) * plotHeight if (h <= 0f) return@forEachIndexed val x = plotLeft + i * slot + (slot - barWidth) / 2 + val own = barColor?.invoke(i) ?: bar drawRoundRect( - color = if (selected == null || selected == i) bar else dimmed, + color = if (selected == null || selected == i) own else own.copy(alpha = 0.35f), topLeft = Offset(x, plotBottom - h), size = Size(barWidth, h), cornerRadius = radius, ) } + if (referenceLine != null && referenceLine > 0 && referenceLine <= scale.max) { + val y = plotBottom - scale.fraction(referenceLine) * plotHeight + val dash = 6.dp.toPx() + drawLine( + referenceColor, Offset(plotLeft, y), Offset(size.width, y), strokeWidth = 1.5.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(dash, dash / 1.5f)), + ) + } + val widest = xLabels.maxOfOrNull { it.size.width } ?: 0 val every = ceil((widest + gapPx) / slot).toInt().coerceAtLeast(1) xLabels.forEachIndexed { i, label -> diff --git a/app/src/main/java/com/scrollmeter/app/ui/components/ChartLabels.kt b/app/src/main/java/com/scrollmeter/app/ui/components/ChartLabels.kt index fd2ad83..3b7c43d 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/components/ChartLabels.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/components/ChartLabels.kt @@ -2,36 +2,47 @@ package com.scrollmeter.app.ui.components import com.scrollmeter.app.format.TimeFormatter import com.scrollmeter.app.insights.ChartScale -import com.scrollmeter.app.insights.HistoryBar -import com.scrollmeter.app.insights.HistoryPeriod +import com.scrollmeter.app.insights.DayBar +import com.scrollmeter.app.insights.Period +import com.scrollmeter.app.insights.PeriodKind import com.scrollmeter.app.settings.UnitPreference import java.text.NumberFormat import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale -/** Axis and bar labels for the history charts: dates in the app's locale, 0 without a unit. */ +/** Axis, bar and period labels: dates in the app's locale, 0 without a unit. */ class ChartLabels(locale: Locale) { private val weekday = DateTimeFormatter.ofPattern("EEE", locale) + private val weekdayDayMonth = DateTimeFormatter.ofPattern("EEE d. M.", locale) private val dayOfMonth = DateTimeFormatter.ofPattern("d.", locale) - private val monthShort = DateTimeFormatter.ofPattern("LLL", locale) private val dayMonth = DateTimeFormatter.ofPattern("d. M.", locale) + private val dayMonthYear = DateTimeFormatter.ofPattern("d. M. yyyy", locale) private val month = DateTimeFormatter.ofPattern("LLLL yyyy", locale) - /** Under a bar: weekday for 7 days, day of month for 30, month for 12 months. */ - fun bar(period: HistoryPeriod, bar: HistoryBar): String = when (period) { - HistoryPeriod.DAYS_7 -> weekday.format(bar.start) - HistoryPeriod.DAYS_30 -> dayOfMonth.format(bar.start) - HistoryPeriod.MONTHS_12 -> monthShort.format(bar.start) - } - - /** Above the chart for a selected bar: "24. 9." or "září 2026". */ - fun selected(period: HistoryPeriod, bar: HistoryBar): String = - if (period == HistoryPeriod.MONTHS_12) month.format(bar.start) else dayMonth.format(bar.start) + /** Under a bar: the weekday in a week's chart (up to 7 bars), the day of the month otherwise. */ + fun bar(bar: DayBar, barCount: Int): String = if (barCount <= 7) weekday.format(bar.date) else dayOfMonth.format(bar.date) /** A day in a statistic: "24. 9." */ fun day(date: LocalDate): String = dayMonth.format(date) + /** + * A period's dates (ADR-036): "čt 25. 9.", "21.–27. 9.", "29. 9. – 5. 10.", "září 2026". A week + * across a new year carries the years. + */ + fun dates(period: Period): String { + val range = period.range + return when (period.kind) { + PeriodKind.DAY -> weekdayDayMonth.format(range.from) + PeriodKind.MONTH -> month.format(range.from) + PeriodKind.WEEK -> when { + range.from.month == range.to.month -> "${dayOfMonth.format(range.from)}–${dayMonth.format(range.to)}" + range.from.year == range.to.year -> "${dayMonth.format(range.from)} – ${dayMonth.format(range.to)}" + else -> "${dayMonthYear.format(range.from)} – ${dayMonthYear.format(range.to)}" + } + } + } + companion object { /** A distance axis in millimetres; [ChartScale.of]'s round steps are round in m and km too. */ fun distanceScale(maxMm: Double): ChartScale = ChartScale.of(maxMm, emptyMax = 100_000.0) diff --git a/app/src/main/java/com/scrollmeter/app/ui/components/LimitCard.kt b/app/src/main/java/com/scrollmeter/app/ui/components/LimitCard.kt new file mode 100644 index 0000000..ac3cd1d --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/components/LimitCard.kt @@ -0,0 +1,66 @@ +package com.scrollmeter.app.ui.components + +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.scrollmeter.app.R +import com.scrollmeter.app.insights.LimitLevel +import com.scrollmeter.app.insights.LimitStatus +import com.scrollmeter.app.ui.theme.limitColors + +/** + * A distance held against the daily limit (ADR-036). The background is the limit level — green, + * orange, red, the neutral surface without a limit — and [lines] say the same in words, so colour is + * never the only signal. With [onClick] the whole card is one button, marked by a chevron. + */ +@Composable +fun LimitCard(title: String?, value: String, level: LimitLevel, lines: List, onClick: (() -> Unit)? = null) { + val colors = limitColors(level) + val cardColors = CardDefaults.cardColors(containerColor = colors.container, contentColor = colors.onContainer) + val content: @Composable ColumnScope.() -> Unit = { + Row(Modifier.fillMaxWidth().padding(20.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (title != null) Text(title, style = MaterialTheme.typography.titleMedium) + Text(value, style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.SemiBold, maxLines = 1) + lines.forEach { Text(it, style = MaterialTheme.typography.bodyLarge) } + } + if (onClick != null) Icon(painterResource(R.drawable.ic_chevron_right), contentDescription = null) + } + } + if (onClick != null) { + Card(onClick = onClick, modifier = Modifier.fillMaxWidth(), colors = cardColors, content = content) + } else { + Card(Modifier.fillMaxWidth(), colors = cardColors, content = content) + } +} + +/** One day against the limit: "Zbývá 80 m z limitu 500 m" / "Překročeno o 120 m (limit 500 m)"; null without a limit. */ +@Composable +fun limitSentence(status: LimitStatus, distance: (Double) -> String): String? = when (status.level) { + LimitLevel.NONE -> null + LimitLevel.OVER -> stringResource(R.string.limit_over, distance(status.overMm), distance(status.limitMm)) + LimitLevel.UNDER, LimitLevel.NEAR -> stringResource(R.string.limit_remaining, distance(status.remainingMm), distance(status.limitMm)) +} + +/** A week or month: "Průměr 420 m / den · limit 500 m", without a limit only the average; null when no day counts. */ +@Composable +fun averageSentence(averageMm: Double?, limitMm: Double, distance: (Double) -> String): String? = when { + averageMm == null -> null + limitMm > 0.0 -> stringResource(R.string.limit_average_of, distance(averageMm), distance(limitMm)) + else -> stringResource(R.string.limit_average, distance(averageMm)) +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt index 72f2c39..dde90d2 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt @@ -1,6 +1,5 @@ package com.scrollmeter.app.ui.dashboard -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -20,6 +19,8 @@ import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -30,52 +31,52 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.scrollmeter.app.AppGraph import com.scrollmeter.app.R import com.scrollmeter.app.calibration.CalibrationMethod import com.scrollmeter.app.data.model.AppSummary -import com.scrollmeter.app.data.model.DateRange import com.scrollmeter.app.format.DistanceFormatter import com.scrollmeter.app.format.TimeFormatter +import com.scrollmeter.app.insights.AppRanking +import com.scrollmeter.app.insights.DailyLimit import com.scrollmeter.app.insights.DistanceComparison import com.scrollmeter.app.insights.DistanceComparisonProvider +import com.scrollmeter.app.insights.LimitLevel +import com.scrollmeter.app.insights.Period +import com.scrollmeter.app.insights.PeriodKind import com.scrollmeter.app.insights.Qualifier import com.scrollmeter.app.insights.Reference -import com.scrollmeter.app.insights.TopApps import com.scrollmeter.app.measurement.PhysicalScaleProvider import com.scrollmeter.app.settings.Settings -import com.scrollmeter.app.settings.UnitPreference -import com.scrollmeter.app.ui.components.AppIcon +import com.scrollmeter.app.ui.components.AppRow +import com.scrollmeter.app.ui.components.LimitCard import com.scrollmeter.app.ui.components.ServiceStatus import com.scrollmeter.app.ui.components.appLocale +import com.scrollmeter.app.ui.components.averageSentence import com.scrollmeter.app.ui.components.launchWrite -import com.scrollmeter.app.ui.components.rememberAppInfo +import com.scrollmeter.app.ui.components.limitSentence import com.scrollmeter.app.ui.components.rememberServiceStatus import com.scrollmeter.app.ui.components.rememberToday import com.scrollmeter.app.ui.components.rememberUsageGranted -import java.util.Locale +import com.scrollmeter.app.ui.theme.limitColors +import java.time.LocalDate /** - * Přehled (spec §21): today's distance against the goal, this week / month / in total, the top - * apps today, and at most one comparison. Everything reads live from [AppGraph.scrollRepository] - * (stored + not yet flushed). When the service is not running, a banner says so first (§32). - * Time in app (D19) appears in the app rows once Usage access is granted; until then one card - * offers it and can be dismissed for good. An app row opens its detail. + * Přehled (spec §21, ADR-036): today's distance against the daily limit — the card turns green, orange, + * red as the limit comes closer — this week and this month with their average per day, the apps + * scrolled most today, and at most one comparison. Everything reads live from [AppGraph.scrollRepository] + * (stored + not yet flushed). When the service is not running, a banner says so first (§32). Time in + * app (D19) appears once Usage access is granted; until then one card offers it and can be dismissed + * for good. Today, the week and the month open Statistiky; an app opens its detail for today; + * Nastavení sits behind the gear — there is no bottom bar. */ @Composable fun DashboardScreen( @@ -83,7 +84,9 @@ fun DashboardScreen( onOpenAccessibilitySettings: () -> Unit, onOpenUsageAccess: () -> Unit, onOpenAccuracy: () -> Unit, - onOpenApp: (String) -> Unit, + onOpenSettings: () -> Unit, + onOpenPeriod: (Period) -> Unit, + onOpenApp: (String, Period) -> Unit, ) { val locale = appLocale() val scope = rememberCoroutineScope() @@ -92,20 +95,24 @@ fun DashboardScreen( val usageGranted = rememberUsageGranted(graph) // The screen may stay open across midnight: "Dnes" rolls over without waiting for a resume. val today = rememberToday() + val day = Period(PeriodKind.DAY, today) + val week = Period(PeriodKind.WEEK, today) + val month = Period(PeriodKind.MONTH, today) val repository = graph.scrollRepository - // Null until loaded: a card the user dismissed must not flash up while settings are read. + // Null until loaded: a card the user dismissed must not flash up, nor the default limit's colour. val loadedSettings by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = null) val settings = loadedSettings ?: Settings() - val todayMm by remember(today) { repository.distance(DateRange.day(today)) }.collectAsStateWithLifecycle(initialValue = null) - val weekMm by remember(today) { repository.distance(DateRange.week(today)) }.collectAsStateWithLifecycle(initialValue = null) - val monthMm by remember(today) { repository.distance(DateRange.month(today)) }.collectAsStateWithLifecycle(initialValue = null) - val lifetimeMm by remember { repository.lifetimeDistance() }.collectAsStateWithLifecycle(initialValue = null) - val apps by remember(today) { repository.apps(DateRange.day(today)) }.collectAsStateWithLifecycle(initialValue = emptyList()) + val limitMm = loadedSettings?.dailyLimitMm ?: Settings.NO_LIMIT + val todayMm by remember(today) { repository.distance(day.range) }.collectAsStateWithLifecycle(initialValue = null) + val weekMm by remember(today) { repository.distance(week.range) }.collectAsStateWithLifecycle(initialValue = null) + val monthMm by remember(today) { repository.distance(month.range) }.collectAsStateWithLifecycle(initialValue = null) + val first by remember { repository.firstMeasuredDay() }.collectAsStateWithLifecycle(initialValue = null) + val apps by remember(today) { repository.apps(day.range) }.collectAsStateWithLifecycle(initialValue = emptyList()) val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } - fun distance(mm: Double?): String = mm?.let { DistanceFormatter.format(it, settings.unitPreference, locale) } ?: TimeFormatter.UNKNOWN + fun distance(mm: Double): String = DistanceFormatter.format(mm, settings.unitPreference, locale) Scaffold { padding -> Column( @@ -115,11 +122,30 @@ fun DashboardScreen( Row(verticalAlignment = Alignment.CenterVertically) { Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) if (status == ServiceStatus.ON) MeasuringChip() + IconButton(onClick = onOpenSettings) { + Icon(painterResource(R.drawable.ic_nav_settings), contentDescription = stringResource(R.string.settings_title)) + } } if (status != ServiceStatus.ON) ServiceBanner(status, onOpenAccessibilitySettings) - TodayHeader(todayMm, settings.dailyGoalMm, ::distance) - PeriodStats(distance(weekMm), distance(monthMm), distance(lifetimeMm)) - TopAppsCard(graph, TopApps.of(apps), usageGranted, settings.unitPreference, locale, onOpenApp) + + val todayStatus = DailyLimit.status(todayMm, limitMm) + val timeToday = if (usageGranted) apps.sumOf { it.foregroundMs ?: 0L }.takeIf { it > 0 } else null + LimitCard( + title = stringResource(R.string.home_today_title), + value = todayMm?.let(::distance) ?: TimeFormatter.UNKNOWN, + level = todayStatus.level, + lines = listOfNotNull( + limitSentence(todayStatus, ::distance), + timeToday?.let { stringResource(R.string.period_time_in_apps, TimeFormatter.duration(it)) }, + ), + onClick = { onOpenPeriod(day) }, + ) + Card(Modifier.fillMaxWidth()) { + PeriodRow(week, weekMm, first, today, limitMm, ::distance, onOpenPeriod) + HorizontalDivider() + PeriodRow(month, monthMm, first, today, limitMm, ::distance, onOpenPeriod) + } + MostTodayCard(graph, AppRanking.list(apps), usageGranted, ::distance, onOpenAll = { onOpenPeriod(day) }, onOpenApp = { onOpenApp(it, day) }) if (!usageGranted && loadedSettings?.usageTimeCardDismissed == false) { UsageTimeCard( onShow = onOpenUsageAccess, @@ -163,98 +189,64 @@ private fun ServiceBanner(status: ServiceStatus, onOpenAccessibilitySettings: () } } -/** The main number, very prominent (spec §42), inside a progress ring towards the daily goal (§21, §25). */ +/** + * This week / this month: its total, the average per day against the daily limit, and a dot in the + * limit colour of that average (ADR-036). The text carries the same, so the dot is never the only signal. + */ @Composable -private fun TodayHeader(todayMm: Double?, goalMm: Double, distance: (Double?) -> String) { - val progress = ((todayMm ?: 0.0) / goalMm).toFloat().coerceIn(0f, 1f) - val reached = todayMm != null && todayMm >= goalMm - val track = MaterialTheme.colorScheme.surfaceVariant - val bar = MaterialTheme.colorScheme.primary - val strokePx = with(LocalDensity.current) { 14.dp.toPx() } - Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Text(stringResource(R.string.home_today_title), style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.size(8.dp)) - Box(Modifier.size(232.dp), contentAlignment = Alignment.Center) { - Canvas(Modifier.fillMaxSize()) { - val inset = strokePx / 2 - val arcSize = Size(size.width - strokePx, size.height - strokePx) - drawArc(track, 0f, 360f, useCenter = false, topLeft = Offset(inset, inset), size = arcSize, style = Stroke(strokePx)) - if (progress > 0f) { - drawArc(bar, -90f, 360f * progress, useCenter = false, topLeft = Offset(inset, inset), size = arcSize, style = Stroke(strokePx, cap = StrokeCap.Round)) - } - } - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(distance(todayMm), style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.SemiBold, maxLines = 1) - Text( - stringResource(if (reached) R.string.dashboard_goal_reached else R.string.dashboard_goal, distance(goalMm)), - style = MaterialTheme.typography.bodyMedium, - color = if (reached) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } +private fun PeriodRow( + period: Period, + totalMm: Double?, + first: LocalDate?, + today: LocalDate, + limitMm: Double, + distance: (Double) -> String, + onOpen: (Period) -> Unit, +) { + val average = DailyLimit.averagePerDay(totalMm, period.range, first, today) + val level = if (average == null) LimitLevel.NONE else DailyLimit.level(average, limitMm) + Row(Modifier.fillMaxWidth().clickable { onOpen(period) }.padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) { + if (level != LimitLevel.NONE) { + Box(Modifier.size(12.dp).clip(CircleShape).background(limitColors(level).bar)) + Spacer(Modifier.width(12.dp)) } - } -} - -@Composable -private fun PeriodStats(week: String, month: String, lifetime: String) { - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { - StatRow(stringResource(R.string.dashboard_week), week) - HorizontalDivider() - StatRow(stringResource(R.string.dashboard_month), month) - HorizontalDivider() - StatRow(stringResource(R.string.dashboard_lifetime), lifetime) + Column(Modifier.weight(1f)) { + Text(stringResource(if (period.kind == PeriodKind.WEEK) R.string.period_this_week else R.string.period_this_month), style = MaterialTheme.typography.bodyLarge) + averageSentence(average, limitMm, distance)?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } } + Spacer(Modifier.width(12.dp)) + Text(totalMm?.let(distance) ?: TimeFormatter.UNKNOWN, style = MaterialTheme.typography.titleMedium) + Icon(painterResource(R.drawable.ic_chevron_right), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } } +/** The three apps scrolled most today, and the way to all of them. */ @Composable -private fun StatRow(label: String, value: String) { - Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { - Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - Text(value, style = MaterialTheme.typography.titleMedium) - } -} - -@Composable -private fun TopAppsCard(graph: AppGraph, top: TopApps, usageGranted: Boolean, unit: UnitPreference, locale: Locale, onOpenApp: (String) -> Unit) { +private fun MostTodayCard( + graph: AppGraph, + apps: List, + usageGranted: Boolean, + distance: (Double) -> String, + onOpenAll: () -> Unit, + onOpenApp: (String) -> Unit, +) { Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text(stringResource(R.string.dashboard_top_apps), style = MaterialTheme.typography.titleMedium) - if (top.isEmpty) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text(stringResource(R.string.dashboard_most_today), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 4.dp)) + if (apps.isEmpty()) { Text(stringResource(R.string.dashboard_no_scroll_yet), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } - top.apps.forEach { app -> AppRow(graph, app, usageGranted, unit, locale) { onOpenApp(app.packageName) } } - top.other?.let { AppRow(graph, it, usageGranted, unit, locale, onClick = null) } + apps.take(TOP_APPS).forEach { app -> AppRow(graph, app, usageGranted, distance) { onOpenApp(app.packageName) } } + if (apps.size > TOP_APPS) { + TextButton(onClick = onOpenAll, modifier = Modifier.align(Alignment.End)) { Text(stringResource(R.string.dashboard_all_apps)) } + } } } } -/** Distance, and time: in app with Usage access, scrolling otherwise (ADR-022). Unknown is "—", never 0. */ -@Composable -private fun AppRow(graph: AppGraph, app: AppSummary, usageGranted: Boolean, unit: UnitPreference, locale: Locale, onClick: (() -> Unit)?) { - val other = app.packageName == TopApps.OTHER - val info by rememberAppInfo(graph.appInfoProvider, app.packageName, 36.dp, skip = other) - val label = if (other) stringResource(R.string.dashboard_other_apps) else info?.label ?: app.packageName - val detail = if (usageGranted) { - val pace = TimeFormatter.pace(app.distanceMm, app.foregroundMs, locale) - val time = TimeFormatter.duration(app.foregroundMs) - if (pace == TimeFormatter.UNKNOWN) stringResource(R.string.dashboard_time_in_app, time) else stringResource(R.string.dashboard_time_in_app_pace, time, pace) - } else { - stringResource(R.string.dashboard_scroll_time, TimeFormatter.duration(app.activeScrollMs)) - } - Row(Modifier.fillMaxWidth().then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), verticalAlignment = Alignment.CenterVertically) { - AppIcon(info, label) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text(label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) - } - Spacer(Modifier.width(12.dp)) - Text(app.distanceMm?.let { DistanceFormatter.format(it, unit, locale) } ?: TimeFormatter.UNKNOWN, style = MaterialTheme.typography.titleMedium) - } -} +private const val TOP_APPS = 3 @Composable private fun UsageTimeCard(onShow: () -> Unit, onDismiss: () -> Unit) { diff --git a/app/src/main/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt b/app/src/main/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt index 99b77b8..2b2e2bf 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt @@ -1,14 +1,8 @@ package com.scrollmeter.app.ui.navigation -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -16,30 +10,23 @@ 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.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavDestination.Companion.hierarchy -import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable -import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import com.scrollmeter.app.AppGraph -import com.scrollmeter.app.R import com.scrollmeter.app.apps.PackageNames import com.scrollmeter.app.onboarding.AccessibilityGate import com.scrollmeter.app.devtools.DevTools +import com.scrollmeter.app.insights.Period import com.scrollmeter.app.ui.apps.AppDetailScreen -import com.scrollmeter.app.ui.apps.AppsScreen import com.scrollmeter.app.ui.calibration.AccuracyScreen import com.scrollmeter.app.ui.calibration.CalibrationScreen import com.scrollmeter.app.ui.dashboard.DashboardScreen -import com.scrollmeter.app.ui.history.HistoryScreen import com.scrollmeter.app.ui.onboarding.DisclosureScreen +import com.scrollmeter.app.ui.period.PeriodScreen import com.scrollmeter.app.ui.settings.AboutScreen import com.scrollmeter.app.ui.settings.ExcludedAppsScreen import com.scrollmeter.app.ui.settings.ExportScreen @@ -49,12 +36,12 @@ import com.scrollmeter.app.ui.settings.SettingsScreen import com.scrollmeter.app.ui.usage.UsageAccessScreen import kotlinx.serialization.Serializable -// Type-safe routes (D14). A package name is the only argument; it is ours, read back from Room. +// Type-safe routes (D14). Arguments are ours — a package name read back from Room, a period kind and +// an ISO date — and are still validated on arrival. @Serializable data object OverviewRoute -@Serializable data object HistoryRoute -@Serializable data object AppsRoute +@Serializable data class PeriodRoute(val kind: String, val anchor: String) @Serializable data object SettingsRoute -@Serializable data class AppDetailRoute(val packageName: String) +@Serializable data class AppDetailRoute(val packageName: String, val kind: String, val anchor: String) @Serializable data object AccuracyRoute @Serializable data object CalibrationRoute @Serializable data object UsageAccessRoute @@ -65,21 +52,14 @@ import kotlinx.serialization.Serializable @Serializable data object AboutRoute @Serializable data object DisclosureRoute -private class TopLevel(val route: Any, @get:StringRes val label: Int, @get:DrawableRes val icon: Int) +private fun Period.route() = PeriodRoute(kind.name, anchor.toString()) -private val TOP_LEVEL = listOf( - TopLevel(OverviewRoute, R.string.nav_overview, R.drawable.ic_nav_overview), - TopLevel(HistoryRoute, R.string.nav_history, R.drawable.ic_nav_history), - TopLevel(AppsRoute, R.string.nav_apps, R.drawable.ic_nav_apps), - TopLevel(SettingsRoute, R.string.nav_settings, R.drawable.ic_nav_settings), -) +private fun appRoute(packageName: String, period: Period) = AppDetailRoute(packageName, period.kind.name, period.anchor.toString()) /** - * Přehled / Historie / Aplikace / Nastavení in a bottom bar (spec §43); the bar hides on the screens - * below them (app detail, Přesnost, Kalibrace, Čas v aplikacích, Vyloučené aplikace, Export, - * Soukromí, O aplikaci, the disclosure, developer screens). [onOpenAccessibilitySettings] is only ever - * called behind the gate, never passed on (PolicyGuardTest). Switching tabs - * keeps each tab's state. [initialDevTool] opens a developer screen from the launch intent (debug). + * Přehled is the one top-level screen (ADR-036: no bottom bar); Statistiky, Nastavení and everything + * else open over it with a way back. [onOpenAccessibilitySettings] is only ever called behind the gate, + * never passed on (PolicyGuardTest). [initialDevTool] opens a developer screen from the launch intent (debug). */ @Composable fun ScrollMeterNavHost( @@ -89,9 +69,6 @@ fun ScrollMeterNavHost( onOpenAccessibilitySettings: () -> Unit, ) { val nav = rememberNavController() - val entry by nav.currentBackStackEntryAsState() - val destination = entry?.destination - val onTopLevel = TOP_LEVEL.any { destination?.hasRoute(it.route::class) == true } // Spec §30: the dashboard banner and Nastavení reach the accessibility settings only through the // prominent disclosure while it has not been accepted (ADR-032). [disclosureAccepted] is the stored // flag MainActivity has already loaded — no first frames with a default. @@ -107,28 +84,8 @@ fun ScrollMeterNavHost( launchHandled = true } - Scaffold( - bottomBar = { - if (onTopLevel) { - NavigationBar { - TOP_LEVEL.forEach { item -> - NavigationBarItem( - selected = destination?.hierarchy?.any { it.hasRoute(item.route::class) } == true, - onClick = { - nav.navigate(item.route) { - popUpTo(nav.graph.findStartDestination().id) { saveState = true } - launchSingleTop = true - restoreState = true - } - }, - icon = { Icon(painterResource(item.icon), contentDescription = null) }, - label = { Text(stringResource(item.label)) }, - ) - } - } - } - }, - ) { padding -> + // Only for the system bar insets now that there is no bottom bar. + Scaffold { padding -> NavHost(nav, startDestination = OverviewRoute, modifier = Modifier.padding(padding).consumeWindowInsets(padding)) { composable { DashboardScreen( @@ -136,15 +93,31 @@ fun ScrollMeterNavHost( onOpenAccessibilitySettings = openAccessibilitySettings, onOpenUsageAccess = { nav.open(UsageAccessRoute) }, onOpenAccuracy = { nav.open(AccuracyRoute) }, - onOpenApp = { nav.open(AppDetailRoute(it)) }, + onOpenSettings = { nav.open(SettingsRoute) }, + onOpenPeriod = { nav.open(it.route()) }, + onOpenApp = { pkg, period -> nav.open(appRoute(pkg, period)) }, ) } - composable { HistoryScreen(graph) } - composable { AppsScreen(graph, onOpenApp = { nav.open(AppDetailRoute(it)) }) } - composable { + composable { back -> + val route = back.toRoute() + val period = Period.parse(route.kind, route.anchor) + if (period != null) { + PeriodScreen( + graph = graph, + initial = period, + onBack = { nav.leave(back) }, + onOpenDay = { nav.openFrom(back, it.route()) }, + onOpenApp = { pkg, shown -> nav.openFrom(back, appRoute(pkg, shown)) }, + ) + } else { + LaunchedEffect(back) { nav.leave(back) } + } + } + composable { back -> SettingsScreen( graph = graph, devTools = DevTools.entries, + onBack = { nav.leave(back) }, actions = SettingsActions( openAccessibilitySettings = openAccessibilitySettings, openUsageAccess = { nav.open(UsageAccessRoute) }, @@ -168,10 +141,11 @@ fun ScrollMeterNavHost( }, onBack = { nav.leave(back) }) } composable { back -> - val packageName = back.toRoute().packageName + val route = back.toRoute() + val period = Period.parse(route.kind, route.anchor) // Defence in depth next to MainActivity's deep-link scrub: never show an arbitrary string as an app name. - if (PackageNames.isValid(packageName)) { - AppDetailScreen(graph, packageName, onBack = { nav.leave(back) }) + if (PackageNames.isValid(route.packageName) && period != null) { + AppDetailScreen(graph, route.packageName, period, onBack = { nav.leave(back) }) } else { LaunchedEffect(back) { nav.leave(back) } } @@ -195,6 +169,14 @@ fun ScrollMeterNavHost( /** A double tap must not stack the same screen twice. */ private fun NavController.open(route: Any) = navigate(route) { launchSingleTop = true } +/** + * A day opened from a week is a new screen on top of the week, which launchSingleTop would replace + * instead: here only the screen [from] may open it, and only while it is on top — the double tap guard. + */ +private fun NavController.openFrom(from: NavBackStackEntry, route: Any) { + if (currentBackStackEntry?.id == from.id) navigate(route) +} + /** * Leaves [entry] only while it is the top of the stack: a second tap on "Zpět" during the exit * animation would otherwise pop the screen below as well — down to an empty host. Identity, not diff --git a/app/src/main/java/com/scrollmeter/app/ui/period/PeriodScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/period/PeriodScreen.kt new file mode 100644 index 0000000..bd9fd49 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/period/PeriodScreen.kt @@ -0,0 +1,183 @@ +package com.scrollmeter.app.ui.period + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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 +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.scrollmeter.app.AppGraph +import com.scrollmeter.app.R +import com.scrollmeter.app.format.DistanceFormatter +import com.scrollmeter.app.format.TimeFormatter +import com.scrollmeter.app.insights.AppRanking +import com.scrollmeter.app.insights.DailyLimit +import com.scrollmeter.app.insights.DaySeries +import com.scrollmeter.app.insights.LimitLevel +import com.scrollmeter.app.insights.Period +import com.scrollmeter.app.insights.PeriodKind +import com.scrollmeter.app.insights.PeriodRelation +import com.scrollmeter.app.ui.components.AppRow +import com.scrollmeter.app.ui.components.BarChart +import com.scrollmeter.app.ui.components.ChartLabels +import com.scrollmeter.app.ui.components.LimitCard +import com.scrollmeter.app.ui.components.PeriodSelector +import com.scrollmeter.app.ui.components.ScreenHeader +import com.scrollmeter.app.ui.components.appLocale +import com.scrollmeter.app.ui.components.averageSentence +import com.scrollmeter.app.ui.components.limitSentence +import com.scrollmeter.app.ui.components.rememberToday +import com.scrollmeter.app.ui.components.rememberUsageGranted +import com.scrollmeter.app.ui.theme.limitColors +import java.time.LocalDate +import kotlin.math.max + +/** + * Statistiky (ADR-036) — replaces Historie and Aplikace: one calendar day, week or month (Den / Týden / + * Měsíc, ‹ › to the one before and after, never past today or before the first measured day) with its + * distance against the daily limit; for a week or month a bar per day in its own limit colour with the + * limit as a dashed line; then the apps, the longest distance first, each with its time in app. A bar + * opens that day, an app its detail for this period. + */ +@Composable +fun PeriodScreen(graph: AppGraph, initial: Period, onBack: () -> Unit, onOpenDay: (Period) -> Unit, onOpenApp: (String, Period) -> Unit) { + val locale = appLocale() + val labels = remember(locale) { ChartLabels(locale) } + val today = rememberToday() + val usageGranted = rememberUsageGranted(graph) + var key by rememberSaveable { mutableStateOf(initial.key) } + val period = Period.fromKey(key) ?: initial + val range = period.range + val repository = graph.scrollRepository + // Null until loaded: the colours must not show the default limit to someone who switched it off. + val settings by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = null) + val total by remember(range) { repository.distance(range) }.collectAsStateWithLifecycle(initialValue = null) + val apps by remember(range) { repository.apps(range) }.collectAsStateWithLifecycle(initialValue = null) + val days by remember(range) { repository.days(range) }.collectAsStateWithLifecycle(initialValue = emptyList()) + val first by remember { repository.firstMeasuredDay() }.collectAsStateWithLifecycle(initialValue = null) + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ScreenHeader(stringResource(R.string.period_title), onBack) + PeriodSelector(PeriodKind.entries, period.kind, { stringResource(kindLabel(it)) }, { key = period.withKind(it).key }) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { key = period.previous().key }, enabled = period.canGoBack(first)) { + Icon(painterResource(R.drawable.ic_chevron_left), contentDescription = stringResource(R.string.period_previous)) + } + Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) { + Text(periodName(period, today, labels), style = MaterialTheme.typography.titleMedium) + if (period.relation(today) != PeriodRelation.OTHER) { + Text(labels.dates(period), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + IconButton(onClick = { key = period.next().key }, enabled = period.canGoForward(today)) { + Icon(painterResource(R.drawable.ic_chevron_right), contentDescription = stringResource(R.string.period_next)) + } + } + + val current = settings ?: return@Column + val unit = current.unitPreference + val limitMm = current.dailyLimitMm + fun distance(mm: Double) = DistanceFormatter.format(mm, unit, locale) + val timeInApps = if (usageGranted) apps?.sumOf { it.foregroundMs ?: 0L }?.takeIf { it > 0 } else null + val timeLine = timeInApps?.let { stringResource(R.string.period_time_in_apps, TimeFormatter.duration(it)) } + + if (period.kind == PeriodKind.DAY) { + val status = DailyLimit.status(total, limitMm) + LimitCard(null, total?.let(::distance) ?: TimeFormatter.UNKNOWN, status.level, listOfNotNull(limitSentence(status, ::distance), timeLine)) + } else { + val average = DailyLimit.averagePerDay(total, range, first, today) + val level = if (average == null) LimitLevel.NONE else DailyLimit.level(average, limitMm) + LimitCard(null, total?.let(::distance) ?: TimeFormatter.UNKNOWN, level, listOfNotNull(averageSentence(average, limitMm, ::distance), timeLine)) + } + + val list = apps?.let(AppRanking::list) ?: return@Column + if (list.isEmpty()) { + Text(stringResource(R.string.period_empty), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + return@Column + } + + if (period.kind != PeriodKind.DAY) { + val bars = remember(range, days) { DaySeries.bars(range, days) } + val maxBar = bars.maxOfOrNull { it.value } ?: 0.0 + // The limit line only when it sits within reach of the bars — far above, it would squash them flat. + val showLimit = limitMm > 0.0 && limitMm <= 2 * maxBar + val scale = remember(maxBar, showLimit, limitMm) { ChartLabels.distanceScale(if (showLimit) max(maxBar, limitMm) else maxBar) } + val palette = LimitLevel.entries.associateWith { limitColors(it).bar } + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.period_chart_title), style = MaterialTheme.typography.titleSmall) + BarChart( + values = bars.map { it.value }, + scale = scale, + axisLabel = remember(scale, unit, locale) { ChartLabels.distanceAxis(scale, unit, locale) }, + barLabel = { labels.bar(bars[it], bars.size) }, + contentDescription = stringResource(R.string.period_chart_description, labels.dates(period)), + selected = null, + onSelect = { index -> + val date = index?.let(bars::getOrNull)?.date + if (date != null && !date.isAfter(today)) onOpenDay(Period(PeriodKind.DAY, date)) + }, + barColor = { palette.getValue(DailyLimit.level(bars[it].value, limitMm)) }, + referenceLine = limitMm.takeIf { showLimit }, + ) + Text(stringResource(R.string.period_chart_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Text(stringResource(R.string.period_apps), style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp)) + list.forEach { app -> AppRow(graph, app, usageGranted, ::distance) { onOpenApp(app.packageName, period) } } + } + } + } +} + +/** "Dnes", "Minulý týden", or for anything further back its dates. */ +@Composable +fun periodName(period: Period, today: LocalDate, labels: ChartLabels): String = when (period.relation(today)) { + PeriodRelation.CURRENT -> stringResource( + when (period.kind) { + PeriodKind.DAY -> R.string.period_today + PeriodKind.WEEK -> R.string.period_this_week + PeriodKind.MONTH -> R.string.period_this_month + }, + ) + PeriodRelation.PREVIOUS -> stringResource( + when (period.kind) { + PeriodKind.DAY -> R.string.period_yesterday + PeriodKind.WEEK -> R.string.period_last_week + PeriodKind.MONTH -> R.string.period_last_month + }, + ) + PeriodRelation.OTHER -> labels.dates(period) +} + +private fun kindLabel(kind: PeriodKind): Int = when (kind) { + PeriodKind.DAY -> R.string.period_day + PeriodKind.WEEK -> R.string.period_week + PeriodKind.MONTH -> R.string.period_month +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/settings/PrivacyAboutScreens.kt b/app/src/main/java/com/scrollmeter/app/ui/settings/PrivacyAboutScreens.kt index c33476b..c4c0034 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/settings/PrivacyAboutScreens.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/PrivacyAboutScreens.kt @@ -22,10 +22,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.scrollmeter.app.AppGraph import com.scrollmeter.app.BuildConfig import com.scrollmeter.app.R +import com.scrollmeter.app.format.DistanceFormatter import com.scrollmeter.app.measurement.PhysicalScaleProvider import com.scrollmeter.app.ui.calibration.labelRes import com.scrollmeter.app.ui.calibration.pixelLine import com.scrollmeter.app.ui.components.ScreenHeader +import com.scrollmeter.app.ui.components.appLocale +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle /** * Soukromí (spec §29 text, D19 paragraph): what is read, what is stored, what never is, that nothing @@ -48,6 +52,10 @@ fun PrivacyScreen(onBack: () -> Unit) { /** O aplikaci (spec §44): version, device, the measuring method and the scale in force. */ @Composable fun AboutScreen(graph: AppGraph, onBack: () -> Unit) { + val locale = appLocale() + val settings by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = null) + val lifetimeMm by remember { graph.scrollRepository.lifetimeDistance() }.collectAsStateWithLifecycle(initialValue = null) + val first by remember { graph.scrollRepository.firstMeasuredDay() }.collectAsStateWithLifecycle(initialValue = null) val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } val scale = calibration?.let { PhysicalScaleProvider.resolve(it, display) } @@ -57,6 +65,20 @@ fun AboutScreen(graph: AppGraph, onBack: () -> Unit) { ) { ScreenHeader(stringResource(R.string.about_title), onBack) Fact(stringResource(R.string.about_version), "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") + // The lifetime total left Přehled (ADR-036); it stays here, with the day measuring began. + val total = lifetimeMm + val since = first + val unit = settings?.unitPreference + if (total != null && since != null && unit != null) { + Fact( + stringResource(R.string.about_total), + stringResource( + R.string.about_total_value, + DistanceFormatter.format(total, unit, locale), + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale).format(since), + ), + ) + } Fact( stringResource(R.string.about_device), "${Build.MANUFACTURER} ${Build.MODEL}\n" + stringResource(R.string.about_android, Build.VERSION.RELEASE, Build.VERSION.SDK_INT), diff --git a/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsDialogs.kt b/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsDialogs.kt index e57b5c8..be554a0 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsDialogs.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsDialogs.kt @@ -37,25 +37,28 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.scrollmeter.app.R import com.scrollmeter.app.format.DistanceFormatter -import com.scrollmeter.app.format.GoalInput +import com.scrollmeter.app.format.LimitInput +import com.scrollmeter.app.measurement.MeasurementConfig import com.scrollmeter.app.settings.Settings import com.scrollmeter.app.settings.UnitPreference import java.util.Locale +import kotlin.math.roundToInt /** - * Spec §25: 100 m … 5 km, or a custom value (10 m … 100 km) typed in m or km. A goal is not a - * limit — the note says so. + * The daily limit (spec §25 as a limit, ADR-036): 100 m … 5 km, a custom value (10 m … 100 km) typed + * in m or km, or "Bez limitu" — no colours, no limit notification. The note says it is not a target. */ @Composable -fun GoalDialog(currentMm: Double, unit: UnitPreference, locale: Locale, onDismiss: () -> Unit, onSave: (Double) -> Unit) { - val presets = Settings.GOAL_PRESETS_MM - var custom by rememberSaveable { mutableStateOf(currentMm !in presets) } +fun LimitDialog(currentMm: Double, unit: UnitPreference, locale: Locale, onDismiss: () -> Unit, onSave: (Double) -> Unit) { + val presets = Settings.LIMIT_PRESETS_MM + val choices = presets + Settings.NO_LIMIT + var custom by rememberSaveable { mutableStateOf(currentMm !in choices) } var chosen by rememberSaveable { mutableDoubleStateOf(currentMm) } var kilometres by rememberSaveable { mutableStateOf(currentMm >= 1_000_000.0) } var text by rememberSaveable { - mutableStateOf(if (currentMm in presets) "" else trimmed(currentMm / if (currentMm >= 1_000_000.0) 1_000_000.0 else 1_000.0, locale)) + mutableStateOf(if (currentMm in choices) "" else trimmed(currentMm / if (currentMm >= 1_000_000.0) 1_000_000.0 else 1_000.0, locale)) } - val customMm = GoalInput.parseMm(text, kilometres, locale) + val customMm = LimitInput.parseMm(text, kilometres, locale) // Choosing "Vlastní" puts the cursor in the field; a saved custom value opens without the keyboard. val field = remember { FocusRequester() } LaunchedEffect(custom) { if (custom && text.isEmpty()) field.requestFocus() } @@ -63,31 +66,32 @@ fun GoalDialog(currentMm: Double, unit: UnitPreference, locale: Locale, onDismis AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.goal_title)) }, + title = { Text(stringResource(R.string.limit_title)) }, text = { Column(Modifier.verticalScroll(rememberScrollState()).selectableGroup(), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(stringResource(R.string.goal_note), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - presets.forEach { mm -> - Option(DistanceFormatter.format(mm, unit, locale), selected = !custom && chosen == mm) { + Text(stringResource(R.string.limit_note, (MeasurementConfig.LIMIT_WARN_RATIO * 100).roundToInt()), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + choices.forEach { mm -> + val label = if (mm == Settings.NO_LIMIT) stringResource(R.string.limit_none) else DistanceFormatter.format(mm, unit, locale) + Option(label, selected = !custom && chosen == mm) { custom = false chosen = mm } } - Option(stringResource(R.string.goal_custom), selected = custom) { custom = true } + Option(stringResource(R.string.limit_custom), selected = custom) { custom = true } if (custom) { OutlinedTextField( value = text, onValueChange = { text = it }, - label = { Text(stringResource(R.string.goal_custom_label)) }, + label = { Text(stringResource(R.string.limit_custom_label)) }, singleLine = true, isError = text.isNotBlank() && customMm == null, - supportingText = { if (text.isNotBlank() && customMm == null) Text(stringResource(R.string.goal_custom_error)) }, + supportingText = { if (text.isNotBlank() && customMm == null) Text(stringResource(R.string.limit_custom_error)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = Modifier.fillMaxWidth().focusRequester(field), ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip(selected = !kilometres, onClick = { kilometres = false }, label = { Text(stringResource(R.string.goal_unit_m)) }) - FilterChip(selected = kilometres, onClick = { kilometres = true }, label = { Text(stringResource(R.string.goal_unit_km)) }) + FilterChip(selected = !kilometres, onClick = { kilometres = false }, label = { Text(stringResource(R.string.limit_unit_m)) }) + FilterChip(selected = kilometres, onClick = { kilometres = true }, label = { Text(stringResource(R.string.limit_unit_km)) }) } } } diff --git a/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt index 6afa5f6..54dfb67 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt @@ -48,6 +48,7 @@ import com.scrollmeter.app.notifications.NotificationKind import com.scrollmeter.app.settings.ThemePreference import com.scrollmeter.app.settings.UnitPreference import com.scrollmeter.app.ui.components.ChoiceDialog +import com.scrollmeter.app.ui.components.ScreenHeader import com.scrollmeter.app.ui.components.SectionTitle import com.scrollmeter.app.ui.components.ServiceStatus import com.scrollmeter.app.ui.components.SettingRow @@ -71,15 +72,15 @@ class SettingsActions( val openDevTool: (String) -> Unit, ) -private enum class SettingsDialog { GOAL, UNITS, THEME, DELETE } +private enum class SettingsDialog { LIMIT, UNITS, THEME, DELETE } /** - * Nastavení (spec §44): Měření (service, time in app, accuracy, goal, excluded apps), Jednotky, - * Zobrazení, Oznámení (opt-in; POST_NOTIFICATIONS asked when one is switched on — ADR-030), Data - * (CSV export, delete everything), Soukromí, O aplikaci; debug builds add the developer screens. + * Nastavení (spec §44): Měření (service, time in app, accuracy, daily limit — ADR-036, excluded apps), + * Jednotky, Zobrazení, Oznámení (opt-in; POST_NOTIFICATIONS asked when one is switched on — ADR-030), + * Data (CSV export, delete everything), Soukromí, O aplikaci; debug builds add the developer screens. */ @Composable -fun SettingsScreen(graph: AppGraph, devTools: List, actions: SettingsActions) { +fun SettingsScreen(graph: AppGraph, devTools: List, onBack: () -> Unit, actions: SettingsActions) { val context = LocalContext.current val locale = appLocale() val scope = rememberCoroutineScope() @@ -125,7 +126,7 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.titleLarge) + ScreenHeader(stringResource(R.string.settings_title), onBack) SectionTitle(stringResource(R.string.settings_section_measurement)) Card(Modifier.fillMaxWidth()) { @@ -155,9 +156,13 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti ) HorizontalDivider() SettingRow( - title = stringResource(R.string.settings_goal), - value = DistanceFormatter.format(current.dailyGoalMm, current.unitPreference, locale), - onClick = { dialog = SettingsDialog.GOAL }, + title = stringResource(R.string.settings_limit), + value = if (current.dailyLimitMm > 0.0) { + DistanceFormatter.format(current.dailyLimitMm, current.unitPreference, locale) + } else { + stringResource(R.string.limit_none) + }, + onClick = { dialog = SettingsDialog.LIMIT }, ) HorizontalDivider() val excluded = current.excludedPackages.size @@ -184,12 +189,10 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti SectionTitle(stringResource(R.string.settings_section_notifications)) Card(Modifier.fillMaxWidth()) { - SwitchRow(stringResource(R.string.notify_goal), stringResource(R.string.notify_goal_body), current.notifyGoal) { setNotify(NotificationKind.GOAL, it) } - HorizontalDivider() - SwitchRow(stringResource(R.string.notify_record), stringResource(R.string.notify_record_body), current.notifyRecord) { setNotify(NotificationKind.RECORD, it) } + SwitchRow(stringResource(R.string.notify_limit), stringResource(R.string.notify_limit_body), current.notifyLimit) { setNotify(NotificationKind.LIMIT, it) } HorizontalDivider() SwitchRow(stringResource(R.string.notify_summary), stringResource(R.string.notify_summary_body), current.notifySummary) { setNotify(NotificationKind.SUMMARY, it) } - val anyOn = current.notifyGoal || current.notifyRecord || current.notifySummary + val anyOn = current.notifyLimit || current.notifySummary if ((anyOn || permissionRefused) && !notificationsAllowed) { HorizontalDivider() Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { @@ -221,14 +224,14 @@ fun SettingsScreen(graph: AppGraph, devTools: List, actions: Setti } when (dialog) { - SettingsDialog.GOAL -> GoalDialog( - currentMm = current.dailyGoalMm, + SettingsDialog.LIMIT -> LimitDialog( + currentMm = current.dailyLimitMm, unit = current.unitPreference, locale = locale, onDismiss = { dialog = null }, onSave = { mm -> dialog = null - scope.launchWrite(context) { repository.setDailyGoalMm(mm) } + scope.launchWrite(context) { repository.setDailyLimitMm(mm) } }, ) SettingsDialog.UNITS -> ChoiceDialog( diff --git a/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt b/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt index 410ec82..f9a4e99 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/theme/Theme.kt @@ -8,8 +8,11 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import com.scrollmeter.app.insights.LimitLevel // Fallback palette for API 29–30 (no dynamic color). Final colours are a Phase 4 decision. private val Blue = Color(0xFF1E4FD8) @@ -23,6 +26,34 @@ val CalibrationBarBlue = Color(0xFF2F6BFF) private val LightColors = lightColorScheme(primary = Blue, secondary = Teal) private val DarkColors = darkColorScheme(primary = BlueLight, secondary = TealLight) +/** Whether [ScrollMeterTheme] runs dark — the limit colours have their own dark variants. */ +val LocalDarkTheme = staticCompositionLocalOf { false } + +/** + * The daily limit's colours (ADR-036): a card's [container] with [onContainer] text, and a chart's + * [bar]. Fixed, not dynamic — green, orange and red must stay green, orange and red whatever the + * wallpaper. Every text pair is at least 7:1. + */ +data class LimitColors(val container: Color, val onContainer: Color, val bar: Color) + +private val LimitLight = mapOf( + LimitLevel.UNDER to LimitColors(Color(0xFFC8E6C9), Color(0xFF1B5E20), Color(0xFF43A047)), + LimitLevel.NEAR to LimitColors(Color(0xFFFFE0B2), Color(0xFF7A3E00), Color(0xFFFB8C00)), + LimitLevel.OVER to LimitColors(Color(0xFFFFCDD2), Color(0xFF8E1B1B), Color(0xFFE53935)), +) +private val LimitDark = mapOf( + LimitLevel.UNDER to LimitColors(Color(0xFF1E4620), Color(0xFFC8E6C9), Color(0xFF66BB6A)), + LimitLevel.NEAR to LimitColors(Color(0xFF4E3200), Color(0xFFFFE0B2), Color(0xFFFFA726)), + LimitLevel.OVER to LimitColors(Color(0xFF5B1A1A), Color(0xFFFFCDD2), Color(0xFFEF5350)), +) + +/** Without a limit (NONE) the theme's own neutral colours. */ +@Composable +fun limitColors(level: LimitLevel): LimitColors { + val table = if (LocalDarkTheme.current) LimitDark else LimitLight + return table[level] ?: MaterialTheme.colorScheme.let { LimitColors(it.surfaceContainerHigh, it.onSurface, it.primary) } +} + @Composable fun ScrollMeterTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -36,5 +67,7 @@ fun ScrollMeterTheme( darkTheme -> DarkColors else -> LightColors } - MaterialTheme(colorScheme = colorScheme, content = content) + CompositionLocalProvider(LocalDarkTheme provides darkTheme) { + MaterialTheme(colorScheme = colorScheme, content = content) + } } diff --git a/app/src/main/res/drawable/ic_chevron_left.xml b/app/src/main/res/drawable/ic_chevron_left.xml new file mode 100644 index 0000000..d03e7ea --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_left.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 8ba8eb1..a5eed42 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -14,17 +14,9 @@ Turn on measuring Open Accessibility settings Today - of your %1$s goal - Goal of %1$s reached - This week - This month - All time - Top apps today - Other No scrolling yet today. As soon as you scroll in another app, it shows up here. - In app %1$s - In app %1$s · %2$s - Scrolling %1$s + Most today + All apps To picture it Want to see time in apps too? For every app you will see how many minutes you spend in it and how many metres a minute you scroll. @@ -108,31 +100,34 @@ Back - Overview - History - Apps - Settings - - History - 7 days - 30 days - 12 months + + Statistics + Day + Week + Month Today - All time - Average / day - Highest day - Lowest day - Total - %1$s · %2$s - Nothing measured yet. As soon as you scroll, the chart appears here. - Distance chart: total %1$s, highest day %2$s + Yesterday + This week + Last week + This month + Last month + Previous + Next + Apps + Nothing measured in this period. + In apps %1$s + Per day + Distance per day, %1$s + Tap a bar to open that day. + %1$s · %2$s + %1$s left of your %2$s limit + Over by %1$s (limit %2$s) + Average %1$s / day + Average %1$s / day · limit %2$s + No limit + + - Apps - Distance - Time - Nothing for this period yet. - no scroll data - %1$s %% · %2$s Measurement quality: high Measurement quality: medium Measurement quality: low @@ -150,6 +145,9 @@ Scrolling time over 30 days You scroll %1$s of %2$s in the app (%3$s %%). This app does not report scrolling — ScrollMeter only knows the time spent in it. + %1$s · %2$s + Share of the distance + %1$s %% Settings Measuring @@ -158,8 +156,8 @@ Off Time in apps Measurement accuracy + Daily limit - Daily goal Excluded apps None @@ -179,10 +177,8 @@ Comparisons on the overview E.g. “That is about one lap of a running track.” Notifications - Daily goal reached - Once a day, when you pass your goal. - New record - When you scroll more today than on any day before. + Over the limit + Once a day, when you go over your daily limit. Yesterday’s summary With the first scroll of the day: how much it was yesterday. Notifications for ScrollMeter are off in the system, so none will arrive. @@ -196,13 +192,15 @@ About Version, device, measuring method - Daily goal - A goal is not a ban: it blocks nothing, it only shows how much is left. - Custom - Custom goal - m - km - Enter a number from 10 m to 100 km. + + + Daily limit + A limit is not a target but a line you do not want to cross in a day. The colour shows how close you are: green, orange from %1$d %%, red over the limit. Nothing is blocked. + Custom + Custom limit + m + km + Enter a number from 10 m to 100 km. Save Cancel @@ -248,13 +246,13 @@ Measuring method The movement Android reports while scrolling (scrollDeltaX/Y, otherwise the change of position), converted to millimetres using the physical pixel size of this display. Scale + Measured in total + %1$s since %2$s - Goals and records - Daily goal, new record and yesterday’s summary — each at most once a day. - Daily goal reached - Today’s distance passed your goal of %1$s. - New record - Today it is %1$s — more than on any day before. + Limit and summary + Going over the daily limit and yesterday\'s summary — each at most once a day. + Daily limit exceeded + Today you are over your daily limit of %1$s. Yesterday’s scrolling Yesterday it came to %1$s in total. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8ccc2a..88d7a6a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,17 +18,9 @@ Zapnout měření Otevřít nastavení Usnadnění Dnes - z cíle %1$s - Cíl %1$s splněn - Tento týden - Tento měsíc - Celkem - Top aplikace dnes - Ostatní Dnes zatím žádné scrollování. Jakmile budeš scrollovat v jiné aplikaci, objeví se tady. - V aplikaci %1$s - V aplikaci %1$s · %2$s - Scrollování %1$s + Nejvíc dnes + Všechny aplikace Pro představu Chceš vidět i čas v aplikacích? U každé aplikace pak uvidíš, kolik v ní trávíš minut a kolik metrů za minutu nascrolluješ. @@ -128,34 +120,35 @@ Zpět - - Přehled - Historie - Aplikace - Nastavení - - - Historie - 7 dní - 30 dní - 12 měsíců + + Statistiky + Den + Týden + Měsíc Dnes - Celkem - Průměr / den - Nejvyšší den - Nejnižší den - Celkem - %1$s · %2$s - Zatím nic naměřeno. Jakmile budeš scrollovat, objeví se tu graf. - Graf vzdálenosti: celkem %1$s, nejvyšší den %2$s + Včera + Tento týden + Minulý týden + Tento měsíc + Minulý měsíc + Předchozí + Další + Aplikace + Za toto období nic naměřeno. + V aplikacích %1$s + Po dnech + Vzdálenost po dnech, %1$s + Klepnutím na sloupec otevřeš ten den. + %1$s · %2$s + Zbývá %1$s z limitu %2$s + Překročeno o %1$s (limit %2$s) + Průměr %1$s / den + Průměr %1$s / den · limit %2$s + Bez limitu + + - Aplikace - Vzdálenost - Čas - Za toto období zatím nic. - bez dat o scrollu - %1$s %% · %2$s Kvalita měření: vysoká Kvalita měření: střední Kvalita měření: nízká @@ -173,6 +166,9 @@ Čas scrollování za 30 dní Scrolluješ %1$s z %2$s v aplikaci (%3$s %%). Tahle aplikace scrollování nehlásí — ScrollMeter u ní zná jen čas v aplikaci. + %1$s · %2$s + Podíl na vzdálenosti + %1$s %% Nastavení @@ -182,9 +178,9 @@ Vypnuto Čas v aplikacích Přesnost měření + Denní limit - Denní cíl Vyloučené aplikace Žádné @@ -206,10 +202,8 @@ Srovnání na přehledu Např. „To je přibližně délka jednoho běžeckého okruhu.“ Oznámení - Denní cíl dosažen - Jednou denně, když přejdeš svůj cíl. - Nový rekord - Když dnes nascrolluješ víc než kterýkoli den předtím. + Překročení limitu + Jednou denně, když přejdeš svůj denní limit. Shrnutí včerejška S prvním scrollováním dne: kolik to bylo včera. Oznámení má ScrollMeter v systému vypnutá, takže nepřijde žádné. @@ -223,14 +217,15 @@ O aplikaci Verze, zařízení, metoda měření - - Denní cíl - Cíl není zákaz: nic neblokuje, jen ukazuje, kolik zbývá. - Vlastní - Vlastní cíl - m - km - Zadej číslo od 10 m do 100 km. + + + Denní limit + Limit není cíl, ale hranice, kterou za den nechceš překročit. Barva ukáže, jak blízko jsi: zelená, od %1$d %% oranžová, nad limitem červená. Nic neblokuje. + Vlastní + Vlastní limit + m + km + Zadej číslo od 10 m do 100 km. Uložit Zrušit @@ -281,14 +276,14 @@ Metoda měření Posun, který Android hlásí při scrollování (scrollDeltaX/Y, jinak změna pozice), převedený na milimetry podle fyzické velikosti pixelu tohoto displeje. Měřítko + Celkem naměřeno + %1$s od %2$s - Cíle a rekordy - Denní cíl, nový rekord a shrnutí včerejška — každé nejvýš jednou denně. - Denní cíl dosažen - Dnešní vzdálenost přesáhla tvůj cíl %1$s. - Nový rekord - Dnes je to %1$s — víc než kterýkoli den předtím. + Limit a shrnutí + Překročení denního limitu a shrnutí včerejška — každé nejvýš jednou denně. + Denní limit překročen + Dnes jsi přes svůj denní limit %1$s. Včerejší scrollování Včera to dohromady bylo %1$s. diff --git a/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt b/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt index 144dbc4..43cadb2 100644 --- a/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt +++ b/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt @@ -225,15 +225,14 @@ class ScrollRepositoryTest { } @Test - fun notificationFactsSeeTheBestEarlierDayTodayLiveAndYesterday() = runBlocking { + fun notificationFactsSeeTodayLiveAndYesterday() = runBlocking { repository.write( listOf(delta(date = "2026-09-20", mm = 300.0), delta(date = "2026-09-21", mm = 100.0), delta(date = "2026-09-22", mm = 50.0), delta(date = "2026-09-23", mm = 20.0)), emptyList(), ) unflushed.value = mapOf(("2026-09-23" to "a") to 5.0) - val facts = repository.notificationFacts(LocalDate.parse("2026-09-23"), goalMm = 500.0) - assertThat(facts.previousBestMm).isEqualTo(300.0) - assertThat(facts.priorMeasuredDays).isEqualTo(3) + val facts = repository.notificationFacts(LocalDate.parse("2026-09-23"), limitMm = 500.0) + assertThat(facts.limitMm).isEqualTo(500.0) assertThat(facts.todayMm).isEqualTo(25.0) assertThat(facts.yesterdayMm).isEqualTo(50.0) } diff --git a/app/src/test/java/com/scrollmeter/app/format/LimitInputTest.kt b/app/src/test/java/com/scrollmeter/app/format/LimitInputTest.kt index ec01a48..14db727 100644 --- a/app/src/test/java/com/scrollmeter/app/format/LimitInputTest.kt +++ b/app/src/test/java/com/scrollmeter/app/format/LimitInputTest.kt @@ -4,25 +4,25 @@ import com.google.common.truth.Truth.assertThat import java.util.Locale import org.junit.Test -/** Spec §25: a custom goal in m or km, within 10 m … 100 km. */ -class GoalInputTest { +/** Spec §25, ADR-036: a custom limit in m or km, within 10 m … 100 km. */ +class LimitInputTest { private val cs = Locale.forLanguageTag("cs") @Test fun metresAndKilometresWithEitherSeparator() { - assertThat(GoalInput.parseMm("750", kilometres = false, cs)).isEqualTo(750_000.0) - assertThat(GoalInput.parseMm("1,5", kilometres = true, cs)).isEqualTo(1_500_000.0) - assertThat(GoalInput.parseMm("1.5", kilometres = true, cs)).isEqualTo(1_500_000.0) - assertThat(GoalInput.parseMm(" 2 ", kilometres = true, cs)).isEqualTo(2_000_000.0) + assertThat(LimitInput.parseMm("750", kilometres = false, cs)).isEqualTo(750_000.0) + assertThat(LimitInput.parseMm("1,5", kilometres = true, cs)).isEqualTo(1_500_000.0) + assertThat(LimitInput.parseMm("1.5", kilometres = true, cs)).isEqualTo(1_500_000.0) + assertThat(LimitInput.parseMm(" 2 ", kilometres = true, cs)).isEqualTo(2_000_000.0) } @Test fun outsideTheRangeOrNotANumberIsRejected() { - assertThat(GoalInput.parseMm("5", kilometres = false, cs)).isNull() - assertThat(GoalInput.parseMm("101", kilometres = true, cs)).isNull() - assertThat(GoalInput.parseMm("abc", kilometres = false, cs)).isNull() - assertThat(GoalInput.parseMm("5 km", kilometres = false, cs)).isNull() - assertThat(GoalInput.parseMm("", kilometres = false, cs)).isNull() - assertThat(GoalInput.parseMm("1,2,3", kilometres = false, cs)).isNull() + assertThat(LimitInput.parseMm("5", kilometres = false, cs)).isNull() + assertThat(LimitInput.parseMm("101", kilometres = true, cs)).isNull() + assertThat(LimitInput.parseMm("abc", kilometres = false, cs)).isNull() + assertThat(LimitInput.parseMm("5 km", kilometres = false, cs)).isNull() + assertThat(LimitInput.parseMm("", kilometres = false, cs)).isNull() + assertThat(LimitInput.parseMm("1,2,3", kilometres = false, cs)).isNull() } } diff --git a/app/src/test/java/com/scrollmeter/app/format/TimeFormatterTest.kt b/app/src/test/java/com/scrollmeter/app/format/TimeFormatterTest.kt index d071e7e..7a5df16 100644 --- a/app/src/test/java/com/scrollmeter/app/format/TimeFormatterTest.kt +++ b/app/src/test/java/com/scrollmeter/app/format/TimeFormatterTest.kt @@ -18,6 +18,21 @@ class TimeFormatterTest { assertThat(TimeFormatter.duration(60 * min - 1)).isEqualTo("59 min") assertThat(TimeFormatter.duration(60 * min)).isEqualTo("1 h") assertThat(TimeFormatter.duration(65 * min)).isEqualTo("1 h 5 min") + assertThat(TimeFormatter.duration(-1)).isEqualTo("—") + } + + /** ADR-036: from 24 h the days come first; zero parts are left out. */ + @Test + fun daysHoursMinutes() { + val hour = 60 * min + assertThat(TimeFormatter.duration(23 * hour + 59 * min)).isEqualTo("23 h 59 min") + assertThat(TimeFormatter.duration(24 * hour - 1)).isEqualTo("23 h 59 min") + assertThat(TimeFormatter.duration(24 * hour)).isEqualTo("1 d") + assertThat(TimeFormatter.duration(25 * hour + min)).isEqualTo("1 d 1 h 1 min") + assertThat(TimeFormatter.duration(24 * hour + 5 * min)).isEqualTo("1 d 5 min") + assertThat(TimeFormatter.duration(25 * hour)).isEqualTo("1 d 1 h") + assertThat(TimeFormatter.duration(48 * hour)).isEqualTo("2 d") + assertThat(TimeFormatter.duration(47 * hour + 59 * min)).isEqualTo("1 d 23 h 59 min") } @Test diff --git a/app/src/test/java/com/scrollmeter/app/insights/AppRankingTest.kt b/app/src/test/java/com/scrollmeter/app/insights/AppRankingTest.kt index 818a0df..2b240ef 100644 --- a/app/src/test/java/com/scrollmeter/app/insights/AppRankingTest.kt +++ b/app/src/test/java/com/scrollmeter/app/insights/AppRankingTest.kt @@ -3,50 +3,40 @@ package com.scrollmeter.app.insights import com.google.common.truth.Truth.assertThat import com.scrollmeter.app.calibration.CalibrationConfidence import com.scrollmeter.app.data.model.AppSummary -import com.scrollmeter.app.data.model.DateRange import com.scrollmeter.app.measurement.MeasurementQuality -import java.time.LocalDate import org.junit.Test -/** Spec §24, D19: order, share, quality; time-only apps without scroll data. */ +/** ADR-036: one list per period, by distance; time-only apps (D19) last; share and quality for the detail. */ class AppRankingTest { private fun app(pkg: String, mm: Double?, fg: Long? = null, active: Long? = null, measured: Long = 100) = AppSummary(pkg, mm, active, fg, if (mm == null) null else measured, if (mm == null) null else 0, if (mm == null) null else 0, if (mm == null) null else 0) - private val apps = listOf(app("a", 30.0, fg = 600_000, active = 50_000), app("b", 70.0, fg = 60_000, active = 90_000), app("youtube", null, fg = 3_600_000)) - @Test - fun byDistanceWithSharesAndQuality() { - val ranked = AppRanking.rank(apps, AppSort.DISTANCE, usageGranted = true, calibration = CalibrationConfidence.HIGH) - assertThat(ranked.map { it.app.packageName }).containsExactly("b", "a", "youtube").inOrder() - assertThat(ranked[0].sharePercent).isWithin(1e-9).of(70.0) - assertThat(ranked[0].quality).isEqualTo(MeasurementQuality.HIGH) - assertThat(ranked[2].sharePercent).isNull() - assertThat(ranked[2].quality).isNull() + fun byDistanceThenTimeOnlyAppsByTime() { + val apps = listOf( + app("a", 30.0, fg = 600_000), app("youtube", null, fg = 3_600_000), app("b", 70.0, fg = 60_000), + app("maps", null, fg = 120_000), app("same", 30.0, fg = 900_000), + ) + assertThat(AppRanking.list(apps).map { it.packageName }).containsExactly("b", "same", "a", "youtube", "maps").inOrder() } @Test - fun byTimeUsesTimeInAppWithAccessAndScrollTimeWithout() { - assertThat(AppRanking.rank(apps, AppSort.TIME, usageGranted = true, calibration = CalibrationConfidence.MEDIUM).map { it.app.packageName }) - .containsExactly("youtube", "a", "b").inOrder() - assertThat(AppRanking.rank(apps, AppSort.TIME, usageGranted = false, calibration = CalibrationConfidence.MEDIUM).map { it.app.packageName }) - .containsExactly("b", "a", "youtube").inOrder() + fun rowsWithNothingToShowAreLeftOut() { + val apps = listOf(app("empty", 0.0, fg = 0), app("nothing", null), app("unmeasurable", 0.0, active = 5_000)) + assertThat(AppRanking.list(apps).map { it.packageName }).containsExactly("unmeasurable") } @Test - fun periodsEndTodayAndLifetimeIsEverything() { - val today = LocalDate.parse("2026-09-24") - assertThat(AppsPeriod.TODAY.range(today).fromKey).isEqualTo("2026-09-24") - assertThat(AppsPeriod.DAYS_7.range(today).fromKey).isEqualTo("2026-09-18") - assertThat(AppsPeriod.DAYS_30.range(today).fromKey).isEqualTo("2026-08-26") - assertThat(AppsPeriod.LIFETIME.range(today)).isEqualTo(DateRange.ALL) + fun shareOfThePeriod() { + assertThat(AppRanking.sharePercent(30.0, 120.0)).isWithin(1e-9).of(25.0) + assertThat(AppRanking.sharePercent(null, 120.0)).isNull() + assertThat(AppRanking.sharePercent(0.0, 0.0)).isNull() + assertThat(AppRanking.sharePercent(1.0, null)).isNull() } @Test - fun qualityComesFromTheAllTimeCountersWhenGiven() { - val today = listOf(app("a", 10.0, measured = 3)) // too few events today to judge - assertThat(AppRanking.rank(today, AppSort.DISTANCE, true, CalibrationConfidence.HIGH).single().quality).isEqualTo(MeasurementQuality.UNKNOWN) - val lifetime = mapOf("a" to app("a", 900.0, measured = 500)) - assertThat(AppRanking.rank(today, AppSort.DISTANCE, true, CalibrationConfidence.HIGH, lifetime).single().quality).isEqualTo(MeasurementQuality.HIGH) + fun qualityFromTheCounters() { + assertThat(AppRanking.quality(app("a", 10.0, measured = 3), CalibrationConfidence.HIGH)).isEqualTo(MeasurementQuality.UNKNOWN) + assertThat(AppRanking.quality(app("a", 900.0, measured = 500), CalibrationConfidence.HIGH)).isEqualTo(MeasurementQuality.HIGH) } } diff --git a/app/src/test/java/com/scrollmeter/app/insights/DailyLimitTest.kt b/app/src/test/java/com/scrollmeter/app/insights/DailyLimitTest.kt new file mode 100644 index 0000000..fc035a6 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/insights/DailyLimitTest.kt @@ -0,0 +1,54 @@ +package com.scrollmeter.app.insights + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.data.model.DateRange +import java.time.LocalDate +import org.junit.Test + +/** ADR-036: green under 70 % of the limit, orange up to it, red from it; the average counts measured days only. */ +class DailyLimitTest { + private val limit = 500_000.0 + private fun d(s: String) = LocalDate.parse(s) + + @Test + fun levelsAtTheEdges() { + assertThat(DailyLimit.level(0.0, limit)).isEqualTo(LimitLevel.UNDER) + assertThat(DailyLimit.level(349_999.0, limit)).isEqualTo(LimitLevel.UNDER) + assertThat(DailyLimit.level(350_000.0, limit)).isEqualTo(LimitLevel.NEAR) + assertThat(DailyLimit.level(499_999.0, limit)).isEqualTo(LimitLevel.NEAR) + assertThat(DailyLimit.level(500_000.0, limit)).isEqualTo(LimitLevel.OVER) + assertThat(DailyLimit.level(null, limit)).isEqualTo(LimitLevel.UNDER) + assertThat(DailyLimit.level(900_000.0, 0.0)).isEqualTo(LimitLevel.NONE) + } + + @Test + fun remainingAndOver() { + assertThat(DailyLimit.status(428_000.0, limit)).isEqualTo(LimitStatus(LimitLevel.NEAR, limit, 72_000.0, 0.0)) + assertThat(DailyLimit.status(580_000.0, limit)).isEqualTo(LimitStatus(LimitLevel.OVER, limit, 0.0, 80_000.0)) + assertThat(DailyLimit.status(null, limit)).isEqualTo(LimitStatus(LimitLevel.UNDER, limit, limit, 0.0)) + } + + @Test + fun averageOfTheCurrentWeekCountsDaysUpToToday() { + val week = DateRange.week(d("2026-09-22")) // Mon 21 – Sun 27 + assertThat(DailyLimit.averagePerDay(1_000.0, week, d("2026-01-01"), d("2026-09-22"))).isEqualTo(500.0) + } + + @Test + fun averageOfAPastWeekCountsAllSevenDays() { + assertThat(DailyLimit.averagePerDay(700.0, DateRange.week(d("2026-09-14")), d("2026-01-01"), d("2026-09-25"))).isEqualTo(100.0) + } + + @Test + fun averageStartsAtTheFirstMeasuredDay() { + // Measuring since Wednesday, today is Friday: 3 days. + assertThat(DailyLimit.averagePerDay(900.0, DateRange.week(d("2026-09-25")), d("2026-09-23"), d("2026-09-25"))).isEqualTo(300.0) + } + + @Test + fun noAverageWithoutCountedDays() { + assertThat(DailyLimit.averagePerDay(0.0, DateRange.week(d("2026-09-25")), null, d("2026-09-25"))).isNull() + assertThat(DailyLimit.averagePerDay(0.0, DateRange.week(d("2026-09-14")), d("2026-09-23"), d("2026-09-25"))).isNull() + assertThat(DailyLimit.averagePerDay(null, DateRange.week(d("2026-09-25")), d("2026-09-23"), d("2026-09-25"))).isEqualTo(0.0) + } +} diff --git a/app/src/test/java/com/scrollmeter/app/insights/DaySeriesTest.kt b/app/src/test/java/com/scrollmeter/app/insights/DaySeriesTest.kt index 2b4ea74..1d92719 100644 --- a/app/src/test/java/com/scrollmeter/app/insights/DaySeriesTest.kt +++ b/app/src/test/java/com/scrollmeter/app/insights/DaySeriesTest.kt @@ -1,72 +1,43 @@ package com.scrollmeter.app.insights import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.data.model.DateRange import com.scrollmeter.app.data.model.DaySummary import java.time.LocalDate import org.junit.Test -/** Spec §23: 7 / 30 days and 12 months, statistics per day since the first measured day. */ -class HistorySeriesTest { - private val today = LocalDate.parse("2026-09-24") - private fun day(date: String, mm: Double?) = DaySummary(date, mm, null, null) +class DaySeriesTest { private fun d(s: String) = LocalDate.parse(s) + private fun day(date: String, mm: Double?, fg: Long? = null) = DaySummary(date, mm, null, fg) @Test - fun sevenDaysFillMissingDaysWithZero() { - val series = HistorySeriesBuilder.build(HistoryPeriod.DAYS_7, today, listOf(day("2026-09-20", 100.0), day("2026-09-24", 50.0)), d("2026-09-01")) - assertThat(series.bars.map { it.start }).containsExactlyElementsIn((18..24).map { d("2026-09-%02d".format(it)) }).inOrder() - assertThat(series.bars.map { it.value }).containsExactly(0.0, 0.0, 100.0, 0.0, 0.0, 0.0, 50.0).inOrder() - val stats = series.stats!! - assertThat(stats.total).isEqualTo(150.0) - assertThat(stats.averagePerDay).isWithin(1e-9).of(150.0 / 7) - assertThat(stats.maxDay).isEqualTo(DayValue(d("2026-09-20"), 100.0)) - assertThat(stats.minDay).isEqualTo(DayValue(d("2026-09-23"), 0.0)) // ties: the most recent zero day + fun aCalendarWeekHasSevenBarsMissingDaysZero() { + val bars = DaySeries.bars(DateRange.week(d("2026-09-24")), listOf(day("2026-09-22", 100.0), day("2026-09-27", 5.0))) + assertThat(bars.map { it.date.toString() }).containsExactly( + "2026-09-21", "2026-09-22", "2026-09-23", "2026-09-24", "2026-09-25", "2026-09-26", "2026-09-27", + ).inOrder() + assertThat(bars.map { it.value }).containsExactly(0.0, 100.0, 0.0, 0.0, 0.0, 0.0, 5.0).inOrder() } @Test - fun statisticsStartAtTheFirstMeasuredDay() { - val series = HistorySeriesBuilder.build(HistoryPeriod.DAYS_30, today, listOf(day("2026-09-23", 30.0), day("2026-09-24", 10.0)), d("2026-09-23")) - assertThat(series.bars).hasSize(30) - val stats = series.stats!! - assertThat(stats.averagePerDay).isEqualTo(20.0) - assertThat(stats.minDay).isEqualTo(DayValue(d("2026-09-24"), 10.0)) + fun monthsHaveTheirOwnLength() { + assertThat(DaySeries.bars(DateRange.month(d("2026-02-10")), emptyList())).hasSize(28) + assertThat(DaySeries.bars(DateRange.month(d("2028-02-10")), emptyList())).hasSize(29) + assertThat(DaySeries.bars(DateRange.month(d("2026-09-10")), emptyList())).hasSize(30) + assertThat(DaySeries.bars(DateRange.month(d("2026-10-10")), emptyList())).hasSize(31) } @Test - fun twelveMonthsSumPerMonthButStatisticsStayPerDay() { - val days = listOf(day("2025-10-01", 1.0), day("2025-10-31", 2.0), day("2026-02-28", 4.0), day("2026-09-24", 8.0), day("2025-09-30", 99.0)) - val series = HistorySeriesBuilder.build(HistoryPeriod.MONTHS_12, today, days, d("2025-06-01")) - assertThat(series.range.from).isEqualTo(d("2025-10-01")) - assertThat(series.bars).hasSize(12) - assertThat(series.bars.first()).isEqualTo(HistoryBar(d("2025-10-01"), 3.0)) - assertThat(series.bars.map { it.value }.sum()).isEqualTo(15.0) // the September 2025 row is outside - assertThat(series.bars.last()).isEqualTo(HistoryBar(d("2026-09-01"), 8.0)) - val stats = series.stats!! - assertThat(stats.maxDay.date).isEqualTo(d("2026-09-24")) - assertThat(stats.total).isEqualTo(15.0) + fun daysOutsideTheRangeAndUnreadableDatesAreIgnored() { + val bars = DaySeries.bars(DateRange.day(d("2026-09-24")), listOf(day("2026-09-23", 9.0), day("x", 9.0), day("2026-09-24", 3.0))) + assertThat(bars).containsExactly(DayBar(d("2026-09-24"), 3.0)) } @Test - fun nothingMeasuredHasNoStatistics() { - val series = HistorySeriesBuilder.build(HistoryPeriod.DAYS_7, today, emptyList(), null) - assertThat(series.stats).isNull() - assertThat(series.bars.all { it.value == 0.0 }).isTrue() - } - - @Test - fun anotherColumnCanBeCharted() { - val days = listOf(DaySummary("2026-09-24", 10.0, 1_000L, 60_000L), DaySummary("2026-09-23", null, null, 30_000L)) - val series = HistorySeriesBuilder.build(HistoryPeriod.DAYS_7, today, days, d("2026-09-23")) { it.foregroundMs?.toDouble() } - assertThat(series.bars.takeLast(2).map { it.value }).containsExactly(30_000.0, 60_000.0).inOrder() - assertThat(series.stats!!.total).isEqualTo(90_000.0) - } - - @Test - fun aFirstDayAfterTodayStillHasStatistics() { - // The clock was moved back: rows exist "in the future", today counts as measured. - val series = HistorySeriesBuilder.build(HistoryPeriod.DAYS_7, today, emptyList(), d("2026-09-30")) - val stats = series.stats!! - assertThat(stats.total).isEqualTo(0.0) - assertThat(stats.maxDay.date).isEqualTo(today) + fun anotherColumnWithNullAsZero() { + val bars = DaySeries.bars(DateRange(d("2026-09-23"), d("2026-09-24")), listOf(day("2026-09-23", 1.0, fg = 60_000), day("2026-09-24", 2.0))) { + it.foregroundMs?.toDouble() + } + assertThat(bars.map { it.value }).containsExactly(60_000.0, 0.0).inOrder() } } diff --git a/app/src/test/java/com/scrollmeter/app/insights/PeriodTest.kt b/app/src/test/java/com/scrollmeter/app/insights/PeriodTest.kt new file mode 100644 index 0000000..3690235 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/insights/PeriodTest.kt @@ -0,0 +1,71 @@ +package com.scrollmeter.app.insights + +import com.google.common.truth.Truth.assertThat +import java.time.LocalDate +import org.junit.Test + +/** ADR-036: calendar day / week (Mon–Sun) / month, ‹ › bounded by the first measured day and today. */ +class PeriodTest { + private fun d(s: String) = LocalDate.parse(s) + + @Test + fun rangesAreCalendarPeriods() { + assertThat(Period(PeriodKind.WEEK, d("2026-09-24")).range.let { it.fromKey to it.toKey }).isEqualTo("2026-09-21" to "2026-09-27") + assertThat(Period(PeriodKind.MONTH, d("2028-02-10")).range.let { it.fromKey to it.toKey }).isEqualTo("2028-02-01" to "2028-02-29") + assertThat(Period(PeriodKind.DAY, d("2026-09-24")).range.let { it.fromKey to it.toKey }).isEqualTo("2026-09-24" to "2026-09-24") + } + + @Test + fun steppingCrossesMonthAndYearEnds() { + assertThat(Period(PeriodKind.WEEK, d("2026-01-01")).previous().range.fromKey).isEqualTo("2025-12-22") + assertThat(Period(PeriodKind.MONTH, d("2026-01-15")).previous().range.fromKey).isEqualTo("2025-12-01") + assertThat(Period(PeriodKind.MONTH, d("2026-03-31")).previous().range.fromKey).isEqualTo("2026-02-01") + assertThat(Period(PeriodKind.DAY, d("2026-12-31")).next().anchor).isEqualTo(d("2027-01-01")) + } + + @Test + fun forwardStopsAtTheCurrentPeriod() { + val today = d("2026-09-25") + assertThat(Period(PeriodKind.WEEK, today).canGoForward(today)).isFalse() + assertThat(Period(PeriodKind.WEEK, d("2026-09-18")).canGoForward(today)).isTrue() + assertThat(Period(PeriodKind.DAY, d("2026-09-24")).canGoForward(today)).isTrue() + assertThat(Period(PeriodKind.MONTH, d("2026-09-01")).canGoForward(today)).isFalse() + } + + @Test + fun backStopsAtThePeriodOfTheFirstMeasuredDay() { + val first = d("2026-09-23") + assertThat(Period(PeriodKind.WEEK, d("2026-09-25")).canGoBack(first)).isFalse() + assertThat(Period(PeriodKind.DAY, d("2026-09-24")).canGoBack(first)).isTrue() + assertThat(Period(PeriodKind.DAY, d("2026-09-23")).canGoBack(first)).isFalse() + assertThat(Period(PeriodKind.WEEK, d("2026-09-30")).canGoBack(first)).isTrue() + assertThat(Period(PeriodKind.DAY, d("2026-09-25")).canGoBack(null)).isFalse() + } + + @Test + fun switchingTheKindKeepsTheDay() { + val day = Period(PeriodKind.DAY, d("2026-09-17")) + assertThat(day.withKind(PeriodKind.WEEK).range.fromKey).isEqualTo("2026-09-14") + assertThat(day.withKind(PeriodKind.MONTH).range.fromKey).isEqualTo("2026-09-01") + } + + @Test + fun relationToToday() { + val today = d("2026-09-25") + assertThat(Period(PeriodKind.DAY, today).relation(today)).isEqualTo(PeriodRelation.CURRENT) + assertThat(Period(PeriodKind.DAY, d("2026-09-24")).relation(today)).isEqualTo(PeriodRelation.PREVIOUS) + assertThat(Period(PeriodKind.WEEK, d("2026-09-15")).relation(today)).isEqualTo(PeriodRelation.PREVIOUS) + assertThat(Period(PeriodKind.MONTH, d("2026-07-15")).relation(today)).isEqualTo(PeriodRelation.OTHER) + } + + @Test + fun routeArgumentsAndKeysRoundTrip() { + val week = Period(PeriodKind.WEEK, d("2026-09-21")) + assertThat(week.key).isEqualTo("WEEK:2026-09-21") + assertThat(Period.fromKey(week.key)).isEqualTo(week) + assertThat(Period.parse("MONTH", "2026-09-01")).isEqualTo(Period(PeriodKind.MONTH, d("2026-09-01"))) + assertThat(Period.parse("YEAR", "2026-09-01")).isNull() + assertThat(Period.parse("DAY", "bad")).isNull() + assertThat(Period.fromKey("DAY")).isNull() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/notifications/AndroidNotificationPosterTest.kt b/app/src/test/java/com/scrollmeter/app/notifications/AndroidNotificationPosterTest.kt index 376792d..9dd6e4d 100644 --- a/app/src/test/java/com/scrollmeter/app/notifications/AndroidNotificationPosterTest.kt +++ b/app/src/test/java/com/scrollmeter/app/notifications/AndroidNotificationPosterTest.kt @@ -29,25 +29,25 @@ class AndroidNotificationPosterTest { localeManager.applicationLocales = LocaleList.getEmptyLocaleList() } - private fun postGoal(): Notification { + private fun postLimit(): Notification { shadowOf(app).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) - AndroidNotificationPoster(app).post(Notice(NotificationKind.GOAL, 1_250_000.0), Settings()) + AndroidNotificationPoster(app).post(Notice(NotificationKind.LIMIT, 1_250_000.0), Settings()) return shadowOf(notificationManager).allNotifications.single() } @Test fun aCzechAppOnAnEnglishPhoneNotifiesInCzech() { localeManager.applicationLocales = LocaleList.forLanguageTags("cs") - val notice = postGoal() - assertThat(notice.extras.getString(Notification.EXTRA_TITLE)).isEqualTo("Denní cíl dosažen") - assertThat(notice.extras.getString(Notification.EXTRA_TEXT)).isEqualTo("Dnešní vzdálenost přesáhla tvůj cíl 1,25 km.") - assertThat(notificationManager.getNotificationChannel("goals").name.toString()).isEqualTo("Cíle a rekordy") + val notice = postLimit() + assertThat(notice.extras.getString(Notification.EXTRA_TITLE)).isEqualTo("Denní limit překročen") + assertThat(notice.extras.getString(Notification.EXTRA_TEXT)).isEqualTo("Dnes jsi přes svůj denní limit 1,25 km.") + assertThat(notificationManager.getNotificationChannel("goals").name.toString()).isEqualTo("Limit a shrnutí") } @Test fun withoutAnAppLanguageThePhonesLanguageIsUsed() { - val notice = postGoal() - assertThat(notice.extras.getString(Notification.EXTRA_TITLE)).isEqualTo("Daily goal reached") - assertThat(notice.extras.getString(Notification.EXTRA_TEXT)).isEqualTo("Today’s distance passed your goal of 1.25 km.") + val notice = postLimit() + assertThat(notice.extras.getString(Notification.EXTRA_TITLE)).isEqualTo("Daily limit exceeded") + assertThat(notice.extras.getString(Notification.EXTRA_TEXT)).isEqualTo("Today you are over your daily limit of 1.25 km.") } } diff --git a/app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt b/app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt index 63ffa5d..fe23ba5 100644 --- a/app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt +++ b/app/src/test/java/com/scrollmeter/app/notifications/NotificationRulesTest.kt @@ -6,29 +6,34 @@ import java.time.LocalDate import kotlinx.coroutines.test.runTest import org.junit.Test -/** Spec §26: optional, never per event, at most once a day per kind. */ +/** Spec §26, ADR-036: optional, never per event, at most once a day per kind; a limit, no record. */ class NotificationRulesTest { private val today = LocalDate.parse("2026-09-24") private val all = NotificationKind.entries.toSet() - private fun facts(todayMm: Double, best: Double = 800_000.0, priorDays: Int = 5, yesterday: Double = 300_000.0) = - NotificationFacts(today, todayMm, goalMm = 500_000.0, previousBestMm = best, priorMeasuredDays = priorDays, yesterdayMm = yesterday) + private fun facts(todayMm: Double, limit: Double = 500_000.0, yesterday: Double = 300_000.0) = + NotificationFacts(today, todayMm, limitMm = limit, yesterdayMm = yesterday) @Test - fun goalRecordAndSummaryFollowTheirThresholds() { + fun limitAndSummaryFollowTheirThresholds() { assertThat(NotificationRules.decide(facts(499_999.0), all).map { it.kind }).containsExactly(NotificationKind.SUMMARY) - assertThat(NotificationRules.decide(facts(500_000.0), all).map { it.kind }).containsExactly(NotificationKind.GOAL, NotificationKind.SUMMARY) - val record = NotificationRules.decide(facts(900_000.0), setOf(NotificationKind.RECORD)).single() - assertThat(record).isEqualTo(Notice(NotificationKind.RECORD, 900_000.0)) + assertThat(NotificationRules.decide(facts(500_000.0), all)).containsExactly( + Notice(NotificationKind.LIMIT, 500_000.0), Notice(NotificationKind.SUMMARY, 300_000.0), + ).inOrder() } @Test - fun noRecordTooEarlyOrTooSmallAndNoEmptySummary() { - assertThat(NotificationRules.decide(facts(900_000.0, priorDays = 2), setOf(NotificationKind.RECORD))).isEmpty() - assertThat(NotificationRules.decide(facts(9_000.0, best = 5_000.0), setOf(NotificationKind.RECORD))).isEmpty() + fun noLimitNoticeWithoutALimitAndNoEmptySummary() { + assertThat(NotificationRules.decide(facts(900_000.0, limit = 0.0), setOf(NotificationKind.LIMIT))).isEmpty() assertThat(NotificationRules.decide(facts(900_000.0, yesterday = 0.0), setOf(NotificationKind.SUMMARY))).isEmpty() } + @Test + fun theSwitchesPickTheKinds() { + assertThat(NotificationRules.enabled(Settings())).isEmpty() + assertThat(NotificationRules.enabled(Settings(notifyLimit = true, notifySummary = true))).containsExactly(NotificationKind.LIMIT, NotificationKind.SUMMARY) + } + @Test fun theWatcherPostsEachKindOnceADayAndSkipsWithoutPermission() = runTest { val posted = mutableListOf() @@ -41,8 +46,8 @@ class NotificationRulesTest { } var queries = 0 val watcher = NotificationWatcher( - settings = { Settings(notifyGoal = true) }, - facts = { d, _ -> queries++; NotificationFacts(d, 600_000.0, 500_000.0, 0.0, 0, 0.0) }, + settings = { Settings(notifyLimit = true) }, + facts = { d, _ -> queries++; NotificationFacts(d, 600_000.0, 500_000.0, 0.0) }, state = state, poster = object : NotificationPoster { override fun canPost() = permitted @@ -52,7 +57,7 @@ class NotificationRulesTest { ) watcher.check() watcher.check() - assertThat(posted).containsExactly(NotificationKind.GOAL) + assertThat(posted).containsExactly(NotificationKind.LIMIT) assertThat(queries).isEqualTo(1) // posted today: the second check reads nothing day = today.plusDays(1) @@ -61,7 +66,7 @@ class NotificationRulesTest { assertThat(posted).hasSize(1) permitted = true watcher.check() - assertThat(posted).containsExactly(NotificationKind.GOAL, NotificationKind.GOAL) + assertThat(posted).containsExactly(NotificationKind.LIMIT, NotificationKind.LIMIT) } /** A delete between reading the facts and posting: the notice would describe deleted data (ADR-031). */ @@ -75,8 +80,8 @@ class NotificationRulesTest { } var epoch = 0L val watcher = NotificationWatcher( - settings = { Settings(notifyGoal = true) }, - facts = { d, _ -> epoch++; NotificationFacts(d, 600_000.0, 500_000.0, 0.0, 0, 0.0) }, + settings = { Settings(notifyLimit = true) }, + facts = { d, _ -> epoch++; NotificationFacts(d, 600_000.0, 500_000.0, 0.0) }, state = state, poster = object : NotificationPoster { override fun canPost() = true @@ -96,8 +101,8 @@ class NotificationRulesTest { val lock = kotlinx.coroutines.sync.Mutex() var lockedWhilePosting: Boolean? = null val watcher = NotificationWatcher( - settings = { Settings(notifyGoal = true) }, - facts = { d, _ -> NotificationFacts(d, 600_000.0, 500_000.0, 0.0, 0, 0.0) }, + settings = { Settings(notifyLimit = true) }, + facts = { d, _ -> NotificationFacts(d, 600_000.0, 500_000.0, 0.0) }, state = object : NotificationState { override suspend fun lastPosted(kind: NotificationKind): LocalDate? = null override suspend fun setLastPosted(kind: NotificationKind, date: LocalDate) = Unit diff --git a/app/src/test/java/com/scrollmeter/app/settings/SettingsRepositoryTest.kt b/app/src/test/java/com/scrollmeter/app/settings/SettingsRepositoryTest.kt index 908bbac..b905509 100644 --- a/app/src/test/java/com/scrollmeter/app/settings/SettingsRepositoryTest.kt +++ b/app/src/test/java/com/scrollmeter/app/settings/SettingsRepositoryTest.kt @@ -39,7 +39,7 @@ class SettingsRepositoryTest { @Test fun everySettingRoundTrips() = runBlocking { - repository.setDailyGoalMm(1_000_000.0) + repository.setDailyLimitMm(1_000_000.0) repository.setExcluded("a", true) repository.setExcluded("b", true) repository.setExcluded("a", false) @@ -57,9 +57,33 @@ class SettingsRepositoryTest { } @Test - fun aGoalOutsideTheRangeIsClamped() = runBlocking { - repository.setDailyGoalMm(1.0) - assertThat(repository.settings.first().dailyGoalMm).isEqualTo(Settings.GOAL_RANGE_MM.start) + fun aLimitOutsideTheRangeIsClamped() = runBlocking { + repository.setDailyLimitMm(1.0) + assertThat(repository.settings.first().dailyLimitMm).isEqualTo(Settings.LIMIT_RANGE_MM.start) + } + + /** ADR-036: "Bez limitu" is stored as 0 and read back as 0, not as the default. */ + @Test + fun noLimitIsStoredAsZero() = runBlocking { + repository.setDailyLimitMm(0.0) + assertThat(repository.settings.first().dailyLimitMm).isEqualTo(Settings.NO_LIMIT) + repository.setDailyLimitMm(-3.0) + assertThat(repository.settings.first().dailyLimitMm).isEqualTo(Settings.NO_LIMIT) + } + + /** ADR-036: the former goal's keys carry over — the value becomes the limit, the switch stays on. */ + @Test + fun theFormerGoalCarriesOverAsTheLimit() = runBlocking { + dataStore.edit { + it[androidx.datastore.preferences.core.doublePreferencesKey("daily_goal_mm")] = 250_000.0 + it[androidx.datastore.preferences.core.booleanPreferencesKey("notify_goal")] = true + it[androidx.datastore.preferences.core.booleanPreferencesKey("notify_record")] = true + it[androidx.datastore.preferences.core.stringPreferencesKey("notified_goal_date")] = "2026-09-25" + } + val settings = repository.settings.first() + assertThat(settings.dailyLimitMm).isEqualTo(250_000.0) + assertThat(settings.notifyLimit).isTrue() + assertThat(repository.lastPosted(com.scrollmeter.app.notifications.NotificationKind.LIMIT)).isEqualTo(java.time.LocalDate.parse("2026-09-25")) } @Test @@ -67,7 +91,7 @@ class SettingsRepositoryTest { dataStore.edit { it[SettingsRepository.UNIT] = "LIGHT_YEARS" it[SettingsRepository.THEME] = "NEON" - it[SettingsRepository.DAILY_GOAL_MM] = -5.0 + it[SettingsRepository.DAILY_LIMIT_MM] = -5.0 } assertThat(repository.settings.first()).isEqualTo(Settings()) } diff --git a/app/src/test/java/com/scrollmeter/app/ui/components/ChartLabelsTest.kt b/app/src/test/java/com/scrollmeter/app/ui/components/ChartLabelsTest.kt index e01976e..02cf0b5 100644 --- a/app/src/test/java/com/scrollmeter/app/ui/components/ChartLabelsTest.kt +++ b/app/src/test/java/com/scrollmeter/app/ui/components/ChartLabelsTest.kt @@ -2,8 +2,9 @@ package com.scrollmeter.app.ui.components import com.google.common.truth.Truth.assertThat import com.scrollmeter.app.insights.ChartScale -import com.scrollmeter.app.insights.HistoryBar -import com.scrollmeter.app.insights.HistoryPeriod +import com.scrollmeter.app.insights.DayBar +import com.scrollmeter.app.insights.Period +import com.scrollmeter.app.insights.PeriodKind import com.scrollmeter.app.settings.UnitPreference import java.time.LocalDate import java.util.Locale @@ -37,12 +38,23 @@ class ChartLabelsTest { } @Test - fun barsAreDaysOrMonths() { + fun barsAreWeekdaysInAWeekAndDaysInAMonth() { val labels = ChartLabels(cs) - val bar = HistoryBar(LocalDate.parse("2026-09-24"), 1.0) - assertThat(labels.bar(HistoryPeriod.DAYS_30, bar)).isEqualTo("24.") - assertThat(labels.selected(HistoryPeriod.DAYS_7, bar)).isEqualTo("24. 9.") - assertThat(labels.selected(HistoryPeriod.MONTHS_12, HistoryBar(LocalDate.parse("2026-09-01"), 1.0))).contains("2026") + val bar = DayBar(LocalDate.parse("2026-09-24"), 1.0) + assertThat(labels.bar(bar, 7)).isEqualTo("čt") + assertThat(labels.bar(bar, 30)).isEqualTo("24.") assertThat(labels.day(LocalDate.parse("2026-01-05"))).isEqualTo("5. 1.") } + + @Test + fun periodDates() { + val labels = ChartLabels(cs) + fun p(kind: PeriodKind, date: String) = labels.dates(Period(kind, LocalDate.parse(date))) + assertThat(p(PeriodKind.DAY, "2026-09-25")).isEqualTo("pá 25. 9.") + assertThat(p(PeriodKind.WEEK, "2026-09-24")).isEqualTo("21.–27. 9.") + assertThat(p(PeriodKind.WEEK, "2026-10-01")).isEqualTo("28. 9. – 4. 10.") + assertThat(p(PeriodKind.WEEK, "2026-01-01")).isEqualTo("29. 12. 2025 – 4. 1. 2026") + assertThat(p(PeriodKind.MONTH, "2026-09-10")).isEqualTo("září 2026") + assertThat(ChartLabels(Locale.ENGLISH).dates(Period(PeriodKind.MONTH, LocalDate.parse("2026-09-10")))).isEqualTo("September 2026") + } } From 12135bb397cccc80ec30d34dd00af75ed86fb4a4 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Fri, 25 Sep 2026 21:17:22 +0200 Subject: [PATCH 3/5] docs: ADR-036, README, architecture, PLAN and handoff for Phase 9 Co-Authored-By: Claude Opus 5.5 (1M context) --- PLAN.md | 8 +++++- README.md | 50 +++++++++++++++++++---------------- docs/architecture.md | 18 +++++++------ docs/measurement-decisions.md | 1 + docs/play-listing.md | 12 ++++----- handoff.md | 28 +++++++++++++++++++- 6 files changed, 78 insertions(+), 39 deletions(-) diff --git a/PLAN.md b/PLAN.md index dff814d..071d55b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -207,9 +207,15 @@ Každá fáze = větev `phase-N-` → PR → Honza merguje → tag `v0.N`. **Odhad:** 1–2 sessions + Honzovy testy. +### Phase 9 — Statistiky a denní limit (větev `phase-9-ux-limit`, přidáno 25. 9. 2026) + +**Rozsah (Honza, 25. 9.):** dnes / tento týden / tento měsíc s náskrolováním po dnech; klepnutí na období otevře jeho historii s rozpadem po aplikacích (metry od největšího, čas v aplikaci v závorce na stejném řádku); čas v minutách → hodinách → dnech; co nejjednodušší UX; **není to soutěž** — cíl se mění na **limit**, který uživatel nechce překročit, a karta přechází zelená → oranžová → červená. Návrh `docs/plans/phase-9-ux-limit.md`, rozhodnutí ADR-036. + +**DoD:** obrazovka Statistiky nahrazuje Historii a Aplikace; limit s barvou a slovy, *Bez limitu*; oznámení o rekordu pryč; aktualizace z 0.1.0 zachová data i nastavený cíl jako limit; screenshoty cs/en, světlý/tmavý; tag `v0.9`, release `v0.2.0` nainstalovaný na OnePlus i Samsung. + ### Backlog Phase 1.1 / Phase 2 (až po stabilním měření) -Kompatibilitní test v nastavení (SPEC §48), Accuracy panel (§49), sessions v UI + scroll speed (§18, §50), widget (Glance, §27), gamifikace a challenges (§47), lokální JSON backup. Databázi modelů telefonů **nedělat** (§10). +Kompatibilitní test v nastavení (SPEC §48), Accuracy panel (§49), sessions v UI + scroll speed (§18, §50), widget (Glance, §27), lokální JSON backup. Gamifikace a challenges (§47) **vyřazeny** 25. 9. 2026 — není to soutěž (ADR-036). Databázi modelů telefonů **nedělat** (§10). ## 5. Testovací strategie diff --git a/README.md b/README.md index 26ccb20..fa85ad7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,9 @@ finger across the touchscreen. Project founded 2026-09-23. Phases 0–7 merged (`v0.0` … `v0.7`): bootstrap, measurement proof of concept (GO/NO-GO gate), calibration, persistence with optional time in app, dashboard, history and apps, export and settings, onboarding with the Play disclosure and English. Phase 8 (release hardening, `v0.8`) adds the signed -release build, the emulator matrix API 28–36, the battery protocol and the first release `v0.1.0`. +release build, the emulator matrix API 28–36, the battery protocol and the first release `v0.1.0`. Phase 9 +(`v0.9`, release `v0.2.0`) replaces the goal with a daily limit in colour and History + Apps with one +*Statistiky* screen (ADR-036). The app is for family phones, installed from the APK — not Google Play. **Requires Android 10 (API 29) or newer** — Android 9 does not deliver the scroll events (ADR-033). Manual on-phone checks still owed are listed under "Dluh ověření" in `handoff.md`. See `PLAN.md` (phases, gates) and `handoff.md` (live state). @@ -119,33 +121,35 @@ Accuracy with the calibration in force: `python3 tools/device_accuracy.py --surf then `python3 tools/accuracy.py out/ground_truth.csv out/measured.csv`. The test list itself shows MAE / MAPE over the runs of a series (*Vynulovat* closes a run, *Nová série* starts over). -## Dashboard (Phase 4) - -*Přehled* shows today's distance in a ring against the daily goal (500 m by default, set in *Nastavení*), -this week / month / in total, the top apps today with their names and icons, and one comparison ("To je -přibližně délka jednoho běžeckého okruhu."). Every number is live — stored plus what the service has not -written yet. When the service is off, a red banner with *Zapnout měření* comes first. With Usage access the -app rows add time in app and m/min; without it they show scroll time, and a card offers the feature once -(*Ukázat jak* → a disclosure screen → Android settings). - -## History and apps (Phase 5) - -A bottom bar switches between *Přehled*, *Historie*, *Aplikace* and *Nastavení*. *Historie* charts the -distance per day (7 or 30 days) or per month (12 months); tap a bar for its value; below it the average, -highest and lowest day and the total, counted per day from the first measured day. *Aplikace* lists the apps -of *Dnes / 7 dní / 30 dní / Celkem* by distance or time, with each app's share and a measurement-quality word -(never an accuracy percentage — ADR-029); apps with time but no scroll data (YouTube) show "—". A row opens -the app's detail with its 30-day charts. +## Přehled and Statistiky (Phases 4, 5, 9) + +*Přehled* is the one main screen — no bottom bar; *Nastavení* sits behind the gear. The *Dnes* card shows +today's distance against the **daily limit** (500 m by default, or *Bez limitu*): green, orange from 70 %, red +over it, and always in words too ("Zbývá 80 m z limitu 500 m", "Překročeno o 120 m (limit 500 m)"). Below it +*Tento týden* and *Tento měsíc* with their total and average per day, the three apps scrolled most today and +one comparison ("To je přibližně délka jednoho běžeckého okruhu."). Every number is live — stored plus what +the service has not written yet. When the service is off, a red banner with *Zapnout měření* comes first. It +is not a competition: no goals, no records (ADR-036). + +Tapping *Dnes*, a week or a month opens **Statistiky**: *Den / Týden / Měsíc* with ‹ ›, for a week or month a +bar per day in its limit colour with the limit as a dashed line (tap a bar to open that day), and every app +of the period from the longest distance down, with the time in the app on the same row — "Chrome +1,21 km (3 h 40 min)". Apps with time but no scroll data (YouTube) show "— (2 h 15 min)" at the end. An app +opens its detail for that period: distance, share, time in app, scroll time, pace, a measurement-quality word +(never an accuracy percentage — ADR-029) and 30-day charts. Durations roll over from minutes to hours to days +("1 d 1 h 1 min"). Time in app needs Usage access; until it is granted a card offers it once (*Ukázat jak* → +a disclosure screen → Android settings), and the rows show no brackets. ## Settings, export and notifications (Phase 6) -*Nastavení* holds the service and Usage-access status, *Přesnost měření*, the daily goal (100 m – 5 km or a -custom 10 m – 100 km), *Vyloučené aplikace* (launcher, keyboard and System UI are suggested; an excluded app -disappears from every total, history and export and comes back when switched on again), units, theme, -the three opt-in notifications (goal reached, new record, yesterday's summary — each at most once a day; on +*Nastavení* holds the service and Usage-access status, *Přesnost měření*, the daily limit (100 m – 5 km, a +custom 10 m – 100 km, or *Bez limitu*), *Vyloučené aplikace* (launcher, keyboard and System UI are suggested; an +excluded app disappears from every total, history and export and comes back when switched on again), units, +theme, the two opt-in notifications (over the limit, yesterday's summary — each at most once a day; on Android 13+ the system asks for permission when one is switched on), *Exportovat CSV* (save `per_app.csv` / `daily_summary.csv` through the system file picker, or share both), *Smazat všechna data* (optionally with the -settings and the card calibration), *Soukromí* and *O aplikaci*; in debug builds also the developer screens. +settings and the card calibration), *Soukromí* and *O aplikaci* (version, device, the total measured since the first day); in debug builds also the +developer screens. The CSV format is in ADR-031: comma separated, dot decimals, UTF-8 with BOM, an empty field means unknown. ## Stored data and time in app (Phase 3) diff --git a/docs/architecture.md b/docs/architecture.md index bccc07b..dfe745a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,7 +69,7 @@ com.scrollmeter.app │ UsageEventsSource, UsageAccessChecker (the only Android parts) ├── settings Settings, SettingsRepository (DataStore) ├── format DistanceFormatter, TimeFormatter, GoalInput (pure Kotlin — ADR-028, ADR-031) -├── insights DistanceComparisonProvider, TopApps, HistorySeries, ChartScale, AppRanking, +├── insights DistanceComparisonProvider, Period, DailyLimit, DaySeries, ChartScale, AppRanking, │ ScrollShare (pure Kotlin — ADR-028, ADR-029) ├── apps AppInfoProvider (labels + icons via PackageManager, cached; UI only — ADR-008), │ SuggestedExclusions (launcher / keyboard / System UI — ADR-030), PackageNames @@ -120,16 +120,18 @@ com.scrollmeter.app ## Navigation -Bottom bar: Přehled · Historie · Aplikace · Nastavení (Navigation Compose 2.9.8, `@Serializable` routes — D14, -ADR-029), in `ui/navigation/ScrollMeterNavHost`. The bar shows only on those four; switching tabs saves and restores -each tab's state. Below them, without the bar: +Přehled is the one top-level screen — no bottom bar since Phase 9 (ADR-036); everything else opens over it +with a way back (Navigation Compose 2.9.8, `@Serializable` routes — D14), in `ui/navigation/ScrollMeterNavHost`: -- Aplikace (or a top-app row on Přehled) → detail `AppDetailRoute(packageName)`, +- Přehled (*Dnes*, *Tento týden*, *Tento měsíc*, *Všechny aplikace*) → *Statistiky* `PeriodRoute(kind, anchor)` + (`ui/period/PeriodScreen`; Den / Týden / Měsíc and ‹ › are state inside it, a bar opens that day as a new + `PeriodRoute` on top — `openFrom`, since `launchSingleTop` would replace the week), +- Statistiky or a Přehled app row → detail `AppDetailRoute(packageName, kind, anchor)` for that period; both + routes validate their arguments on arrival (`PackageNames.isValid`, `Period.parse`) and leave otherwise, +- Přehled gear → Nastavení → Vyloučené aplikace · Export CSV · Soukromí · O aplikaci (limit, units, theme and + delete are dialogs); Nastavení → developer screens (`DevToolRoute`, only when `DevTools.entries` is non-empty — debug), - Přehled or Nastavení → Přesnost měření → Kalibrace displeje (saving or skipping lands back on Přesnost), - Přehled card or Nastavení → Čas v aplikacích (the Usage-access disclosure; closes itself once access is granted), -- Nastavení → Vyloučené aplikace · Export CSV · Soukromí · O aplikaci (goal, units, theme and delete are dialogs), -- Nastavení → developer screens (`DevToolRoute`, registered only when `DevTools.entries` is non-empty — debug). - - Přehled banner or Nastavení → the accessibility settings, through `DisclosureRoute` while the disclosure has not been accepted (`AccessibilityGate`, ADR-032). diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index 4801f78..a70081c 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -40,3 +40,4 @@ supersede with a new entry that references the old one. | ADR-033 | 2026-09-25 | **`minSdk = 29`** (supersedes ADR-002; SPEC §3 says 28). Android 9 is not supported: the app does not install there. Re-opening it would need an API-28-only service config (`res/xml/` for 28, `res/xml-v29/` for 29+) that also asks for `typeWindowStateChanged` — outside the hard rule "only `typeViewScrolled`", so it is Honza's call, not an implementation detail. | Measured on the API 28 emulator (Phase 8): with only our service on, no scroll event of our own test list reached it (18 of 18 runs, View and Compose, ground truth 0.5–30 k px each), and Settings got through only sometimes. AOSP 9 explains it: `canDispatchAccessibilityEventLocked` forwards `TYPE_VIEW_SCROLLED` only from the *active* window, and without a service that tracks windows (`canRetrieveWindowContent` + `flagRetrieveInteractiveWindows`, both forbidden here) the active window moves only on a `TYPE_WINDOW_STATE_CHANGED` — which apps send only when a bound service asks for it (`AbstractAccessibilityServiceConnection.getRelevantEventTypes` = the service's own types). Android 10 (`android-10.0.0_r1`) adds `TYPE_WINDOW_STATE_CHANGED` to what apps send for every service that does not use the cache, still without delivering it to the service — the same gate, now fed; unchanged through Android 14. So on Android 9 ScrollMeter would measure next to nothing while claiming to measure, against spec §32 and §71 (correctness first). API 29 also has SQLite 3.22, so ADR-026's two-statement flush stands; `UsageAccessChecker` drops the pre-29 branch. | accepted | | ADR-034 | 2026-09-25 | **Release build (Phase 8).** (1) **Signing** only through `SIGNING_KEYSTORE_PATH` / `SIGNING_STORE_PASSWORD` / `SIGNING_KEY_ALIAS` / `SIGNING_KEY_PASSWORD` (pattern from DETECT). No variables → an unsigned release (CI, local gates); a path set to nothing that exists, or a missing password → the build fails. The upload key is a PKCS12 keystore outside the repository (`~/.android-keystores/scrollmeter-upload.jks`, RSA 4096, 30 years, alias `scrollmeter-upload`, certificate SHA-256 `046f8cd0…669c0e88`); its password lives in the macOS Keychain (`scrollmeter-upload-keystore`) and reaches Gradle only through the environment of `tools/build_release.sh`, which builds without the configuration cache (it stores the values of the environment variables a build reads, passwords included, under `.gradle/`; `app/build.gradle.kts` refuses a signed build while it is active), refuses a working tree with changes (every artifact is exactly the printed commit) and fails unless both the APK (`apksigner`) and the AAB (`keytool -printcert -jarfile`) carry this certificate — a file signed with another key is refused by Play and, as an update, by phones. On Google Play the key is the *upload* key under Play App Signing (resettable through Play support); for sideloaded APKs it is the app's identity — losing it means users must uninstall to update, so it needs a backup (Honza's decision where). APK signature scheme v2 (minSdk 29 needs no v1). (2) **No logcat output in release:** `proguard-rules.pro` declares `android.util.Log.v/d/i/w/e/wtf/println` free of side effects and `isLoggable` false, so R8 removes every call — ours (already behind `BuildConfig.DEBUG`) and the libraries'. (3) **`tools/check_release_apk.py`** checks the built APK: not debuggable, the descriptor `Landroid/util/Log;` nowhere in the dex bytes (with every call removed nothing references the class — a gate independent of dexdump's format; dexdump then names any method that still calls it, and a dump without method code is a tool error), no debug-recording literals. It checks every class, not only ours: R8's horizontal class merging put `androidx.profileinstaller.ProfileInstaller$2` (which logs) into `CalibrationRepository$Companion` once minSdk rose to 29, so ownership cannot be read from a renamed class. CI builds `assembleRelease` (unsigned) and runs the check. (4) `versionName 0.1.0`, `versionCode 1`; the release tag is `v0.1.0`, phase tags stay `v0.N` (different strings). (5) **No language splits in the app bundle** (`bundle { language { enableSplit = false } }`): Play installs only the splits of the phone's languages, so a phone in a third language switched to English for this app (ADR-032) would lack the English strings; lint `AppBundleLocaleChanges` flagged it. Two languages cost a few kB. | PLAN Phase 8 (signing via env, keystore never in git; release without debug screens or logging); spec §34, §66 (no raw event logging in release). Library logs carry no event data, but a privacy-first app has no reason to write anything to a log other apps with READ_LOGS (adb, OEM tools) can read; removing them also makes the check exact instead of a guess about which renamed class is whose. | accepted | | ADR-035 | 2026-09-25 | **Texts shown outside an activity use the app's language** (amends ADR-032). The per-app language of Android 13+ reaches activity contexts only; the application context keeps the phone's language. The two places that build text from it — the toast after *Smazat všechna data* (it runs in the application scope, ADR-032 (5)) and `AndroidNotificationPoster` (title, text, number format, channel name) — now take their resources from `Context.inAppLanguage()`: `LocaleManager.getApplicationLocales()` applied through `createConfigurationContext` (API 33+, no permission; below 33 there is no per-app language). The channel is re-created with each post, so its name follows a language change. | Found on the API 33 and API 36 emulators in Phase 8 (Czech chosen for the app, English phone): *Soubor uložen.* (activity context) came out Czech, *All measured data is deleted.* (application context) English — and the notifications, built the same way, would have too. Pinned by `AppLanguageTest` (the plain application context stays English, `inAppLanguage()` gives Czech) and `AndroidNotificationPosterTest` (a Czech app on an English phone posts "Denní cíl dosažen" / "1,25 km" on channel "Cíle a rekordy"). | accepted | +| ADR-036 | 2026-09-25 | **Periods with their apps, and a daily limit instead of a goal (Phase 9, v0.2.0; amends ADR-029, ADR-031 (4)(5); spec §21, §23–§26, §43).** (1) **Not a competition.** The goal becomes a *limit* — how much the user does not want to scroll in a day; no record notice, no "cíl splněn". It stays in the DataStore key `daily_goal_mm`, so a goal set in 0.1.0 carries over as the limit (unset → 500 m, on); 0 = *Bez limitu* (no colour, no limit notice). The keys `notify_goal` / `notified_goal_date` and the channel id `goals` are kept so an update keeps the user's choices; the channel is renamed *Limit a shrnutí*. The record notice is removed with `RECORD_MIN_PRIOR_DAYS`, `RECORD_MIN_MM`, its facts and its DAO query; the stored `notify_record` keys are left unread. (2) **Levels**, ratio = distance / limit: UNDER below `LIMIT_WARN_RATIO` (0.7), NEAR from there, OVER from 1.0; NONE without a limit. A week or month is judged on its **average per day**, counted from the first measured day (or the period's start) to today, as ADR-029's statistics were, so unmeasured days and the rest of the current week do not dilute it. (3) **Colour** on the *Dnes* card, the *Statistiky* header, each bar and the week / month dots — never the whole screen, never alone: the text says the same ("Zbývá 80 m z limitu 500 m", "Překročeno o 120 m (limit 500 m)", "Průměr 420 m / den · limit 500 m"). A fixed palette, not dynamic colour (green must stay green whatever the wallpaper); container / text / bar, light · dark: UNDER `#C8E6C9/#1B5E20/#43A047` · `#1E4620/#C8E6C9/#66BB6A`, NEAR `#FFE0B2/#7A3E00/#FB8C00` · `#4E3200/#FFE0B2/#FFA726`, OVER `#FFCDD2/#8E1B1B/#E53935` · `#5B1A1A/#FFCDD2/#EF5350`; every text pair ≥ 7:1. (4) **Navigation.** *Přehled* is the one top-level screen — no bottom bar, *Nastavení* behind a gear. *Historie* and *Aplikace* become one screen, *Statistiky* (`PeriodRoute(kind, anchor)`): Den / Týden / Měsíc (calendar; a week is Monday–Sunday), ‹ › never past the current period nor before the one holding the first measured day; for a week or month a bar per day in its level colour with the limit as a dashed line (drawn only when the limit ≤ 2 × the highest bar, so it never squashes the bars flat); a bar opens that day as a new screen, and Back returns to the week; then the apps by distance, apps with time only (D19) last by time, each row "1,21 km (3 h 40 min)" — no brackets without Usage access, never a 0. Gone: rolling 7 / 30 days and 12 months, highest / lowest day, sort chips, share and quality in rows, "Ostatní", the lifetime tile (now in *O aplikaci* with the first measured day). The app detail takes the period it was opened from (`AppDetailRoute(packageName, kind, anchor)`, validated on arrival like the package name) and adds its share of the period's distance; its two charts stay the last 30 days. (5) **Durations** roll over to days: "< 1 min", "45 min", "1 h 1 min", "1 d 1 h 1 min" (zero parts left out, the same units in cs and en). | Honza, 2026-09-25: today / this week / this month with the scroll per day, one tap from a period to its apps with metres and time on the same row, "není to soutěž", the limit turning green → orange → red. 70 % gives a warning band before the limit — at the 500 m default the card turns orange at 350 m. Keeping the storage key makes the rename free for existing users: `theFormerGoalCarriesOverAsTheLimit`, and on the emulator 0.1.0 with a 1 km goal → 0.2.0 showed "limit 1.00 km". Spec §47 (gamification, challenges) is out for the same reason. | accepted | diff --git a/docs/play-listing.md b/docs/play-listing.md index b3f2548..e79de35 100644 --- a/docs/play-listing.md +++ b/docs/play-listing.md @@ -26,8 +26,8 @@ scroll.* Secondary: *Screen time ti řekne jak dlouho. ScrollMeter ti ukáže ja > ScrollMeter měří, o kolik se při scrollování posune obsah na displeji — v sociálních sítích, v prohlížeči i jinde > — a převádí to na metry a kilometry podle fyzické velikosti pixelu tvého displeje. > -> • Dnes, týden, měsíc i celkem, s denním cílem a srovnáním („přibližně délka běžeckého okruhu“) -> • Historie za 7 dní, 30 dní a 12 měsíců +> • Dnes, tento týden a tento měsíc proti dennímu limitu — zelená, oranžová, červená — a srovnání („přibližně délka běžeckého okruhu“) +> • Den, týden i měsíc po dnech, jedním klepnutím i po aplikacích > • Kolik v které aplikaci, volitelně i kolik času v ní trávíš a kolik metrů za minutu > • Kalibrace platební kartou pro přesnější měření > • Export do CSV, smazání všech dat jedním tlačítkem @@ -45,8 +45,8 @@ scroll.* Secondary: *Screen time ti řekne jak dlouho. ScrollMeter ti ukáže ja > ScrollMeter measures how far content moves on your screen while you scroll — in social apps, in the browser and > elsewhere — and turns it into metres and kilometres using the physical pixel size of your display. > -> • Today, this week, this month and all time, with a daily goal and a comparison ("about one lap of a running track") -> • History for 7 days, 30 days and 12 months +> • Today, this week and this month against a daily limit — green, orange, red — and a comparison ("about one lap of a running track") +> • Any day, week or month per day, and per app with one tap > • How far in which app, and optionally how much time you spend there and how many metres a minute > • Calibration with a payment card for more accurate measuring > • CSV export, delete all data with one button @@ -59,7 +59,7 @@ scroll.* Secondary: *Screen time ti řekne jak dlouho. ScrollMeter ti ukáže ja > says so. **Category:** Lifestyle (alternative: Health & Fitness) — Honza's choice. -**Screenshots:** Přehled with a goal ring, Historie 30 dní, Aplikace with time, app detail, onboarding disclosure, +**Screenshots:** Přehled with the limit card, Statistiky (a week and a day with its apps), app detail, onboarding disclosure, Soukromí. Czech and English sets (the app has both, ADR-032). ## Accessibility API declaration (Play Console → App content → Sensitive permissions and APIs) @@ -98,7 +98,7 @@ device**; the user exports it by their own action through the system share sheet |---|---|---| | Accessibility service binding (`BIND_ACCESSIBILITY_SERVICE` on our service) | scroll events | user switches it on in Android's settings after the disclosure | | `PACKAGE_USAGE_STATS` | optional time in app (ADR-021) | user grants Usage access in Android's settings after its own disclosure | -| `POST_NOTIFICATIONS` | optional goal / record / yesterday notifications (ADR-030) | asked when a notification switch is turned on | +| `POST_NOTIFICATIONS` | optional over-the-limit / yesterday notifications (ADR-030, ADR-036) | asked when a notification switch is turned on | | `` LAUNCHER + HOME | app names and icons; the launcher as a suggested exclusion (ADR-008, ADR-030) | — | No `INTERNET`, no `QUERY_ALL_PACKAGES`, no foreground service, no overlay, no storage permission. diff --git a/handoff.md b/handoff.md index 35f7b50..f3ba750 100644 --- a/handoff.md +++ b/handoff.md @@ -46,6 +46,27 @@ | 6 Export + Nastavení | hotovo, mergnuto (tag `v0.6`) | `phase-6-export-settings` / [#8](https://github.com/hupcus/scrollmeter/pull/8) | ADR-030/031; oznámení a export na telefonu → „Dluh ověření“ V6; CI zablokované billingem → brána v čistém checkoutu | | 7 Onboarding + Policy | hotovo, mergnuto (tag `v0.7`) | `phase-7-onboarding-policy` / [#9](https://github.com/hupcus/scrollmeter/pull/9) | ADR-032; onboarding se sideloadem a volba jazyka na telefonu → „Dluh ověření“ V7; CI zablokované billingem → brána v čistém checkoutu | | 8 Release | hotovo, mergnuto (tag `v0.8`; GitHub Release `v0.1.0`) | `phase-8-release` / [#10](https://github.com/hupcus/scrollmeter/pull/10) | ADR-033/034/035; baterie na telefonech a ColorOS → „Dluh ověření“ V8, V9; CI zablokované billingem → brána v čistém checkoutu | +| 9 Statistiky + limit | hotovo, mergnuto (tag `v0.9`; GitHub Release `v0.2.0`) | `phase-9-ux-limit` / PR_PLACEHOLDER | ADR-036; Honzův test na telefonech → „Dluh ověření“ V10; CI zablokované billingem → brána v čistém checkoutu | + +## Phase 9 — exit report (2026-09-25) + +Zadání (Honza, 25. 9.): dnes / tento týden / tento měsíc i kolik na den; klepnutím na období jeho historie s rozpadem po aplikacích (metry od největšího, minuty/hodiny na stejném řádku), bez přepínání Historie ↔ Aplikace; čas min → h → d; **není to soutěž** — cíl je limit, který nechce překročit, a barva jde zelená → oranžová → červená. Návrh `docs/plans/phase-9-ux-limit.md`, rozhodnutí ADR-036. + +**Hotovo a ověřené** (větev `phase-9-ux-limit`): +- **Přehled** bez spodní lišty: karta *Dnes* v barvě limitu se slovy („Zbývá 80 m z limitu 500 m“ / „Překročeno o 120 m (limit 500 m)“) a časem v aplikacích; *Tento týden* / *Tento měsíc* s průměrem na den a barevnou tečkou; *Nejvíc dnes* 3 aplikace + *Všechny aplikace*; Nastavení pod ozubeným kolem. +- **Statistiky** (nahrazují Historii i Aplikace): Den / Týden / Měsíc, ‹ › (ne do budoucna, ne před první naměřený den), graf po dnech s každým sloupcem v barvě jeho dne a čárkovanou čarou limitu, klepnutí na sloupec otevře ten den (Zpět vrátí týden), aplikace podle metrů s časem v závorce „888 m (4 h 2 min)“, jen-časové aplikace „— (12 h 23 min)“ na konci. +- **Detail aplikace** za období, ze kterého se otevřel („Včera · čt 24. 9.“), + podíl na vzdálenosti; grafy dál 30 dní. +- **Limit**: 100 m – 5 km, vlastní, nebo *Bez limitu* (bez barev); oranžová od 70 % (`LIMIT_WARN_RATIO`); týden a měsíc podle průměru na den od prvního naměřeného dne. Oznámení „Denní limit překročen“ (1× denně) + shrnutí včerejška; rekord zrušený. Celkem naměřeno se přesunulo do *O aplikaci*. +- Čas: „< 1 min“, „45 min“, „1 h 1 min“, „1 d 1 h 1 min“. Anglické datumy anglicky („21–27 Sep“), české česky („21.–27. 9.“). +- `versionCode 2`, `versionName 0.2.0`. +- **Emulátor (API 34):** instalace 0.1.0 debug s cílem 1 km → nasazená data (32 dní, 6 aplikací) → aktualizace na 0.2.0: data i cíl drží („limit 1.00 km“ v angličtině emulátoru), bez pádu; limit 500 m / 250 m / Bez limitu (oranžová / červená / neutrální); týden, den, měsíc, ‹ › i zákaz před srpnem, sloupec → den → aplikace → Zpět → Zpět → Přehled; cs + en, světlý + tmavý, šířka 360 dp. 19 screenshotů prošel subagent: žádná vada (jediná poznámka: detail aplikace pro dnešek bez času v aplikaci ukazuje „—“ — záměr, čas za dnešek ještě nebyl). +- Brány: 278 unit testů, lint (jen známé varování v debug `TestListScreen`), debug + release build, manifest policy, Python testy. + +**Review** (`/topshit` nad celým diffem) — opraveno ve větvi: anglické datumy v českém tvaru; „Dnes zatím žádné scrollování“ a věta o limitu problikly před načtením; přepínač oznámení o limitu bez nastaveného limitu tiše nic nedělal (teď to říká). + +**Stage 2 — bezpečnost** (diff čtený bezpečnostní optikou): žádné nové oprávnění ani `INTERNET` (manifest policy zelená), služba beze změny (jen `typeViewScrolled`, žádný text z eventů); nové argumenty tras `kind` / `anchor` se po příchodu validují (`Period.parse`, u detailu i `PackageNames.isValid`) a navíc je čistí `NavIntents`; DAO dotazy dál parametrizované (`daysBefore` smazaný); limit z DataStore přijme jen 0 nebo rozsah 10 m – 100 km, jinak výchozí. Nález žádný. + +**Neověřeno / přijato:** jednotlivé sloupce grafu nejsou pro TalkBack samostatné (dny jsou dosažitelné přes Den + ‹ ›); přesně na limitu karta napíše „Překročeno o 0 m“ (vzácné). ## Phase 8 — exit report (2026-09-25) @@ -393,10 +414,11 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop | V3 | MAPE vlastního test listu s `MANUAL_CARD` | 2 | session | po V2: `python3 tools/device_accuracy.py --surface view,column --markdown --csv-out ` + `tools/accuracy.py` → zapsat do `docs/accuracy-testing.md` | MAPE ≥ 5 % → hledat v kalibraci / pipeline | | V4 | Restart telefonu (ColorOS) | 3 | Honza + session | na emulátoru ověřeno (data drží, služba se sama vrátí); na OnePlusu: nascrollovat, počkat 15 s, restartovat; po startu Domů → „Dnes“ drží hodnotu a služba běží | služba se nezapne → ColorOS ji po restartu nevrací (Phase 7 nápověda) | | V5 | Čas v aplikaci proti Digital Wellbeing | 3 | Honza + session | povolit „Přístup k údajům o využití“, otevřít aplikaci; session porovná `daily_app_usage` pro dnešek a včerejšek s Digitální rovnováhou (± 5 %) → `docs/accuracy-testing.md` | odchylka > 5 % → ADR-025 (tolerance, uzavírače) přeladit | -| V6 | Oznámení a export na telefonu | 6 | Honza + session | na emulátoru ověřeno (výzva k povolení, cíl / rekord / shrnutí přijdou jednou denně, export přes výběr souboru i sdílení); na OnePlusu: zapnout „Denní cíl dosažen“, nastavit cíl 100 m, nascrollovat → oznámení přijde do ~10 s; Export CSV → Sdílet → otevřít v Tabulkách Google / Excelu (čeština a čísla správně) | oznámení nepřijde → ColorOS ho tlumí (Phase 7 nápověda); CSV se rozpadne → ADR-031 formát | +| V6 | Oznámení a export na telefonu | 6 | Honza + session | na emulátoru ověřeno (výzva k povolení, cíl / rekord / shrnutí přijdou jednou denně, export přes výběr souboru i sdílení); na OnePlusu: zapnout „Překročení limitu“, nastavit limit 100 m, nascrollovat → oznámení „Denní limit překročen“ přijde do ~10 s; Export CSV → Sdílet → otevřít v Tabulkách Google / Excelu (čeština a čísla správně) | oznámení nepřijde → ColorOS ho tlumí (Phase 7 nápověda); CSV se rozpadne → ADR-031 formát | | V7 | Onboarding se sideloadem + volba jazyka | 7 | Honza + session | APK z GitHub Release [`v0.1.0`](https://github.com/hupcus/scrollmeter/releases/tag/v0.1.0) (podepsané upload klíčem) nainstalovat **přes Soubory / prohlížeč** (ne adb — ten omezení nespustí); projít onboarding: krok 4 → Android napíše „Omezené nastavení“ → „Otevřít informace o aplikaci“ → ⋮ → „Povolit omezená nastavení“ → zpět → zapnout službu; pak Nastavení Androidu › Aplikace › ScrollMeter › Jazyk → English | ColorOS nabídku ⋮ nemá nebo ji jmenuje jinak → upravit nápovědu kroku 4 (text z telefonu) | | V8 | Baterie na telefonech (SPEC §39) | 8 | Honza + session | OnePlus + druhý telefon s release z `v0.1.0`: `dumpsys batterystats --reset`, pak 1 h aktivního scrollování, 8 h běžného používání, 24 h normálního dne; session vytáhne `dumpsys batterystats` (uid ScrollMeteru: CPU, wakelocky, spotřeba) a počet zápisů → tabulka „Battery“ v `docs/accuracy-testing.md` | podíl ScrollMeteru v Nastavení › Baterie nad ~1 % za den → nejdřív ticker flushů (otevřený bod), pak práh 50 událostí | | V9 | ColorOS: odvolání Usage access + restart | 8 | Honza + session | na OnePlusu vzít „Přístup k údajům o využití“ → aplikace musí ukázat „Čas scrollování“ bez pádu (na emulátoru API 36 ověřeno); pak restart → služba běží, čas v aplikaci po novém povolení doběhne | pád nebo prázdné obrazovky → oprava v `UsageAccessChecker` / detailu aplikace | +| V10 | Statistiky a limit v běžném používání (v0.2.0) | 9 | Honza | pár dní používat na OnePlusu i Samsungu: sedí barvy (venku i v tmavém režimu), „Zbývá … z limitu …“, rozpad po aplikacích a čas v závorce; hlavně jestli 500 m a oranžová od 70 % odpovídají tomu, co chce hlídat | jiný limit nebo práh → jedna konstanta / předvolba (ADR-036); jiné slovo nebo pořadí → texty | ## Otevřené body @@ -454,6 +476,10 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop ## Log rozhodnutí (nejnovější nahoře) +### 2026-09-25 — Phase 9 Statistiky + denní limit (Opus 5.5, návrh Fable 5.1) +- Honza: období s rozpadem po aplikacích, čas na stejném řádku, „není to soutěž“ — cíl → limit se zelenou / oranžovou / červenou. Detail ADR-036. +- Rozhodnuto samostatně (výchozí doporučení): žádná spodní lišta, Nastavení pod ozubeným kolem; limit 500 m zapnutý (kdo si v 0.1.0 nastavil cíl, má ho jako limit); oranžová od 70 %; týden / měsíc podle průměru na den; srovnání „Pro představu“ zůstává; oznámení o rekordu a gamifikace (SPEC §47) pryč. + ### 2026-09-25 — distribuce: rodina, ne Google Play (Honza) - Honza: aplikace jen pro rodinu, případně volně ke stažení na GitHubu; na Google Play ne. Play podklady (`docs/play-listing.md`, AAB z `build_release.sh`) zůstávají, nic se kvůli tomu v kódu nemění. - Dopad: záloha keystoru je kritická (bez Play App Signing), od 2027 účet „limited distribution“ (≤ 20 zařízení, zdarma), aktualizace jen ručně nebo přes Obtainium z veřejných releasů. From 4b355dc1987f20f860a6d243cc973871ab21c3b1 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Fri, 25 Sep 2026 21:17:22 +0200 Subject: [PATCH 4/5] fix: English dates in English, no flashes while loading, limit notice without a limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the Phase 9 diff: English showed Czech-style dates ("21.–27. 9."), the dashboard flashed "no scrolling yet" and a limit sentence for "—" before the data loaded, and the limit notification switch did nothing without a limit and did not say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../app/ui/components/ChartLabels.kt | 17 +++++++++-------- .../app/ui/dashboard/DashboardScreen.kt | 9 +++++---- .../scrollmeter/app/ui/period/PeriodScreen.kt | 2 +- .../app/ui/settings/SettingsScreen.kt | 7 ++++++- app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../app/ui/components/ChartLabelsTest.kt | 12 ++++++++++++ 7 files changed, 35 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/scrollmeter/app/ui/components/ChartLabels.kt b/app/src/main/java/com/scrollmeter/app/ui/components/ChartLabels.kt index 3b7c43d..19a63a3 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/components/ChartLabels.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/components/ChartLabels.kt @@ -11,24 +11,25 @@ import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Locale -/** Axis, bar and period labels: dates in the app's locale, 0 without a unit. */ +/** Axis, bar and period labels: dates in the app's locale and its own way — "25. 9." in Czech, "25 Sep" in English — 0 without a unit. */ class ChartLabels(locale: Locale) { + private val czech = locale.language == "cs" private val weekday = DateTimeFormatter.ofPattern("EEE", locale) - private val weekdayDayMonth = DateTimeFormatter.ofPattern("EEE d. M.", locale) - private val dayOfMonth = DateTimeFormatter.ofPattern("d.", locale) - private val dayMonth = DateTimeFormatter.ofPattern("d. M.", locale) - private val dayMonthYear = DateTimeFormatter.ofPattern("d. M. yyyy", locale) + private val weekdayDayMonth = DateTimeFormatter.ofPattern(if (czech) "EEE d. M." else "EEE d MMM", locale) + private val dayOfMonth = DateTimeFormatter.ofPattern(if (czech) "d." else "d", locale) + private val dayMonth = DateTimeFormatter.ofPattern(if (czech) "d. M." else "d MMM", locale) + private val dayMonthYear = DateTimeFormatter.ofPattern(if (czech) "d. M. yyyy" else "d MMM yyyy", locale) private val month = DateTimeFormatter.ofPattern("LLLL yyyy", locale) /** Under a bar: the weekday in a week's chart (up to 7 bars), the day of the month otherwise. */ fun bar(bar: DayBar, barCount: Int): String = if (barCount <= 7) weekday.format(bar.date) else dayOfMonth.format(bar.date) - /** A day in a statistic: "24. 9." */ + /** A day in a statistic: "24. 9." / "24 Sep" */ fun day(date: LocalDate): String = dayMonth.format(date) /** - * A period's dates (ADR-036): "čt 25. 9.", "21.–27. 9.", "29. 9. – 5. 10.", "září 2026". A week - * across a new year carries the years. + * A period's dates (ADR-036): "pá 25. 9.", "21.–27. 9.", "28. 9. – 4. 10.", "září 2026"; in English + * "Fri 25 Sep", "21–27 Sep", "28 Sep – 4 Oct", "September 2026". A week across a new year carries the years. */ fun dates(period: Period): String { val range = period.range diff --git a/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt index dde90d2..f9ee0b5 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt @@ -108,7 +108,8 @@ fun DashboardScreen( val weekMm by remember(today) { repository.distance(week.range) }.collectAsStateWithLifecycle(initialValue = null) val monthMm by remember(today) { repository.distance(month.range) }.collectAsStateWithLifecycle(initialValue = null) val first by remember { repository.firstMeasuredDay() }.collectAsStateWithLifecycle(initialValue = null) - val apps by remember(today) { repository.apps(day.range) }.collectAsStateWithLifecycle(initialValue = emptyList()) + // Null until loaded: "Dnes zatím žádné scrollování" must not flash up before the rows. + val apps by remember(today) { repository.apps(day.range) }.collectAsStateWithLifecycle(initialValue = null) val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } @@ -129,13 +130,13 @@ fun DashboardScreen( if (status != ServiceStatus.ON) ServiceBanner(status, onOpenAccessibilitySettings) val todayStatus = DailyLimit.status(todayMm, limitMm) - val timeToday = if (usageGranted) apps.sumOf { it.foregroundMs ?: 0L }.takeIf { it > 0 } else null + val timeToday = if (usageGranted) apps?.sumOf { it.foregroundMs ?: 0L }?.takeIf { it > 0 } else null LimitCard( title = stringResource(R.string.home_today_title), value = todayMm?.let(::distance) ?: TimeFormatter.UNKNOWN, level = todayStatus.level, lines = listOfNotNull( - limitSentence(todayStatus, ::distance), + todayMm?.let { limitSentence(todayStatus, ::distance) }, timeToday?.let { stringResource(R.string.period_time_in_apps, TimeFormatter.duration(it)) }, ), onClick = { onOpenPeriod(day) }, @@ -145,7 +146,7 @@ fun DashboardScreen( HorizontalDivider() PeriodRow(month, monthMm, first, today, limitMm, ::distance, onOpenPeriod) } - MostTodayCard(graph, AppRanking.list(apps), usageGranted, ::distance, onOpenAll = { onOpenPeriod(day) }, onOpenApp = { onOpenApp(it, day) }) + apps?.let { MostTodayCard(graph, AppRanking.list(it), usageGranted, ::distance, onOpenAll = { onOpenPeriod(day) }, onOpenApp = { pkg -> onOpenApp(pkg, day) }) } if (!usageGranted && loadedSettings?.usageTimeCardDismissed == false) { UsageTimeCard( onShow = onOpenUsageAccess, diff --git a/app/src/main/java/com/scrollmeter/app/ui/period/PeriodScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/period/PeriodScreen.kt index bd9fd49..f357867 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/period/PeriodScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/period/PeriodScreen.kt @@ -105,7 +105,7 @@ fun PeriodScreen(graph: AppGraph, initial: Period, onBack: () -> Unit, onOpenDay if (period.kind == PeriodKind.DAY) { val status = DailyLimit.status(total, limitMm) - LimitCard(null, total?.let(::distance) ?: TimeFormatter.UNKNOWN, status.level, listOfNotNull(limitSentence(status, ::distance), timeLine)) + LimitCard(null, total?.let(::distance) ?: TimeFormatter.UNKNOWN, status.level, listOfNotNull(total?.let { limitSentence(status, ::distance) }, timeLine)) } else { val average = DailyLimit.averagePerDay(total, range, first, today) val level = if (average == null) LimitLevel.NONE else DailyLimit.level(average, limitMm) diff --git a/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt index 54dfb67..4865de5 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt @@ -189,7 +189,12 @@ fun SettingsScreen(graph: AppGraph, devTools: List, onBack: () -> SectionTitle(stringResource(R.string.settings_section_notifications)) Card(Modifier.fillMaxWidth()) { - SwitchRow(stringResource(R.string.notify_limit), stringResource(R.string.notify_limit_body), current.notifyLimit) { setNotify(NotificationKind.LIMIT, it) } + SwitchRow( + stringResource(R.string.notify_limit), + // Without a limit there is nothing to go over: say so rather than leave a switch that never fires. + stringResource(if (current.dailyLimitMm > 0.0) R.string.notify_limit_body else R.string.notify_limit_no_limit), + current.notifyLimit, + ) { setNotify(NotificationKind.LIMIT, it) } HorizontalDivider() SwitchRow(stringResource(R.string.notify_summary), stringResource(R.string.notify_summary_body), current.notifySummary) { setNotify(NotificationKind.SUMMARY, it) } val anyOn = current.notifyLimit || current.notifySummary diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index a5eed42..77def03 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -179,6 +179,7 @@ Notifications Over the limit Once a day, when you go over your daily limit. + No daily limit is set — nothing is sent. Yesterday’s summary With the first scroll of the day: how much it was yesterday. Notifications for ScrollMeter are off in the system, so none will arrive. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88d7a6a..96e0c1a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -204,6 +204,7 @@ Oznámení Překročení limitu Jednou denně, když přejdeš svůj denní limit. + Není nastavený denní limit — nic se neposílá. Shrnutí včerejška S prvním scrollováním dne: kolik to bylo včera. Oznámení má ScrollMeter v systému vypnutá, takže nepřijde žádné. diff --git a/app/src/test/java/com/scrollmeter/app/ui/components/ChartLabelsTest.kt b/app/src/test/java/com/scrollmeter/app/ui/components/ChartLabelsTest.kt index 02cf0b5..631fb10 100644 --- a/app/src/test/java/com/scrollmeter/app/ui/components/ChartLabelsTest.kt +++ b/app/src/test/java/com/scrollmeter/app/ui/components/ChartLabelsTest.kt @@ -57,4 +57,16 @@ class ChartLabelsTest { assertThat(p(PeriodKind.MONTH, "2026-09-10")).isEqualTo("září 2026") assertThat(ChartLabels(Locale.ENGLISH).dates(Period(PeriodKind.MONTH, LocalDate.parse("2026-09-10")))).isEqualTo("September 2026") } + + @Test + fun englishDatesAreWrittenTheEnglishWay() { + val labels = ChartLabels(Locale.ENGLISH) + fun p(kind: PeriodKind, date: String) = labels.dates(Period(kind, LocalDate.parse(date))) + assertThat(p(PeriodKind.DAY, "2026-09-25")).isEqualTo("Fri 25 Sep") + assertThat(p(PeriodKind.WEEK, "2026-09-24")).isEqualTo("21–27 Sep") + assertThat(p(PeriodKind.WEEK, "2026-10-01")).isEqualTo("28 Sep – 4 Oct") + assertThat(p(PeriodKind.WEEK, "2026-01-01")).isEqualTo("29 Dec 2025 – 4 Jan 2026") + assertThat(labels.bar(DayBar(LocalDate.parse("2026-09-24"), 1.0), 30)).isEqualTo("24") + assertThat(labels.day(LocalDate.parse("2026-01-05"))).isEqualTo("5 Jan") + } } From 94f752e944b5c3a8a5d930689f470f133893c910 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Fri, 25 Sep 2026 21:19:02 +0200 Subject: [PATCH 5/5] docs: handoff links PR #12 Co-Authored-By: Claude Opus 5.5 (1M context) --- handoff.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/handoff.md b/handoff.md index f3ba750..641e6a8 100644 --- a/handoff.md +++ b/handoff.md @@ -46,7 +46,7 @@ | 6 Export + Nastavení | hotovo, mergnuto (tag `v0.6`) | `phase-6-export-settings` / [#8](https://github.com/hupcus/scrollmeter/pull/8) | ADR-030/031; oznámení a export na telefonu → „Dluh ověření“ V6; CI zablokované billingem → brána v čistém checkoutu | | 7 Onboarding + Policy | hotovo, mergnuto (tag `v0.7`) | `phase-7-onboarding-policy` / [#9](https://github.com/hupcus/scrollmeter/pull/9) | ADR-032; onboarding se sideloadem a volba jazyka na telefonu → „Dluh ověření“ V7; CI zablokované billingem → brána v čistém checkoutu | | 8 Release | hotovo, mergnuto (tag `v0.8`; GitHub Release `v0.1.0`) | `phase-8-release` / [#10](https://github.com/hupcus/scrollmeter/pull/10) | ADR-033/034/035; baterie na telefonech a ColorOS → „Dluh ověření“ V8, V9; CI zablokované billingem → brána v čistém checkoutu | -| 9 Statistiky + limit | hotovo, mergnuto (tag `v0.9`; GitHub Release `v0.2.0`) | `phase-9-ux-limit` / PR_PLACEHOLDER | ADR-036; Honzův test na telefonech → „Dluh ověření“ V10; CI zablokované billingem → brána v čistém checkoutu | +| 9 Statistiky + limit | hotovo, mergnuto (tag `v0.9`; GitHub Release `v0.2.0`) | `phase-9-ux-limit` / [#12](https://github.com/hupcus/scrollmeter/pull/12) | ADR-036; Honzův test na telefonech → „Dluh ověření“ V10; CI zablokované billingem → brána v čistém checkoutu | ## Phase 9 — exit report (2026-09-25)