diff --git a/README.md b/README.md index e675271..b5e081e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ 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 3 (persistence: Room, flushes, sessions, settings, optional time in app, `v0.3`), Phase 4 (dashboard, `v0.4`). 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). @@ -99,6 +99,15 @@ Accuracy with the calibration in force: `python3 tools/device_accuracy.py --surf then `python3 tools/accuracy.py out/ground_truth.csv out/measured.csv`. The test list itself shows MAE / MAPE over the runs of a series (*Vynulovat* closes a run, *Nová série* starts over). +## Dashboard (Phase 4) + +*Přehled* shows today's distance in a ring against the daily goal (500 m until Phase 6 adds the setting), +this week / month / in total, the top apps today with their names and icons, and one comparison ("To je +přibližně délka jednoho běžeckého okruhu."). Every number is live — stored plus what the service has not +written yet. When the service is off, a red banner with *Zapnout měření* comes first. With Usage access the +app rows add time in app and m/min; without it they show scroll time, and a card offers the feature once +(*Ukázat jak* → a disclosure screen → Android settings). + ## 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/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 050d541..727cb67 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,17 @@ android:name="android.permission.PACKAGE_USAGE_STATS" tools:ignore="ProtectedPermissions" /> + + + + + + + + false + ThemePreference.DARK -> true + else -> isSystemInDarkTheme() + } + ScrollMeterTheme(darkTheme = dark) { ScrollMeterApp( graph = graph, initialTool = DevTools.toolFromLaunch(intent), @@ -61,24 +71,18 @@ class MainActivity : ComponentActivity() { } } -/** The POC's screens. Navigation Compose with the bottom bar arrives in Phase 4 (D14). */ -private enum class Screen { HOME, ACCURACY, CALIBRATION } +/** 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 } /** - * Home → Přesnost → Kalibrace (or Home → Kalibrace directly). Back returns to where the - * calibration was opened from; saving or skipping shows the result on Přesnost. Debug builds - * add the developer screens. + * 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) } - var calibrationOpenedFrom by rememberSaveable { mutableStateOf(Screen.HOME) } - val openCalibration = { from: Screen -> - calibrationOpenedFrom = from - screen = Screen.CALIBRATION - } val tool = devTools.getOrNull(openTool) when { tool != null -> { @@ -86,19 +90,23 @@ private fun ScrollMeterApp(graph: AppGraph, initialTool: Int, onOpenAccessibilit tool.content(graph) { openTool = NO_TOOL } } screen == Screen.CALIBRATION -> { - BackHandler { screen = calibrationOpenedFrom } - CalibrationScreen(graph, onBack = { screen = calibrationOpenedFrom }, onSaved = { screen = Screen.ACCURACY }) + 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 = { openCalibration(Screen.ACCURACY) }) + 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 -> HomeScreen( + else -> DashboardScreen( graph = graph, devTools = devTools, onOpenAccessibilitySettings = onOpenAccessibilitySettings, + onOpenUsageAccess = { screen = Screen.USAGE_ACCESS }, onOpenAccuracy = { screen = Screen.ACCURACY }, - onCalibrate = { openCalibration(Screen.HOME) }, onOpenDevTool = { openTool = it }, ) } diff --git a/app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt b/app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt new file mode 100644 index 0000000..7ad1ae6 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt @@ -0,0 +1,38 @@ +package com.scrollmeter.app.apps + +import android.content.Context +import android.content.pm.PackageManager +import android.util.LruCache +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.drawable.toBitmap + +/** What the UI shows for a package: its label, or the package name when the app is not visible. */ +data class AppInfo(val packageName: String, val label: String, val icon: ImageBitmap?) + +/** + * App labels and icons through `PackageManager` (spec §15, ADR-008). Visibility comes from the + * manifest's `` for launcher activities — no `QUERY_ALL_PACKAGES`; an app we cannot see + * (no launcher entry, uninstalled) shows its package name and no icon, and nothing throws. + * UI only, never on the measurement path; blocking — call it off the main thread. Cached. + */ +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 pm = context.packageManager + val info = try { + val app = pm.getApplicationInfo(packageName, 0) + val icon = runCatching { pm.getApplicationIcon(app).toBitmap(iconSizePx, iconSizePx).asImageBitmap() }.getOrNull() + AppInfo(packageName, app.loadLabel(pm).toString().ifBlank { packageName }, icon) + } catch (e: PackageManager.NameNotFoundException) { + AppInfo(packageName, packageName, null) + } catch (e: RuntimeException) { + // Spec §15: a package we cannot read must never crash the screen. + AppInfo(packageName, packageName, null) + } + cache.put(packageName, info) + return info + } +} diff --git a/app/src/main/java/com/scrollmeter/app/format/DistanceFormatter.kt b/app/src/main/java/com/scrollmeter/app/format/DistanceFormatter.kt new file mode 100644 index 0000000..cef254c --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/format/DistanceFormatter.kt @@ -0,0 +1,47 @@ +package com.scrollmeter.app.format + +import com.scrollmeter.app.settings.UnitPreference +import java.text.NumberFormat +import java.util.Locale +import kotlin.math.round + +/** + * Millimetres for display (spec §1, §26 Units): "428 m", "2,84 km". The number of decimals follows + * the size of the value, and is decided on the rounded value, so 999,6 m never shows as "1 000 m" + * and 9,996 km never as "10,00 km". AUTOMATIC switches to km at 1 km. Pure Kotlin. + */ +object DistanceFormatter { + fun format(mm: Double, unit: UnitPreference, locale: Locale): String { + val metres = if (mm.isFinite() && mm > 0) mm / 1_000.0 else 0.0 + return when (unit) { + UnitPreference.METRES -> metres(metres, locale) + UnitPreference.KILOMETRES -> kilometres(metres / 1_000.0, locale) + UnitPreference.AUTOMATIC -> if (round(metres) < 1_000) metres(metres, locale) else kilometres(metres / 1_000.0, locale) + } + } + + private fun metres(m: Double, locale: Locale): String { + val digits = if (roundTo(m, 1) < 10) 1 else 0 + return number(m, digits, locale) + " m" + } + + private fun kilometres(km: Double, locale: Locale): String { + val digits = when { + roundTo(km, 2) < 10 -> 2 + roundTo(km, 1) < 100 -> 1 + else -> 0 + } + return number(km, digits, locale) + " km" + } + + private fun roundTo(value: Double, digits: Int): Double { + val factor = Math.pow(10.0, digits.toDouble()) + return round(value * factor) / factor + } + + private fun number(value: Double, digits: Int, locale: Locale): String = + NumberFormat.getNumberInstance(locale).apply { + minimumFractionDigits = digits + maximumFractionDigits = digits + }.format(value) +} diff --git a/app/src/main/java/com/scrollmeter/app/format/TimeFormatter.kt b/app/src/main/java/com/scrollmeter/app/format/TimeFormatter.kt new file mode 100644 index 0000000..df623e8 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/format/TimeFormatter.kt @@ -0,0 +1,42 @@ +package com.scrollmeter.app.format + +import java.text.NumberFormat +import java.util.Locale + +/** + * Durations and pace for display. An unknown value is "—", never "0 min" (PLAN Phase 4 DoD); a + * known duration under a minute is "< 1 min". Pure Kotlin. + */ +object TimeFormatter { + const val UNKNOWN = "—" + private const val MINUTE_MS = 60_000L + + /** "< 1 min", "12 min", "1 h 5 min", "2 h" — minutes are floored, so 59,9 min never reads "60 min". */ + fun duration(ms: Long?): String { + if (ms == null || ms < 0) return UNKNOWN + if (ms < MINUTE_MS) return "< 1 min" + val minutes = ms / MINUTE_MS + val hours = minutes / 60 + val rest = minutes % 60 + return when { + hours == 0L -> "$minutes min" + rest == 0L -> "$hours h" + else -> "$hours h $rest min" + } + } + + /** + * Metres per minute (ADR-022). "—" when the time is unknown or under a minute — a pace over a few + * seconds says nothing. Under 10 m/min with one decimal. + */ + fun pace(distanceMm: Double?, timeMs: Long?, locale: Locale): String { + if (distanceMm == null || timeMs == null || timeMs < MINUTE_MS || !distanceMm.isFinite() || distanceMm < 0) return UNKNOWN + val perMinute = (distanceMm / 1_000.0) / (timeMs / MINUTE_MS.toDouble()) + val digits = if (Math.round(perMinute * 10) / 10.0 < 10) 1 else 0 + val number = NumberFormat.getNumberInstance(locale).apply { + minimumFractionDigits = digits + maximumFractionDigits = digits + }.format(perMinute) + return "$number m/min" + } +} diff --git a/app/src/main/java/com/scrollmeter/app/insights/DistanceComparisonProvider.kt b/app/src/main/java/com/scrollmeter/app/insights/DistanceComparisonProvider.kt new file mode 100644 index 0000000..dd8a639 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/insights/DistanceComparisonProvider.kt @@ -0,0 +1,41 @@ +package com.scrollmeter.app.insights + +import kotlin.math.roundToInt + +/** Illustrative real-world lengths (spec §22). They never change the measured data. */ +enum class Reference(val metres: Double) { + FOOTBALL_FIELD(105.0), + EIFFEL_TOWER(330.0), + RUNNING_TRACK_LAP(400.0), + RUN_5K(5_000.0), + HALF_MARATHON(21_097.5), + MARATHON(42_195.0), +} + +enum class Qualifier { ALMOST, ABOUT, MORE_THAN } + +/** "[qualifier] [count ×] [reference]" — the UI turns it into a sentence with plurals. */ +data class DistanceComparison(val reference: Reference, val qualifier: Qualifier, val count: Int) + +/** + * Picks the one comparison that fits the distance best (spec §22): the longest reference the + * distance reaches at least 80 % of. Up to 95 % it is "almost", up to 110 % "about", up to 150 % + * "more than" one; beyond that "about N ×", N rounded. Under 80 % of a football field: none. + * 428 m → about 1 running-track lap; 4,2 km → almost a 5 km run; 42,3 km → about a marathon. + */ +object DistanceComparisonProvider { + private const val REACH = 0.8 + + fun compare(distanceMm: Double): DistanceComparison? { + if (!distanceMm.isFinite() || distanceMm <= 0) return null + val metres = distanceMm / 1_000.0 + val reference = Reference.entries.sortedByDescending { it.metres }.firstOrNull { metres / it.metres >= REACH } ?: return null + val ratio = metres / reference.metres + return when { + ratio < 0.95 -> DistanceComparison(reference, Qualifier.ALMOST, 1) + ratio <= 1.1 -> DistanceComparison(reference, Qualifier.ABOUT, 1) + ratio < 1.5 -> DistanceComparison(reference, Qualifier.MORE_THAN, 1) + else -> DistanceComparison(reference, Qualifier.ABOUT, ratio.roundToInt()) + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/insights/TopApps.kt b/app/src/main/java/com/scrollmeter/app/insights/TopApps.kt new file mode 100644 index 0000000..d479c20 --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/insights/TopApps.kt @@ -0,0 +1,35 @@ +package com.scrollmeter.app.insights + +import com.scrollmeter.app.data.model.AppSummary + +/** The dashboard's "Top aplikace dnes" (spec §21): a few apps by distance, the rest as one "Ostatní" row. */ +data class TopApps(val apps: List, val other: AppSummary?) { + val isEmpty: Boolean get() = apps.isEmpty() + + companion object { + const val OTHER = "other" + const val LIMIT = 4 + + /** + * Apps with scrolled distance, longest first. More than [limit] + 1 → the tail becomes one + * "Ostatní" row (distance and times summed; a time stays unknown only if every app's is); + * exactly [limit] + 1 → the last app is shown by name instead of a one-app "Ostatní". + */ + fun of(apps: List, limit: Int = LIMIT): TopApps { + val scrolled = apps.filter { (it.distanceMm ?: 0.0) > 0 }.sortedByDescending { it.distanceMm } + if (scrolled.size <= limit + 1) return TopApps(scrolled, null) + val tail = scrolled.drop(limit) + val other = AppSummary( + packageName = OTHER, + distanceMm = tail.sumOf { it.distanceMm ?: 0.0 }, + activeScrollMs = tail.mapNotNull { it.activeScrollMs }.takeIf { it.isNotEmpty() }?.sum(), + foregroundMs = tail.mapNotNull { it.foregroundMs }.takeIf { it.isNotEmpty() }?.sum(), + measuredEventCount = null, + fallbackEventCount = null, + unmeasurableEventCount = null, + rejectedOutlierCount = null, + ) + return TopApps(scrolled.take(limit), other) + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/components/AppLocale.kt b/app/src/main/java/com/scrollmeter/app/ui/components/AppLocale.kt new file mode 100644 index 0000000..2d72e3c --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/components/AppLocale.kt @@ -0,0 +1,17 @@ +package com.scrollmeter.app.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import com.scrollmeter.app.R +import java.util.Locale + +/** + * The locale numbers are formatted in: the language of the strings the app shows, not the device's. + * A Czech UI on an English phone still writes "2,84 km" (values-en arrives in Phase 7). + */ +@Composable +fun appLocale(): Locale { + val tag = stringResource(R.string.number_locale) + return remember(tag) { Locale.forLanguageTag(tag) } +} 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 new file mode 100644 index 0000000..33862be --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/dashboard/DashboardScreen.kt @@ -0,0 +1,357 @@ +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 +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +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 +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.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 +import com.scrollmeter.app.insights.DistanceComparisonProvider +import com.scrollmeter.app.insights.Qualifier +import com.scrollmeter.app.insights.Reference +import com.scrollmeter.app.insights.TopApps +import com.scrollmeter.app.measurement.PhysicalScaleProvider +import com.scrollmeter.app.settings.Settings +import com.scrollmeter.app.settings.UnitPreference +import com.scrollmeter.app.ui.components.appLocale +import java.time.LocalDate +import java.time.ZoneId +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. + */ +@Composable +fun DashboardScreen( + graph: AppGraph, + devTools: List, + onOpenAccessibilitySettings: () -> Unit, + onOpenUsageAccess: () -> Unit, + onOpenAccuracy: () -> Unit, + onOpenDevTool: (Int) -> 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 repository = graph.scrollRepository + // Null until loaded: a card the user dismissed must not flash up while settings are read. + val loadedSettings by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = null) + val settings = loadedSettings ?: Settings() + val todayMm by remember(today) { repository.distance(DateRange.day(today)) }.collectAsStateWithLifecycle(initialValue = null) + val weekMm by remember(today) { repository.distance(DateRange.week(today)) }.collectAsStateWithLifecycle(initialValue = null) + val monthMm by remember(today) { repository.distance(DateRange.month(today)) }.collectAsStateWithLifecycle(initialValue = null) + val lifetimeMm by remember { repository.lifetimeDistance() }.collectAsStateWithLifecycle(initialValue = null) + val apps by remember(today) { repository.apps(DateRange.day(today)) }.collectAsStateWithLifecycle(initialValue = emptyList()) + val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) + val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } + + fun distance(mm: Double?): String = mm?.let { DistanceFormatter.format(it, settings.unitPreference, locale) } ?: TimeFormatter.UNKNOWN + + Scaffold { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f)) + if (status == ServiceStatus.ON) MeasuringChip() + } + 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) + if (!usageGranted && loadedSettings?.usageTimeCardDismissed == false) { + UsageTimeCard( + onShow = onOpenUsageAccess, + onDismiss = { scope.launch { graph.settingsRepository.setUsageTimeCardDismissed(true) } }, + ) + } + 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)) } + } + } + } + } +} + +@Composable +private fun MeasuringChip() { + Row( + modifier = Modifier.clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer).padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(8.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.dashboard_measuring), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onPrimaryContainer) + } +} + +/** Spec §32: prominent, first, with the one action that fixes it. */ +@Composable +private fun ServiceBanner(status: ServiceStatus, onOpenAccessibilitySettings: () -> Unit) { + val (title, body) = if (status == ServiceStatus.OFF) { + R.string.home_status_off to R.string.home_status_off_body + } else { + R.string.home_status_not_running to R.string.home_status_not_running_body + } + Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onErrorContainer) + Text(stringResource(body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer) + Button(onClick = onOpenAccessibilitySettings) { + Text(stringResource(if (status == ServiceStatus.OFF) R.string.home_enable_button else R.string.home_settings_button)) + } + } + } +} + +/** The main number, very prominent (spec §42), inside a progress ring towards the daily goal (§21, §25). */ +@Composable +private fun TodayHeader(todayMm: Double?, goalMm: Double, distance: (Double?) -> String) { + val progress = ((todayMm ?: 0.0) / goalMm).toFloat().coerceIn(0f, 1f) + val reached = todayMm != null && todayMm >= goalMm + val track = MaterialTheme.colorScheme.surfaceVariant + val bar = MaterialTheme.colorScheme.primary + val strokePx = with(LocalDensity.current) { 14.dp.toPx() } + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text(stringResource(R.string.home_today_title), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.size(8.dp)) + Box(Modifier.size(232.dp), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { + val inset = strokePx / 2 + val arcSize = Size(size.width - strokePx, size.height - strokePx) + drawArc(track, 0f, 360f, useCenter = false, topLeft = Offset(inset, inset), size = arcSize, style = Stroke(strokePx)) + if (progress > 0f) { + drawArc(bar, -90f, 360f * progress, useCenter = false, topLeft = Offset(inset, inset), size = arcSize, style = Stroke(strokePx, cap = StrokeCap.Round)) + } + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(distance(todayMm), style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.SemiBold, maxLines = 1) + Text( + stringResource(if (reached) R.string.dashboard_goal_reached else R.string.dashboard_goal, distance(goalMm)), + style = MaterialTheme.typography.bodyMedium, + color = if (reached) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +private fun PeriodStats(week: String, month: String, lifetime: String) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + StatRow(stringResource(R.string.dashboard_week), week) + HorizontalDivider() + StatRow(stringResource(R.string.dashboard_month), month) + HorizontalDivider() + StatRow(stringResource(R.string.dashboard_lifetime), lifetime) + } + } +} + +@Composable +private fun StatRow(label: String, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { + Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + Text(value, style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +private fun TopAppsCard(graph: AppGraph, top: TopApps, usageGranted: Boolean, unit: UnitPreference, locale: Locale) { + 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) } + } + } +} + +/** 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) { + 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 label = if (other) stringResource(R.string.dashboard_other_apps) else info?.label ?: app.packageName + val detail = if (usageGranted) { + val pace = TimeFormatter.pace(app.distanceMm, app.foregroundMs, locale) + val time = TimeFormatter.duration(app.foregroundMs) + if (pace == TimeFormatter.UNKNOWN) stringResource(R.string.dashboard_time_in_app, time) else stringResource(R.string.dashboard_time_in_app_pace, time, pace) + } else { + stringResource(R.string.dashboard_scroll_time, TimeFormatter.duration(app.activeScrollMs)) + } + Row(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) + } + } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(detail, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Spacer(Modifier.width(12.dp)) + Text(app.distanceMm?.let { DistanceFormatter.format(it, unit, locale) } ?: TimeFormatter.UNKNOWN, style = MaterialTheme.typography.titleMedium) + } +} + +@Composable +private fun UsageTimeCard(onShow: () -> Unit, onDismiss: () -> Unit) { + Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.dashboard_usage_card_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSecondaryContainer) + Text(stringResource(R.string.dashboard_usage_card_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSecondaryContainer) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onShow) { Text(stringResource(R.string.dashboard_usage_card_show)) } + TextButton(onClick = onDismiss) { Text(stringResource(R.string.not_now)) } + } + } + } +} + +/** Spec §22: one comparison, an illustration only. */ +@Composable +private fun ComparisonCard(comparison: DistanceComparison) { + val qualifier = stringResource( + when (comparison.qualifier) { + Qualifier.ALMOST -> R.string.comparison_almost + Qualifier.ABOUT -> R.string.comparison_about + Qualifier.MORE_THAN -> R.string.comparison_more_than + }, + ) + val plural = when (comparison.reference) { + Reference.FOOTBALL_FIELD -> R.plurals.comparison_football_field + Reference.EIFFEL_TOWER -> R.plurals.comparison_eiffel_tower + Reference.RUNNING_TRACK_LAP -> R.plurals.comparison_running_track_lap + Reference.RUN_5K -> R.plurals.comparison_run_5k + Reference.HALF_MARATHON -> R.plurals.comparison_half_marathon + Reference.MARATHON -> R.plurals.comparison_marathon + } + val phrase = pluralStringResource(plural, comparison.count, comparison.count) + // A neutral surface: under dynamic colour the tertiary tone can land close to the red warning banner. + Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(R.string.dashboard_comparison_title), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text(stringResource(R.string.comparison_sentence, qualifier, phrase), style = MaterialTheme.typography.bodyLarge) + } + } +} + +@Composable +private fun AccuracyRow(manual: Boolean, onOpenAccuracy: () -> Unit) { + Card(Modifier.fillMaxWidth().clickable(onClick = onOpenAccuracy)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(stringResource(R.string.home_accuracy_title), style = MaterialTheme.typography.titleSmall) + Text( + stringResource(if (manual) R.string.home_accuracy_manual_short else R.string.home_accuracy_auto_short), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/com/scrollmeter/app/ui/home/HomeScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/home/HomeScreen.kt deleted file mode 100644 index 84f6e97..0000000 --- a/app/src/main/java/com/scrollmeter/app/ui/home/HomeScreen.kt +++ /dev/null @@ -1,156 +0,0 @@ -package com.scrollmeter.app.ui.home - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -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.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.res.stringResource -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.calibration.CalibrationMethod -import com.scrollmeter.app.data.model.DateRange -import com.scrollmeter.app.devtools.DevToolEntry -import com.scrollmeter.app.measurement.PhysicalScale -import com.scrollmeter.app.measurement.PhysicalScaleProvider -import com.scrollmeter.app.ui.components.Format -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 } - -/** - * POC home: service status with the way to Accessibility settings, today's distance (stored plus - * what the service has not written yet — live), the calibration in use with the way to the accuracy - * screen, and (debug builds) the developer screens. Replaced by the dashboard in Phase 4. - */ -@Composable -fun HomeScreen( - graph: AppGraph, - devTools: List, - onOpenAccessibilitySettings: () -> Unit, - onOpenAccuracy: () -> Unit, - onCalibrate: () -> Unit, - onOpenDevTool: (Int) -> Unit, -) { - val connected by graph.monitor.serviceConnected.collectAsStateWithLifecycle() - val calibration by graph.calibrationRepository.state.collectAsStateWithLifecycle(initialValue = null) - val display = remember(LocalConfiguration.current.orientation) { graph.displayMetricsProvider.read() } - var enabledInSettings by remember { mutableStateOf(graph.statusChecker.isEnabled()) } - var today by remember { mutableStateOf(LocalDate.now()) } - LifecycleResumeEffect(Unit) { - enabledInSettings = graph.statusChecker.isEnabled() - today = LocalDate.now() - onPauseOrDispose { } - } - // 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 todayMm by remember(today) { graph.scrollRepository.distance(DateRange.day(today)) } - .collectAsStateWithLifecycle(initialValue = null) - LaunchedEffect(connected) { enabledInSettings = graph.statusChecker.isEnabled() } - val status = when { - connected -> ServiceStatus.ON - enabledInSettings -> ServiceStatus.ENABLED_NOT_RUNNING - else -> ServiceStatus.OFF - } - - Scaffold { padding -> - Column( - modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text(stringResource(R.string.app_name), style = MaterialTheme.typography.headlineMedium) - Text(stringResource(R.string.home_subtitle), style = MaterialTheme.typography.bodyMedium) - StatusCard(status, onOpenAccessibilitySettings) - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(stringResource(R.string.home_today_title), style = MaterialTheme.typography.titleMedium) - Text(todayMm?.let(Format::metres) ?: "—", style = MaterialTheme.typography.displaySmall) - } - } - calibration?.let { AccuracyCard(PhysicalScaleProvider.resolve(it, display), onOpenAccuracy, onCalibrate) } - if (devTools.isNotEmpty()) { - Text(stringResource(R.string.home_devtools_title), style = MaterialTheme.typography.titleMedium) - devTools.forEachIndexed { index, tool -> - OutlinedButton(onClick = { onOpenDevTool(index) }, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(tool.titleRes)) - } - } - } - } - } -} - -/** Spec §8: offer the card calibration until it is done; afterwards show what it gives. */ -@Composable -private fun AccuracyCard(scale: PhysicalScale, onOpenAccuracy: () -> Unit, onCalibrate: () -> Unit) { - val manual = scale.method == CalibrationMethod.MANUAL_CARD - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(R.string.home_accuracy_title), style = MaterialTheme.typography.titleMedium) - Text( - if (manual) { - stringResource(R.string.home_accuracy_manual, Format.decimal(scale.mmPerPxY, 4)) - } else { - stringResource(R.string.home_accuracy_auto) - }, - style = MaterialTheme.typography.bodyMedium, - ) - if (!manual) { - Button(onClick = onCalibrate) { Text(stringResource(R.string.home_calibrate)) } - } - OutlinedButton(onClick = onOpenAccuracy) { Text(stringResource(R.string.home_accuracy_open)) } - } - } -} - -@Composable -private fun StatusCard(status: ServiceStatus, onOpenAccessibilitySettings: () -> Unit) { - val (title, body) = when (status) { - ServiceStatus.ON -> R.string.home_status_on to R.string.home_status_on_body - ServiceStatus.ENABLED_NOT_RUNNING -> R.string.home_status_not_running to R.string.home_status_not_running_body - ServiceStatus.OFF -> R.string.home_status_off to R.string.home_status_off_body - } - val colors = if (status == ServiceStatus.ON) { - CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) - } else { - CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer) - } - Card(Modifier.fillMaxWidth(), colors = colors) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(stringResource(title), style = MaterialTheme.typography.titleLarge) - Text(stringResource(body), style = MaterialTheme.typography.bodyMedium) - if (status == ServiceStatus.OFF) { - Button(onClick = onOpenAccessibilitySettings) { Text(stringResource(R.string.home_enable_button)) } - } else { - OutlinedButton(onClick = onOpenAccessibilitySettings) { Text(stringResource(R.string.home_settings_button)) } - } - } - } -} diff --git a/app/src/main/java/com/scrollmeter/app/ui/usage/UsageAccessScreen.kt b/app/src/main/java/com/scrollmeter/app/ui/usage/UsageAccessScreen.kt new file mode 100644 index 0000000..b2061ad --- /dev/null +++ b/app/src/main/java/com/scrollmeter/app/ui/usage/UsageAccessScreen.kt @@ -0,0 +1,54 @@ +package com.scrollmeter.app.ui.usage + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import com.scrollmeter.app.AppGraph +import com.scrollmeter.app.R + +/** + * The disclosure before Usage-access settings (ADR-021, User Data policy: app usage is sensitive). + * Says what is read, what is stored, what is not, and that the app works without it; only the + * explicit "Povolit" opens the settings. Coming back with access granted closes the screen. + * Phase 7 reuses it as onboarding step 6. + */ +@Composable +fun UsageAccessScreen(graph: AppGraph, onGranted: () -> Unit, onBack: () -> Unit) { + val context = LocalContext.current + LifecycleResumeEffect(Unit) { + if (graph.usageAccessChecker.isGranted()) onGranted() + onPauseOrDispose { } + } + Scaffold { padding -> + Column( + modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(stringResource(R.string.usage_access_title), style = MaterialTheme.typography.headlineMedium) + Text(stringResource(R.string.usage_access_intro), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.usage_access_what), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.usage_access_not), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.usage_access_optional), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.usage_access_steps), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Button(onClick = { graph.usageAccessChecker.openSettings(context) }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.usage_access_allow)) + } + TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.not_now)) } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 94d5ed5..5a88945 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,22 +5,89 @@ ScrollMeter — měření scrollování ScrollMeter měří, o kolik se při scrollování posune obsah na displeji, a převádí to na metry. Služba přijímá jen události scrollování: čísla o posunu a název aplikace. Nečte text, zprávy ani obsah obrazovky, nic neovládá a data nikam neposílá — aplikace nemá přístup k internetu. - Prototyp měření, kalibrace a ukládání (fáze 3) - Měření je zapnuté - Služba přijímá jen události scrollování. + + cs + + + Měří se Měření je povolené, ale služba neběží Vypni službu ScrollMeter v nastavení Usnadnění a znovu ji zapni. Měření je vypnuté - Zapni službu ScrollMeter v nastavení Usnadnění. + Nic se teď nepočítá. Zapni službu ScrollMeter v nastavení Usnadnění. Zapnout měření Otevřít nastavení Usnadnění Dnes + z cíle %1$s + Cíl %1$s splněn + Tento týden + Tento měsíc + Celkem + Top aplikace dnes + Ostatní + Dnes zatím žádné scrollování. Jakmile budeš scrollovat v jiné aplikaci, objeví se tady. + V aplikaci %1$s + V aplikaci %1$s · %2$s + Scrollování %1$s + Pro představu + Chceš vidět i čas v aplikacích? + U každé aplikace pak uvidíš, kolik v ní trávíš minut a kolik metrů za minutu nascrolluješ. + Ukázat jak + Teď ne Vývojářské nástroje Přesnost měření - Kalibrováno platební kartou · 1 pixel = %1$s mm - Měří se podle automatického odhadu z údajů displeje. Zkalibruj displej pro přesnější měření. - Přesnost měření - Zkalibrovat displej + Kalibrováno platební kartou + Automatický odhad · zkalibruj displej pro větší přesnost + + + To je %1$s %2$s. + téměř + přibližně + víc než + + délka jednoho fotbalového hřiště + délka %d fotbalových hřišť + délka %d fotbalových hřišť + délka %d fotbalových hřišť + + + výška Eiffelovy věže + %d× výška Eiffelovy věže + %d× výška Eiffelovy věže + %d× výška Eiffelovy věže + + + délka jednoho běžeckého okruhu + délka %d běžeckých okruhů + délka %d běžeckých okruhů + délka %d běžeckých okruhů + + + běh na 5 km + %d× běh na 5 km + %d× běh na 5 km + %d× běh na 5 km + + + půlmaraton + %d× půlmaraton + %d× půlmaraton + %d× půlmaraton + + + maraton + %d× maraton + %d× maraton + %d× maraton + + + + Čas v aplikacích + ScrollMeter umí ke vzdálenosti přidat, kolik času v každé aplikaci trávíš. + K tomu potřebuje „Přístup k údajům o využití“. ScrollMeter čte ze systému, kdy byla která aplikace v popředí, a ukládá jen součet minut na aplikaci a den. Nic z toho neopouští telefon. + Nečte obsah aplikací, zprávy ani historii prohlížeče. Oprávnění můžeš kdykoli odebrat v nastavení Androidu. + Bez něj ScrollMeter měří dál — u času v aplikacích se jen ukáže „—“. + V nastavení, které se otevře, najdi ScrollMeter a zapni „Povolit přístup k údajům o využití“. + Povolit Zpět diff --git a/app/src/test/java/com/scrollmeter/app/format/DistanceFormatterTest.kt b/app/src/test/java/com/scrollmeter/app/format/DistanceFormatterTest.kt new file mode 100644 index 0000000..d741e9e --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/format/DistanceFormatterTest.kt @@ -0,0 +1,50 @@ +package com.scrollmeter.app.format + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.settings.UnitPreference +import java.util.Locale +import org.junit.Test + +/** Spec §21, §26 Units: m/km boundaries and rounding, Czech decimal comma. */ +class DistanceFormatterTest { + private val cs = Locale.forLanguageTag("cs") + private fun auto(metres: Double) = DistanceFormatter.format(metres * 1_000, UnitPreference.AUTOMATIC, cs).nbsp() + private fun String.nbsp() = replace(' ', ' ').replace(' ', ' ') + + @Test + fun spec21Examples() { + assertThat(auto(428.0)).isEqualTo("428 m") + assertThat(auto(2_840.0)).isEqualTo("2,84 km") + assertThat(auto(11_700.0)).isEqualTo("11,7 km") + assertThat(auto(42_600.0)).isEqualTo("42,6 km") + } + + @Test + fun smallValuesKeepOneDecimalAndNothingIsZero() { + assertThat(auto(0.0)).isEqualTo("0,0 m") + assertThat(auto(3.44)).isEqualTo("3,4 m") + assertThat(auto(9.96)).isEqualTo("10 m") + assertThat(auto(Double.NaN)).isEqualTo("0,0 m") + assertThat(auto(-5.0)).isEqualTo("0,0 m") + } + + @Test + fun theKilometreBoundaryIsDecidedOnTheRoundedValue() { + assertThat(auto(999.4)).isEqualTo("999 m") + assertThat(auto(999.6)).isEqualTo("1,00 km") + assertThat(auto(9_996.0)).isEqualTo("10,0 km") + assertThat(auto(99_960.0)).isEqualTo("100 km") + assertThat(auto(1_234_000.0)).isEqualTo("1 234 km") + } + + @Test + fun fixedUnits() { + assertThat(DistanceFormatter.format(2_840_000.0, UnitPreference.METRES, cs).nbsp()).isEqualTo("2 840 m") + assertThat(DistanceFormatter.format(428_000.0, UnitPreference.KILOMETRES, cs).nbsp()).isEqualTo("0,43 km") + } + + @Test + fun englishUsesADecimalPoint() { + assertThat(DistanceFormatter.format(2_840_000.0, UnitPreference.AUTOMATIC, Locale.US)).isEqualTo("2.84 km") + } +} diff --git a/app/src/test/java/com/scrollmeter/app/format/TimeFormatterTest.kt b/app/src/test/java/com/scrollmeter/app/format/TimeFormatterTest.kt new file mode 100644 index 0000000..d071e7e --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/format/TimeFormatterTest.kt @@ -0,0 +1,31 @@ +package com.scrollmeter.app.format + +import com.google.common.truth.Truth.assertThat +import java.util.Locale +import org.junit.Test + +/** PLAN Phase 4: an unknown time is "—", never "0 min"; pace needs at least a minute. */ +class TimeFormatterTest { + private val cs = Locale.forLanguageTag("cs") + private val min = 60_000L + + @Test + fun durations() { + assertThat(TimeFormatter.duration(null)).isEqualTo("—") + assertThat(TimeFormatter.duration(0)).isEqualTo("< 1 min") + assertThat(TimeFormatter.duration(59_999)).isEqualTo("< 1 min") + assertThat(TimeFormatter.duration(12 * min + 59_000)).isEqualTo("12 min") + assertThat(TimeFormatter.duration(60 * min - 1)).isEqualTo("59 min") + assertThat(TimeFormatter.duration(60 * min)).isEqualTo("1 h") + assertThat(TimeFormatter.duration(65 * min)).isEqualTo("1 h 5 min") + } + + @Test + fun pace() { + assertThat(TimeFormatter.pace(120_000.0, 10 * min, cs)).isEqualTo("12 m/min") + assertThat(TimeFormatter.pace(45_000.0, 10 * min, cs)).isEqualTo("4,5 m/min") + assertThat(TimeFormatter.pace(45_000.0, 59_000, cs)).isEqualTo("—") + assertThat(TimeFormatter.pace(null, 10 * min, cs)).isEqualTo("—") + assertThat(TimeFormatter.pace(45_000.0, null, cs)).isEqualTo("—") + } +} diff --git a/app/src/test/java/com/scrollmeter/app/insights/DistanceComparisonProviderTest.kt b/app/src/test/java/com/scrollmeter/app/insights/DistanceComparisonProviderTest.kt new file mode 100644 index 0000000..1149f42 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/insights/DistanceComparisonProviderTest.kt @@ -0,0 +1,31 @@ +package com.scrollmeter.app.insights + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** Spec §22 and PLAN Phase 4: one fitting comparison, never a flood. */ +class DistanceComparisonProviderTest { + private fun m(metres: Double) = DistanceComparisonProvider.compare(metres * 1_000) + + @Test + fun planExamples() { + assertThat(m(428.0)).isEqualTo(DistanceComparison(Reference.RUNNING_TRACK_LAP, Qualifier.ABOUT, 1)) + assertThat(m(4_200.0)).isEqualTo(DistanceComparison(Reference.RUN_5K, Qualifier.ALMOST, 1)) + assertThat(m(42_300.0)).isEqualTo(DistanceComparison(Reference.MARATHON, Qualifier.ABOUT, 1)) + } + + @Test + fun longerDistancesCountTheReference() { + assertThat(m(1_200.0)).isEqualTo(DistanceComparison(Reference.RUNNING_TRACK_LAP, Qualifier.ABOUT, 3)) + assertThat(m(150_000.0)).isEqualTo(DistanceComparison(Reference.MARATHON, Qualifier.ABOUT, 4)) + assertThat(m(30_000.0)).isEqualTo(DistanceComparison(Reference.HALF_MARATHON, Qualifier.MORE_THAN, 1)) + } + + @Test + fun tooShortOrInvalidHasNoComparison() { + assertThat(m(80.0)).isNull() + assertThat(m(0.0)).isNull() + assertThat(DistanceComparisonProvider.compare(Double.NaN)).isNull() + assertThat(m(90.0)).isEqualTo(DistanceComparison(Reference.FOOTBALL_FIELD, Qualifier.ALMOST, 1)) + } +} diff --git a/app/src/test/java/com/scrollmeter/app/insights/TopAppsTest.kt b/app/src/test/java/com/scrollmeter/app/insights/TopAppsTest.kt new file mode 100644 index 0000000..bc91f38 --- /dev/null +++ b/app/src/test/java/com/scrollmeter/app/insights/TopAppsTest.kt @@ -0,0 +1,42 @@ +package com.scrollmeter.app.insights + +import com.google.common.truth.Truth.assertThat +import com.scrollmeter.app.data.model.AppSummary +import org.junit.Test + +/** Spec §21: top apps today, the rest as "Ostatní". */ +class TopAppsTest { + private fun app(pkg: String, mm: Double?, fg: Long? = null, active: Long? = null) = + AppSummary(pkg, mm, active, fg, null, null, null, null) + + @Test + fun appsWithoutDistanceAreLeftOutAndTheRestIsSorted() { + val top = TopApps.of(listOf(app("video", null, fg = 900_000), app("b", 5.0), app("a", 50.0), app("zero", 0.0))) + assertThat(top.apps.map { it.packageName }).containsExactly("a", "b").inOrder() + assertThat(top.other).isNull() + } + + @Test + fun theTailBecomesOneOtherRow() { + val apps = (1..7).map { app("p$it", it * 10.0, fg = if (it == 1) null else 60_000, active = 1_000) } + val top = TopApps.of(apps) + assertThat(top.apps.map { it.packageName }).containsExactly("p7", "p6", "p5", "p4").inOrder() + assertThat(top.other!!.distanceMm).isEqualTo(60.0) // p3 + p2 + p1 + assertThat(top.other!!.foregroundMs).isEqualTo(120_000) + assertThat(top.other!!.activeScrollMs).isEqualTo(3_000) + } + + @Test + fun oneAppMoreThanTheLimitIsShownByName() { + val top = TopApps.of((1..5).map { app("p$it", it * 10.0) }) + assertThat(top.apps).hasSize(5) + assertThat(top.other).isNull() + } + + @Test + fun unknownTimesStayUnknownInOther() { + val top = TopApps.of((1..6).map { app("p$it", it * 10.0) }) + assertThat(top.other!!.foregroundMs).isNull() + assertThat(top.other!!.activeScrollMs).isNull() + } +} diff --git a/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt b/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt index cbe85c6..138856a 100644 --- a/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt +++ b/app/src/test/java/com/scrollmeter/app/policy/PolicyGuardTest.kt @@ -96,11 +96,11 @@ class PolicyGuardTest { @Test fun pureKotlinPackagesHaveNoAndroidImports() { val main = File(appDir, "src/main/java/com/scrollmeter/app") - val pure = listOf("measurement", "aggregation", "data/model", "usage").flatMap { dir -> + val pure = listOf("measurement", "aggregation", "data/model", "usage", "format", "insights").flatMap { dir -> File(main, dir).walkTopDown().filter { it.extension == "kt" }.toList() }.filterNot { it.name in USAGE_PLATFORM_ADAPTERS } assertWithMessage("expected the pure packages to be scanned").that(pure.map { it.name }) - .containsAtLeast("ScrollMeasurementEngine.kt", "ScrollPipeline.kt", "ForegroundTimeAggregator.kt", "UsageSyncer.kt") + .containsAtLeast("ScrollMeasurementEngine.kt", "ScrollPipeline.kt", "ForegroundTimeAggregator.kt", "UsageSyncer.kt", "DistanceFormatter.kt", "DistanceComparisonProvider.kt") val hits = pure.filter { file -> file.readLines().any { it.startsWith("import android.") || it.startsWith("import androidx.") } } .map { it.name } assertWithMessage("pure packages must not import Android").that(hits).isEmpty() @@ -157,6 +157,25 @@ class PolicyGuardTest { } } + /** ADR-008: package visibility only for apps with a launcher entry — one intent query, nothing else. */ + @Test + fun packageVisibilityIsTheLauncherQueryOnly() { + val root = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } + .newDocumentBuilder().parse(File(appDir, "src/main/AndroidManifest.xml")).documentElement + val queries = root.getElementsByTagName("queries") + assertThat(queries.length).isEqualTo(1) + val children = queries.item(0).childNodes.let { nodes -> + (0 until nodes.length).map { nodes.item(it) }.filterIsInstance() + } + assertWithMessage(" children").that(children.map { it.tagName }).containsExactly("intent") + fun names(tag: String) = children.single().getElementsByTagName(tag).let { nodes -> + (0 until nodes.length).map { (nodes.item(it) as org.w3c.dom.Element).getAttributeNS(ANDROID_NS, "name") } + } + assertThat(names("action")).containsExactly("android.intent.action.MAIN") + assertThat(names("category")).containsExactly("android.intent.category.LAUNCHER") + assertThat(children.single().getElementsByTagName("data").length).isEqualTo(0) + } + /** The one class allowed to read usage events; everything else gets samples from it (ADR-021). */ @Test fun onlyUsageEventsSourceReadsUsageEvents() { diff --git a/docs/accessibility-policy.md b/docs/accessibility-policy.md index 9c27781..d412ff3 100644 --- a/docs/accessibility-policy.md +++ b/docs/accessibility-policy.md @@ -59,6 +59,11 @@ activity's start with its end and are never stored (ADR-025). `UsageEventsSource `android.app.usage` (`PolicyGuardTest` pins it). The app works fully without it (time then shows as "—"). Disclosure and onboarding step: Phase 7. +App names and icons on the dashboard come from `PackageManager` through a single `` entry for launcher +activities (ADR-008) — no `QUERY_ALL_PACKAGES`; `PolicyGuardTest` allows exactly that query. Before the Usage-access +settings open, the app shows its own disclosure screen (*Čas v aplikacích*): what is read, what is stored, what is +not, and that everything works without it; only the explicit *Povolit* button opens the settings. + Permissions are an **allowlist** in both guards: `PACKAGE_USAGE_STATS` now, `POST_NOTIFICATIONS` from Phase 6. Anything else fails CI and needs an ADR plus Honza's OK. diff --git a/docs/architecture.md b/docs/architecture.md index 97a3cfb..f2ff38c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,6 +65,9 @@ com.scrollmeter.app ├── usage ForegroundTimeAggregator, UsageSyncer, UsageEvents (pure Kotlin); │ UsageEventsSource, UsageAccessChecker (the only Android parts) ├── settings Settings, SettingsRepository (DataStore) +├── format DistanceFormatter, TimeFormatter (pure Kotlin — ADR-028) +├── insights DistanceComparisonProvider, TopApps (pure Kotlin) +├── apps AppInfoProvider (labels + icons via PackageManager, cached; UI only — ADR-008) ├── ui onboarding, dashboard, history, apps, calibration, settings, about, components, theme ├── export CsvExporter ├── AppGraph.kt, ScrollMeterApplication.kt, MainActivity.kt @@ -103,6 +106,7 @@ com.scrollmeter.app Bottom bar: Přehled · Historie · Aplikace · Nastavení (Navigation Compose 2.9, type-safe routes). Onboarding is a separate graph shown until `onboardingCompleted`. -Until Phase 4 `MainActivity` switches between Home → Přesnost měření (`ui/calibration/AccuracyScreen`) → -Kalibrace displeje (`ui/calibration/CalibrationScreen`) with a saveable enum; saving or skipping a calibration -lands on Přesnost. +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. diff --git a/docs/measurement-decisions.md b/docs/measurement-decisions.md index bcdcfc6..66afab5 100644 --- a/docs/measurement-decisions.md +++ b/docs/measurement-decisions.md @@ -32,3 +32,4 @@ supersede with a new entry that references the old one. | ADR-025 | 2026-09-24 | **Time in app — the details left open by ADR-021.** (1) An app is in the foreground while ≥ 1 of its activities is RESUMED; the activity class is read **only** to pair RESUMED with PAUSED/STOPPED and is never stored. (2) When its last activity pauses it has *left* at that moment unless it resumes within `USAGE_SAME_APP_GRACE_MS = 2_000` and no other app came up meanwhile — another app's RESUMED ends it at its pause. (3) SCREEN_NON_INTERACTIVE and DEVICE_SHUTDOWN end every app; DEVICE_STARTUP without a shutdown before it discards what was still open (its end is unknown); an unpaired PAUSED is ignored; what is open at the end of the window ends at *now*. (4) Split screen counts both apps. (5) Intervals are split at **local** midnight (DST days have 23/25 h); a launch counts on the day of its RESUMED. (6) Window: whole local days from `max(lastSync − 1 day, now − USAGE_SYNC_MAX_DAYS = 9 days)`, events read from `USAGE_SYNC_LOOKBACK_MS = 12 h` before it; those days are replaced in one transaction. (7) The own package and the user's excluded apps are left out. (8) DataStore keeps the wall time of the last successful sync (`usage_sync_last_ms`) — PLAN called it `usageSyncLastEventTs`; the sync time is what the window needs. The service syncs on connect when that is ≥ `USAGE_SYNC_STALE_MS = 6 h` old (or in the future — a clock set back) and at every local day change; `MainActivity.onResume` always syncs. | Moving between two activities of one app pauses one before resuming the next (tens of ms apart); without the grace every such move would count as a new launch. The lead-in pairs an app that was already open at the first recomputed midnight. Measured on the API 34 emulator: after the first sync the per-app totals equal the system's own `totalTimeUsed` (`dumpsys usagestats`, today): launcher 13:34.4 vs 13:34, Settings 3:19.7 vs 3:20. Digital Wellbeing on a real phone is still on the verification debt list. | accepted | | 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 | diff --git a/handoff.md b/handoff.md index 2948821..b6579b4 100644 --- a/handoff.md +++ b/handoff.md @@ -39,13 +39,45 @@ | 0 Bootstrap | hotovo, mergnuto (tag `v0.0`) | `phase-0-bootstrap` / [#1](https://github.com/hupcus/scrollmeter/pull/1) | build/test/lint zelené lokálně i v CI; `installDebug` + spuštění na OnePlus OK | | 1 Measurement POC | **GO (Honza, 2026-09-23)** | `phase-1-measurement-poc` / [#2](https://github.com/hupcus/scrollmeter/pull/2) | bez doměření Instagramu / TikToku — přijaté riziko | | 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, PR otevřený (merge + tag `v0.3` po CI) | `phase-3-persistence` | + čas v aplikaci (D19); restart telefonu a Digital Wellbeing → „Dluh ověření“ V4, V5 | -| 4 Dashboard | nezačato | — | | +| 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 | — | | | 6 Export + Nastavení | nezačato | — | | | 7 Onboarding + Policy | nezačato | — | | | 8 Release | nezačato | — | | +## Phase 4 — exit report (2026-09-24) + +**Hotovo a ověřené** (větev `phase-4-dashboard`): +- **Přehled** (SPEC §21) nahradil domovskou obrazovku z POC. Obsahuje: + - „Dnes“ velkým číslem v prstenci k dennímu cíli („z cíle 500 m“ / „Cíl … splněn“), + - tento týden, měsíc a celkem, + - top aplikace dnes s názvem a ikonou (4 řádky + „Ostatní“), + - jedno srovnání („To je přibližně délka jednoho běžeckého okruhu.“), + - řádek „Přesnost měření“, + - v debug buildu vývojářské nástroje. + + Všechna čísla jsou živá. +- **Služba vypnutá (§32):** červený banner „Měření je vypnuté“ + „Zapnout měření“ je první na obrazovce; štítek „Měří se“ se ukazuje jen, když služba opravdu běží. +- **Čas v aplikacích (D19):** + - s oprávněním má řádek aplikace „V aplikaci 12 min · 9,5 m/min“, + - bez něj „Scrollování 3 min“ a jedna zavíratelná karta → vlastní obrazovka s vysvětlením (co se čte, co se ukládá, co ne) → teprve pak nastavení Androidu, + - po návratu s oprávněním se obrazovka sama zavře. +- **Formátování (ADR-028):** + - `DistanceFormatter`: m/km, desetinné čárky podle jazyka textů, ne telefonu, + - `TimeFormatter`: „< 1 min“, neznámé „—“, nikdy „0 min“, + - `DistanceComparisonProvider`: reference ze SPEC §22, prahy téměř / přibližně / víc než, + - `TopApps`, + - české plurály jsou v resources. +- **Názvy a ikony aplikací** přes `PackageManager` s jediným `` pro launcher (ADR-008). `PolicyGuardTest` povoluje přesně tenhle dotaz a čistě kotlinovské balíčky `format/` a `insights/` hlídá proti importům Androidu. +- **Motiv** se řídí `Settings.theme`, výběr přijde ve Phase 6. +- **Emulátor:** + - snímky ověřené subagentem: světlý a tmavý motiv, horní i spodní část, bez Usage access (karta, „Scrollování …“), vypnutá služba (banner) a obrazovka s vysvětlením, + - kruh k cíli, km s čárkou a srovnání ověřené na syntetických řádcích vložených jen pro snímek a hned smazaných, + - Chrome se měří (5 započtených, zbytek je jeho duplicitní proud podle ADR-020). + +**Zjištění:** `uiautomator dump` na emulátoru odpojí službu, objeví se banner a rozhození layoutu posune tapy. Navigovat se proto musí s vypnutou službou (stejná past jako na telefonu). + ## Phase 3 — exit report (2026-09-24) **Hotovo a ověřené** (větev `phase-3-persistence`): @@ -171,6 +203,10 @@ Každý bod: co udělat, kdo, a co by špatný výsledek změnil. Pořadí = dop ## Otevřené body - [x] **OnePlus blokoval `settings put` přes adb** (`WRITE_SECURE_SETTINGS` denied — ColorOS „sledování oprávnění“). Vyřešeno 2026-09-23: Možnosti pro vývojáře → úplně dole **„Zakázat sledování oprávnění“** zapnuto (bez restartu), `settings put` funguje. Zároveň zapnuto „Při dobíjení nevypínat obrazovku“ (`stay_on_while_plugged_in=7`). Po resetu telefonu / aktualizaci OS zkontrolovat znovu. +- [ ] **GitHub Actions nestartují (od 2026-09-24 07:04 UTC):** „The job was not started because recent account payments have failed or your spending limit needs to be increased.“ Jde o billing účtu, ne o kód. + - Do vyřešení se fáze mergují na **CI-ekvivalentní bráně v čistém checkoutu**: stejné příkazy jako `.github/workflows/ci.yml` + `assembleRelease`, stejné JDK 21; wrapper validace odpadá jen tam, kde se wrapper nemění. Každý PR to uvádí. + - Honza: zvýšit spending limit, nebo přesunout job na self-hosted runner (v hh-main běží privátní joby od 24. 9. v LXC 106 — potřeboval by Android SDK). + - Po opravě pustit CI znovu na `main` (`gh workflow run CI` nebo re-run posledního běhu). - [x] Přenos dat na nový telefon (device-to-device): rozhodnuto ADR-027 — nic se nepřenáší (`dataExtractionRules`), data si uživatel odnese CSV exportem (Phase 6). - [ ] **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. @@ -210,6 +246,13 @@ 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 4 dashboard (Opus 5.5) +- Toolchain beze změny. +- ADR-028 (pravidla přehledu). +- Kalibrace se z přehledu otevírá přes „Přesnost měření“, zmizela přímá cesta z domovské obrazovky, a tím i logika „odkud byla otevřena“. +- Bottom navigation (SPEC §43) přijde ve Phase 5 spolu s obrazovkami, na které vede. +- Denní cíl 500 m je výchozí z `Settings`; volbu přidá Phase 6. + ### 2026-09-24 — Phase 3 persistence + čas v aplikaci (Opus 5.5) - Toolchain beze změny pinů. Nově zapojené: - Room `@Database` (2.8.4, KSP),