diff --git a/README.md b/README.md
index 4749320..9fdb826 100644
--- a/README.md
+++ b/README.md
@@ -102,7 +102,7 @@ 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),
+*Přehled* shows today's distance in a ring against the daily goal (500 m by default, set in *Nastavení*),
this week / month / in total, the top apps today with their names and icons, and one comparison ("To je
přibližně délka jednoho běžeckého okruhu."). Every number is live — stored plus what the service has not
written yet. When the service is off, a red banner with *Zapnout měření* comes first. With Usage access the
@@ -116,8 +116,18 @@ distance per day (7 or 30 days) or per month (12 months); tap a bar for its valu
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.
+the app's detail with its 30-day charts.
+
+## Settings, export and notifications (Phase 6)
+
+*Nastavení* holds the service and Usage-access status, *Přesnost měření*, the daily goal (100 m – 5 km or a
+custom 10 m – 100 km), *Vyloučené aplikace* (launcher, keyboard and System UI are suggested; an excluded app
+disappears from every total, history and export and comes back when switched on again), units, theme,
+the three opt-in notifications (goal reached, new record, yesterday's summary — each at most once a day; on
+Android 13+ the system asks for permission when one is switched on), *Exportovat CSV* (save `per_app.csv` /
+`daily_summary.csv` through the system file picker, or share both), *Smazat všechna data* (optionally with the
+settings and the card calibration), *Soukromí* and *O aplikaci*; in debug builds also the developer screens.
+The CSV format is in ADR-031: comma separated, dot decimals, UTF-8 with BOM, an empty field means unknown.
## Stored data and time in app (Phase 3)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 727cb67..0730082 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -16,14 +16,25 @@
tools:ignore="ProtectedPermissions" />
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/scrollmeter/app/AppGraph.kt b/app/src/main/java/com/scrollmeter/app/AppGraph.kt
index 98e6b8f..203af6b 100644
--- a/app/src/main/java/com/scrollmeter/app/AppGraph.kt
+++ b/app/src/main/java/com/scrollmeter/app/AppGraph.kt
@@ -17,7 +17,15 @@ import com.scrollmeter.app.settings.SettingsRepository
import com.scrollmeter.app.usage.UsageAccessChecker
import com.scrollmeter.app.usage.UsageEventsSource
import com.scrollmeter.app.usage.UsageSyncer
+import com.scrollmeter.app.apps.SuggestedExclusions
+import com.scrollmeter.app.data.DataEraser
+import com.scrollmeter.app.export.CsvExportWriter
+import com.scrollmeter.app.notifications.AndroidNotificationPoster
+import com.scrollmeter.app.notifications.NotificationWatcher
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
/**
* Lazy singletons shared by the UI and the accessibility service. Constructors stay plain so
@@ -39,7 +47,14 @@ class AppGraph(context: Context) {
/** One Room instance per process — the service writes, the UI reads its Flows (D8). */
val database by lazy { ScrollDatabase.create(appContext) }
- val scrollRepository by lazy { ScrollRepository(database.scrollDao(), monitor.unflushed) }
+ val scrollRepository by lazy {
+ ScrollRepository(
+ database.scrollDao(),
+ monitor.unflushed,
+ settingsRepository.settings.map { it.excludedPackages },
+ exportExcluded = { settingsRepository.stored.first().excludedPackages },
+ )
+ }
val usageAccessChecker by lazy { UsageAccessChecker(appContext) }
@@ -56,6 +71,40 @@ class AppGraph(context: Context) {
)
}
+ val suggestedExclusions by lazy { SuggestedExclusions(appContext) }
+
+ val csvExportWriter by lazy { CsvExportWriter(appContext, scrollRepository, appInfoProvider) }
+
+ /** After every flush of the service (ADR-031); a no-op until the user switches a notification on. */
+ private val notificationPoster by lazy { AndroidNotificationPoster(appContext) }
+
+ val notificationWatcher by lazy {
+ NotificationWatcher(
+ settings = { settingsRepository.stored.first() },
+ facts = { today, settings -> scrollRepository.notificationFacts(today, settings.dailyGoalMm) },
+ state = settingsRepository,
+ poster = notificationPoster,
+ epoch = monitor.dataEpoch::get,
+ postLock = monitor.writeLock,
+ )
+ }
+
+ /** "Smazat všechna data" (spec §45, ADR-031). */
+ val dataEraser by lazy {
+ DataEraser(
+ monitor = monitor,
+ clearTables = { withContext(Dispatchers.IO) { database.clearAllTables() } },
+ clearSettings = settingsRepository::clear,
+ forgetCalibration = calibrationRepository::useAutomatic,
+ setFloor = settingsRepository::setDataFloorMs,
+ clearLeftovers = {
+ withContext(Dispatchers.IO) { csvExportWriter.deleteCached() }
+ notificationPoster.clearShown()
+ },
+ usageExclusive = usageSyncer::exclusive,
+ )
+ }
+
/** Debug builds: event log + logcat. Release builds: none (src/release DevTools). */
val measurementSinks: List by lazy { DevTools.measurementSinks(this) }
diff --git a/app/src/main/java/com/scrollmeter/app/MainActivity.kt b/app/src/main/java/com/scrollmeter/app/MainActivity.kt
index be743c7..ed35c3b 100644
--- a/app/src/main/java/com/scrollmeter/app/MainActivity.kt
+++ b/app/src/main/java/com/scrollmeter/app/MainActivity.kt
@@ -2,12 +2,15 @@ package com.scrollmeter.app
import android.content.ActivityNotFoundException
import android.content.Intent
+import android.graphics.Color
import android.os.Bundle
import android.provider.Settings
import androidx.activity.ComponentActivity
+import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
@@ -32,6 +35,15 @@ class MainActivity : ComponentActivity() {
ThemePreference.DARK -> true
else -> isSystemInDarkTheme()
}
+ // System bar icons follow the app's theme, not the system's: a forced dark theme on a light
+ // system would otherwise draw dark icons on the dark app.
+ DisposableEffect(dark) {
+ enableEdgeToEdge(
+ statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { dark },
+ navigationBarStyle = SystemBarStyle.auto(LIGHT_SCRIM, DARK_SCRIM) { dark },
+ )
+ onDispose { }
+ }
ScrollMeterTheme(darkTheme = dark) {
ScrollMeterNavHost(
graph = graph,
@@ -62,4 +74,10 @@ class MainActivity : ComponentActivity() {
startActivity(Intent(Settings.ACTION_SETTINGS))
}
}
+
+ private companion object {
+ // androidx.activity's own defaults for the three-button navigation bar scrim.
+ val LIGHT_SCRIM = Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
+ val DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
+ }
}
diff --git a/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt b/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt
index 91607ca..6fcfd0c 100644
--- a/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt
+++ b/app/src/main/java/com/scrollmeter/app/accessibility/ScrollAccessibilityService.kt
@@ -11,6 +11,7 @@ import com.scrollmeter.app.BuildConfig
import com.scrollmeter.app.ScrollMeterApplication
import com.scrollmeter.app.aggregation.ScrollPipeline
import com.scrollmeter.app.calibration.CalibrationState
+import com.scrollmeter.app.measurement.MeasurementConfig
import com.scrollmeter.app.measurement.ScrollMeasurementEngine
import com.scrollmeter.app.settings.Settings
import kotlinx.coroutines.CoroutineScope
@@ -38,6 +39,8 @@ import kotlinx.coroutines.launch
* Nothing is converted before the stored calibration and settings are loaded (samples wait in
* the channel); afterwards every change applies to new events only (spec §65). Time in app is
* re-synced on connect when the last sync is older than 6 h, and when the day changes (ADR-021).
+ * After every committed flush the optional notifications are checked (spec §26, ADR-031) — never
+ * per event; on connect and at a new day sessions older than the retention are pruned.
*/
class ScrollAccessibilityService : AccessibilityService() {
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -67,7 +70,11 @@ class ScrollAccessibilityService : AccessibilityService() {
monitor = graph.monitor,
sinks = graph.measurementSinks,
uptimeMs = SystemClock::uptimeMillis,
- onDayChanged = { syncUsage(graph, onlyIfStale = false) },
+ onDayChanged = {
+ syncUsage(graph, onlyIfStale = false)
+ pruneSessions(graph)
+ },
+ onFlushed = { checkNotifications(graph) },
)
this.graph = graph
this.engine = engine
@@ -94,6 +101,29 @@ class ScrollAccessibilityService : AccessibilityService() {
}
}
syncUsage(graph, onlyIfStale = true)
+ pruneSessions(graph)
+ }
+
+ /** Off the pipeline; the watcher is cheap when nothing is switched on (ADR-031). */
+ private fun checkNotifications(graph: AppGraph) {
+ serviceScope.launch(Dispatchers.IO) {
+ try {
+ graph.notificationWatcher.check()
+ } catch (e: Exception) {
+ graph.monitor.processingFailures.incrementAndGet()
+ }
+ }
+ }
+
+ /** Sessions have no UI in the MVP: keep [MeasurementConfig.SESSION_RETENTION_DAYS] (ADR-031). */
+ private fun pruneSessions(graph: AppGraph) {
+ serviceScope.launch(Dispatchers.IO) {
+ try {
+ graph.scrollRepository.pruneSessions(System.currentTimeMillis() - MeasurementConfig.SESSION_RETENTION_DAYS * DAY_MS)
+ } catch (e: Exception) {
+ lifecycle("session prune failed: ${e.javaClass.simpleName}")
+ }
+ }
}
private fun applyCalibration(graph: AppGraph, engine: ScrollMeasurementEngine, state: CalibrationState) {
@@ -174,4 +204,8 @@ class ScrollAccessibilityService : AccessibilityService() {
Log.d("ScrollMeter", "service $what id=${System.identityHashCode(this)} connected=${graph?.monitor?.serviceConnected?.value}")
}
}
+
+ private companion object {
+ const val DAY_MS = 24 * 60 * 60_000L
+ }
}
diff --git a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt
index 021ea8d..f3b6327 100644
--- a/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt
+++ b/app/src/main/java/com/scrollmeter/app/aggregation/ScrollPipeline.kt
@@ -13,7 +13,6 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@@ -32,6 +31,10 @@ import kotlinx.coroutines.withContext
* What is summed but not yet committed — pending in the accumulator or in a write still running —
* is published as [MeasurementMonitor.unflushed], so the UI shows every counted millimetre at once
* instead of up to 10 s late.
+ *
+ * Flushes take [MeasurementMonitor.writeLock]; when "Smazat všechna data" raised
+ * [MeasurementMonitor.dataEpoch], whatever the pipeline still holds is dropped instead of written
+ * (ADR-031). [onFlushed] runs after every committed write — the notification check hangs there.
*/
class ScrollPipeline(
private val engine: ScrollMeasurementEngine,
@@ -43,13 +46,12 @@ class ScrollPipeline(
private val nowMs: () -> Long = System::currentTimeMillis,
private val zone: () -> ZoneId = ZoneId::systemDefault,
private val onDayChanged: () -> Unit = {},
+ private val onFlushed: () -> Unit = {},
) {
private val accumulator = ScrollAccumulator(zone)
private val sessions = ScrollSessionManager()
private val inFlight = ArrayList