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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,17 @@
android:name="android.permission.PACKAGE_USAGE_STATS"
tools:ignore="ProtectedPermissions" />

<!--
ADR-008: app names and icons on the dashboard. Makes apps with a launcher entry visible to
PackageManager (API 30+) — no QUERY_ALL_PACKAGES. PolicyGuardTest allows exactly this query.
-->
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>

<application
android:name=".ScrollMeterApplication"
android:allowBackup="false"
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/scrollmeter/app/AppGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.scrollmeter.app

import android.content.Context
import com.scrollmeter.app.accessibility.AccessibilityStatusChecker
import com.scrollmeter.app.apps.AppInfoProvider
import com.scrollmeter.app.calibration.CalibrationRepository
import com.scrollmeter.app.calibration.CalibrationState
import com.scrollmeter.app.calibration.DisplayMetricsProvider
Expand Down Expand Up @@ -41,6 +42,9 @@ class AppGraph(context: Context) {
val scrollRepository by lazy { ScrollRepository(database.scrollDao(), monitor.unflushed) }

val usageAccessChecker by lazy { UsageAccessChecker(appContext) }

/** Labels and icons for the UI (ADR-008) — never used by the measurement pipeline. */
val appInfoProvider by lazy { AppInfoProvider(appContext) }
val usageSyncer by lazy {
UsageSyncer(
reader = UsageEventsSource(appContext),
Expand Down
42 changes: 25 additions & 17 deletions app/src/main/java/com/scrollmeter/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,22 @@ 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.ui.home.HomeScreen
import com.scrollmeter.app.settings.ThemePreference
import com.scrollmeter.app.ui.dashboard.DashboardScreen
import com.scrollmeter.app.ui.theme.ScrollMeterTheme
import com.scrollmeter.app.ui.usage.UsageAccessScreen
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

Expand All @@ -29,7 +33,13 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
val graph = (application as ScrollMeterApplication).graph
setContent {
ScrollMeterTheme {
val theme by graph.settingsRepository.settings.collectAsStateWithLifecycle(initialValue = null)
val dark = when (theme?.theme) {
ThemePreference.LIGHT -> false
ThemePreference.DARK -> true
else -> isSystemInDarkTheme()
}
ScrollMeterTheme(darkTheme = dark) {
ScrollMeterApp(
graph = graph,
initialTool = DevTools.toolFromLaunch(intent),
Expand Down Expand Up @@ -61,44 +71,42 @@ 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 -> {
BackHandler { openTool = NO_TOOL }
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 },
)
}
Expand Down
38 changes: 38 additions & 0 deletions app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt
Original file line number Diff line number Diff line change
@@ -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 `<queries>` 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<String, AppInfo>(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
}
}
47 changes: 47 additions & 0 deletions app/src/main/java/com/scrollmeter/app/format/DistanceFormatter.kt
Original file line number Diff line number Diff line change
@@ -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)
}
42 changes: 42 additions & 0 deletions app/src/main/java/com/scrollmeter/app/format/TimeFormatter.kt
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
}
35 changes: 35 additions & 0 deletions app/src/main/java/com/scrollmeter/app/insights/TopApps.kt
Original file line number Diff line number Diff line change
@@ -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<AppSummary>, 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<AppSummary>, 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)
}
}
}
17 changes: 17 additions & 0 deletions app/src/main/java/com/scrollmeter/app/ui/components/AppLocale.kt
Original file line number Diff line number Diff line change
@@ -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) }
}
Loading
Loading