From d516ad1077bd8ed36aa70e9df5b7183f2840385b Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 09:32:00 +0200 Subject: [PATCH 1/5] feat(insights): measurement quality, history series, chart scale, app ranking Pure Kotlin for Phase 5: MeasurementQuality HIGH/MEDIUM/LOW/UNKNOWN from the event counters and the calibration in force (thresholds in MeasurementConfig), history bars per day or month with per-day statistics from the first measured day, round chart axes (decimal steps for distance, clock steps for minutes), app ranking by distance or time with share and quality, the scroll share of time in app, and per-app day summaries plus the first measured day from Room. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../scrollmeter/app/data/local/ScrollDao.kt | 24 ++++++ .../scrollmeter/app/data/model/DateRange.kt | 6 ++ .../app/data/repository/ScrollRepository.kt | 17 +++++ .../scrollmeter/app/insights/AppRanking.kt | 56 ++++++++++++++ .../scrollmeter/app/insights/ChartScale.kt | 42 +++++++++++ .../scrollmeter/app/insights/HistorySeries.kt | 75 +++++++++++++++++++ .../scrollmeter/app/insights/ScrollShare.kt | 17 +++++ .../app/measurement/MeasurementConfig.kt | 13 ++++ .../app/measurement/MeasurementQuality.kt | 42 +++++++++++ .../com/scrollmeter/app/data/DateRangeTest.kt | 6 ++ .../app/data/ScrollRepositoryTest.kt | 13 ++++ .../app/insights/AppRankingTest.kt | 44 +++++++++++ .../app/insights/ChartScaleTest.kt | 41 ++++++++++ .../app/insights/HistorySeriesTest.kt | 63 ++++++++++++++++ .../app/insights/ScrollShareTest.kt | 21 ++++++ .../app/measurement/MeasurementQualityTest.kt | 49 ++++++++++++ 16 files changed, 529 insertions(+) create mode 100644 app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt create mode 100644 app/src/main/java/com/scrollmeter/app/insights/ChartScale.kt create mode 100644 app/src/main/java/com/scrollmeter/app/insights/HistorySeries.kt create mode 100644 app/src/main/java/com/scrollmeter/app/insights/ScrollShare.kt create mode 100644 app/src/main/java/com/scrollmeter/app/measurement/MeasurementQuality.kt create mode 100644 app/src/test/java/com/scrollmeter/app/insights/AppRankingTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/insights/ChartScaleTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/insights/HistorySeriesTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/insights/ScrollShareTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/measurement/MeasurementQualityTest.kt 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 99f9448..88af750 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 @@ -136,6 +136,30 @@ abstract class ScrollDao { ) abstract fun daysBetween(fromDate: String, toDate: String): Flow> + /** One app per day — the app detail's chart (spec §24): distance, scroll time and time in app. */ + @Query( + """ + SELECT date, + SUM(distanceMm) AS distanceMm, + SUM(activeScrollMs) AS activeScrollMs, + SUM(foregroundMs) AS foregroundMs + FROM ( + SELECT date, distanceMm, activeScrollMs, NULL AS foregroundMs + FROM daily_app_aggregate WHERE packageName = :packageName AND date BETWEEN :fromDate AND :toDate + UNION ALL + SELECT date, NULL, NULL, foregroundMs + FROM daily_app_usage WHERE packageName = :packageName AND date BETWEEN :fromDate AND :toDate + ) + GROUP BY date + ORDER BY date + """, + ) + abstract fun appDaysBetween(packageName: String, fromDate: String, toDate: String): Flow> + + /** The first day anything was measured — history statistics start there (spec §23). */ + @Query("SELECT MIN(date) FROM daily_app_aggregate") + abstract fun firstMeasuredDate(): Flow + @Query("SELECT * FROM daily_app_aggregate WHERE date = :date AND packageName = :packageName") abstract suspend fun aggregate(date: String, packageName: String): DailyAppAggregateEntity? diff --git a/app/src/main/java/com/scrollmeter/app/data/model/DateRange.kt b/app/src/main/java/com/scrollmeter/app/data/model/DateRange.kt index 2d121d6..4658e07 100644 --- a/app/src/main/java/com/scrollmeter/app/data/model/DateRange.kt +++ b/app/src/main/java/com/scrollmeter/app/data/model/DateRange.kt @@ -10,6 +10,12 @@ data class DateRange(val from: LocalDate, val to: LocalDate) { val toKey: String get() = to.toString() companion object { + /** + * Every stored day. Both ends are four-digit years so the keys still compare as strings + * (`LocalDate.MAX` prints "+999999999-12-31", which sorts before "2026-…"). + */ + val ALL = DateRange(LocalDate.of(1970, 1, 1), LocalDate.of(9999, 12, 31)) + fun day(date: LocalDate) = DateRange(date, date) /** Monday–Sunday (spec §19, `WeekFields.ISO`). */ 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 9edbd6e..a1da5f3 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 @@ -12,6 +12,7 @@ import com.scrollmeter.app.data.model.DateRange import com.scrollmeter.app.data.model.DaySummary import com.scrollmeter.app.usage.UsageDay import com.scrollmeter.app.usage.UsageStore +import java.time.LocalDate import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOf @@ -63,6 +64,22 @@ class ScrollRepository( merged.sortedBy { it.date } } + /** One app per day in [range] (app detail), unflushed distance of that app included. */ + fun appDays(packageName: String, range: DateRange): Flow> = + combine(dao.appDaysBetween(packageName, range.fromKey, range.toKey), unflushed) { stored, pending -> + val extra = HashMap() + pending.forEach { (key, mm) -> if (key.second == packageName && key.first in range) extra.merge(key.first, mm, Double::plus) } + if (extra.isEmpty()) return@combine stored + val merged = stored.map { day -> extra.remove(day.date)?.let { day.copy(distanceMm = (day.distanceMm ?: 0.0) + it) } ?: day } + + extra.map { (date, mm) -> DaySummary(date, mm, null, null) } + merged.sortedBy { it.date } + } + + /** The first measured day, or null before anything was stored; a day only in memory counts too. */ + fun firstMeasuredDay(): Flow = combine(dao.firstMeasuredDate(), unflushed) { stored, pending -> + (listOfNotNull(stored) + pending.keys.map { it.first }).minOrNull()?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + } + private operator fun DateRange.contains(date: String): Boolean = date in fromKey..toKey private fun entity(d: AggregateDelta) = DailyAppAggregateEntity( diff --git a/app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt b/app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt new file mode 100644 index 0000000..e0d9a96 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt @@ -0,0 +1,56 @@ +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). 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. + */ +object AppRanking { + fun rank(apps: List, sort: AppSort, usageGranted: Boolean, calibration: CalibrationConfidence): 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) { + MeasurementQualityRater.rate( + app.measuredEventCount ?: 0, app.fallbackEventCount ?: 0, app.unmeasurableEventCount ?: 0, + app.rejectedOutlierCount ?: 0, 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 }) + } +} diff --git a/app/src/main/java/com/scrollmeter/app/insights/ChartScale.kt b/app/src/main/java/com/scrollmeter/app/insights/ChartScale.kt new file mode 100644 index 0000000..d5587e2 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/insights/ChartScale.kt @@ -0,0 +1,42 @@ +package com.scrollmeter.app.insights + +import kotlin.math.ceil +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.pow + +/** + * The y axis of a bar chart (D13, spec §23): a round maximum and evenly spaced grid lines, so the + * labels read "0 · 100 m · 200 m …" instead of "0 · 106 m · 212 m". Steps are 1, 2, 2,5 or 5 × 10ⁿ + * with about four intervals. Pure Kotlin; values in any unit. + */ +data class ChartScale(val max: Double, val step: Double) { + val gridLines: List get() = (0..Math.round(max / step).toInt()).map { it * step } + + /** 0..1 of the plot height; values above [max] are clipped. */ + fun fraction(value: Double): Float = if (max <= 0) 0f else (value / max).coerceIn(0.0, 1.0).toFloat() + + companion object { + private val NICE = doubleArrayOf(1.0, 2.0, 2.5, 5.0, 10.0) + + /** Minute steps that read as clock time ("15 min", "30 min", "1 h", "2 h"), never "2,5 min". */ + private val MINUTE_STEPS = doubleArrayOf(1.0, 2.0, 5.0, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0, 180.0, 240.0, 360.0, 480.0, 720.0) + + /** A time axis in minutes; [emptyMax] as in [of]. Above the last step it keeps the 12 h step. */ + fun ofMinutes(maxMinutes: Double, emptyMax: Double, intervals: Int = 4): ChartScale { + val top = if (maxMinutes.isFinite() && maxMinutes > 0) maxMinutes else emptyMax + val raw = top / intervals + val step = MINUTE_STEPS.firstOrNull { it >= raw - 1e-9 * raw } ?: MINUTE_STEPS.last() + return ChartScale(ceil(top / step - 1e-9) * step, step) + } + + /** [emptyMax] is the axis when every value is 0 (e.g. 1 m in millimetres). */ + fun of(maxValue: Double, emptyMax: Double, intervals: Int = 4): ChartScale { + val top = if (maxValue.isFinite() && maxValue > 0) maxValue else emptyMax + val raw = top / intervals + val magnitude = 10.0.pow(floor(log10(raw))) + val step = NICE.first { it * magnitude >= raw - 1e-9 * raw } * magnitude + return ChartScale(ceil(top / step - 1e-9) * step, step) + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/insights/HistorySeries.kt b/app/src/main/java/com/scrollmeter/app/insights/HistorySeries.kt new file mode 100644 index 0000000..d981bcc --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/insights/HistorySeries.kt @@ -0,0 +1,75 @@ +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?) + +/** + * 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. + */ +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) + 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)) + } + + private fun stats(allDays: List, byDate: Map, first: LocalDate?, today: LocalDate): HistoryStats? { + if (first == null || first.isAfter(today)) return null + val counted = allDays.filter { !it.isBefore(first) }.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/ScrollShare.kt b/app/src/main/java/com/scrollmeter/app/insights/ScrollShare.kt new file mode 100644 index 0000000..b9dddb3 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/insights/ScrollShare.kt @@ -0,0 +1,17 @@ +package com.scrollmeter.app.insights + +import kotlin.math.roundToInt + +/** + * "Scrolluješ X min z Y min v aplikaci (Z %)" (D19). The two times come from different sources — + * scroll time from our events (ADR-022), time in app from UsageStats (ADR-021) — so the share is + * clamped to 0–100. Null when either is unknown or the time in app is under a minute. Pure Kotlin. + */ +object ScrollShare { + private const val MIN_FOREGROUND_MS = 60_000L + + fun percent(activeScrollMs: Long?, foregroundMs: Long?): Int? { + if (activeScrollMs == null || foregroundMs == null || foregroundMs < MIN_FOREGROUND_MS || activeScrollMs < 0) return null + return (activeScrollMs.toDouble() / foregroundMs * 100).roundToInt().coerceIn(0, 100) + } +} 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 871f49e..9dd0cc7 100644 --- a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt +++ b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementConfig.kt @@ -80,6 +80,19 @@ object MeasurementConfig { */ const val USAGE_SAME_APP_GRACE_MS = 2_000L + /** + * Measurement quality per app (spec §20, ADR-029). Under [QUALITY_MIN_EVENTS] stored events there is + * too little to judge. LOW when at least half the events carried no usable pixels or 5 % were + * outliers; HIGH when ≥ 90 % of counted events were direct deltas, < 10 % unmeasurable, < 1 % + * outliers and the display is calibrated with a card; MEDIUM otherwise. + */ + const val QUALITY_MIN_EVENTS = 20L + const val QUALITY_LOW_UNMEASURABLE_SHARE = 0.5 + const val QUALITY_LOW_OUTLIER_SHARE = 0.05 + const val QUALITY_HIGH_DIRECT_SHARE = 0.9 + const val QUALITY_HIGH_MAX_UNMEASURABLE_SHARE = 0.1 + const val QUALITY_HIGH_MAX_OUTLIER_SHARE = 0.01 + /** ISO/IEC 7810 ID-1 card width for manual calibration — used from Phase 2 (spec §8). */ const val CARD_WIDTH_MM = 85.60 const val MM_PER_INCH = 25.4 diff --git a/app/src/main/java/com/scrollmeter/app/measurement/MeasurementQuality.kt b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementQuality.kt new file mode 100644 index 0000000..b1f4ebf --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/measurement/MeasurementQuality.kt @@ -0,0 +1,42 @@ +package com.scrollmeter.app.measurement + +import com.scrollmeter.app.calibration.CalibrationConfidence + +/** Spec §20: a word, never a made-up accuracy percentage. UNKNOWN = too few events to judge. */ +enum class MeasurementQuality { HIGH, MEDIUM, LOW, UNKNOWN } + +/** + * Rates how trustworthy an app's stored distance is from its event counters and the calibration + * in force (spec §20, ADR-029). Pure Kotlin; thresholds in [MeasurementConfig]. + * + * - LOW: most events carried no usable pixels, or outliers are frequent — the app does not report + * its scrolling in a way we can measure well. + * - HIGH: almost everything counted came from direct deltas, little was unmeasurable or rejected, + * and the display is calibrated with a card. + * - MEDIUM: everything between — typically the automatic xdpi/ydpi scale or a frequent position fallback. + */ +object MeasurementQualityRater { + fun rate( + measured: Long, + fallback: Long, + unmeasurable: Long, + outliers: Long, + calibration: CalibrationConfidence, + ): MeasurementQuality { + val total = measured + fallback + unmeasurable + outliers + if (total < MeasurementConfig.QUALITY_MIN_EVENTS) return MeasurementQuality.UNKNOWN + val unmeasurableShare = unmeasurable.toDouble() / total + val outlierShare = outliers.toDouble() / total + val counted = measured + fallback + val directShare = if (counted == 0L) 0.0 else measured.toDouble() / counted + return when { + unmeasurableShare >= MeasurementConfig.QUALITY_LOW_UNMEASURABLE_SHARE || + outlierShare >= MeasurementConfig.QUALITY_LOW_OUTLIER_SHARE -> MeasurementQuality.LOW + directShare >= MeasurementConfig.QUALITY_HIGH_DIRECT_SHARE && + unmeasurableShare < MeasurementConfig.QUALITY_HIGH_MAX_UNMEASURABLE_SHARE && + outlierShare < MeasurementConfig.QUALITY_HIGH_MAX_OUTLIER_SHARE && + calibration == CalibrationConfidence.HIGH -> MeasurementQuality.HIGH + else -> MeasurementQuality.MEDIUM + } + } +} diff --git a/app/src/test/java/com/scrollmeter/app/data/DateRangeTest.kt b/app/src/test/java/com/scrollmeter/app/data/DateRangeTest.kt index e922000..e5987e5 100644 --- a/app/src/test/java/com/scrollmeter/app/data/DateRangeTest.kt +++ b/app/src/test/java/com/scrollmeter/app/data/DateRangeTest.kt @@ -23,4 +23,10 @@ class DateRangeTest { fun keysAreIsoDates() { assertThat(DateRange.day(LocalDate.parse("2026-01-05")).fromKey).isEqualTo("2026-01-05") } + + @Test + fun allCoversEveryPlausibleDayAsAStringRange() { + assertThat(DateRange.ALL.fromKey < "2000-01-01").isTrue() + assertThat(DateRange.ALL.toKey > "2999-12-31").isTrue() + } } 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 a9939d5..ea38ed5 100644 --- a/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt +++ b/app/src/test/java/com/scrollmeter/app/data/ScrollRepositoryTest.kt @@ -161,5 +161,18 @@ class ScrollRepositoryTest { assertThat(days.map { it.date to it.distanceMm }).containsExactly("2026-09-20" to 1.0, "2026-09-21" to 42.0, "2026-09-22" to 5.0).inOrder() } + @Test + fun oneAppPerDayJoinsDistanceAndTimeAndTheFirstMeasuredDay() = runBlocking { + assertThat(repository.firstMeasuredDay().first()).isNull() + repository.write(listOf(delta(date = "2026-09-20", pkg = "a", mm = 3.0, activeMs = 100), delta(date = "2026-09-21", pkg = "b", mm = 9.0)), emptyList()) + repository.replaceDays("2026-09-20", "2026-09-22", listOf(usage("2026-09-22", "a", 60_000)), 1) + unflushed.value = mapOf(("2026-09-21" to "a") to 1.5, ("2026-09-19" to "b") to 1.0) + val days = repository.appDays("a", DateRange(LocalDate.parse("2026-09-20"), LocalDate.parse("2026-09-22"))).first() + assertThat(days.map { Triple(it.date, it.distanceMm, it.foregroundMs) }) + .containsExactly(Triple("2026-09-20", 3.0, null), Triple("2026-09-21", 1.5, null), Triple("2026-09-22", null, 60_000L)).inOrder() + assertThat(days[0].activeScrollMs).isEqualTo(100) + assertThat(repository.firstMeasuredDay().first()).isEqualTo(LocalDate.parse("2026-09-19")) // only in memory so far + } + private fun usage(date: String, pkg: String, ms: Long) = UsageDay(date, pkg, ms, 1, null) } diff --git a/app/src/test/java/com/scrollmeter/app/insights/AppRankingTest.kt b/app/src/test/java/com/scrollmeter/app/insights/AppRankingTest.kt new file mode 100644 index 0000000..ef2e81c --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/insights/AppRankingTest.kt @@ -0,0 +1,44 @@ +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. */ +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() + } + + @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() + } + + @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) + } +} diff --git a/app/src/test/java/com/scrollmeter/app/insights/ChartScaleTest.kt b/app/src/test/java/com/scrollmeter/app/insights/ChartScaleTest.kt new file mode 100644 index 0000000..3616cdd --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/insights/ChartScaleTest.kt @@ -0,0 +1,41 @@ +package com.scrollmeter.app.insights + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** D13: round axis maxima and grid lines. */ +class ChartScaleTest { + @Test + fun roundMaximaWithAboutFourIntervals() { + val s = ChartScale.of(423.0, 1.0) + assertThat(s.step).isEqualTo(200.0) + assertThat(s.max).isEqualTo(600.0) + assertThat(s.gridLines).containsExactly(0.0, 200.0, 400.0, 600.0).inOrder() + assertThat(ChartScale.of(1000.0, 1.0)).isEqualTo(ChartScale(1000.0, 250.0)) + assertThat(ChartScale.of(2_840_000.0, 1.0)).isEqualTo(ChartScale(3_000_000.0, 1_000_000.0)) + assertThat(ChartScale.of(7.0, 1.0)).isEqualTo(ChartScale(8.0, 2.0)) + } + + @Test + fun emptyDataUsesTheGivenAxis() { + assertThat(ChartScale.of(0.0, 1_000.0)).isEqualTo(ChartScale(1_000.0, 250.0)) + assertThat(ChartScale.of(Double.NaN, 1_000.0)).isEqualTo(ChartScale(1_000.0, 250.0)) + } + + @Test + fun fractionIsClipped() { + val s = ChartScale(100.0, 25.0) + assertThat(s.fraction(50.0)).isEqualTo(0.5f) + assertThat(s.fraction(150.0)).isEqualTo(1f) + assertThat(s.fraction(-1.0)).isEqualTo(0f) + } + + @Test + fun minuteAxesUseClockSteps() { + assertThat(ChartScale.ofMinutes(47.0, 10.0)).isEqualTo(ChartScale(60.0, 15.0)) + assertThat(ChartScale.ofMinutes(9.0, 10.0)).isEqualTo(ChartScale(10.0, 5.0)) + assertThat(ChartScale.ofMinutes(200.0, 10.0)).isEqualTo(ChartScale(240.0, 60.0)) + assertThat(ChartScale.ofMinutes(0.0, 10.0)).isEqualTo(ChartScale(10.0, 5.0)) + assertThat(ChartScale.ofMinutes(9_000.0, 10.0)).isEqualTo(ChartScale(9_360.0, 720.0)) + } +} diff --git a/app/src/test/java/com/scrollmeter/app/insights/HistorySeriesTest.kt b/app/src/test/java/com/scrollmeter/app/insights/HistorySeriesTest.kt new file mode 100644 index 0000000..139225b --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/insights/HistorySeriesTest.kt @@ -0,0 +1,63 @@ +package com.scrollmeter.app.insights + +import com.google.common.truth.Truth.assertThat +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) + private fun d(s: String) = LocalDate.parse(s) + + @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 + } + + @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)) + } + + @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) + } + + @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) + } +} diff --git a/app/src/test/java/com/scrollmeter/app/insights/ScrollShareTest.kt b/app/src/test/java/com/scrollmeter/app/insights/ScrollShareTest.kt new file mode 100644 index 0000000..39b8435 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/insights/ScrollShareTest.kt @@ -0,0 +1,21 @@ +package com.scrollmeter.app.insights + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** D19: the share of the time in app spent scrolling. */ +class ScrollShareTest { + @Test + fun shareIsRoundedAndClamped() { + assertThat(ScrollShare.percent(90_000, 600_000)).isEqualTo(15) + assertThat(ScrollShare.percent(700_000, 600_000)).isEqualTo(100) + assertThat(ScrollShare.percent(0, 600_000)).isEqualTo(0) + } + + @Test + fun unknownOrTooShortHasNoShare() { + assertThat(ScrollShare.percent(null, 600_000)).isNull() + assertThat(ScrollShare.percent(10_000, null)).isNull() + assertThat(ScrollShare.percent(10_000, 59_999)).isNull() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/measurement/MeasurementQualityTest.kt b/app/src/test/java/com/scrollmeter/app/measurement/MeasurementQualityTest.kt new file mode 100644 index 0000000..739e462 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/measurement/MeasurementQualityTest.kt @@ -0,0 +1,49 @@ +package com.scrollmeter.app.measurement + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.calibration.CalibrationConfidence.HIGH +import com.scrollmeter.app.calibration.CalibrationConfidence.MEDIUM +import org.junit.Test + +/** Spec §20 / ADR-029 thresholds. */ +class MeasurementQualityTest { + private fun rate(measured: Long, fallback: Long = 0, unmeasurable: Long = 0, outliers: Long = 0, card: Boolean = true) = + MeasurementQualityRater.rate(measured, fallback, unmeasurable, outliers, if (card) HIGH else MEDIUM) + + @Test + fun tooFewEventsIsUnknown() { + assertThat(rate(19)).isEqualTo(MeasurementQuality.UNKNOWN) + assertThat(rate(20)).isEqualTo(MeasurementQuality.HIGH) + } + + @Test + fun directDeltasWithACardAreHigh() { + assertThat(rate(90, fallback = 10)).isEqualTo(MeasurementQuality.HIGH) + assertThat(rate(89, fallback = 11)).isEqualTo(MeasurementQuality.MEDIUM) + } + + @Test + fun theAutomaticScaleIsAtBestMedium() { + assertThat(rate(100, card = false)).isEqualTo(MeasurementQuality.MEDIUM) + } + + @Test + fun frequentFallbackIsMedium() { + assertThat(rate(20, fallback = 80)).isEqualTo(MeasurementQuality.MEDIUM) + } + + @Test + fun mostlyUnmeasurableOrManyOutliersIsLow() { + assertThat(rate(50, unmeasurable = 50)).isEqualTo(MeasurementQuality.LOW) + assertThat(rate(51, unmeasurable = 49)).isEqualTo(MeasurementQuality.MEDIUM) + assertThat(rate(95, outliers = 5)).isEqualTo(MeasurementQuality.LOW) + assertThat(rate(0, unmeasurable = 40)).isEqualTo(MeasurementQuality.LOW) + } + + @Test + fun highNeedsFewUnmeasurableAndOutliers() { + assertThat(rate(90, unmeasurable = 10)).isEqualTo(MeasurementQuality.MEDIUM) // 10 % unmeasurable + assertThat(rate(99, outliers = 1)).isEqualTo(MeasurementQuality.MEDIUM) // 1 % outliers + assertThat(rate(199, outliers = 1)).isEqualTo(MeasurementQuality.HIGH) // 0,5 % + } +} From 192496fb0a0acf49e381227f2ea62919f01f3e55 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 09:32:01 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(ui):=20Historie,=20Aplikace,=20app=20d?= =?UTF-8?q?etail,=20Nastaven=C3=AD=20and=20the=20bottom=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation Compose 2.9.8 with @Serializable routes and a bottom bar Přehled / Historie / Aplikace / Nastavení. Historie: 7 / 30 days and 12 months in a custom Canvas bar chart with tap-to-read bars and per-day statistics. Aplikace: Dnes / 7 dní / 30 dní / Celkem, sort by distance or time, share and a quality word; time-only apps show "—" and "bez dat o scrollu". The detail shows the period, quality with its explanation and 30-day distance and time charts. Nastavení holds the service and usage-access status, Přesnost měření and, in debug builds, the developer screens that left Přehled. ADR-029. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/build.gradle.kts | 4 + .../java/com/scrollmeter/app/MainActivity.kt | 58 +---- .../scrollmeter/app/apps/AppInfoProvider.kt | 5 +- .../app/ui/apps/AppDetailScreen.kt | 201 ++++++++++++++++++ .../com/scrollmeter/app/ui/apps/AppsScreen.kt | 151 +++++++++++++ .../app/ui/components/AppIdentity.kt | 44 ++++ .../scrollmeter/app/ui/components/BarChart.kt | 113 ++++++++++ .../app/ui/components/ChartLabels.kt | 56 +++++ .../app/ui/components/LiveState.kt | 75 +++++++ .../app/ui/components/PeriodSelector.kt | 26 +++ .../app/ui/dashboard/DashboardScreen.kt | 86 ++------ .../app/ui/history/HistoryScreen.kt | 132 ++++++++++++ .../app/ui/navigation/ScrollMeterNavHost.kt | 154 ++++++++++++++ .../app/ui/settings/SettingsScreen.kt | 112 ++++++++++ app/src/main/res/drawable/ic_back.xml | 12 ++ .../main/res/drawable/ic_chevron_right.xml | 12 ++ 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 ++ app/src/main/res/drawable/ic_nav_settings.xml | 12 ++ app/src/main/res/values/strings.xml | 55 +++++ .../app/ui/components/ChartLabelsTest.kt | 45 ++++ docs/architecture.md | 23 +- docs/measurement-decisions.md | 1 + 24 files changed, 1282 insertions(+), 131 deletions(-) create mode 100644 app/src/main/java/com/scrollmeter/app/ui/apps/AppDetailScreen.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/apps/AppsScreen.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/components/AppIdentity.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/components/BarChart.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/components/ChartLabels.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/components/LiveState.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/components/PeriodSelector.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/history/HistoryScreen.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/res/drawable/ic_back.xml create mode 100644 app/src/main/res/drawable/ic_chevron_right.xml create mode 100644 app/src/main/res/drawable/ic_nav_apps.xml create mode 100644 app/src/main/res/drawable/ic_nav_history.xml create mode 100644 app/src/main/res/drawable/ic_nav_overview.xml create mode 100644 app/src/main/res/drawable/ic_nav_settings.xml create mode 100644 app/src/test/java/com/scrollmeter/app/ui/components/ChartLabelsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 28eb9d0..bcec69e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -4,6 +4,7 @@ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.ksp) } @@ -74,6 +75,9 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.activity.compose) + // D14: bottom navigation with type-safe (@Serializable) routes, from Phase 5. + implementation(libs.androidx.navigation.compose) + implementation(libs.kotlinx.serialization.json) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) diff --git a/app/src/main/java/com/scrollmeter/app/MainActivity.kt b/app/src/main/java/com/scrollmeter/app/MainActivity.kt index a06bb31..872e0ce 100644 --- a/app/src/main/java/com/scrollmeter/app/MainActivity.kt +++ b/app/src/main/java/com/scrollmeter/app/MainActivity.kt @@ -5,25 +5,16 @@ import android.content.Intent import android.os.Bundle import android.provider.Settings import androidx.activity.ComponentActivity -import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import com.scrollmeter.app.devtools.DevTools -import com.scrollmeter.app.ui.calibration.AccuracyScreen -import com.scrollmeter.app.ui.calibration.CalibrationScreen import com.scrollmeter.app.settings.ThemePreference -import com.scrollmeter.app.ui.dashboard.DashboardScreen +import com.scrollmeter.app.ui.navigation.ScrollMeterNavHost import com.scrollmeter.app.ui.theme.ScrollMeterTheme -import com.scrollmeter.app.ui.usage.UsageAccessScreen import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -40,9 +31,9 @@ class MainActivity : ComponentActivity() { else -> isSystemInDarkTheme() } ScrollMeterTheme(darkTheme = dark) { - ScrollMeterApp( + ScrollMeterNavHost( graph = graph, - initialTool = DevTools.toolFromLaunch(intent), + initialDevTool = DevTools.entries.getOrNull(DevTools.toolFromLaunch(intent))?.key, onOpenAccessibilitySettings = ::openAccessibilitySettings, ) } @@ -70,46 +61,3 @@ class MainActivity : ComponentActivity() { } } } - -/** Until the bottom navigation of Phase 5 (spec §43, D14) the screens switch on a saveable enum. */ -private enum class Screen { HOME, ACCURACY, CALIBRATION, USAGE_ACCESS } - -/** - * Přehled → Přesnost → Kalibrace, and Přehled → Čas v aplikacích. Saving or skipping a calibration - * shows the result on Přesnost. Debug builds add the developer screens. - */ -@Composable -private fun ScrollMeterApp(graph: AppGraph, initialTool: Int, onOpenAccessibilitySettings: () -> Unit) { - val devTools = DevTools.entries - var openTool by rememberSaveable { mutableIntStateOf(initialTool) } - var screen by rememberSaveable { mutableStateOf(Screen.HOME) } - val tool = devTools.getOrNull(openTool) - when { - tool != null -> { - BackHandler { openTool = NO_TOOL } - tool.content(graph) { openTool = NO_TOOL } - } - screen == Screen.CALIBRATION -> { - BackHandler { screen = Screen.ACCURACY } - CalibrationScreen(graph, onBack = { screen = Screen.ACCURACY }, onSaved = { screen = Screen.ACCURACY }) - } - screen == Screen.ACCURACY -> { - BackHandler { screen = Screen.HOME } - AccuracyScreen(graph, onBack = { screen = Screen.HOME }, onCalibrate = { screen = Screen.CALIBRATION }) - } - screen == Screen.USAGE_ACCESS -> { - BackHandler { screen = Screen.HOME } - UsageAccessScreen(graph, onGranted = { screen = Screen.HOME }, onBack = { screen = Screen.HOME }) - } - else -> DashboardScreen( - graph = graph, - devTools = devTools, - onOpenAccessibilitySettings = onOpenAccessibilitySettings, - onOpenUsageAccess = { screen = Screen.USAGE_ACCESS }, - onOpenAccuracy = { screen = Screen.ACCURACY }, - onOpenDevTool = { openTool = it }, - ) - } -} - -private const val NO_TOOL = -1 diff --git a/app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt b/app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt index 7ad1ae6..ff9d401 100644 --- a/app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt +++ b/app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt @@ -20,7 +20,8 @@ class AppInfoProvider(private val context: Context) { private val cache = LruCache(64) fun load(packageName: String, iconSizePx: Int): AppInfo { - cache.get(packageName)?.let { return it } + val key = "$packageName@$iconSizePx" // the list and the detail draw different sizes + cache.get(key)?.let { return it } val pm = context.packageManager val info = try { val app = pm.getApplicationInfo(packageName, 0) @@ -32,7 +33,7 @@ class AppInfoProvider(private val context: Context) { // Spec §15: a package we cannot read must never crash the screen. AppInfo(packageName, packageName, null) } - cache.put(packageName, info) + cache.put(key, info) return info } } 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 new file mode 100644 index 0000000..ca5b195 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/apps/AppDetailScreen.kt @@ -0,0 +1,201 @@ +package com.scrollmeter.app.ui.apps + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +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.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.AppSummary +import com.scrollmeter.app.format.DistanceFormatter +import com.scrollmeter.app.format.TimeFormatter +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.ScrollShare +import com.scrollmeter.app.measurement.MeasurementQualityRater +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 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). + */ +@Composable +fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { + val locale = appLocale() + val labels = remember(locale) { ChartLabels(locale) } + val today = rememberToday() + val usageGranted = rememberUsageGranted(graph) + 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 summary by remember(range, packageName) { + graph.scrollRepository.apps(range).map { apps -> apps.firstOrNull { it.packageName == packageName } } + }.collectAsStateWithLifecycle(initialValue = null) + val chartRange = HistorySeriesBuilder.range(HistoryPeriod.DAYS_30, today) + val days by remember(chartRange, packageName) { graph.scrollRepository.appDays(packageName, chartRange) } + .collectAsStateWithLifecycle(initialValue = emptyList()) + val first by remember { graph.scrollRepository.firstMeasuredDay() }.collectAsStateWithLifecycle(initialValue = null) + + fun distance(mm: Double) = DistanceFormatter.format(mm, unit, locale) + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { Icon(painterResource(R.drawable.ic_back), contentDescription = stringResource(R.string.back)) } + 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)) + } + PeriodSelector(AppsPeriod.entries, period, { stringResource(appsPeriodLabel(it)) }, { period = it }) + PeriodSummary(summary, usageGranted, ::distance, locale) + + val app = summary + if (app?.distanceMm != null) { + val quality = MeasurementQualityRater.rate( + app.measuredEventCount ?: 0, app.fallbackEventCount ?: 0, app.unmeasurableEventCount ?: 0, app.rejectedOutlierCount ?: 0, calibration, + ) + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(qualityLabel(quality)), style = MaterialTheme.typography.titleSmall) + Text(stringResource(qualityInfo(quality)), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + + val distanceSeries = remember(days, today, first) { HistorySeriesBuilder.build(HistoryPeriod.DAYS_30, today, days, first) } + ChartCard( + title = stringResource(R.string.app_detail_distance_chart), + values = distanceSeries.bars.map { it.value }, + scale = remember(distanceSeries) { ChartLabels.distanceScale(distanceSeries.bars.maxOf { it.value }) }, + axisLabel = { ChartLabels.distanceAxis(it, unit, locale) }, + barLabel = { labels.bar(HistoryPeriod.DAYS_30, distanceSeries.bars[it]) }, + selectedText = { i -> stringResource(R.string.history_day_value, labels.day(distanceSeries.bars[i].start), distance(distanceSeries.bars[i].value)) }, + ) + val timeSeries = remember(days, today, first, usageGranted) { + HistorySeriesBuilder.build(HistoryPeriod.DAYS_30, today, days, first) { + (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 }) }, + axisLabel = ChartLabels::minutesAxis, + barLabel = { labels.bar(HistoryPeriod.DAYS_30, timeSeries.bars[it]) }, + selectedText = { i -> + stringResource(R.string.history_day_value, labels.day(timeSeries.bars[i].start), TimeFormatter.duration(timeSeries.bars[i].value.toLong())) + }, + ) + } +} + +/** + * 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. + */ +@Composable +private fun PeriodSummary(app: AppSummary?, 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 (usageGranted) { + HorizontalDivider() + SummaryRow(stringResource(R.string.app_detail_time_in_app), TimeFormatter.duration(app?.foregroundMs)) + } + HorizontalDivider() + SummaryRow(stringResource(R.string.app_detail_scroll_time), TimeFormatter.duration(app?.activeScrollMs)) + if (usageGranted) { + HorizontalDivider() + SummaryRow(stringResource(R.string.app_detail_pace), TimeFormatter.pace(app?.distanceMm, app?.foregroundMs, locale)) + } + val share = if (usageGranted) ScrollShare.percent(app?.activeScrollMs, app?.foregroundMs) else null + val note = when { + app != null && app.distanceMm == null && app.foregroundMs != null -> stringResource(R.string.app_detail_no_scroll) + share != null -> stringResource( + R.string.app_detail_scroll_share, + TimeFormatter.duration(app?.activeScrollMs), + TimeFormatter.duration(app?.foregroundMs), + share.toString(), + ) + else -> null + } + if (note != null) { + HorizontalDivider() + Text(note, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 10.dp)) + } + } + } +} + +@Composable +private fun SummaryRow(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 ChartCard( + title: String, + values: List, + scale: ChartScale, + axisLabel: (Double) -> String, + barLabel: (Int) -> String, + selectedText: @Composable (Int) -> String, +) { + var selected by rememberSaveable(values.size) { mutableStateOf(null) } + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(selected?.takeIf { it in values.indices }?.let { selectedText(it) } ?: title, style = MaterialTheme.typography.titleSmall) + BarChart(values, scale, axisLabel, barLabel, contentDescription = title, selected = selected, onSelect = { selected = it }, height = 160.dp) + } + } +} 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 new file mode 100644 index 0000000..69dea6b --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/apps/AppsScreen.kt @@ -0,0 +1,151 @@ +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.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) + val ranked = remember(apps, sort, usageGranted, calibration) { + apps?.let { AppRanking.rank(it, sort, usageGranted, calibration) } + } + + 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, 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/components/AppIdentity.kt b/app/src/main/java/com/scrollmeter/app/ui/components/AppIdentity.kt new file mode 100644 index 0000000..5c6a12a --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/components/AppIdentity.kt @@ -0,0 +1,44 @@ +package com.scrollmeter.app.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.scrollmeter.app.apps.AppInfo +import com.scrollmeter.app.apps.AppInfoProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** Label and icon of [packageName], loaded off the main thread; null while loading or for [skip]. */ +@Composable +fun rememberAppInfo(provider: AppInfoProvider, packageName: String, size: Dp, skip: Boolean = false): State { + val iconPx = with(LocalDensity.current) { size.roundToPx() } + return produceState(initialValue = null, packageName, iconPx, skip) { + value = if (skip) null else withContext(Dispatchers.IO) { provider.load(packageName, iconPx) } + } +} + +/** The app's icon, or its initial in a circle when the icon is unknown (spec §15). */ +@Composable +fun AppIcon(info: AppInfo?, label: String, size: Dp = 36.dp) { + val icon = info?.icon + if (icon != null) { + Image(icon, contentDescription = null, modifier = Modifier.size(size)) + } else { + Box(Modifier.size(size).clip(CircleShape).background(MaterialTheme.colorScheme.secondaryContainer), contentAlignment = Alignment.Center) { + Text(label.take(1).uppercase(), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSecondaryContainer) + } + } +} 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 new file mode 100644 index 0000000..5ed33c6 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/components/BarChart.kt @@ -0,0 +1,113 @@ +package com.scrollmeter.app.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +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.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.scrollmeter.app.insights.ChartScale +import kotlin.math.ceil +import kotlin.math.max + +/** + * A plain bar chart (D13, ADR-012, spec §23): y grid lines from [scale] labelled by [axisLabel], + * 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. + */ +@Composable +fun BarChart( + values: List, + scale: ChartScale, + axisLabel: (Double) -> String, + barLabel: (Int) -> String, + contentDescription: String, + selected: Int?, + onSelect: (Int?) -> Unit, + modifier: Modifier = Modifier, + height: Dp = 200.dp, +) { + val measurer = rememberTextMeasurer() + // The tap handler outlives recompositions; it must see the current selection and callback. + val currentSelected by rememberUpdatedState(selected) + val currentOnSelect by rememberUpdatedState(onSelect) + 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) } } + val xLabels = remember(values.size, barLabel, style) { List(values.size) { measurer.measure(barLabel(it), style) } } + val axisWidth = (axisLabels.maxOfOrNull { it.size.width } ?: 0) + gapPx + val xLabelHeight = xLabels.maxOfOrNull { it.size.height } ?: 0 + val topPad = (axisLabels.firstOrNull()?.size?.height ?: 0) / 2f + + Canvas( + modifier + .fillMaxWidth() + .height(height) + .semantics { this.contentDescription = contentDescription } + .pointerInput(values.size, axisWidth) { + detectTapGestures { offset -> + val slot = (size.width - axisWidth) / values.size.coerceAtLeast(1) + val index = ((offset.x - axisWidth) / slot).toInt() + currentOnSelect(if (offset.x < axisWidth || index !in values.indices || index == currentSelected) null else index) + } + }, + ) { + val plotLeft = axisWidth + val plotTop = topPad + val plotBottom = size.height - xLabelHeight - gapPx / 2 + val plotHeight = (plotBottom - plotTop).coerceAtLeast(1f) + val stroke = 1.dp.toPx() + + scale.gridLines.forEachIndexed { i, line -> + val y = plotBottom - scale.fraction(line) * plotHeight + drawLine(grid, Offset(plotLeft, y), Offset(size.width, y), strokeWidth = stroke) + val label = axisLabels[i] + drawText(label, topLeft = Offset(plotLeft - gapPx - label.size.width, y - label.size.height / 2f)) + } + + if (values.isEmpty()) return@Canvas + val slot = (size.width - plotLeft) / values.size + val barWidth = max(slot * 0.6f, 2.dp.toPx()) + val radius = CornerRadius(minOf(barWidth / 2, 4.dp.toPx())) + values.forEachIndexed { i, value -> + val h = scale.fraction(value) * plotHeight + if (h <= 0f) return@forEachIndexed + val x = plotLeft + i * slot + (slot - barWidth) / 2 + drawRoundRect( + color = if (selected == null || selected == i) bar else dimmed, + topLeft = Offset(x, plotBottom - h), + size = Size(barWidth, h), + cornerRadius = radius, + ) + } + + val widest = xLabels.maxOfOrNull { it.size.width } ?: 0 + val every = ceil((widest + gapPx) / slot).toInt().coerceAtLeast(1) + xLabels.forEachIndexed { i, label -> + if ((values.lastIndex - i) % every != 0 || label.size.width == 0) return@forEachIndexed + val center = plotLeft + i * slot + slot / 2 + val x = (center - label.size.width / 2f).coerceIn(plotLeft, size.width - label.size.width) + drawText(label, topLeft = Offset(x, plotBottom + gapPx / 2)) + } + } +} 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 new file mode 100644 index 0000000..d564325 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/components/ChartLabels.kt @@ -0,0 +1,56 @@ +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.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. */ +class ChartLabels(locale: Locale) { + private val weekday = DateTimeFormatter.ofPattern("EEE", 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 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) + + /** A day in a statistic: "24. 9." */ + fun day(date: LocalDate): String = dayMonth.format(date) + + 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) + + /** + * Grid values are round, so without the fixed decimals of `DistanceFormatter`: "250 m", "2 km", + * "2,5 km". The unit follows the preference; AUTOMATIC switches to km at 1 km. + */ + fun distanceAxis(mm: Double, unit: UnitPreference, locale: Locale): String { + if (mm == 0.0) return "0" + val metres = mm / 1_000.0 + val km = unit == UnitPreference.KILOMETRES || (unit == UnitPreference.AUTOMATIC && metres >= 1_000) + val number = NumberFormat.getNumberInstance(locale).apply { maximumFractionDigits = 3 } + return if (km) "${number.format(metres / 1_000.0)} km" else "${number.format(metres)} m" + } + + /** Time axes run in minutes so the steps read as clock time. */ + fun minutesScale(maxMs: Double): ChartScale = ChartScale.ofMinutes(maxMs / 60_000.0, emptyMax = 10.0) + + fun minutesAxis(minutes: Double): String = if (minutes == 0.0) "0" else TimeFormatter.duration(Math.round(minutes * 60_000.0)) + } +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/components/LiveState.kt b/app/src/main/java/com/scrollmeter/app/ui/components/LiveState.kt new file mode 100644 index 0000000..bfb389e --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/components/LiveState.kt @@ -0,0 +1,75 @@ +package com.scrollmeter.app.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalConfiguration +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.scrollmeter.app.AppGraph +import com.scrollmeter.app.calibration.CalibrationConfidence +import com.scrollmeter.app.measurement.PhysicalScaleProvider +import java.time.LocalDate +import java.time.ZoneId +import kotlinx.coroutines.delay + +/** Spec §32: never claim data is being collected unless the service is actually running. */ +enum class ServiceStatus { ON, ENABLED_NOT_RUNNING, OFF } + +/** Connected = running; otherwise whether it is switched on in the system settings, re-read on resume. */ +@Composable +fun rememberServiceStatus(graph: AppGraph): ServiceStatus { + val connected by graph.monitor.serviceConnected.collectAsStateWithLifecycle() + var enabledInSettings by remember { mutableStateOf(graph.statusChecker.isEnabled()) } + LifecycleResumeEffect(Unit) { + enabledInSettings = graph.statusChecker.isEnabled() + onPauseOrDispose { } + } + LaunchedEffect(connected) { enabledInSettings = graph.statusChecker.isEnabled() } + return when { + connected -> ServiceStatus.ON + enabledInSettings -> ServiceStatus.ENABLED_NOT_RUNNING + else -> ServiceStatus.OFF + } +} + +/** Usage access (ADR-021) can be granted or revoked in the system settings: re-read on resume. */ +@Composable +fun rememberUsageGranted(graph: AppGraph): Boolean { + var granted by remember { mutableStateOf(graph.usageAccessChecker.isGranted()) } + LifecycleResumeEffect(Unit) { + granted = graph.usageAccessChecker.isGranted() + onPauseOrDispose { } + } + return granted +} + +/** Today's local date: re-read on resume, and rolled over at midnight while the screen stays open. */ +@Composable +fun rememberToday(): LocalDate { + var today by remember { mutableStateOf(LocalDate.now()) } + LifecycleResumeEffect(Unit) { + today = LocalDate.now() + onPauseOrDispose { } + } + LaunchedEffect(today) { + val midnight = today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() + delay((midnight - System.currentTimeMillis()).coerceAtLeast(0) + 1_000) + today = LocalDate.now() + } + return today +} + +/** + * The confidence of the scale in force now (ADR-016, ADR-024). Measurement quality rates stored data with it + * (ADR-029); until the calibration is read it is MEDIUM — the automatic scale, the lower claim. + */ +@Composable +fun rememberCalibrationConfidence(graph: AppGraph): CalibrationConfidence { + val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) + val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } + return calibration?.let { PhysicalScaleProvider.resolve(it, display).confidence } ?: CalibrationConfidence.MEDIUM +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/components/PeriodSelector.kt b/app/src/main/java/com/scrollmeter/app/ui/components/PeriodSelector.kt new file mode 100644 index 0000000..5e7dd87 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/components/PeriodSelector.kt @@ -0,0 +1,26 @@ +package com.scrollmeter.app.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow + +/** One row of mutually exclusive choices (period, sort order). */ +@Composable +fun PeriodSelector(options: List, selected: T, label: @Composable (T) -> String, onSelect: (T) -> Unit, modifier: Modifier = Modifier) { + SingleChoiceSegmentedButtonRow(modifier.fillMaxWidth()) { + options.forEachIndexed { index, option -> + SegmentedButton( + selected = option == selected, + onClick = { onSelect(option) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + icon = {}, + label = { Text(label(option), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) + } + } +} 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 33862be..c5cfc74 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,7 +1,6 @@ package com.scrollmeter.app.ui.dashboard import androidx.compose.foundation.Canvas -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -22,18 +21,13 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -49,15 +43,12 @@ 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.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.scrollmeter.app.AppGraph import com.scrollmeter.app.R -import com.scrollmeter.app.apps.AppInfo import com.scrollmeter.app.calibration.CalibrationMethod import com.scrollmeter.app.data.model.AppSummary import com.scrollmeter.app.data.model.DateRange -import com.scrollmeter.app.devtools.DevToolEntry import com.scrollmeter.app.format.DistanceFormatter import com.scrollmeter.app.format.TimeFormatter import com.scrollmeter.app.insights.DistanceComparison @@ -68,58 +59,37 @@ 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.ServiceStatus import com.scrollmeter.app.ui.components.appLocale -import java.time.LocalDate -import java.time.ZoneId +import com.scrollmeter.app.ui.components.rememberAppInfo +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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** Spec §32: never claim data is being collected unless the service is actually running. */ -enum class ServiceStatus { ON, ENABLED_NOT_RUNNING, OFF } /** * 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. + * offers it and can be dismissed for good. An app row opens its detail. */ @Composable fun DashboardScreen( graph: AppGraph, - devTools: List, onOpenAccessibilitySettings: () -> Unit, onOpenUsageAccess: () -> Unit, onOpenAccuracy: () -> Unit, - onOpenDevTool: (Int) -> Unit, + onOpenApp: (String) -> Unit, ) { val locale = appLocale() val scope = rememberCoroutineScope() - val connected by graph.monitor.serviceConnected.collectAsStateWithLifecycle() - var enabledInSettings by remember { mutableStateOf(graph.statusChecker.isEnabled()) } - var usageGranted by remember { mutableStateOf(graph.usageAccessChecker.isGranted()) } - var today by remember { mutableStateOf(LocalDate.now()) } - LifecycleResumeEffect(Unit) { - enabledInSettings = graph.statusChecker.isEnabled() - usageGranted = graph.usageAccessChecker.isGranted() - today = LocalDate.now() - onPauseOrDispose { } - } - LaunchedEffect(connected) { enabledInSettings = graph.statusChecker.isEnabled() } - // The screen may stay open across midnight: roll "Dnes" over without waiting for a resume. - LaunchedEffect(today) { - val midnight = today.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli() - delay((midnight - System.currentTimeMillis()).coerceAtLeast(0) + 1_000) - today = LocalDate.now() - } - val status = when { - connected -> ServiceStatus.ON - enabledInSettings -> ServiceStatus.ENABLED_NOT_RUNNING - else -> ServiceStatus.OFF - } + val status = rememberServiceStatus(graph) + val usageGranted = rememberUsageGranted(graph) + // The screen may stay open across midnight: "Dnes" rolls over without waiting for a resume. + val today = rememberToday() val repository = graph.scrollRepository // Null until loaded: a card the user dismissed must not flash up while settings are read. @@ -147,7 +117,7 @@ fun DashboardScreen( 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) + TopAppsCard(graph, TopApps.of(apps), usageGranted, settings.unitPreference, locale, onOpenApp) if (!usageGranted && loadedSettings?.usageTimeCardDismissed == false) { UsageTimeCard( onShow = onOpenUsageAccess, @@ -156,12 +126,6 @@ fun DashboardScreen( } if (settings.showComparisons) todayMm?.let(DistanceComparisonProvider::compare)?.let { ComparisonCard(it) } calibration?.let { AccuracyRow(PhysicalScaleProvider.resolve(it, display).method == CalibrationMethod.MANUAL_CARD, onOpenAccuracy) } - if (devTools.isNotEmpty()) { - Text(stringResource(R.string.home_devtools_title), style = MaterialTheme.typography.titleSmall) - devTools.forEachIndexed { index, tool -> - OutlinedButton(onClick = { onOpenDevTool(index) }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(tool.titleRes)) } - } - } } } } @@ -252,27 +216,24 @@ private fun StatRow(label: String, value: String) { } @Composable -private fun TopAppsCard(graph: AppGraph, top: TopApps, usageGranted: Boolean, unit: UnitPreference, locale: Locale) { +private fun TopAppsCard(graph: AppGraph, top: TopApps, usageGranted: Boolean, unit: UnitPreference, locale: Locale, 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) { 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) } - top.other?.let { AppRow(graph, it, usageGranted, unit, locale) } + top.apps.forEach { app -> AppRow(graph, app, usageGranted, unit, locale) { onOpenApp(app.packageName) } } + top.other?.let { AppRow(graph, it, usageGranted, unit, locale, onClick = null) } } } } /** 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) { +private fun AppRow(graph: AppGraph, app: AppSummary, usageGranted: Boolean, unit: UnitPreference, locale: Locale, onClick: (() -> Unit)?) { val other = app.packageName == TopApps.OTHER - val iconPx = with(LocalDensity.current) { 36.dp.roundToPx() } - val info by produceState(initialValue = null, app.packageName) { - value = if (other) null else withContext(Dispatchers.IO) { graph.appInfoProvider.load(app.packageName, iconPx) } - } + 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) @@ -281,15 +242,8 @@ private fun AppRow(graph: AppGraph, app: AppSummary, usageGranted: Boolean, unit } else { stringResource(R.string.dashboard_scroll_time, TimeFormatter.duration(app.activeScrollMs)) } - Row(verticalAlignment = Alignment.CenterVertically) { - val icon = info?.icon - if (icon != null) { - Image(icon, contentDescription = null, modifier = Modifier.size(36.dp)) - } else { - Box(Modifier.size(36.dp).clip(CircleShape).background(MaterialTheme.colorScheme.secondaryContainer), contentAlignment = Alignment.Center) { - Text(label.take(1).uppercase(), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSecondaryContainer) - } - } + 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) 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 new file mode 100644 index 0000000..854b776 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/history/HistoryScreen.kt @@ -0,0 +1,132 @@ +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 = { ChartLabels.distanceAxis(it, 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/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt b/app/src/main/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt new file mode 100644 index 0000000..29d7d6e --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/navigation/ScrollMeterNavHost.kt @@ -0,0 +1,154 @@ +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 +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.lifecycle.Lifecycle +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.devtools.DevTools +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.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. +@Serializable data object OverviewRoute +@Serializable data object HistoryRoute +@Serializable data object AppsRoute +@Serializable data object SettingsRoute +@Serializable data class AppDetailRoute(val packageName: String) +@Serializable data object AccuracyRoute +@Serializable data object CalibrationRoute +@Serializable data object UsageAccessRoute +@Serializable data class DevToolRoute(val key: String) + +private class TopLevel(val route: Any, @get:StringRes val label: Int, @get:DrawableRes val icon: Int) + +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), +) + +/** + * 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, developer screens). Switching tabs + * keeps each tab's state. [initialDevTool] opens a developer screen from the launch intent (debug). + */ +@Composable +fun ScrollMeterNavHost(graph: AppGraph, initialDevTool: String?, 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 } + var launchHandled by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(initialDevTool) { + if (!launchHandled && initialDevTool != null) nav.navigate(DevToolRoute(initialDevTool)) + 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 -> + NavHost(nav, startDestination = OverviewRoute, modifier = Modifier.padding(padding).consumeWindowInsets(padding)) { + composable { + DashboardScreen( + graph = graph, + onOpenAccessibilitySettings = onOpenAccessibilitySettings, + onOpenUsageAccess = { nav.open(UsageAccessRoute) }, + onOpenAccuracy = { nav.open(AccuracyRoute) }, + onOpenApp = { nav.open(AppDetailRoute(it)) }, + ) + } + composable { HistoryScreen(graph) } + composable { AppsScreen(graph, onOpenApp = { nav.open(AppDetailRoute(it)) }) } + composable { + SettingsScreen( + graph = graph, + devTools = DevTools.entries, + onOpenAccessibilitySettings = onOpenAccessibilitySettings, + onOpenUsageAccess = { nav.open(UsageAccessRoute) }, + onOpenAccuracy = { nav.open(AccuracyRoute) }, + onOpenDevTool = { nav.open(DevToolRoute(it)) }, + ) + } + composable { back -> + AppDetailScreen(graph, back.toRoute().packageName, onBack = { nav.leave(back) }) + } + composable { back -> + AccuracyScreen(graph, onBack = { nav.leave(back) }, onCalibrate = { nav.open(CalibrationRoute) }) + } + // Saving or skipping a calibration returns to Přesnost, which shows the result. + composable { back -> CalibrationScreen(graph, onBack = { nav.leave(back) }, onSaved = { nav.leave(back) }) } + composable { back -> UsageAccessScreen(graph, onGranted = { nav.leave(back) }, onBack = { nav.leave(back) }) } + if (DevTools.entries.isNotEmpty()) { + composable { back -> + val key = back.toRoute().key + DevTools.entries.firstOrNull { it.key == key }?.content?.invoke(graph) { nav.leave(back) } + } + } + } + } +} + +/** A double tap must not stack the same screen twice. */ +private fun NavController.open(route: Any) = navigate(route) { launchSingleTop = true } + +/** + * Leaves [entry] only while it is the resumed screen: a second tap on "Zpět" during the exit + * animation would otherwise pop the screen below as well — down to an empty host. + */ +private fun NavController.leave(entry: NavBackStackEntry) { + if (entry.lifecycle.currentState == Lifecycle.State.RESUMED) popBackStack() +} 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 new file mode 100644 index 0000000..cf3c57f --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/settings/SettingsScreen.kt @@ -0,0 +1,112 @@ +package com.scrollmeter.app.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +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.calibration.CalibrationMethod +import com.scrollmeter.app.devtools.DevToolEntry +import com.scrollmeter.app.measurement.PhysicalScaleProvider +import com.scrollmeter.app.ui.components.ServiceStatus +import com.scrollmeter.app.ui.components.rememberServiceStatus +import com.scrollmeter.app.ui.components.rememberUsageGranted + +/** + * Nastavení — the Phase 5 core: whether the service runs, whether time in app is on, and the way to + * Přesnost měření; debug builds list the developer screens here (ADR-010). Phase 6 adds the goal, + * units, notifications, excluded apps, data and privacy sections (spec §44). + */ +@Composable +fun SettingsScreen( + graph: AppGraph, + devTools: List, + onOpenAccessibilitySettings: () -> Unit, + onOpenUsageAccess: () -> Unit, + onOpenAccuracy: () -> Unit, + onOpenDevTool: (String) -> Unit, +) { + val context = LocalContext.current + val status = rememberServiceStatus(graph) + val usageGranted = rememberUsageGranted(graph) + val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) + val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } + val manual = calibration?.let { PhysicalScaleProvider.resolve(it, display).method == CalibrationMethod.MANUAL_CARD } + + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.titleLarge) + Text(stringResource(R.string.settings_section_measurement), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + Card(Modifier.fillMaxWidth()) { + SettingRow( + title = stringResource(R.string.settings_service), + value = stringResource( + when (status) { + ServiceStatus.ON -> R.string.settings_on + ServiceStatus.ENABLED_NOT_RUNNING -> R.string.home_status_not_running + ServiceStatus.OFF -> R.string.settings_off + }, + ), + onClick = onOpenAccessibilitySettings, + ) + HorizontalDivider() + SettingRow( + title = stringResource(R.string.settings_usage_access), + value = stringResource(if (usageGranted) R.string.settings_on else R.string.settings_off), + // Granted: straight to the system page, where it can be revoked; otherwise the disclosure first. + onClick = { if (usageGranted) graph.usageAccessChecker.openSettings(context) else onOpenUsageAccess() }, + ) + HorizontalDivider() + SettingRow( + title = stringResource(R.string.settings_accuracy), + value = manual?.let { stringResource(if (it) R.string.home_accuracy_manual_short else R.string.home_accuracy_auto_short) }, + onClick = onOpenAccuracy, + ) + } + if (devTools.isNotEmpty()) { + Text(stringResource(R.string.home_devtools_title), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + devTools.forEach { tool -> + OutlinedButton(onClick = { onOpenDevTool(tool.key) }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(tool.titleRes)) } + } + } + } +} + +@Composable +private fun SettingRow(title: String, value: String?, onClick: () -> Unit) { + Row(Modifier.fillMaxWidth().clickable(onClick = onClick).padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + if (value != null) Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.width(8.dp)) + Icon(painterResource(R.drawable.ic_chevron_right), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } +} diff --git a/app/src/main/res/drawable/ic_back.xml b/app/src/main/res/drawable/ic_back.xml new file mode 100644 index 0000000..145958f --- /dev/null +++ b/app/src/main/res/drawable/ic_back.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_chevron_right.xml b/app/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..e9e2691 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_nav_apps.xml b/app/src/main/res/drawable/ic_nav_apps.xml new file mode 100644 index 0000000..e4b708e --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_apps.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_nav_history.xml b/app/src/main/res/drawable/ic_nav_history.xml new file mode 100644 index 0000000..2498ee0 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_history.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_nav_overview.xml b/app/src/main/res/drawable/ic_nav_overview.xml new file mode 100644 index 0000000..c3c38a9 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_overview.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_nav_settings.xml b/app/src/main/res/drawable/ic_nav_settings.xml new file mode 100644 index 0000000..7035249 --- /dev/null +++ b/app/src/main/res/drawable/ic_nav_settings.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5a88945..7035a55 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -91,6 +91,61 @@ Zpět + + Přehled + Historie + Aplikace + Nastavení + + + Historie + 7 dní + 30 dní + 12 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 + + + Aplikace + Vzdálenost + Čas + Za toto období zatím nic. + bez dat o scrollu + %1$s %% + Kvalita měření: vysoká + Kvalita měření: střední + Kvalita měření: nízká + Kvalita měření: málo dat + Tato aplikace poskytuje standardní Android scroll data a displej je zkalibrovaný, takže měření je pravděpodobně spolehlivé. + Měření je použitelné, ale buď displej není zkalibrovaný kartou, nebo aplikace často hlásí jen polohu místo posunu. + Velká část událostí z této aplikace nenese použitelný údaj o posunu, takže naměřená vzdálenost bude spíš nižší než skutečná. + Zatím je z této aplikace málo událostí na posouzení. + Vzdálenost + Čas v aplikaci + Čas scrollování + Tempo + Vzdálenost za 30 dní + Čas v aplikaci za 30 dní + Č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. + + + Nastavení + Měření + Služba Usnadnění + Zapnuto + Vypnuto + Čas v aplikacích + Přesnost měření + Kalibrace displeje Přilož platební kartu k displeji a posuvníkem uprav modrou čáru tak, aby měla stejnou délku jako delší strana karty. 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 new file mode 100644 index 0000000..186f39e --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/ui/components/ChartLabelsTest.kt @@ -0,0 +1,45 @@ +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.settings.UnitPreference +import java.time.LocalDate +import java.util.Locale +import org.junit.Test + +/** D13: axis labels read as round distances and clock times; bars as days or months. */ +class ChartLabelsTest { + private val cs = Locale.forLanguageTag("cs") + + @Test + fun distanceAxisIsRoundInMetresAndKilometres() { + assertThat(ChartLabels.distanceScale(423_000.0)).isEqualTo(ChartScale(600_000.0, 200_000.0)) + assertThat(ChartLabels.distanceScale(0.0)).isEqualTo(ChartScale(100_000.0, 25_000.0)) + assertThat(ChartLabels.distanceAxis(0.0, UnitPreference.AUTOMATIC, cs)).isEqualTo("0") + assertThat(ChartLabels.distanceAxis(200_000.0, UnitPreference.AUTOMATIC, cs)).isEqualTo("200 m") + assertThat(ChartLabels.distanceAxis(2_000_000.0, UnitPreference.AUTOMATIC, cs)).isEqualTo("2 km") + assertThat(ChartLabels.distanceAxis(2_500_000.0, UnitPreference.AUTOMATIC, cs)).isEqualTo("2,5 km") + assertThat(ChartLabels.distanceAxis(25_000.0, UnitPreference.KILOMETRES, cs)).isEqualTo("0,025 km") + assertThat(ChartLabels.distanceAxis(2_000_000.0, UnitPreference.METRES, cs)).isEqualTo("2\u00a0000 m") + } + + @Test + fun timeAxisRunsInMinutes() { + assertThat(ChartLabels.minutesScale(47 * 60_000.0)).isEqualTo(ChartScale(60.0, 15.0)) + assertThat(ChartLabels.minutesAxis(0.0)).isEqualTo("0") + assertThat(ChartLabels.minutesAxis(15.0)).isEqualTo("15 min") + assertThat(ChartLabels.minutesAxis(120.0)).isEqualTo("2 h") + } + + @Test + fun barsAreDaysOrMonths() { + 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") + assertThat(labels.day(LocalDate.parse("2026-01-05"))).isEqualTo("5. 1.") + } +} diff --git a/docs/architecture.md b/docs/architecture.md index f2ff38c..8b067b3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -66,9 +66,12 @@ com.scrollmeter.app │ UsageEventsSource, UsageAccessChecker (the only Android parts) ├── settings Settings, SettingsRepository (DataStore) ├── format DistanceFormatter, TimeFormatter (pure Kotlin — ADR-028) -├── insights DistanceComparisonProvider, TopApps (pure Kotlin) +├── insights DistanceComparisonProvider, TopApps, HistorySeries, ChartScale, AppRanking, +│ ScrollShare (pure Kotlin — ADR-028, ADR-029) ├── apps AppInfoProvider (labels + icons via PackageManager, cached; UI only — ADR-008) -├── ui onboarding, dashboard, history, apps, calibration, settings, about, components, theme +├── ui navigation (routes + bottom bar), dashboard, history, apps (list + detail), calibration, +│ usage, settings, components (BarChart, PeriodSelector, AppIcon, LiveState), theme; +│ onboarding and about come in Phases 6–7 ├── export CsvExporter ├── AppGraph.kt, ScrollMeterApplication.kt, MainActivity.kt └── src/debug/… DebugEventLog, DebugMeasurementScreen, TestListScreen (never in release) @@ -103,10 +106,14 @@ com.scrollmeter.app ## Navigation -Bottom bar: Přehled · Historie · Aplikace · Nastavení (Navigation Compose 2.9, type-safe routes). -Onboarding is a separate graph shown until `onboardingCompleted`. +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: -Until the bottom bar of Phase 5, `MainActivity` switches on a saveable enum: Přehled (`ui/dashboard/DashboardScreen`) -→ Přesnost měření (`ui/calibration/AccuracyScreen`) → Kalibrace displeje (`ui/calibration/CalibrationScreen`), and -Přehled → Čas v aplikacích (`ui/usage/UsageAccessScreen`, the Usage-access disclosure). Saving or skipping a -calibration lands on Přesnost. +- Aplikace (or a top-app row on Přehled) → detail `AppDetailRoute(packageName)`, +- 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í → developer screens (`DevToolRoute`, registered only when `DevTools.entries` is non-empty — debug). + +A "Zpět" tap leaves a screen only while it is the resumed one, so a double tap cannot pop the screen below. +Onboarding (Phase 7) will be a separate graph shown until `onboardingCompleted`. diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index 66afab5..3e62bbc 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -33,3 +33,4 @@ supersede with a new entry that references the old one. | ADR-026 | 2026-09-24 | **Persistence (Phase 3).** Room v1 (`scrollmeter.db`, schema JSON in `app/schemas/`): `daily_app_aggregate` (SPEC §17 + `calibrationVersion` + `activeScrollMs`), `scroll_session`, `daily_app_usage`. A flush is one transaction of *insert-or-add* per (date, package): `INSERT OR IGNORE` of an empty row, then `UPDATE … SET x = x + :x`, first timestamp = MIN, last = MAX, `calibrationVersion` = MAX — two statements because API 28 ships SQLite 3.22 and `ON CONFLICT DO UPDATE` needs 3.24. The service's pipeline (`ScrollPipeline`, pure Kotlin, one thread via `Dispatchers.Default.limitedParallelism(1)`) writes every 10 s while data is pending, at the 50th event, at a new local day, on `onInterrupt`, and as the last act of the pipeline on unbind / destroy / reconnect; the write itself runs `NonCancellable` so a committed transaction is never restored and written twice; a failed write keeps its deltas and sessions for the next flush. Our own package is **never stored** — in debug test mode it is measured for the live comparison only. Debug builds may drop the DB on a schema change; release builds need a real migration. `calibrationVersion` stays informational (the newest calibration a row was measured under), not a key: it can repeat only if the calibration DataStore is lost while the DB survives, and nothing joins on it. | Spec §16 caps the loss at one flush interval. Measured on the API 34 emulator (Settings, `input swipe`): after 12 s the DB equals the logcat sum of counted events (242.026 vs 242.027 mm, display rounding); `kill -9` right after scrolling lost exactly the unflushed ~4 s (463.85 mm), the system restarted the service by itself and the next flush added up (704.19 mm = 242.03 + 462.16). **`am force-stop` also clears the service from `enabled_accessibility_services` on API 34** — measurement stays off until the user turns it on again, and Home says so (spec §32). | accepted | | ADR-027 | 2026-09-24 | **Nothing leaves the phone through Android's data transfer.** Besides `allowBackup="false"` (API 28–30), `android:dataExtractionRules` excludes every domain from both `` and `` (API 31+): the database, both DataStores and any file stay on the phone that measured them. Taking data along = CSV export (Phase 6). Guards: `PolicyGuardTest` pins the rules file; `check_manifest_policy.py` requires the attribute, checks `uses-permission-sdk-23` as well, matches the androidx signature permission against the manifest's own package exactly, and fails on any exported component other than the launcher activity that has no permission. The service and the usage sync read settings through a flow that does **not** fall back to defaults on a read error — an unreadable file keeps the exclusions applied last instead of measuring apps the user excluded. | Security review of Phase 3: on API 31+ `allowBackup="false"` stops cloud backup but, on some devices, not device-to-device transfer, which would copy the scroll history, time in app and the card calibration to a new phone — against the disclosure's "data zůstávají v telefonu" and the stale-calibration risk ADR-024 names. The history is not worth weakening that promise; the open point on D2D transfer (handoff.md) is closed by this. | accepted | | ADR-028 | 2026-09-24 | **Dashboard presentation rules (Phase 4).** (1) Distances: AUTOMATIC shows metres below 1 km and km above, decided on the *rounded* value; below 10 m one decimal, km with 2 / 1 / 0 decimals below 10 / 100 / above (`DistanceFormatter`). (2) Numbers are formatted in the locale of the app's strings (`number_locale`, `cs` now; values-en in Phase 7), not the device's — a Czech UI never mixes in "2.84 km". (3) Durations floor to minutes; under a minute "< 1 min"; unknown "—", never "0 min"; pace (m/min) only when the time is known and ≥ 1 min. (4) One comparison (spec §22): the longest reference the distance reaches ≥ 80 % of — "téměř" < 95 %, "přibližně" ≤ 110 %, "víc než" < 150 %, else "přibližně N×"; none under 84 m. Czech plurals live in resources. (5) Top apps today: apps with distance, longest first, four rows plus "Ostatní"; exactly five apps → all five by name. Rows show time in app + pace with Usage access, scroll time without it. (6) The Usage-access card shows until access is granted or the user taps "Teď ne" (stored). (7) App labels and icons via `PackageManager` with the manifest's single `` launcher intent (ADR-008, pinned by `PolicyGuardTest`); an invisible app shows its package name. (8) The theme follows `Settings.theme` (system / light / dark). | Spec §21, §22, §25, §42, §43; PLAN Phase 4 DoD ("never 0 min for an unknown value"). The thresholds keep the one comparison honest: the sentence never claims more than the reference by more than half. | accepted | +| ADR-029 | 2026-09-24 | **History, apps and navigation (Phase 5).** (1) `MeasurementQuality` (spec §20) from an app's counters over the shown period: UNKNOWN below 20 events (measured + fallback + unmeasurable + outliers); LOW when ≥ 50 % are unmeasurable or ≥ 5 % are outliers; HIGH when ≥ 90 % of the counted events are direct deltas, < 10 % unmeasurable, < 1 % outliers **and** the scale in force is the card (confidence HIGH); MEDIUM otherwise. The UI shows the word and one explaining sentence, never a percentage. It rates with the calibration in force *now* — rows keep `calibrationVersion`, not the method, so a day measured before a card calibration can read HIGH; accepted, the word mostly describes how the app reports scrolling. (2) Historie: bars per day (7 / 30 days) or per calendar month (12 months, from the first day of the month 11 months back); missing days are 0. Průměr / den, Nejvyšší den, Nejnižší den, Celkem are always **per day**, and only days since the first measured day of all time count, so a new user's average is not diluted by days before install; ties go to the most recent day. Axes: distance in round steps of 1 / 2 / 2,5 / 5 × 10ⁿ mm (round in m and km too), time in minutes with clock steps (1 … 720 min); axis labels drop trailing zeros ("2 km"). A tapped bar shows its date and value. (3) Aplikace: Dnes / 7 dní / 30 dní / Celkem (Celkem = `DateRange.ALL`, 1970–9999, so keys still compare as strings); sort by distance, or by time (time in app with Usage access, scroll time without); share = the app's distance / the period's total distance. Apps with time but no scroll data (YouTube) show "—" and "bez dat o scrollu", have no share or quality, and sort after the apps with data of the chosen kind. An app we cannot see keeps its package name (ADR-008). Detail: the period's distance, time in app, scroll time, pace (pace only with Usage access, ADR-022), quality with its explanation, and 30-day charts of distance and time. "Scrolluješ X z Y v aplikaci (Z %)" appears only with Usage access and ≥ 1 min in the app; Z is clamped to 0–100 because the two times come from different sources. (4) Navigation Compose 2.9.8 with `@Serializable` routes (D14; kotlinx-serialization 1.9.0, R8 release verified); bottom bar only on the four top-level screens; developer screens moved from Přehled to Nastavení and are a route registered only in debug. | Spec §20, §23, §24, §43; PLAN Phase 5 DoD ("žádné falešné procento přesnosti v UI"). Statistics from the first measured day and the clamped share keep every number defensible without ground truth. | accepted | From 6f3100f645dcb386b92d2666bad077eec13c1232 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 09:34:14 +0200 Subject: [PATCH 3/5] fix(apps): rate quality on all-time counters, localizable row subtitle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quality describes how an app reports scrolling, not a period: rated on today's handful of events it read "málo dat" every morning. The list and the detail now rate on the app's all-time counters. The "43,7 % · Kvalita …" subtitle is one format string instead of a concatenation, and the detail no longer reads the first measured day it never used. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../scrollmeter/app/insights/AppRanking.kt | 26 ++++++++++++------- .../app/ui/apps/AppDetailScreen.kt | 23 +++++++++------- .../com/scrollmeter/app/ui/apps/AppsScreen.kt | 9 ++++--- app/src/main/res/values/strings.xml | 2 +- .../app/insights/AppRankingTest.kt | 8 ++++++ docs/measurement-decisions.md | 2 +- 6 files changed, 45 insertions(+), 25 deletions(-) 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 e0d9a96..2084765 100644 --- a/app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt +++ b/app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt @@ -25,26 +25,27 @@ enum class AppsPeriod { data class RankedApp(val app: AppSummary, val sharePercent: Double?, val quality: MeasurementQuality?) /** - * Orders the apps of a period (spec §24, D19). By distance: longest first. By time: time in app + * 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. */ object AppRanking { - fun rank(apps: List, sort: AppSort, usageGranted: Boolean, calibration: CalibrationConfidence): List { + 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) { - MeasurementQualityRater.rate( - app.measuredEventCount ?: 0, app.fallbackEventCount ?: 0, app.unmeasurableEventCount ?: 0, - app.rejectedOutlierCount ?: 0, calibration, - ) - } else { - null - }, + quality = if (scrolled) quality(qualityBasis[app.packageName] ?: app, calibration) else null, ) } val key: (RankedApp) -> Double? = when (sort) { @@ -53,4 +54,9 @@ object AppRanking { } return ranked.sortedWith(compareBy { key(it) == null }.thenByDescending { key(it) ?: 0.0 }.thenBy { it.app.packageName }) } + + /** The quality word for one app's counters (spec §20, ADR-029). */ + 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/ui/apps/AppDetailScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/apps/AppDetailScreen.kt index ca5b195..70ef097 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 @@ -32,14 +32,15 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.scrollmeter.app.AppGraph import com.scrollmeter.app.R 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.AppsPeriod import com.scrollmeter.app.insights.ChartScale import com.scrollmeter.app.insights.HistoryPeriod import com.scrollmeter.app.insights.HistorySeriesBuilder import com.scrollmeter.app.insights.ScrollShare -import com.scrollmeter.app.measurement.MeasurementQualityRater import com.scrollmeter.app.settings.Settings import com.scrollmeter.app.ui.components.AppIcon import com.scrollmeter.app.ui.components.BarChart @@ -77,7 +78,10 @@ fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { val chartRange = HistorySeriesBuilder.range(HistoryPeriod.DAYS_30, today) val days by remember(chartRange, packageName) { graph.scrollRepository.appDays(packageName, chartRange) } .collectAsStateWithLifecycle(initialValue = emptyList()) - val first by remember { graph.scrollRepository.firstMeasuredDay() }.collectAsStateWithLifecycle(initialValue = null) + // Quality describes the app, not the period: rated on its all-time counters (ADR-029). + val allTime by remember(packageName) { + graph.scrollRepository.apps(DateRange.ALL).map { apps -> apps.firstOrNull { it.packageName == packageName } } + }.collectAsStateWithLifecycle(initialValue = null) fun distance(mm: Double) = DistanceFormatter.format(mm, unit, locale) @@ -95,11 +99,9 @@ fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { PeriodSelector(AppsPeriod.entries, period, { stringResource(appsPeriodLabel(it)) }, { period = it }) PeriodSummary(summary, usageGranted, ::distance, locale) - val app = summary - if (app?.distanceMm != null) { - val quality = MeasurementQualityRater.rate( - app.measuredEventCount ?: 0, app.fallbackEventCount ?: 0, app.unmeasurableEventCount ?: 0, app.rejectedOutlierCount ?: 0, calibration, - ) + val basis = allTime + if (basis?.distanceMm != null) { + val quality = AppRanking.quality(basis, calibration) Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Text(stringResource(qualityLabel(quality)), style = MaterialTheme.typography.titleSmall) @@ -108,7 +110,8 @@ fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { } } - val distanceSeries = remember(days, today, first) { HistorySeriesBuilder.build(HistoryPeriod.DAYS_30, today, days, first) } + // Charts only: no statistics, so no first measured day. + val distanceSeries = remember(days, today) { HistorySeriesBuilder.build(HistoryPeriod.DAYS_30, today, days, firstMeasuredDay = null) } ChartCard( title = stringResource(R.string.app_detail_distance_chart), values = distanceSeries.bars.map { it.value }, @@ -117,8 +120,8 @@ fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { barLabel = { labels.bar(HistoryPeriod.DAYS_30, distanceSeries.bars[it]) }, selectedText = { i -> stringResource(R.string.history_day_value, labels.day(distanceSeries.bars[i].start), distance(distanceSeries.bars[i].value)) }, ) - val timeSeries = remember(days, today, first, usageGranted) { - HistorySeriesBuilder.build(HistoryPeriod.DAYS_30, today, days, first) { + val timeSeries = remember(days, today, usageGranted) { + HistorySeriesBuilder.build(HistoryPeriod.DAYS_30, today, days, firstMeasuredDay = null) { (if (usageGranted) it.foregroundMs else it.activeScrollMs)?.toDouble() } } 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 index 69dea6b..af405f7 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/apps/AppsScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/apps/AppsScreen.kt @@ -29,6 +29,7 @@ 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 @@ -63,8 +64,10 @@ fun AppsScreen(graph: AppGraph, onOpenApp: (String) -> Unit) { 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) - val ranked = remember(apps, sort, usageGranted, calibration) { - apps?.let { AppRanking.rank(it, sort, usageGranted, calibration) } + // 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 = emptyList()) + val ranked = remember(apps, allTime, sort, usageGranted, calibration) { + apps?.let { AppRanking.rank(it, sort, usageGranted, calibration, allTime.associateBy { a -> a.packageName }) } } LazyColumn( @@ -104,7 +107,7 @@ private fun AppListRow(graph: AppGraph, row: RankedApp, usageGranted: Boolean, u 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, decimal(row.sharePercent, locale)) + " · " + stringResource(qualityLabel(row.quality)) + 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) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7035a55..85868bc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,7 +118,7 @@ Čas Za toto období zatím nic. bez dat o scrollu - %1$s %% + %1$s %% · %2$s Kvalita měření: vysoká Kvalita měření: střední Kvalita měření: nízká 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 ef2e81c..818a0df 100644 --- a/app/src/test/java/com/scrollmeter/app/insights/AppRankingTest.kt +++ b/app/src/test/java/com/scrollmeter/app/insights/AppRankingTest.kt @@ -41,4 +41,12 @@ class AppRankingTest { assertThat(AppsPeriod.DAYS_30.range(today).fromKey).isEqualTo("2026-08-26") assertThat(AppsPeriod.LIFETIME.range(today)).isEqualTo(DateRange.ALL) } + + @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) + } } diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index 3e62bbc..946d016 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -33,4 +33,4 @@ supersede with a new entry that references the old one. | ADR-026 | 2026-09-24 | **Persistence (Phase 3).** Room v1 (`scrollmeter.db`, schema JSON in `app/schemas/`): `daily_app_aggregate` (SPEC §17 + `calibrationVersion` + `activeScrollMs`), `scroll_session`, `daily_app_usage`. A flush is one transaction of *insert-or-add* per (date, package): `INSERT OR IGNORE` of an empty row, then `UPDATE … SET x = x + :x`, first timestamp = MIN, last = MAX, `calibrationVersion` = MAX — two statements because API 28 ships SQLite 3.22 and `ON CONFLICT DO UPDATE` needs 3.24. The service's pipeline (`ScrollPipeline`, pure Kotlin, one thread via `Dispatchers.Default.limitedParallelism(1)`) writes every 10 s while data is pending, at the 50th event, at a new local day, on `onInterrupt`, and as the last act of the pipeline on unbind / destroy / reconnect; the write itself runs `NonCancellable` so a committed transaction is never restored and written twice; a failed write keeps its deltas and sessions for the next flush. Our own package is **never stored** — in debug test mode it is measured for the live comparison only. Debug builds may drop the DB on a schema change; release builds need a real migration. `calibrationVersion` stays informational (the newest calibration a row was measured under), not a key: it can repeat only if the calibration DataStore is lost while the DB survives, and nothing joins on it. | Spec §16 caps the loss at one flush interval. Measured on the API 34 emulator (Settings, `input swipe`): after 12 s the DB equals the logcat sum of counted events (242.026 vs 242.027 mm, display rounding); `kill -9` right after scrolling lost exactly the unflushed ~4 s (463.85 mm), the system restarted the service by itself and the next flush added up (704.19 mm = 242.03 + 462.16). **`am force-stop` also clears the service from `enabled_accessibility_services` on API 34** — measurement stays off until the user turns it on again, and Home says so (spec §32). | accepted | | ADR-027 | 2026-09-24 | **Nothing leaves the phone through Android's data transfer.** Besides `allowBackup="false"` (API 28–30), `android:dataExtractionRules` excludes every domain from both `` and `` (API 31+): the database, both DataStores and any file stay on the phone that measured them. Taking data along = CSV export (Phase 6). Guards: `PolicyGuardTest` pins the rules file; `check_manifest_policy.py` requires the attribute, checks `uses-permission-sdk-23` as well, matches the androidx signature permission against the manifest's own package exactly, and fails on any exported component other than the launcher activity that has no permission. The service and the usage sync read settings through a flow that does **not** fall back to defaults on a read error — an unreadable file keeps the exclusions applied last instead of measuring apps the user excluded. | Security review of Phase 3: on API 31+ `allowBackup="false"` stops cloud backup but, on some devices, not device-to-device transfer, which would copy the scroll history, time in app and the card calibration to a new phone — against the disclosure's "data zůstávají v telefonu" and the stale-calibration risk ADR-024 names. The history is not worth weakening that promise; the open point on D2D transfer (handoff.md) is closed by this. | accepted | | ADR-028 | 2026-09-24 | **Dashboard presentation rules (Phase 4).** (1) Distances: AUTOMATIC shows metres below 1 km and km above, decided on the *rounded* value; below 10 m one decimal, km with 2 / 1 / 0 decimals below 10 / 100 / above (`DistanceFormatter`). (2) Numbers are formatted in the locale of the app's strings (`number_locale`, `cs` now; values-en in Phase 7), not the device's — a Czech UI never mixes in "2.84 km". (3) Durations floor to minutes; under a minute "< 1 min"; unknown "—", never "0 min"; pace (m/min) only when the time is known and ≥ 1 min. (4) One comparison (spec §22): the longest reference the distance reaches ≥ 80 % of — "téměř" < 95 %, "přibližně" ≤ 110 %, "víc než" < 150 %, else "přibližně N×"; none under 84 m. Czech plurals live in resources. (5) Top apps today: apps with distance, longest first, four rows plus "Ostatní"; exactly five apps → all five by name. Rows show time in app + pace with Usage access, scroll time without it. (6) The Usage-access card shows until access is granted or the user taps "Teď ne" (stored). (7) App labels and icons via `PackageManager` with the manifest's single `` launcher intent (ADR-008, pinned by `PolicyGuardTest`); an invisible app shows its package name. (8) The theme follows `Settings.theme` (system / light / dark). | Spec §21, §22, §25, §42, §43; PLAN Phase 4 DoD ("never 0 min for an unknown value"). The thresholds keep the one comparison honest: the sentence never claims more than the reference by more than half. | accepted | -| ADR-029 | 2026-09-24 | **History, apps and navigation (Phase 5).** (1) `MeasurementQuality` (spec §20) from an app's counters over the shown period: UNKNOWN below 20 events (measured + fallback + unmeasurable + outliers); LOW when ≥ 50 % are unmeasurable or ≥ 5 % are outliers; HIGH when ≥ 90 % of the counted events are direct deltas, < 10 % unmeasurable, < 1 % outliers **and** the scale in force is the card (confidence HIGH); MEDIUM otherwise. The UI shows the word and one explaining sentence, never a percentage. It rates with the calibration in force *now* — rows keep `calibrationVersion`, not the method, so a day measured before a card calibration can read HIGH; accepted, the word mostly describes how the app reports scrolling. (2) Historie: bars per day (7 / 30 days) or per calendar month (12 months, from the first day of the month 11 months back); missing days are 0. Průměr / den, Nejvyšší den, Nejnižší den, Celkem are always **per day**, and only days since the first measured day of all time count, so a new user's average is not diluted by days before install; ties go to the most recent day. Axes: distance in round steps of 1 / 2 / 2,5 / 5 × 10ⁿ mm (round in m and km too), time in minutes with clock steps (1 … 720 min); axis labels drop trailing zeros ("2 km"). A tapped bar shows its date and value. (3) Aplikace: Dnes / 7 dní / 30 dní / Celkem (Celkem = `DateRange.ALL`, 1970–9999, so keys still compare as strings); sort by distance, or by time (time in app with Usage access, scroll time without); share = the app's distance / the period's total distance. Apps with time but no scroll data (YouTube) show "—" and "bez dat o scrollu", have no share or quality, and sort after the apps with data of the chosen kind. An app we cannot see keeps its package name (ADR-008). Detail: the period's distance, time in app, scroll time, pace (pace only with Usage access, ADR-022), quality with its explanation, and 30-day charts of distance and time. "Scrolluješ X z Y v aplikaci (Z %)" appears only with Usage access and ≥ 1 min in the app; Z is clamped to 0–100 because the two times come from different sources. (4) Navigation Compose 2.9.8 with `@Serializable` routes (D14; kotlinx-serialization 1.9.0, R8 release verified); bottom bar only on the four top-level screens; developer screens moved from Přehled to Nastavení and are a route registered only in debug. | Spec §20, §23, §24, §43; PLAN Phase 5 DoD ("žádné falešné procento přesnosti v UI"). Statistics from the first measured day and the clamped share keep every number defensible without ground truth. | accepted | +| ADR-029 | 2026-09-24 | **History, apps and navigation (Phase 5).** (1) `MeasurementQuality` (spec §20) from an app's **all-time** counters, whatever period is shown — it describes how the app reports scrolling, and rating a single morning would read "málo dat" every day: UNKNOWN below 20 events (measured + fallback + unmeasurable + outliers); LOW when ≥ 50 % are unmeasurable or ≥ 5 % are outliers; HIGH when ≥ 90 % of the counted events are direct deltas, < 10 % unmeasurable, < 1 % outliers **and** the scale in force is the card (confidence HIGH); MEDIUM otherwise. The UI shows the word and one explaining sentence, never a percentage. It rates with the calibration in force *now* — rows keep `calibrationVersion`, not the method, so a day measured before a card calibration can read HIGH; accepted, the word mostly describes how the app reports scrolling. (2) Historie: bars per day (7 / 30 days) or per calendar month (12 months, from the first day of the month 11 months back); missing days are 0. Průměr / den, Nejvyšší den, Nejnižší den, Celkem are always **per day**, and only days since the first measured day of all time count, so a new user's average is not diluted by days before install; ties go to the most recent day. Axes: distance in round steps of 1 / 2 / 2,5 / 5 × 10ⁿ mm (round in m and km too), time in minutes with clock steps (1 … 720 min); axis labels drop trailing zeros ("2 km"). A tapped bar shows its date and value. (3) Aplikace: Dnes / 7 dní / 30 dní / Celkem (Celkem = `DateRange.ALL`, 1970–9999, so keys still compare as strings); sort by distance, or by time (time in app with Usage access, scroll time without); share = the app's distance / the period's total distance. Apps with time but no scroll data (YouTube) show "—" and "bez dat o scrollu", have no share or quality, and sort after the apps with data of the chosen kind. An app we cannot see keeps its package name (ADR-008). Detail: the period's distance, time in app, scroll time, pace (pace only with Usage access, ADR-022), quality with its explanation, and 30-day charts of distance and time. "Scrolluješ X z Y v aplikaci (Z %)" appears only with Usage access and ≥ 1 min in the app; Z is clamped to 0–100 because the two times come from different sources. (4) Navigation Compose 2.9.8 with `@Serializable` routes (D14; kotlinx-serialization 1.9.0, R8 release verified); bottom bar only on the four top-level screens; developer screens moved from Přehled to Nastavení and are a route registered only in debug. | Spec §20, §23, §24, §43; PLAN Phase 5 DoD ("žádné falešné procento přesnosti v UI"). Statistics from the first measured day and the clamped share keep every number defensible without ground truth. | accepted | From 7fa592000e967606d0521e81c9d5240e7dace2ce Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 09:48:41 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20Stage=202=20review=20=E2=80=94=20dee?= =?UTF-8?q?p-link=20scrub,=20paired=20pace,=20one=20unit=20per=20axis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Navigation honours explicit deep-link extras on any intent that starts the exported launcher activity: MainActivity now drops them, and the app-detail route only renders a well-formed package name (MEDIUM, UI spoofing). - Pace and the scroll share sum both sides only over days that have a time in app; the usage sync looks back at most 9 days, so "Celkem" mixed spans (MEDIUM). - Aplikace ranks only once the all-time counters are read (no quality flash); a bar-chart label cannot invert its range on a narrow plot; "Zpět" checks the top entry instead of RESUMED; one unit per distance axis; history statistics survive a first day after today; chart selection is keyed by date. - kotlinx-serialization-core instead of -json: routes need no JSON. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/build.gradle.kts | 2 +- .../java/com/scrollmeter/app/MainActivity.kt | 2 ++ .../com/scrollmeter/app/apps/PackageNames.kt | 9 ++++++ .../app/insights/AppPeriodTotals.kt | 32 +++++++++++++++++++ .../scrollmeter/app/insights/HistorySeries.kt | 6 ++-- .../app/ui/apps/AppDetailScreen.kt | 32 +++++++++++-------- .../com/scrollmeter/app/ui/apps/AppsScreen.kt | 7 ++-- .../scrollmeter/app/ui/components/BarChart.kt | 3 +- .../app/ui/components/ChartLabels.kt | 19 +++++++---- .../app/ui/history/HistoryScreen.kt | 2 +- .../app/ui/navigation/NavIntents.kt | 24 ++++++++++++++ .../app/ui/navigation/ScrollMeterNavHost.kt | 17 +++++++--- .../scrollmeter/app/apps/PackageNamesTest.kt | 24 ++++++++++++++ .../app/insights/AppPeriodTotalsTest.kt | 31 ++++++++++++++++++ .../app/insights/HistorySeriesTest.kt | 9 ++++++ .../app/ui/components/ChartLabelsTest.kt | 17 ++++++---- .../app/ui/navigation/NavIntentsTest.kt | 30 +++++++++++++++++ gradle/libs.versions.toml | 2 +- 18 files changed, 228 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/scrollmeter/app/apps/PackageNames.kt create mode 100644 app/src/main/java/com/scrollmeter/app/insights/AppPeriodTotals.kt create mode 100644 app/src/main/java/com/scrollmeter/app/ui/navigation/NavIntents.kt create mode 100644 app/src/test/java/com/scrollmeter/app/apps/PackageNamesTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/insights/AppPeriodTotalsTest.kt create mode 100644 app/src/test/java/com/scrollmeter/app/ui/navigation/NavIntentsTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bcec69e..d35d3a6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -77,7 +77,7 @@ dependencies { implementation(libs.androidx.activity.compose) // D14: bottom navigation with type-safe (@Serializable) routes, from Phase 5. implementation(libs.androidx.navigation.compose) - implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.serialization.core) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) diff --git a/app/src/main/java/com/scrollmeter/app/MainActivity.kt b/app/src/main/java/com/scrollmeter/app/MainActivity.kt index 872e0ce..be743c7 100644 --- a/app/src/main/java/com/scrollmeter/app/MainActivity.kt +++ b/app/src/main/java/com/scrollmeter/app/MainActivity.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import com.scrollmeter.app.devtools.DevTools import com.scrollmeter.app.settings.ThemePreference +import com.scrollmeter.app.ui.navigation.NavIntents import com.scrollmeter.app.ui.navigation.ScrollMeterNavHost import com.scrollmeter.app.ui.theme.ScrollMeterTheme import kotlinx.coroutines.Dispatchers @@ -23,6 +24,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() val graph = (application as ScrollMeterApplication).graph + intent = NavIntents.scrubbed(intent) // before the NavHost reads it setContent { val theme by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = null) val dark = when (theme?.theme) { diff --git a/app/src/main/java/com/scrollmeter/app/apps/PackageNames.kt b/app/src/main/java/com/scrollmeter/app/apps/PackageNames.kt new file mode 100644 index 0000000..b807847 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/apps/PackageNames.kt @@ -0,0 +1,9 @@ +package com.scrollmeter.app.apps + +/** Android package names: two or more dot-separated segments of letters, digits and "_", each starting with a letter. */ +object PackageNames { + private val PATTERN = Regex("^[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+$") + private const val MAX_LENGTH = 255 + + fun isValid(name: String): Boolean = name.length <= MAX_LENGTH && PATTERN.matches(name) +} diff --git a/app/src/main/java/com/scrollmeter/app/insights/AppPeriodTotals.kt b/app/src/main/java/com/scrollmeter/app/insights/AppPeriodTotals.kt new file mode 100644 index 0000000..e4bdaee --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/insights/AppPeriodTotals.kt @@ -0,0 +1,32 @@ +package com.scrollmeter.app.insights + +import com.scrollmeter.app.data.model.DaySummary + +/** + * One app over a period, summed from its day rows (spec §24, D19). Null = no data of that kind. + * + * Distance and scroll time go back to the first measured day, but time in app exists only for days + * the usage sync covered (it looks back at most 9 days when access is granted — ADR-025). Pace and + * the scroll share therefore pair both sides over the same days: [pairedDistanceMm] and + * [pairedScrollMs] count only days that have a time in app. Pure Kotlin. + */ +data class AppPeriodTotals( + val distanceMm: Double?, + val activeScrollMs: Long?, + val foregroundMs: Long?, + val pairedDistanceMm: Double, + val pairedScrollMs: Long, +) { + companion object { + fun of(days: List): AppPeriodTotals { + val paired = days.filter { it.foregroundMs != null } + return AppPeriodTotals( + distanceMm = days.mapNotNull { it.distanceMm }.takeIf { it.isNotEmpty() }?.sum(), + activeScrollMs = days.mapNotNull { it.activeScrollMs }.takeIf { it.isNotEmpty() }?.sum(), + foregroundMs = paired.takeIf { it.isNotEmpty() }?.sumOf { it.foregroundMs ?: 0L }, + pairedDistanceMm = paired.sumOf { it.distanceMm ?: 0.0 }, + pairedScrollMs = paired.sumOf { it.activeScrollMs ?: 0L }, + ) + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/insights/HistorySeries.kt b/app/src/main/java/com/scrollmeter/app/insights/HistorySeries.kt index d981bcc..8186cc1 100644 --- a/app/src/main/java/com/scrollmeter/app/insights/HistorySeries.kt +++ b/app/src/main/java/com/scrollmeter/app/insights/HistorySeries.kt @@ -57,8 +57,10 @@ object HistorySeriesBuilder { } private fun stats(allDays: List, byDate: Map, first: LocalDate?, today: LocalDate): HistoryStats? { - if (first == null || first.isAfter(today)) return null - val counted = allDays.filter { !it.isBefore(first) }.map { DayValue(it, byDate[it] ?: 0.0) } + 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( 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 70ef097..1bb0834 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 @@ -31,10 +31,10 @@ 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.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.AppPeriodTotals import com.scrollmeter.app.insights.AppRanking import com.scrollmeter.app.insights.AppsPeriod import com.scrollmeter.app.insights.ChartScale @@ -72,9 +72,9 @@ fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { val settings by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = Settings()) val unit = settings.unitPreference val range = period.range(today) - val summary by remember(range, packageName) { - graph.scrollRepository.apps(range).map { apps -> apps.firstOrNull { it.packageName == packageName } } - }.collectAsStateWithLifecycle(initialValue = null) + 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 days by remember(chartRange, packageName) { graph.scrollRepository.appDays(packageName, chartRange) } .collectAsStateWithLifecycle(initialValue = emptyList()) @@ -97,7 +97,7 @@ fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { Text(label, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) } PeriodSelector(AppsPeriod.entries, period, { stringResource(appsPeriodLabel(it)) }, { period = it }) - PeriodSummary(summary, usageGranted, ::distance, locale) + PeriodSummary(totals, usageGranted, ::distance, locale) val basis = allTime if (basis?.distanceMm != null) { @@ -112,12 +112,14 @@ 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 }) } ChartCard( title = stringResource(R.string.app_detail_distance_chart), values = distanceSeries.bars.map { it.value }, - scale = remember(distanceSeries) { ChartLabels.distanceScale(distanceSeries.bars.maxOf { it.value }) }, - axisLabel = { ChartLabels.distanceAxis(it, unit, locale) }, + 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)) }, ) val timeSeries = remember(days, today, usageGranted) { @@ -132,6 +134,7 @@ fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { scale = remember(timeSeries) { ChartLabels.minutesScale(timeSeries.bars.maxOf { it.value }) }, axisLabel = ChartLabels::minutesAxis, barLabel = { labels.bar(HistoryPeriod.DAYS_30, timeSeries.bars[it]) }, + firstBar = timeSeries.range.from.toString(), selectedText = { i -> stringResource(R.string.history_day_value, labels.day(timeSeries.bars[i].start), TimeFormatter.duration(timeSeries.bars[i].value.toLong())) }, @@ -141,10 +144,11 @@ fun AppDetailScreen(graph: AppGraph, packageName: String, onBack: () -> Unit) { /** * 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. + * 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: AppSummary?, usageGranted: Boolean, distance: (Double) -> String, locale: Locale) { +private fun PeriodSummary(app: AppPeriodTotals?, 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) @@ -156,14 +160,14 @@ private fun PeriodSummary(app: AppSummary?, usageGranted: Boolean, distance: (Do SummaryRow(stringResource(R.string.app_detail_scroll_time), TimeFormatter.duration(app?.activeScrollMs)) if (usageGranted) { HorizontalDivider() - SummaryRow(stringResource(R.string.app_detail_pace), TimeFormatter.pace(app?.distanceMm, app?.foregroundMs, locale)) + SummaryRow(stringResource(R.string.app_detail_pace), TimeFormatter.pace(app?.pairedDistanceMm, app?.foregroundMs, locale)) } - val share = if (usageGranted) ScrollShare.percent(app?.activeScrollMs, app?.foregroundMs) else null + val share = if (usageGranted) ScrollShare.percent(app?.pairedScrollMs, app?.foregroundMs) else null val note = when { app != null && app.distanceMm == null && app.foregroundMs != null -> stringResource(R.string.app_detail_no_scroll) share != null -> stringResource( R.string.app_detail_scroll_share, - TimeFormatter.duration(app?.activeScrollMs), + TimeFormatter.duration(app?.pairedScrollMs), TimeFormatter.duration(app?.foregroundMs), share.toString(), ) @@ -192,9 +196,11 @@ private fun ChartCard( scale: ChartScale, axisLabel: (Double) -> String, barLabel: (Int) -> String, + firstBar: String, selectedText: @Composable (Int) -> String, ) { - var selected by rememberSaveable(values.size) { mutableStateOf(null) } + // Keyed by the first bar's date: after midnight the same index is another day. + var selected by rememberSaveable(firstBar) { mutableStateOf(null) } Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(selected?.takeIf { it in values.indices }?.let { selectedText(it) } ?: title, style = MaterialTheme.typography.titleSmall) 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 index af405f7..6eebe7f 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/apps/AppsScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/apps/AppsScreen.kt @@ -65,9 +65,12 @@ fun AppsScreen(graph: AppGraph, onOpenApp: (String) -> Unit) { 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 = emptyList()) + 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) { - apps?.let { AppRanking.rank(it, sort, usageGranted, calibration, allTime.associateBy { a -> a.packageName }) } + val period = apps ?: return@remember null + val basis = allTime ?: return@remember null + AppRanking.rank(period, sort, usageGranted, calibration, basis.associateBy { it.packageName }) } LazyColumn( 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 5ed33c6..b046f7c 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 @@ -106,7 +106,8 @@ fun BarChart( xLabels.forEachIndexed { i, label -> if ((values.lastIndex - i) % every != 0 || label.size.width == 0) return@forEachIndexed val center = plotLeft + i * slot + slot / 2 - val x = (center - label.size.width / 2f).coerceIn(plotLeft, size.width - label.size.width) + // max(): on a plot narrower than the label (split screen) the range must not invert. + val x = (center - label.size.width / 2f).coerceIn(plotLeft, max(plotLeft, size.width - label.size.width)) drawText(label, topLeft = Offset(x, plotBottom + gapPx / 2)) } } 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 d564325..fd2ad83 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 @@ -37,15 +37,20 @@ class ChartLabels(locale: Locale) { fun distanceScale(maxMm: Double): ChartScale = ChartScale.of(maxMm, emptyMax = 100_000.0) /** - * Grid values are round, so without the fixed decimals of `DistanceFormatter`: "250 m", "2 km", - * "2,5 km". The unit follows the preference; AUTOMATIC switches to km at 1 km. + * Labels for a distance axis. Grid values are round, so without the fixed decimals of + * `DistanceFormatter`: "250 m", "2 km", "2,5 km". One unit per axis — AUTOMATIC picks km when + * the axis reaches 1 km, so it never reads "750 m · 1 km". */ - fun distanceAxis(mm: Double, unit: UnitPreference, locale: Locale): String { - if (mm == 0.0) return "0" - val metres = mm / 1_000.0 - val km = unit == UnitPreference.KILOMETRES || (unit == UnitPreference.AUTOMATIC && metres >= 1_000) + fun distanceAxis(scale: ChartScale, unit: UnitPreference, locale: Locale): (Double) -> String { + val km = unit == UnitPreference.KILOMETRES || (unit == UnitPreference.AUTOMATIC && scale.max >= 1_000_000.0) val number = NumberFormat.getNumberInstance(locale).apply { maximumFractionDigits = 3 } - return if (km) "${number.format(metres / 1_000.0)} km" else "${number.format(metres)} m" + return { mm -> + when { + mm == 0.0 -> "0" + km -> "${number.format(mm / 1_000_000.0)} km" + else -> "${number.format(mm / 1_000.0)} m" + } + } } /** Time axes run in minutes so the steps read as clock time. */ 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 index 854b776..9a2c9d8 100644 --- a/app/src/main/java/com/scrollmeter/app/ui/history/HistoryScreen.kt +++ b/app/src/main/java/com/scrollmeter/app/ui/history/HistoryScreen.kt @@ -83,7 +83,7 @@ fun HistoryScreen(graph: AppGraph) { BarChart( values = series.bars.map { it.value }, scale = scale, - axisLabel = { ChartLabels.distanceAxis(it, unit, locale) }, + axisLabel = remember(scale, unit, locale) { ChartLabels.distanceAxis(scale, unit, locale) }, barLabel = { labels.bar(period, series.bars[it]) }, contentDescription = stringResource( R.string.history_chart_description, diff --git a/app/src/main/java/com/scrollmeter/app/ui/navigation/NavIntents.kt b/app/src/main/java/com/scrollmeter/app/ui/navigation/NavIntents.kt new file mode 100644 index 0000000..7116206 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/navigation/NavIntents.kt @@ -0,0 +1,24 @@ +package com.scrollmeter.app.ui.navigation + +import android.content.Intent +import android.os.Bundle + +/** + * Navigation's `handleDeepLink` honours explicit deep-link extras (`android-support-nav:controller:…`) + * on any intent that starts the exported launcher activity — no intent filter needed. ScrollMeter + * never sends them, so no other app may: they are removed before the NavHost reads the intent. + */ +object NavIntents { + private const val NAV_EXTRA_PREFIX = "android-support-nav:" + + fun scrubbed(intent: Intent): Intent { + val navKeys = try { + intent.extras?.keySet()?.filter { it.startsWith(NAV_EXTRA_PREFIX) }.orEmpty() + } catch (e: RuntimeException) { + // Extras that cannot even be unparcelled did not come from us: drop them all. + return Intent(intent).replaceExtras(null as Bundle?) + } + navKeys.forEach(intent::removeExtra) + return intent + } +} 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 29d7d6e..8befd70 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 @@ -18,7 +18,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.lifecycle.Lifecycle import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController import androidx.navigation.NavDestination.Companion.hasRoute @@ -31,6 +30,7 @@ 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.devtools.DevTools import com.scrollmeter.app.ui.apps.AppDetailScreen import com.scrollmeter.app.ui.apps.AppsScreen @@ -124,7 +124,13 @@ fun ScrollMeterNavHost(graph: AppGraph, initialDevTool: String?, onOpenAccessibi ) } composable { back -> - AppDetailScreen(graph, back.toRoute().packageName, onBack = { nav.leave(back) }) + val packageName = back.toRoute().packageName + // 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) }) + } else { + LaunchedEffect(back) { nav.leave(back) } + } } composable { back -> AccuracyScreen(graph, onBack = { nav.leave(back) }, onCalibrate = { nav.open(CalibrationRoute) }) @@ -146,9 +152,10 @@ fun ScrollMeterNavHost(graph: AppGraph, initialDevTool: String?, onOpenAccessibi private fun NavController.open(route: Any) = navigate(route) { launchSingleTop = true } /** - * Leaves [entry] only while it is the resumed screen: a second tap on "Zpět" during the exit - * animation would otherwise pop the screen below as well — down to an empty host. + * 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 + * lifecycle: an entry is not RESUMED during its enter animation or in an unfocused split window. */ private fun NavController.leave(entry: NavBackStackEntry) { - if (entry.lifecycle.currentState == Lifecycle.State.RESUMED) popBackStack() + if (currentBackStackEntry?.id == entry.id) popBackStack() } diff --git a/app/src/test/java/com/scrollmeter/app/apps/PackageNamesTest.kt b/app/src/test/java/com/scrollmeter/app/apps/PackageNamesTest.kt new file mode 100644 index 0000000..053588d --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/apps/PackageNamesTest.kt @@ -0,0 +1,24 @@ +package com.scrollmeter.app.apps + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** A route argument that is not a package name must never reach the screen as an app label. */ +class PackageNamesTest { + @Test + fun realPackageNamesPass() { + assertThat(PackageNames.isValid("com.android.chrome")).isTrue() + assertThat(PackageNames.isValid("com.google.android.apps.nexuslauncher")).isTrue() + assertThat(PackageNames.isValid("org.telegram.messenger_web")).isTrue() + } + + @Test + fun anythingElseFails() { + assertThat(PackageNames.isValid("Váš účet byl napaden, volejte 123")).isFalse() + assertThat(PackageNames.isValid("chrome")).isFalse() + assertThat(PackageNames.isValid("com..chrome")).isFalse() + assertThat(PackageNames.isValid("com.1chrome")).isFalse() + assertThat(PackageNames.isValid("a." + "b".repeat(300))).isFalse() + assertThat(PackageNames.isValid("")).isFalse() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/insights/AppPeriodTotalsTest.kt b/app/src/test/java/com/scrollmeter/app/insights/AppPeriodTotalsTest.kt new file mode 100644 index 0000000..49d34e4 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/insights/AppPeriodTotalsTest.kt @@ -0,0 +1,31 @@ +package com.scrollmeter.app.insights + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.data.model.DaySummary +import org.junit.Test + +/** D19: pace and scroll share only over days that have both sides. */ +class AppPeriodTotalsTest { + @Test + fun paceSidesArePairedOverDaysWithTimeInApp() { + val days = listOf( + DaySummary("2026-09-01", 100_000.0, 60_000L, null), // before Usage access: no time in app + DaySummary("2026-09-20", 20_000.0, 30_000L, 600_000L), + DaySummary("2026-09-21", null, null, 300_000L), // time only + ) + val totals = AppPeriodTotals.of(days) + assertThat(totals.distanceMm).isEqualTo(120_000.0) + assertThat(totals.activeScrollMs).isEqualTo(90_000L) + assertThat(totals.foregroundMs).isEqualTo(900_000L) + assertThat(totals.pairedDistanceMm).isEqualTo(20_000.0) + assertThat(totals.pairedScrollMs).isEqualTo(30_000L) + } + + @Test + fun noDataOfAKindIsNull() { + val totals = AppPeriodTotals.of(listOf(DaySummary("2026-09-20", null, null, 300_000L))) + assertThat(totals.distanceMm).isNull() + assertThat(totals.activeScrollMs).isNull() + assertThat(AppPeriodTotals.of(emptyList()).foregroundMs).isNull() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/insights/HistorySeriesTest.kt b/app/src/test/java/com/scrollmeter/app/insights/HistorySeriesTest.kt index 139225b..2b4ea74 100644 --- a/app/src/test/java/com/scrollmeter/app/insights/HistorySeriesTest.kt +++ b/app/src/test/java/com/scrollmeter/app/insights/HistorySeriesTest.kt @@ -60,4 +60,13 @@ class HistorySeriesTest { 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) + } } 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 186f39e..e01976e 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 @@ -14,15 +14,18 @@ class ChartLabelsTest { private val cs = Locale.forLanguageTag("cs") @Test - fun distanceAxisIsRoundInMetresAndKilometres() { + fun distanceAxisIsRoundWithOneUnitPerAxis() { assertThat(ChartLabels.distanceScale(423_000.0)).isEqualTo(ChartScale(600_000.0, 200_000.0)) assertThat(ChartLabels.distanceScale(0.0)).isEqualTo(ChartScale(100_000.0, 25_000.0)) - assertThat(ChartLabels.distanceAxis(0.0, UnitPreference.AUTOMATIC, cs)).isEqualTo("0") - assertThat(ChartLabels.distanceAxis(200_000.0, UnitPreference.AUTOMATIC, cs)).isEqualTo("200 m") - assertThat(ChartLabels.distanceAxis(2_000_000.0, UnitPreference.AUTOMATIC, cs)).isEqualTo("2 km") - assertThat(ChartLabels.distanceAxis(2_500_000.0, UnitPreference.AUTOMATIC, cs)).isEqualTo("2,5 km") - assertThat(ChartLabels.distanceAxis(25_000.0, UnitPreference.KILOMETRES, cs)).isEqualTo("0,025 km") - assertThat(ChartLabels.distanceAxis(2_000_000.0, UnitPreference.METRES, cs)).isEqualTo("2\u00a0000 m") + val metres = ChartLabels.distanceAxis(ChartScale(600_000.0, 200_000.0), UnitPreference.AUTOMATIC, cs) + assertThat(metres(0.0)).isEqualTo("0") + assertThat(metres(200_000.0)).isEqualTo("200 m") + // An axis that reaches 1 km is in km all the way down — never "750 m · 1 km". + val km = ChartLabels.distanceAxis(ChartScale(1_000_000.0, 250_000.0), UnitPreference.AUTOMATIC, cs) + assertThat(km(250_000.0)).isEqualTo("0,25 km") + assertThat(km(1_000_000.0)).isEqualTo("1 km") + assertThat(ChartLabels.distanceAxis(ChartScale(100_000.0, 25_000.0), UnitPreference.KILOMETRES, cs)(25_000.0)).isEqualTo("0,025 km") + assertThat(ChartLabels.distanceAxis(ChartScale(3_000_000.0, 1_000_000.0), UnitPreference.METRES, cs)(2_000_000.0)).isEqualTo("2\u00a0000 m") } @Test diff --git a/app/src/test/java/com/scrollmeter/app/ui/navigation/NavIntentsTest.kt b/app/src/test/java/com/scrollmeter/app/ui/navigation/NavIntentsTest.kt new file mode 100644 index 0000000..306a356 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/ui/navigation/NavIntentsTest.kt @@ -0,0 +1,30 @@ +package com.scrollmeter.app.ui.navigation + +import android.content.Intent +import android.os.Bundle +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Another app must not open a screen of ours through Navigation's deep-link extras. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class NavIntentsTest { + @Test + fun navigationDeepLinkExtrasAreRemovedAndOthersKept() { + val intent = Intent(Intent.ACTION_MAIN) + .putExtra("android-support-nav:controller:deepLinkIds", intArrayOf(1, 2)) + .putExtra("android-support-nav:controller:deepLinkArgs", arrayListOf()) + .putExtra("devtool", "testlist") + val scrubbed = NavIntents.scrubbed(intent) + assertThat(scrubbed.extras!!.keySet()).containsExactly("devtool") + } + + @Test + fun anIntentWithoutExtrasIsUntouched() { + val intent = Intent(Intent.ACTION_MAIN) + assertThat(NavIntents.scrubbed(intent).extras).isNull() + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8850891..c7335da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } -kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } +kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } From 847b5b64bae32b94a653d3f0364b38f06833d392 Mon Sep 17 00:00:00 2001 From: Honza Hubka Date: Thu, 24 Sep 2026 09:49:48 +0200 Subject: [PATCH 5/5] docs: Phase 5 exit report, ADR-029 details, README Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 13 +++++++- docs/measurement-decisions.md | 2 +- handoff.md | 59 ++++++++++++++++++++++++++++++++--- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b5e081e..4749320 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ finger across the touchscreen. Project founded 2026-09-23. Merged: Phase 0 (bootstrap, `v0.0`), Phase 1 (measurement proof of concept, GO/NO-GO gate, `v0.1`), Phase 2 (calibration: card calibration, accuracy screen, accuracy tooling, `v0.2`), -Phase 3 (persistence: Room, flushes, sessions, settings, optional time in app, `v0.3`), Phase 4 (dashboard, `v0.4`). +Phase 3 (persistence: Room, flushes, sessions, settings, optional time in app, `v0.3`), Phase 4 (dashboard, `v0.4`), +Phase 5 (history, apps, app detail, bottom navigation, `v0.5`). 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). @@ -108,6 +109,16 @@ written yet. When the service is off, a red banner with *Zapnout měření* come 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. *Nastavení* holds the service and Usage-access status, *Přesnost +měření* and, in debug builds, the developer screens. + ## Stored data and time in app (Phase 3) Distances are stored per day and app in an app-private Room database (`scrollmeter.db`) and survive app diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index 946d016..f305544 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -33,4 +33,4 @@ supersede with a new entry that references the old one. | ADR-026 | 2026-09-24 | **Persistence (Phase 3).** Room v1 (`scrollmeter.db`, schema JSON in `app/schemas/`): `daily_app_aggregate` (SPEC §17 + `calibrationVersion` + `activeScrollMs`), `scroll_session`, `daily_app_usage`. A flush is one transaction of *insert-or-add* per (date, package): `INSERT OR IGNORE` of an empty row, then `UPDATE … SET x = x + :x`, first timestamp = MIN, last = MAX, `calibrationVersion` = MAX — two statements because API 28 ships SQLite 3.22 and `ON CONFLICT DO UPDATE` needs 3.24. The service's pipeline (`ScrollPipeline`, pure Kotlin, one thread via `Dispatchers.Default.limitedParallelism(1)`) writes every 10 s while data is pending, at the 50th event, at a new local day, on `onInterrupt`, and as the last act of the pipeline on unbind / destroy / reconnect; the write itself runs `NonCancellable` so a committed transaction is never restored and written twice; a failed write keeps its deltas and sessions for the next flush. Our own package is **never stored** — in debug test mode it is measured for the live comparison only. Debug builds may drop the DB on a schema change; release builds need a real migration. `calibrationVersion` stays informational (the newest calibration a row was measured under), not a key: it can repeat only if the calibration DataStore is lost while the DB survives, and nothing joins on it. | Spec §16 caps the loss at one flush interval. Measured on the API 34 emulator (Settings, `input swipe`): after 12 s the DB equals the logcat sum of counted events (242.026 vs 242.027 mm, display rounding); `kill -9` right after scrolling lost exactly the unflushed ~4 s (463.85 mm), the system restarted the service by itself and the next flush added up (704.19 mm = 242.03 + 462.16). **`am force-stop` also clears the service from `enabled_accessibility_services` on API 34** — measurement stays off until the user turns it on again, and Home says so (spec §32). | accepted | | ADR-027 | 2026-09-24 | **Nothing leaves the phone through Android's data transfer.** Besides `allowBackup="false"` (API 28–30), `android:dataExtractionRules` excludes every domain from both `` and `` (API 31+): the database, both DataStores and any file stay on the phone that measured them. Taking data along = CSV export (Phase 6). Guards: `PolicyGuardTest` pins the rules file; `check_manifest_policy.py` requires the attribute, checks `uses-permission-sdk-23` as well, matches the androidx signature permission against the manifest's own package exactly, and fails on any exported component other than the launcher activity that has no permission. The service and the usage sync read settings through a flow that does **not** fall back to defaults on a read error — an unreadable file keeps the exclusions applied last instead of measuring apps the user excluded. | Security review of Phase 3: on API 31+ `allowBackup="false"` stops cloud backup but, on some devices, not device-to-device transfer, which would copy the scroll history, time in app and the card calibration to a new phone — against the disclosure's "data zůstávají v telefonu" and the stale-calibration risk ADR-024 names. The history is not worth weakening that promise; the open point on D2D transfer (handoff.md) is closed by this. | accepted | | ADR-028 | 2026-09-24 | **Dashboard presentation rules (Phase 4).** (1) Distances: AUTOMATIC shows metres below 1 km and km above, decided on the *rounded* value; below 10 m one decimal, km with 2 / 1 / 0 decimals below 10 / 100 / above (`DistanceFormatter`). (2) Numbers are formatted in the locale of the app's strings (`number_locale`, `cs` now; values-en in Phase 7), not the device's — a Czech UI never mixes in "2.84 km". (3) Durations floor to minutes; under a minute "< 1 min"; unknown "—", never "0 min"; pace (m/min) only when the time is known and ≥ 1 min. (4) One comparison (spec §22): the longest reference the distance reaches ≥ 80 % of — "téměř" < 95 %, "přibližně" ≤ 110 %, "víc než" < 150 %, else "přibližně N×"; none under 84 m. Czech plurals live in resources. (5) Top apps today: apps with distance, longest first, four rows plus "Ostatní"; exactly five apps → all five by name. Rows show time in app + pace with Usage access, scroll time without it. (6) The Usage-access card shows until access is granted or the user taps "Teď ne" (stored). (7) App labels and icons via `PackageManager` with the manifest's single `` launcher intent (ADR-008, pinned by `PolicyGuardTest`); an invisible app shows its package name. (8) The theme follows `Settings.theme` (system / light / dark). | Spec §21, §22, §25, §42, §43; PLAN Phase 4 DoD ("never 0 min for an unknown value"). The thresholds keep the one comparison honest: the sentence never claims more than the reference by more than half. | accepted | -| ADR-029 | 2026-09-24 | **History, apps and navigation (Phase 5).** (1) `MeasurementQuality` (spec §20) from an app's **all-time** counters, whatever period is shown — it describes how the app reports scrolling, and rating a single morning would read "málo dat" every day: UNKNOWN below 20 events (measured + fallback + unmeasurable + outliers); LOW when ≥ 50 % are unmeasurable or ≥ 5 % are outliers; HIGH when ≥ 90 % of the counted events are direct deltas, < 10 % unmeasurable, < 1 % outliers **and** the scale in force is the card (confidence HIGH); MEDIUM otherwise. The UI shows the word and one explaining sentence, never a percentage. It rates with the calibration in force *now* — rows keep `calibrationVersion`, not the method, so a day measured before a card calibration can read HIGH; accepted, the word mostly describes how the app reports scrolling. (2) Historie: bars per day (7 / 30 days) or per calendar month (12 months, from the first day of the month 11 months back); missing days are 0. Průměr / den, Nejvyšší den, Nejnižší den, Celkem are always **per day**, and only days since the first measured day of all time count, so a new user's average is not diluted by days before install; ties go to the most recent day. Axes: distance in round steps of 1 / 2 / 2,5 / 5 × 10ⁿ mm (round in m and km too), time in minutes with clock steps (1 … 720 min); axis labels drop trailing zeros ("2 km"). A tapped bar shows its date and value. (3) Aplikace: Dnes / 7 dní / 30 dní / Celkem (Celkem = `DateRange.ALL`, 1970–9999, so keys still compare as strings); sort by distance, or by time (time in app with Usage access, scroll time without); share = the app's distance / the period's total distance. Apps with time but no scroll data (YouTube) show "—" and "bez dat o scrollu", have no share or quality, and sort after the apps with data of the chosen kind. An app we cannot see keeps its package name (ADR-008). Detail: the period's distance, time in app, scroll time, pace (pace only with Usage access, ADR-022), quality with its explanation, and 30-day charts of distance and time. "Scrolluješ X z Y v aplikaci (Z %)" appears only with Usage access and ≥ 1 min in the app; Z is clamped to 0–100 because the two times come from different sources. (4) Navigation Compose 2.9.8 with `@Serializable` routes (D14; kotlinx-serialization 1.9.0, R8 release verified); bottom bar only on the four top-level screens; developer screens moved from Přehled to Nastavení and are a route registered only in debug. | Spec §20, §23, §24, §43; PLAN Phase 5 DoD ("žádné falešné procento přesnosti v UI"). Statistics from the first measured day and the clamped share keep every number defensible without ground truth. | accepted | +| ADR-029 | 2026-09-24 | **History, apps and navigation (Phase 5).** (1) `MeasurementQuality` (spec §20) from an app's **all-time** counters, whatever period is shown — it describes how the app reports scrolling, and rating a single morning would read "málo dat" every day: UNKNOWN below 20 events (measured + fallback + unmeasurable + outliers); LOW when ≥ 50 % are unmeasurable or ≥ 5 % are outliers; HIGH when ≥ 90 % of the counted events are direct deltas, < 10 % unmeasurable, < 1 % outliers **and** the scale in force is the card (confidence HIGH); MEDIUM otherwise. The UI shows the word and one explaining sentence, never a percentage. It rates with the calibration in force *now* — rows keep `calibrationVersion`, not the method, so a day measured before a card calibration can read HIGH; accepted, the word mostly describes how the app reports scrolling. (2) Historie: bars per day (7 / 30 days) or per calendar month (12 months, from the first day of the month 11 months back); missing days are 0. Průměr / den, Nejvyšší den, Nejnižší den, Celkem are always **per day**, and only days since the first measured day of all time count, so a new user's average is not diluted by days before install; ties go to the most recent day. Axes: distance in round steps of 1 / 2 / 2,5 / 5 × 10ⁿ mm (round in m and km too), time in minutes with clock steps (1 … 720 min); axis labels drop trailing zeros ("2 km"). A tapped bar shows its date and value. (3) Aplikace: Dnes / 7 dní / 30 dní / Celkem (Celkem = `DateRange.ALL`, 1970–9999, so keys still compare as strings); sort by distance, or by time (time in app with Usage access, scroll time without); share = the app's distance / the period's total distance. Apps with time but no scroll data (YouTube) show "—" and "bez dat o scrollu", have no share or quality, and sort after the apps with data of the chosen kind. An app we cannot see keeps its package name (ADR-008). Detail: the period's distance, time in app, scroll time, pace (pace only with Usage access, ADR-022), quality with its explanation, and 30-day charts of distance and time. Time in app exists only for days the usage sync covered (≤ 9 days back from the grant, ADR-025), so pace and "Scrolluješ X z Y v aplikaci (Z %)" pair distance and scroll time with it over **the same days** (`AppPeriodTotals`); the sentence appears only with Usage access and ≥ 1 min in the app, and Z is clamped to 0–100 because the two times come from different sources. (4) Navigation Compose 2.9.8 with `@Serializable` routes (D14; kotlinx-serialization-core 1.9.0, R8 release verified); bottom bar only on the four top-level screens; developer screens moved from Přehled to Nastavení and are a route registered only in debug. Navigation honours explicit deep-link extras (`android-support-nav:controller:…`) on any intent that starts the exported launcher activity; ScrollMeter never sends them, so `MainActivity` removes them (`NavIntents`), and the detail route renders only a well-formed package name (`PackageNames`). Distance axes use one unit per axis (km once the axis reaches 1 km). | Spec §20, §23, §24, §43; PLAN Phase 5 DoD ("žádné falešné procento přesnosti v UI"). Statistics from the first measured day and the clamped share keep every number defensible without ground truth. | accepted | diff --git a/handoff.md b/handoff.md index b6579b4..39760a4 100644 --- a/handoff.md +++ b/handoff.md @@ -20,7 +20,7 @@ | Kotlin / KSP | 2.3.21 / 2.3.11 | 2.3.21 / 2.3.11 (stdlib 2.3.21) | Compose compiler v Kotlinu | | Compose BOM | **2026.06.01** | → compose-ui/foundation/runtime **1.11.4**, material3 **1.4.0** | 2026.08 chce compileSdk 37 + AGP 9.1 | | Room / DataStore | 2.8.4 / 1.1.7 | Room 2.8.4 s `@Database` od Phase 3 (schéma v `app/schemas/`, R8 release ověřený na emulátoru); DataStore Preferences 1.1.7 zapojený v Phase 2 (kalibrace) a Phase 3 (nastavení) — pin beze změny, tranzitivně přibylo `com.squareup.okio:okio 3.4.0`, coroutines zůstávají 1.10.2 | | -| Navigation / Lifecycle / Activity / core-ktx | 2.9.8 / 2.9.4 / 1.13.0 / 1.18.0 | Lifecycle 2.9.4, Activity Compose 1.13.0, core-ktx 1.18.0; Navigation až Phase 4 | | +| Navigation / Lifecycle / Activity / core-ktx | 2.9.8 / 2.9.4 / 1.13.0 / 1.18.0 | Navigation Compose **2.9.8 zapojená v Phase 5** (type-safe routes) + plugin `kotlin.plugin.serialization` (= Kotlin 2.3.21) a `kotlinx-serialization-core` **1.9.0** — v katalogu byl od Phase 0 `-json`, vyměněn za `-core` (routy JSON nepotřebují); navigation tranzitivně chce core 1.7.3 → pin ho zvedá na 1.9.0. Lifecycle zůstává 2.9.4, coroutines 1.10.2, Activity Compose 1.13.0, core-ktx 1.18.0 | R8 release s routami ověřený na emulátoru | | coroutines | 1.10.2 | 1.10.2 | | | Testy | JUnit 4.13.2 · Truth 1.4.5 · Robolectric 4.16 · coroutines-test 1.10.2 | vše zapojené; Robolectric (`@Config(sdk = [34])`) pro parser a in-memory Room, coroutines-test pro `ScrollPipelineTest` ve virtuálním čase (Phase 3) | | | compileSdk / target / min | 36 / 36 / **28** | 36 / 36 / 28 | `scrollDeltaX/Y` od API 28 | @@ -41,11 +41,47 @@ | 2 Kalibrace | hotovo, mergnuto (tag `v0.2`) | `phase-2-calibration` / [#4](https://github.com/hupcus/scrollmeter/pull/4) | kalibrace kartou + MAPE s ní → „Dluh ověření“ | | 3 Persistence | hotovo, mergnuto (tag `v0.3`) | `phase-3-persistence` / [#5](https://github.com/hupcus/scrollmeter/pull/5) | + čas v aplikaci (D19); restart telefonu a Digital Wellbeing → „Dluh ověření“ V4, V5 | | 4 Dashboard | hotovo, mergnuto (tag `v0.4`) | `phase-4-dashboard` / [#6](https://github.com/hupcus/scrollmeter/pull/6) | ADR-028; CI zablokované billingem → brána v čistém checkoutu | -| 5 Historie + Aplikace | nezačato | — | | +| 5 Historie + Aplikace | hotovo, mergnuto (tag `v0.5`) | `phase-5-history-apps` / [#7](https://github.com/hupcus/scrollmeter/pull/7) | ADR-029; CI zablokované billingem → brána v čistém checkoutu | | 6 Export + Nastavení | nezačato | — | | | 7 Onboarding + Policy | nezačato | — | | | 8 Release | nezačato | — | | +## Phase 5 — exit report (2026-09-24) + +**Hotovo a ověřené** (větev `phase-5-history-apps`): +- **Spodní navigace** Přehled / Historie / Aplikace / Nastavení (SPEC §43). Navigation Compose 2.9.8 s `@Serializable` routami (D14). Lišta je jen na čtyřech hlavních obrazovkách; přepnutí záložky si pamatuje stav; dvojí „Zpět“ neodskočí o obrazovku níž. +- **Historie** (SPEC §23): + - 7 dní / 30 dní / 12 měsíců ve vlastním sloupcovém grafu (Canvas, D13); + - kulaté osy s jednou jednotkou na osu („0 · 200 m · 400 m“, „0,25 km … 1 km“); + - klepnutí na sloupec ukáže den a hodnotu; + - pod grafem Průměr / den, Nejvyšší den, Nejnižší den, Celkem — vždy po dnech, od prvního naměřeného dne. +- **Aplikace** (SPEC §24, D19): + - Dnes / 7 dní / 30 dní / Celkem, řazení Vzdálenost | Čas; + - podíl na vzdálenosti a kvalita slovem, nikdy procentem přesnosti; + - YouTube a jiné aplikace jen s časem: „—“ a „bez dat o scrollu“; + - řádek otevře **detail**: souhrn období, tempo, „Scrolluješ X z Y v aplikaci (Z %)“, kvalita s vysvětlením, grafy vzdálenosti a času za 30 dní. Top aplikace na Přehledu vedou do detailu taky. +- **Kvalita měření** (`MeasurementQuality`, ADR-029): + - hodnotí se z čítačů za celou dobu, ne za zvolené období — jinak by ráno všude svítilo „málo dat“; + - HIGH jen s kalibrací kartou. +- **Nastavení** (jádro): stav služby, čas v aplikacích (bez oprávnění → vysvětlení; s oprávněním → systémová stránka, kde jde odebrat), Přesnost měření. V debug buildu jsou tady vývojářské nástroje, z Přehledu zmizely. +- **Brány:** + - 216 JVM testů; + - lint: jen 6 starých `SetTextI18n` z debug plochy Phase 1; + - debug + release build, 25 Python testů, manifest policy. +- **Emulátor:** + - 19 snímků (světlý i tmavý motiv) zkontroloval subagent; + - R8 release: navigace, detail s argumentem, žádné debug třídy v dexu; + - útok přes deep-link extras nic neotevřel; + - vývojářská obrazovka přes `--es devtool` funguje. +- **Review:** + - `/topshit`: kvalita za celou dobu, lokalizovatelný podtitulek řádku, zbytečný dotaz z detailu pryč. + - Stage 2 (Fable): 2× MEDIUM opraveno: + - cizí aplikace mohla přes explicitní deep-link extras Navigation otevřít detail s libovolným textem jako názvem → `NavIntents.scrubbed` + `PackageNames.isValid`; + - tempo a podíl míchaly dny bez času v aplikaci → `AppPeriodTotals` páruje dny. + - Stage 2: 5× LOW opraveno, 1× INFO opraveno; `-json` vyměněn za `-core`. + +**Zjištění:** Launcher a systémové balíčky bez spouštěcí ikony (např. `com.google.android.apps.nexuslauncher`) se v seznamu ukazují názvem balíčku. Je to záměr (ADR-008, jediný `` na LAUNCHER). Phase 6 je stejně navrhne jako výchozí výluky (`resolveActivity(HOME)` potřebuje `` pro HOME → ADR). + ## Phase 4 — exit report (2026-09-24) **Hotovo a ověřené** (větev `phase-4-dashboard`): @@ -211,9 +247,10 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop - [ ] **Phase 6 „Smazat všechna data“** musí smazat i Room (`clearAllTables()`), ne jen DataStore; `scroll_session` roste bez limitu (~desítky řádků denně) — rozhodnout retenci (bezpečnostní review, INFO). - [ ] Vyloučení aplikace platí od chvíle vyloučení (engine: EXCLUDED, sync času: vynechá ji v přepočítaných dnech). Starší řádky zůstávají — Phase 6 rozhodne, jestli je čtení skryje i v historii. - [ ] **Instagram + TikTok (+ X, Reddit) doměřit s účtem** → Dluh ověření V1. GO dané bez nich (přijaté riziko). Reddit na telefonu chybí. -- [ ] YouTube mlčí (ADR-013 poznámka). Sledovat, jestli se chování změní s novou verzí YouTube; jinak „unsupported" v Phase 5 seznamu aplikací. +- [x] YouTube mlčí (ADR-013 poznámka) — od Phase 5 se v seznamu aplikací ukazuje s časem, „—“ a štítkem „bez dat o scrollu“. Sledovat dál, jestli se chování změní s novou verzí YouTube. - [ ] Compose lazy seznamy neměřitelné (ADR-019) — přibývá jich. Hledat zdroj bez čtení obsahu až po GO (backlog). -- [ ] Launcher počítat do součtu, nebo vyloučit? (Phase 5/6, výchozí výluky) +- [ ] Launcher počítat do součtu, nebo vyloučit? → Phase 6 výchozí výluky. Do té doby se v Aplikacích ukazuje názvem balíčku (není vidět přes `` LAUNCHER); `` pro HOME = ADR. +- [ ] Kvalita měření se hodnotí s kalibrací platnou *teď* (ADR-029): den změřený před kalibrací kartou může ukázat „vysoká“. Přijato; kdyby vadilo, uložit metodu kalibrace k řádku (migrace Room). - [ ] R6: zabíjení procesu ColorOS — Phase 3 flush ≤ 10 s, Phase 7 onboarding (výjimka z optimalizace baterie — ověřit, že pomáhá). - [ ] GitHub Actions jsou připnuté na SHA tagů `v4`; bump na aktuální major (checkout v7, setup-java v6, gradle/actions v6, upload-artifact v7) je samostatné rozhodnutí. - [ ] Služba nemá instrumentovaný test životního cyklu. Phase 3 to pokrývá jinak: `ScrollPipelineTest` s virtuálním časem (flush, uzavření, zrušení, neúspěšný zápis) a na emulátoru `kill -9`, reinstalace a force-stop. Reconnect bez zabití procesu je ověřený jen čtením kódu. @@ -246,6 +283,20 @@ 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-24 — Phase 5 historie + aplikace (Opus 5.5, review Fable 5.1) +- Toolchain: + - Navigation Compose 2.9.8 zapojená, pin beze změny; + - `kotlinx-serialization-json` v katalogu → `kotlinx-serialization-core` 1.9.0 (stejná verze, jiný artefakt); + - plugin `kotlin.plugin.serialization` na verzi Kotlinu. +- ADR-029: + - prahy kvality měření (spec §20); + - statistiky historie; + - řazení a podíl v seznamu aplikací; + - párování tempa; + - navigace. +- Konstanty `QUALITY_*` v `MeasurementConfig` (ADR-029). +- Vývojářské nástroje se přestěhovaly z Přehledu do Nastavení. + ### 2026-09-24 — Phase 4 dashboard (Opus 5.5) - Toolchain beze změny. - ADR-028 (pravidla přehledu).