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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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.core)

implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
Expand Down
60 changes: 5 additions & 55 deletions app/src/main/java/com/scrollmeter/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,17 @@ 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.NavIntents
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

Expand All @@ -32,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) {
Expand All @@ -40,9 +33,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,
)
}
Expand Down Expand Up @@ -70,46 +63,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
5 changes: 3 additions & 2 deletions app/src/main/java/com/scrollmeter/app/apps/AppInfoProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ 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 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)
Expand All @@ -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
}
}
9 changes: 9 additions & 0 deletions app/src/main/java/com/scrollmeter/app/apps/PackageNames.kt
Original file line number Diff line number Diff line change
@@ -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)
}
24 changes: 24 additions & 0 deletions app/src/main/java/com/scrollmeter/app/data/local/ScrollDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,30 @@ abstract class ScrollDao {
)
abstract fun daysBetween(fromDate: String, toDate: String): Flow<List<DaySummary>>

/** 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<List<DaySummary>>

/** The first day anything was measured — history statistics start there (spec §23). */
@Query("SELECT MIN(date) FROM daily_app_aggregate")
abstract fun firstMeasuredDate(): Flow<String?>

@Query("SELECT * FROM daily_app_aggregate WHERE date = :date AND packageName = :packageName")
abstract suspend fun aggregate(date: String, packageName: String): DailyAppAggregateEntity?

Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/com/scrollmeter/app/data/model/DateRange.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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`). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<List<DaySummary>> =
combine(dao.appDaysBetween(packageName, range.fromKey, range.toKey), unflushed) { stored, pending ->
val extra = HashMap<String, Double>()
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<LocalDate?> = 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(
Expand Down
32 changes: 32 additions & 0 deletions app/src/main/java/com/scrollmeter/app/insights/AppPeriodTotals.kt
Original file line number Diff line number Diff line change
@@ -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<DaySummary>): 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 },
)
}
}
}
62 changes: 62 additions & 0 deletions app/src/main/java/com/scrollmeter/app/insights/AppRanking.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.scrollmeter.app.insights

import com.scrollmeter.app.calibration.CalibrationConfidence
import com.scrollmeter.app.data.model.AppSummary
import com.scrollmeter.app.data.model.DateRange
import com.scrollmeter.app.measurement.MeasurementQuality
import com.scrollmeter.app.measurement.MeasurementQualityRater
import java.time.LocalDate

enum class AppSort { DISTANCE, TIME }

/** Spec §24: Dnes | 7 dní | 30 dní | Celkem — the Aplikace list and the app detail. */
enum class AppsPeriod {
TODAY, DAYS_7, DAYS_30, LIFETIME;

fun range(today: LocalDate): DateRange = when (this) {
TODAY -> DateRange.day(today)
DAYS_7 -> DateRange(today.minusDays(6), today)
DAYS_30 -> DateRange(today.minusDays(29), today)
LIFETIME -> DateRange.ALL
}
}

/** One row of Aplikace (spec §24): share of the period's distance, quality; null = no scroll data. */
data class RankedApp(val app: AppSummary, val sharePercent: Double?, val quality: MeasurementQuality?)

/**
* Orders the apps of a period (spec §24, D19). Quality is rated on [qualityBasis] — the app's
* all-time counters — when given: it describes how the app reports scrolling, so it should not
* flip to "málo dat" every morning just because today has few events (ADR-029). By distance: longest first. By time: time in app
* when Usage access is granted, scroll time otherwise. Apps with only time in app (YouTube) have no
* share and no quality and sort after every app with data of the chosen kind. Pure Kotlin.
*/
object AppRanking {
fun rank(
apps: List<AppSummary>,
sort: AppSort,
usageGranted: Boolean,
calibration: CalibrationConfidence,
qualityBasis: Map<String, AppSummary> = emptyMap(),
): List<RankedApp> {
val total = apps.sumOf { it.distanceMm ?: 0.0 }
val ranked = apps.map { app ->
val scrolled = app.distanceMm != null
RankedApp(
app = app,
sharePercent = if (scrolled && total > 0) app.distanceMm / total * 100 else null,
quality = if (scrolled) quality(qualityBasis[app.packageName] ?: app, calibration) else null,
)
}
val key: (RankedApp) -> Double? = when (sort) {
AppSort.DISTANCE -> { r -> r.app.distanceMm }
AppSort.TIME -> { r -> (if (usageGranted) r.app.foregroundMs else r.app.activeScrollMs)?.toDouble() }
}
return ranked.sortedWith(compareBy<RankedApp> { 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,
)
}
42 changes: 42 additions & 0 deletions app/src/main/java/com/scrollmeter/app/insights/ChartScale.kt
Original file line number Diff line number Diff line change
@@ -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<Double> 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)
}
}
}
Loading
Loading