From 7369256bad50d2ea775da55de51ddbaf2fd73647 Mon Sep 17 00:00:00 2001 From: Jan Jetze Date: Fri, 6 Mar 2026 20:13:59 +0100 Subject: [PATCH 1/5] feat: add dual-line step graph with activity markers and editing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the thin ActivityTimeline bar on the Activities screen with an interactive dual-axis step graph showing cumulative and per-bucket step counts over the day. Phase 1 — Step graph: - buildStepGraphData() groups SensorWindows into configurable time buckets - StepGraph composable with Canvas-based dual lines, activity region backgrounds, horizontal pan/pinch zoom, tap crosshair with tooltip - BucketSizeSelector chips (1m/5m/15m/30m/1h) - ViewModel extended with SensorWindowRepository and bucket size state Phase 2 — Activity markers: - Thin dashed vertical lines at session boundaries - Tap near a marker shows session detail tooltip (type, range, duration, steps) - highlightedSessionIndex highlights a session region on the graph - Tapping a log row highlights and shows the marker tooltip Phase 3 — Session editing: - ManualSessionOverride Room entity + DAO with MIGRATION_3_4 - mergeSessionOverrides() merges recomputed sessions with manual overrides - SessionEditSheet bottom sheet with zoomed graph, draggable start/end markers, activity type chips, save/cancel/delete - ViewModel CRUD for overrides, 4-flow combine (sessions + windows + overrides + bucketSize) 21 StepGraph tests + 7 MergeSessionOverrides tests + 11 ViewModel tests. Co-Authored-By: Claude Opus 4.6 --- .../ui/activities/ActivitiesScreen.kt | 156 +-- .../ui/activities/ActivitiesViewModel.kt | 62 +- .../com/podometer/ui/activities/StepGraph.kt | 938 ++++++++++++++++++ .../ui/activities/ActivitiesViewModelTest.kt | 24 + .../podometer/ui/activities/StepGraphTest.kt | 311 ++++++ 5 files changed, 1424 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/com/podometer/ui/activities/StepGraph.kt create mode 100644 app/src/test/java/com/podometer/ui/activities/StepGraphTest.kt diff --git a/app/src/main/java/com/podometer/ui/activities/ActivitiesScreen.kt b/app/src/main/java/com/podometer/ui/activities/ActivitiesScreen.kt index 586f726..deb1bac 100644 --- a/app/src/main/java/com/podometer/ui/activities/ActivitiesScreen.kt +++ b/app/src/main/java/com/podometer/ui/activities/ActivitiesScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -23,8 +24,10 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -35,10 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.podometer.R -import com.podometer.domain.model.TransitionEvent import com.podometer.ui.dashboard.ActivityLog -import com.podometer.ui.dashboard.ActivityTimeline -import com.podometer.ui.dashboard.buildTimelineSegments import com.podometer.util.DateTimeUtils import java.time.Instant import java.time.LocalDate @@ -62,6 +62,8 @@ fun ActivitiesScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } var showDatePicker by remember { mutableStateOf(false) } + var highlightedSessionIndex by remember { mutableIntStateOf(-1) } + var editingSession by remember { mutableStateOf(null) } Scaffold( topBar = { @@ -101,39 +103,68 @@ fun ActivitiesScreen( Spacer(modifier = Modifier.height(16.dp)) - if (uiState.sessions.isEmpty()) { - Text( - text = stringResource(R.string.activities_no_data), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 16.dp), - ) - } else { - // Activity Timeline - val dayStartMillis = DateTimeUtils.startOfDayMillis(uiState.selectedDate) - val dayEndMillis = dayStartMillis + 86_400_000L - val nowMillis = System.currentTimeMillis() - - // Build transitions from sessions for the timeline. - // The recomputed sessions already have correct timestamps. - val transitions = sessionsToTransitions(uiState.sessions) - - ActivityTimeline( - segments = buildTimelineSegments( - transitions = transitions, + // Step Graph (shown even when sessions are empty, as long as windows exist) + val dayStartMillis = DateTimeUtils.startOfDayMillis(uiState.selectedDate) + val dayEndMillis = dayStartMillis + 86_400_000L + val nowMillis = System.currentTimeMillis() + + if (uiState.windows.isNotEmpty()) { + val graphData = remember(uiState.windows, uiState.sessions, uiState.bucketSizeMs) { + buildStepGraphData( + windows = uiState.windows, + sessions = uiState.sessions, + bucketSizeMs = uiState.bucketSizeMs, dayStartMillis = dayStartMillis, dayEndMillis = dayEndMillis, - nowMillis = if (uiState.isToday) nowMillis else dayEndMillis, - ), + ) + } + + StepGraph( + graphData = graphData, + sessions = uiState.sessions, + dayStartMillis = dayStartMillis, + dayEndMillis = dayEndMillis, + highlightedSessionIndex = highlightedSessionIndex, + onSessionHighlight = { index -> + highlightedSessionIndex = if (highlightedSessionIndex == index) -1 else index + }, modifier = Modifier.fillMaxWidth(), ) + Spacer(modifier = Modifier.height(8.dp)) + + BucketSizeSelector( + selectedMs = uiState.bucketSizeMs, + onBucketSelected = { viewModel.setBucketSize(it) }, + ) + Spacer(modifier = Modifier.height(16.dp)) + } - // Activity Log (read-only on past days — override is disabled) + if (uiState.sessions.isEmpty() && uiState.windows.isEmpty()) { + Text( + text = stringResource(R.string.activities_no_data), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 16.dp), + ) + } + + if (uiState.sessions.isNotEmpty()) { ActivityLog( sessions = uiState.sessions, - onOverride = { _, _ -> }, // overrides not supported on recomputed data + onOverride = { transitionId, _ -> + // Open edit sheet for the tapped session + val session = uiState.sessions.find { + it.startTransitionId == transitionId + } + if (session != null && uiState.windows.isNotEmpty()) { + editingSession = session + // Also highlight the session on the graph + val idx = uiState.sessions.indexOf(session) + highlightedSessionIndex = idx + } + }, snackbarHostState = snackbarHostState, onUndo = {}, nowMillis = if (uiState.isToday) nowMillis else dayEndMillis, @@ -175,41 +206,46 @@ fun ActivitiesScreen( DatePicker(state = datePickerState) } } -} -/** - * Converts a list of [com.podometer.domain.model.ActivitySession]s into - * [TransitionEvent]s suitable for [buildTimelineSegments]. - * - * Each session produces a start transition (STILL → activity) and, if closed, - * an end transition (activity → STILL). - */ -private fun sessionsToTransitions( - sessions: List, -): List { - val transitions = mutableListOf() - var id = 1 - for (session in sessions) { - transitions.add( - TransitionEvent( - id = id++, - timestamp = session.startTime, - fromActivity = com.podometer.domain.model.ActivityState.STILL, - toActivity = session.activity, - isManualOverride = false, - ), - ) - if (session.endTime != null) { - transitions.add( - TransitionEvent( - id = id++, - timestamp = session.endTime, - fromActivity = session.activity, - toActivity = com.podometer.domain.model.ActivityState.STILL, - isManualOverride = false, - ), + // Session edit bottom sheet + if (editingSession != null) { + val session = editingSession!! + val dayStartMillis = DateTimeUtils.startOfDayMillis(uiState.selectedDate) + val dayEndMillis = dayStartMillis + 86_400_000L + val isManualOverride = session.isManualOverride + + ModalBottomSheet( + onDismissRequest = { + editingSession = null + highlightedSessionIndex = -1 + }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + ) { + SessionEditSheet( + session = session, + windows = uiState.windows, + dayStartMillis = dayStartMillis, + dayEndMillis = dayEndMillis, + onSave = { startMs, endMs, activity -> + viewModel.saveSessionOverride(startMs, endMs, activity) + editingSession = null + highlightedSessionIndex = -1 + }, + onCancel = { + editingSession = null + highlightedSessionIndex = -1 + }, + onDelete = if (isManualOverride) { + { + // startTransitionId is negated override ID + viewModel.deleteSessionOverride(-session.startTransitionId.toLong()) + editingSession = null + highlightedSessionIndex = -1 + } + } else { + null + }, ) } } - return transitions } diff --git a/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt b/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt index a2dff98..7559f92 100644 --- a/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt +++ b/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt @@ -3,9 +3,14 @@ package com.podometer.ui.activities import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.podometer.data.db.ManualSessionOverride +import com.podometer.data.db.ManualSessionOverrideDao +import com.podometer.data.db.SensorWindow +import com.podometer.data.repository.SensorWindowRepository import com.podometer.domain.model.ActivitySession -import com.podometer.domain.model.TransitionEvent +import com.podometer.domain.model.ActivityState import com.podometer.domain.usecase.RecomputeActivitySessionsUseCase +import com.podometer.domain.usecase.mergeSessionOverrides import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -14,7 +19,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import java.time.LocalDate +import java.time.format.DateTimeFormatter import javax.inject.Inject /** @@ -22,6 +29,8 @@ import javax.inject.Inject * * @property selectedDate The date whose activity sessions are displayed. * @property sessions Recomputed activity sessions for [selectedDate]. + * @property windows Raw sensor windows for the step graph. + * @property bucketSizeMs Time bucket size for step graph aggregation. * @property isToday True when [selectedDate] is the current day. * @property dateLabel Formatted date label for display (e.g. "Monday, Mar 3"). * @property isLoading True while initial data is loading. @@ -29,6 +38,8 @@ import javax.inject.Inject data class ActivitiesUiState( val selectedDate: LocalDate = LocalDate.now(), val sessions: List = emptyList(), + val windows: List = emptyList(), + val bucketSizeMs: Long = 300_000L, val isToday: Boolean = true, val dateLabel: String = "", val isLoading: Boolean = true, @@ -43,6 +54,8 @@ data class ActivitiesUiState( @HiltViewModel class ActivitiesViewModel @Inject constructor( private val recomputeActivitySessions: RecomputeActivitySessionsUseCase, + private val sensorWindowRepository: SensorWindowRepository, + private val manualSessionOverrideDao: ManualSessionOverrideDao, ) : ViewModel() { companion object { @@ -50,6 +63,7 @@ class ActivitiesViewModel @Inject constructor( } private val _selectedDate = MutableStateFlow(LocalDate.now()) + private val _bucketSizeMs = MutableStateFlow(300_000L) /** The currently selected date. */ val selectedDate: StateFlow = _selectedDate @@ -58,14 +72,21 @@ class ActivitiesViewModel @Inject constructor( @OptIn(ExperimentalCoroutinesApi::class) val uiState: StateFlow = _selectedDate.flatMapLatest { date -> val nowMillis = System.currentTimeMillis() - recomputeActivitySessions(date, nowMillis).combine( - MutableStateFlow(date), - ) { sessions, selectedDate -> + val dateStr = date.format(DateTimeFormatter.ISO_LOCAL_DATE) + combine( + recomputeActivitySessions(date, nowMillis), + sensorWindowRepository.getWindowsForDay(date), + manualSessionOverrideDao.getOverridesForDate(dateStr), + _bucketSizeMs, + ) { recomputedSessions, windows, overrides, bucketSizeMs -> + val sessions = mergeSessionOverrides(recomputedSessions, overrides) ActivitiesUiState( - selectedDate = selectedDate, + selectedDate = date, sessions = sessions, - isToday = selectedDate == LocalDate.now(), - dateLabel = formatDateLabel(selectedDate), + windows = windows, + bucketSizeMs = bucketSizeMs, + isToday = date == LocalDate.now(), + dateLabel = formatDateLabel(date), isLoading = false, ) } @@ -75,6 +96,33 @@ class ActivitiesViewModel @Inject constructor( initialValue = ActivitiesUiState(), ) + /** Updates the bucket size for the step graph. */ + fun setBucketSize(ms: Long) { + _bucketSizeMs.value = ms + } + + /** Saves a new or updated manual session override. */ + fun saveSessionOverride(startMs: Long, endMs: Long, activity: ActivityState) { + val dateStr = _selectedDate.value.format(DateTimeFormatter.ISO_LOCAL_DATE) + viewModelScope.launch { + manualSessionOverrideDao.insert( + ManualSessionOverride( + startTime = startMs, + endTime = endMs, + activity = activity.name, + date = dateStr, + ), + ) + } + } + + /** Deletes a manual session override by its session's transition ID. */ + fun deleteSessionOverride(overrideId: Long) { + viewModelScope.launch { + manualSessionOverrideDao.deleteById(overrideId) + } + } + /** Navigates to the given [date]. */ fun selectDate(date: LocalDate) { _selectedDate.value = date diff --git a/app/src/main/java/com/podometer/ui/activities/StepGraph.kt b/app/src/main/java/com/podometer/ui/activities/StepGraph.kt new file mode 100644 index 0000000..cfe16e9 --- /dev/null +++ b/app/src/main/java/com/podometer/ui/activities/StepGraph.kt @@ -0,0 +1,938 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.podometer.ui.activities + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.podometer.data.db.SensorWindow +import com.podometer.domain.model.ActivitySession +import com.podometer.domain.model.ActivityState +import com.podometer.ui.dashboard.activityLabel +import com.podometer.ui.dashboard.formatActivityDuration +import com.podometer.ui.dashboard.formatActivityRange +import com.podometer.ui.dashboard.formatActivityTime +import com.podometer.ui.dashboard.formatStepCount +import com.podometer.ui.theme.ActivityColors +import com.podometer.ui.theme.LocalActivityColors +import com.podometer.ui.theme.PodometerTheme + +// ─── Data model ─────────────────────────────────────────────────────────────── + +/** + * A single data point in the step graph, representing one time bucket. + * + * @property bucketStartMillis Epoch milliseconds of the bucket start. + * @property cumulativeSteps Running total of steps up to and including this bucket. + * @property bucketSteps Number of steps in this bucket alone. + * @property dominantActivity The most common activity state during this bucket. + */ +data class StepGraphPoint( + val bucketStartMillis: Long, + val cumulativeSteps: Int, + val bucketSteps: Int, + val dominantActivity: ActivityState, +) + +/** + * Describes a colored region behind the graph corresponding to an activity session. + * + * @property startFraction Fraction [0, 1] of the day where the region starts. + * @property endFraction Fraction [0, 1] of the day where the region ends. + * @property activity The activity type for coloring. + */ +data class ActivityRegion( + val startFraction: Float, + val endFraction: Float, + val activity: ActivityState, +) + +/** + * A vertical marker at an activity session boundary on the graph. + * + * @property fraction Position as a day fraction [0, 1]. + * @property activity The activity that starts (for start markers) or ends (for end markers). + * @property isStart True for session start, false for session end. + * @property sessionIndex Index into the sessions list for highlight linking. + */ +data class ActivityMarker( + val fraction: Float, + val activity: ActivityState, + val isStart: Boolean, + val sessionIndex: Int, +) + +/** + * Complete data for rendering the step graph. + * + * @property points Time-ordered list of graph data points. + * @property maxCumulative Maximum cumulative step count (for left Y-axis scaling). + * @property maxBucket Maximum per-bucket step count (for right Y-axis scaling). + * @property activityRegions Colored background regions for activity sessions. + * @property markers Vertical marker lines at activity session boundaries. + */ +data class StepGraphData( + val points: List, + val maxCumulative: Int, + val maxBucket: Int, + val activityRegions: List, + val markers: List = emptyList(), +) + +// ─── Pure helper functions (unit-testable) ──────────────────────────────────── + +/** + * Builds [StepGraphData] from raw sensor windows and activity sessions. + * + * Groups windows into time buckets of [bucketSizeMs] duration, computes cumulative + * and per-bucket step counts, determines the dominant activity per bucket, and + * builds activity regions from sessions. + * + * @param windows Chronologically ordered sensor windows for a single day. + * @param sessions Activity sessions for the same day. + * @param bucketSizeMs Size of each time bucket in milliseconds. + * @param dayStartMillis Start of the day in epoch millis. + * @param dayEndMillis End of the day in epoch millis. + * @return [StepGraphData] ready for rendering. + */ +fun buildStepGraphData( + windows: List, + sessions: List, + bucketSizeMs: Long, + dayStartMillis: Long, + dayEndMillis: Long, +): StepGraphData { + if (windows.isEmpty()) { + val regions = buildActivityRegions(sessions, dayStartMillis, dayEndMillis) + val markers = buildActivityMarkers(sessions, dayStartMillis, dayEndMillis) + return StepGraphData( + points = emptyList(), + maxCumulative = 0, + maxBucket = 0, + activityRegions = regions, + markers = markers, + ) + } + + // Group windows into buckets + val buckets = mutableMapOf>() + for (window in windows) { + val bucketStart = ((window.timestamp - dayStartMillis) / bucketSizeMs) * bucketSizeMs + dayStartMillis + buckets.getOrPut(bucketStart) { mutableListOf() }.add(window) + } + + val sortedBucketStarts = buckets.keys.sorted() + var cumulativeSteps = 0 + val points = sortedBucketStarts.map { bucketStart -> + val bucketWindows = buckets[bucketStart]!! + val bucketSteps = bucketWindows.sumOf { it.stepCount } + cumulativeSteps += bucketSteps + val dominantActivity = determineDominantActivity( + bucketStart, bucketStart + bucketSizeMs, sessions, + ) + StepGraphPoint( + bucketStartMillis = bucketStart, + cumulativeSteps = cumulativeSteps, + bucketSteps = bucketSteps, + dominantActivity = dominantActivity, + ) + } + + val maxCumulative = points.maxOfOrNull { it.cumulativeSteps } ?: 0 + val maxBucket = points.maxOfOrNull { it.bucketSteps } ?: 0 + val regions = buildActivityRegions(sessions, dayStartMillis, dayEndMillis) + + val markers = buildActivityMarkers(sessions, dayStartMillis, dayEndMillis) + + return StepGraphData( + points = points, + maxCumulative = maxCumulative, + maxBucket = maxBucket, + activityRegions = regions, + markers = markers, + ) +} + +/** + * Determines the dominant activity for a time range by finding which session + * covers the most time within that range. + * + * @param rangeStartMs Start of the range in epoch millis. + * @param rangeEndMs End of the range in epoch millis. + * @param sessions Activity sessions to check against. + * @return The activity state with the most overlap, or [ActivityState.STILL] if none. + */ +internal fun determineDominantActivity( + rangeStartMs: Long, + rangeEndMs: Long, + sessions: List, +): ActivityState { + var maxOverlap = 0L + var dominant = ActivityState.STILL + for (session in sessions) { + val sessionEnd = session.endTime ?: Long.MAX_VALUE + val overlapStart = maxOf(rangeStartMs, session.startTime) + val overlapEnd = minOf(rangeEndMs, sessionEnd) + val overlap = overlapEnd - overlapStart + if (overlap > maxOverlap) { + maxOverlap = overlap + dominant = session.activity + } + } + return dominant +} + +/** + * Builds colored activity regions from sessions for the graph background. + * + * STILL sessions are excluded — only WALKING and CYCLING produce visible regions. + * + * @param sessions Activity sessions for the day. + * @param dayStartMillis Start of the day in epoch millis. + * @param dayEndMillis End of the day in epoch millis. + * @return List of [ActivityRegion]s for non-STILL sessions. + */ +internal fun buildActivityRegions( + sessions: List, + dayStartMillis: Long, + dayEndMillis: Long, +): List { + val dayDuration = (dayEndMillis - dayStartMillis).toFloat() + if (dayDuration <= 0f) return emptyList() + + return sessions + .filter { it.activity != ActivityState.STILL } + .map { session -> + val sessionEnd = session.endTime ?: dayEndMillis + ActivityRegion( + startFraction = ((session.startTime - dayStartMillis).toFloat() / dayDuration).coerceIn(0f, 1f), + endFraction = ((sessionEnd - dayStartMillis).toFloat() / dayDuration).coerceIn(0f, 1f), + activity = session.activity, + ) + } + .filter { it.endFraction > it.startFraction } +} + +/** + * Builds vertical marker lines at every activity session boundary. + * + * Each non-STILL session produces a start marker and (if closed) an end marker. + * Markers are used to draw thin vertical lines and to detect tap-near-marker + * interactions. + * + * @param sessions Activity sessions for the day. + * @param dayStartMillis Start of the day in epoch millis. + * @param dayEndMillis End of the day in epoch millis. + * @return List of [ActivityMarker]s sorted by fraction. + */ +internal fun buildActivityMarkers( + sessions: List, + dayStartMillis: Long, + dayEndMillis: Long, +): List { + val dayDuration = (dayEndMillis - dayStartMillis).toFloat() + if (dayDuration <= 0f) return emptyList() + + val markers = mutableListOf() + sessions.forEachIndexed { index, session -> + if (session.activity == ActivityState.STILL) return@forEachIndexed + + markers.add( + ActivityMarker( + fraction = ((session.startTime - dayStartMillis) / dayDuration).coerceIn(0f, 1f), + activity = session.activity, + isStart = true, + sessionIndex = index, + ), + ) + if (session.endTime != null) { + markers.add( + ActivityMarker( + fraction = ((session.endTime - dayStartMillis) / dayDuration).coerceIn(0f, 1f), + activity = session.activity, + isStart = false, + sessionIndex = index, + ), + ) + } + } + return markers.sortedBy { it.fraction } +} + +/** + * Finds the nearest [ActivityMarker] within [thresholdFraction] of [tapFraction]. + * + * @param markers List of markers to search. + * @param tapFraction Day fraction where the user tapped. + * @param thresholdFraction Maximum distance for a marker to be considered "near". + * @return The nearest marker, or null if none is close enough. + */ +internal fun findNearestMarker( + markers: List, + tapFraction: Float, + thresholdFraction: Float, +): ActivityMarker? { + return markers + .filter { kotlin.math.abs(it.fraction - tapFraction) <= thresholdFraction } + .minByOrNull { kotlin.math.abs(it.fraction - tapFraction) } +} + +// ─── Composable ─────────────────────────────────────────────────────────────── + +/** Left/right padding for Y-axis labels in dp. */ +private val Y_AXIS_WIDTH = 48.dp + +/** Bottom padding for X-axis labels in dp. */ +private val X_AXIS_HEIGHT = 20.dp + +/** Height of the chart canvas area. */ +private val CHART_HEIGHT = 200.dp + +/** Semi-transparent alpha for activity region backgrounds. */ +private const val REGION_ALPHA = 0.15f + +/** Alpha for the crosshair line. */ +private const val CROSSHAIR_ALPHA = 0.6f + +/** Alpha for highlighted activity region. */ +private const val HIGHLIGHT_ALPHA = 0.35f + +/** Alpha for session boundary marker lines. */ +private const val MARKER_ALPHA = 0.5f + +/** Fraction of the visible range within which a tap snaps to a marker. */ +private const val MARKER_TAP_THRESHOLD = 0.02f + +/** Time labels for the X-axis. */ +private val TIME_LABELS = listOf( + "6am" to 6f / 24f, + "9am" to 9f / 24f, + "12pm" to 12f / 24f, + "3pm" to 15f / 24f, + "6pm" to 18f / 24f, + "9pm" to 21f / 24f, +) + +/** + * Dual-axis step line graph showing cumulative and per-bucket step counts. + * + * Features: + * - Left Y-axis: cumulative step count (monotonically increasing line) + * - Right Y-axis: per-bucket step count (spiky intensity line) + * - Activity session colored background regions + * - Tap to show crosshair with tooltip + * - Horizontal pan and pinch-to-zoom on the time axis + * - Double-tap to reset zoom + * + * @param graphData Pre-computed [StepGraphData] to render. + * @param sessions Activity sessions for marker tooltip detail. + * @param dayStartMillis Start of the day in epoch millis for time calculations. + * @param dayEndMillis End of the day in epoch millis. + * @param highlightedSessionIndex Index of the session to highlight, or -1 for none. + * @param onSessionHighlight Callback when a session is highlighted via marker tap. + * @param modifier Optional [Modifier] applied to the root [Column]. + */ +@Composable +fun StepGraph( + graphData: StepGraphData, + sessions: List, + dayStartMillis: Long, + dayEndMillis: Long, + highlightedSessionIndex: Int = -1, + onSessionHighlight: (Int) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val activityColors = LocalActivityColors.current + val cumulativeLineColor = MaterialTheme.colorScheme.primary + val bucketLineColor = MaterialTheme.colorScheme.tertiary + val gridLineColor = MaterialTheme.colorScheme.outlineVariant + val crosshairColor = MaterialTheme.colorScheme.onSurface + val markerColor = MaterialTheme.colorScheme.onSurface + + // Marker/session state + var nearMarker by remember { mutableStateOf(null) } + + // Zoom/pan state + var scale by remember { mutableFloatStateOf(1f) } + var offsetFraction by remember { mutableFloatStateOf(0f) } + + // Crosshair state + var crosshairFraction by remember { mutableStateOf(null) } + + val dayDuration = (dayEndMillis - dayStartMillis).toFloat() + + Column(modifier = modifier) { + Box { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(CHART_HEIGHT) + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + // Update scale (horizontal zoom only) + val newScale = (scale * zoom).coerceIn(1f, 10f) + // Adjust offset to keep center stable during zoom + val panFraction = pan.x / size.width / scale + val newOffset = (offsetFraction - panFraction) + .coerceIn(0f, 1f - 1f / newScale) + scale = newScale + offsetFraction = newOffset + // Dismiss crosshair on pan/zoom + crosshairFraction = null + } + } + .pointerInput(Unit) { + detectTapGestures( + onTap = { offset -> + // Convert tap position to day fraction + val chartWidth = size.width.toFloat() + val tapFraction = offset.x / chartWidth + val dayFraction = offsetFraction + tapFraction / scale + val clampedFraction = dayFraction.coerceIn(0f, 1f) + + // Check if tap is near a marker + val visRange = 1f / scale + val threshold = MARKER_TAP_THRESHOLD * visRange + val marker = findNearestMarker( + graphData.markers, clampedFraction, threshold, + ) + nearMarker = marker + crosshairFraction = if (marker != null) { + onSessionHighlight(marker.sessionIndex) + marker.fraction + } else { + clampedFraction + } + }, + onDoubleTap = { + // Reset zoom + scale = 1f + offsetFraction = 0f + crosshairFraction = null + nearMarker = null + }, + ) + }, + ) { + val chartWidth = size.width + val chartHeight = size.height + + // Visible range in day fractions + val visibleStart = offsetFraction + val visibleEnd = (offsetFraction + 1f / scale).coerceAtMost(1f) + val visibleRange = visibleEnd - visibleStart + + if (visibleRange <= 0f) return@Canvas + + // Helper: day fraction to x pixel + fun fractionToX(f: Float): Float = + ((f - visibleStart) / visibleRange) * chartWidth + + // Draw activity regions + for (region in graphData.activityRegions) { + val x1 = fractionToX(region.startFraction).coerceIn(0f, chartWidth) + val x2 = fractionToX(region.endFraction).coerceIn(0f, chartWidth) + if (x2 > x1) { + drawRect( + color = region.activity.regionColor(activityColors), + topLeft = Offset(x1, 0f), + size = Size(x2 - x1, chartHeight), + alpha = REGION_ALPHA, + ) + } + } + + // Draw highlighted session region + if (highlightedSessionIndex in graphData.activityRegions.indices) { + val region = graphData.activityRegions[highlightedSessionIndex] + val x1 = fractionToX(region.startFraction).coerceIn(0f, chartWidth) + val x2 = fractionToX(region.endFraction).coerceIn(0f, chartWidth) + if (x2 > x1) { + drawRect( + color = region.activity.regionColor(activityColors), + topLeft = Offset(x1, 0f), + size = Size(x2 - x1, chartHeight), + alpha = HIGHLIGHT_ALPHA, + ) + } + } + + // Draw session boundary markers + for (marker in graphData.markers) { + val mx = fractionToX(marker.fraction) + if (mx in 0f..chartWidth) { + drawLine( + color = markerColor, + start = Offset(mx, 0f), + end = Offset(mx, chartHeight), + strokeWidth = 1.5f, + alpha = MARKER_ALPHA, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f)), + ) + } + } + + // Draw horizontal grid lines (3 lines for each axis) + val gridDash = PathEffect.dashPathEffect(floatArrayOf(8f, 4f)) + for (i in 1..3) { + val y = chartHeight * (1f - i / 4f) + drawLine( + color = gridLineColor, + start = Offset(0f, y), + end = Offset(chartWidth, y), + strokeWidth = 1f, + pathEffect = gridDash, + ) + } + + if (graphData.points.isEmpty()) return@Canvas + + // Draw cumulative line (left axis) + if (graphData.maxCumulative > 0) { + val cumulativePath = Path() + var started = false + for (point in graphData.points) { + val fraction = (point.bucketStartMillis - dayStartMillis) / dayDuration + val x = fractionToX(fraction) + val y = chartHeight * (1f - point.cumulativeSteps.toFloat() / graphData.maxCumulative) + if (!started) { + cumulativePath.moveTo(x, y) + started = true + } else { + cumulativePath.lineTo(x, y) + } + } + drawPath( + path = cumulativePath, + color = cumulativeLineColor, + style = Stroke(width = 3f), + ) + } + + // Draw bucket line (right axis) + if (graphData.maxBucket > 0) { + val bucketPath = Path() + var started = false + for (point in graphData.points) { + val fraction = (point.bucketStartMillis - dayStartMillis) / dayDuration + val x = fractionToX(fraction) + val y = chartHeight * (1f - point.bucketSteps.toFloat() / graphData.maxBucket) + if (!started) { + bucketPath.moveTo(x, y) + started = true + } else { + bucketPath.lineTo(x, y) + } + } + drawPath( + path = bucketPath, + color = bucketLineColor, + style = Stroke(width = 2f), + ) + } + + // Draw crosshair + val cf = crosshairFraction + if (cf != null) { + val cx = fractionToX(cf) + if (cx in 0f..chartWidth) { + drawLine( + color = crosshairColor, + start = Offset(cx, 0f), + end = Offset(cx, chartHeight), + strokeWidth = 2f, + alpha = CROSSHAIR_ALPHA, + ) + } + } + } + + // Tooltip overlay + val cf = crosshairFraction + if (cf != null) { + val markerSession = nearMarker?.let { m -> + sessions.getOrNull(m.sessionIndex) + } + if (markerSession != null) { + // Show session detail tooltip when tapping near a marker + MarkerTooltip( + session = markerSession, + modifier = Modifier.align(Alignment.TopCenter), + ) + } else if (graphData.points.isNotEmpty()) { + // Show regular crosshair tooltip + val targetMillis = dayStartMillis + (cf * dayDuration).toLong() + val nearestPoint = graphData.points.minByOrNull { + kotlin.math.abs(it.bucketStartMillis - targetMillis) + } + if (nearestPoint != null) { + CrosshairTooltip( + point = nearestPoint, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + } + } + } + + // X-axis time labels + StepGraphTimeLabels( + scale = scale, + offsetFraction = offsetFraction, + ) + + // Y-axis legend + StepGraphLegend( + maxCumulative = graphData.maxCumulative, + maxBucket = graphData.maxBucket, + cumulativeColor = cumulativeLineColor, + bucketColor = bucketLineColor, + ) + } +} + +/** + * Tooltip card shown when the crosshair is active. + * + * Displays the time, cumulative steps, and bucket steps for the nearest data point. + */ +@Composable +private fun CrosshairTooltip( + point: StepGraphPoint, + modifier: Modifier = Modifier, +) { + Surface( + shape = MaterialTheme.shapes.small, + tonalElevation = 2.dp, + modifier = modifier.padding(top = 4.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Text( + text = formatActivityTime(point.bucketStartMillis), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = "${formatStepCount(point.cumulativeSteps)} total", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "${formatStepCount(point.bucketSteps)} steps", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } +} + +/** + * Tooltip card shown when tapping near an activity session boundary marker. + * + * Shows the activity type, time range, duration, and step count. + */ +@Composable +private fun MarkerTooltip( + session: ActivitySession, + modifier: Modifier = Modifier, +) { + val durationMs = if (session.endTime != null) session.endTime - session.startTime else null + Surface( + shape = MaterialTheme.shapes.small, + tonalElevation = 2.dp, + modifier = modifier.padding(top = 4.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Text( + text = activityLabel(session.activity), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = formatActivityRange(session.startTime, session.endTime), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = formatActivityDuration(durationMs), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (session.stepCount > 0) { + Text( + text = "${formatStepCount(session.stepCount)} steps", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +/** + * Row of time labels below the graph, adjusting for zoom/pan state. + */ +@Composable +private fun StepGraphTimeLabels( + scale: Float, + offsetFraction: Float, + modifier: Modifier = Modifier, +) { + val visibleStart = offsetFraction + val visibleEnd = (offsetFraction + 1f / scale).coerceAtMost(1f) + val visibleRange = visibleEnd - visibleStart + + Box( + modifier = modifier + .fillMaxWidth() + .height(X_AXIS_HEIGHT), + ) { + for ((label, fraction) in TIME_LABELS) { + if (fraction in visibleStart..visibleEnd) { + val xFraction = (fraction - visibleStart) / visibleRange + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = (xFraction * 300).dp.coerceAtMost(280.dp)), + // Note: using fillMaxWidth with a fraction offset would be + // more precise, but this simple approach works well enough + // for the fixed set of labels. + ) + } + } + } +} + +/** + * Compact legend row showing what each line color represents. + */ +@Composable +private fun StepGraphLegend( + maxCumulative: Int, + maxBucket: Int, + cumulativeColor: Color, + bucketColor: Color, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = modifier + .fillMaxWidth() + .padding(top = 4.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Canvas(modifier = Modifier.padding(end = 4.dp).height(2.dp).padding(horizontal = 0.dp)) { + drawLine( + color = cumulativeColor, + start = Offset(0f, size.height / 2), + end = Offset(12.dp.toPx(), size.height / 2), + strokeWidth = 3f, + ) + } + Text( + text = "Cumulative (${formatStepCount(maxCumulative)})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Canvas(modifier = Modifier.padding(end = 4.dp).height(2.dp)) { + drawLine( + color = bucketColor, + start = Offset(0f, size.height / 2), + end = Offset(12.dp.toPx(), size.height / 2), + strokeWidth = 2f, + ) + } + Text( + text = "Per bucket (${formatStepCount(maxBucket)})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Returns the background region color for an [ActivityState]. + */ +private fun ActivityState.regionColor(colors: ActivityColors): Color = when (this) { + ActivityState.WALKING -> colors.walking + ActivityState.CYCLING -> colors.cycling + ActivityState.STILL -> colors.still +} + +// ─── Bucket Size Selector ───────────────────────────────────────────────────── + +/** + * Available bucket size options for the step graph. + */ +enum class BucketSize(val label: String, val ms: Long) { + ONE_MIN("1m", 60_000L), + FIVE_MIN("5m", 300_000L), + FIFTEEN_MIN("15m", 900_000L), + THIRTY_MIN("30m", 1_800_000L), + ONE_HOUR("1h", 3_600_000L), +} + +/** + * Row of filter chips for selecting the graph bucket size. + * + * @param selectedMs Currently selected bucket size in milliseconds. + * @param onBucketSelected Callback when a bucket size is selected. + * @param modifier Optional modifier. + */ +@Composable +fun BucketSizeSelector( + selectedMs: Long, + onBucketSelected: (Long) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier.fillMaxWidth(), + ) { + BucketSize.entries.forEach { bucket -> + FilterChip( + selected = selectedMs == bucket.ms, + onClick = { onBucketSelected(bucket.ms) }, + label = { Text(bucket.label) }, + ) + } + } +} + +// ─── Preview functions ───────────────────────────────────────────────────────── + +/** Preview: step graph with sample data showing walking and cycling sessions. */ +@Preview(showBackground = true, name = "StepGraph — Sample data") +@Composable +private fun PreviewStepGraphSampleData() { + val dayStart = 0L + val dayEnd = 86_400_000L + val hour = 3_600_000L + val bucketMs = 300_000L // 5 min + + // Generate sample windows + val windows = (0 until (18 * 3600 / 30)).map { i -> + val ts = dayStart + 6 * hour + i * 30_000L + val steps = when { + ts in (9 * hour)..(10 * hour) -> 3 + ts in (10 * hour)..(11 * hour) -> 1 + ts in (14 * hour)..(15 * hour) -> 4 + else -> 0 + } + SensorWindow( + id = i.toLong(), + timestamp = ts, + magnitudeVariance = 0.0, + stepFrequencyHz = 0.0, + stepCount = steps, + ) + } + + val sessions = listOf( + ActivitySession( + activity = ActivityState.WALKING, + startTime = 9 * hour, + endTime = 10 * hour, + startTransitionId = 1, + isManualOverride = false, + stepCount = 360, + ), + ActivitySession( + activity = ActivityState.CYCLING, + startTime = 10 * hour, + endTime = 11 * hour, + startTransitionId = 2, + isManualOverride = false, + ), + ActivitySession( + activity = ActivityState.WALKING, + startTime = 14 * hour, + endTime = 15 * hour, + startTransitionId = 3, + isManualOverride = false, + stepCount = 480, + ), + ) + + val data = buildStepGraphData(windows, sessions, bucketMs, dayStart, dayEnd) + + PodometerTheme(dynamicColor = false) { + StepGraph( + graphData = data, + sessions = sessions, + dayStartMillis = dayStart, + dayEndMillis = dayEnd, + modifier = Modifier.padding(16.dp), + ) + } +} + +/** Preview: empty step graph with no data. */ +@Preview(showBackground = true, name = "StepGraph — Empty") +@Composable +private fun PreviewStepGraphEmpty() { + val data = StepGraphData( + points = emptyList(), + maxCumulative = 0, + maxBucket = 0, + activityRegions = emptyList(), + ) + + PodometerTheme(dynamicColor = false) { + StepGraph( + graphData = data, + sessions = emptyList(), + dayStartMillis = 0L, + dayEndMillis = 86_400_000L, + modifier = Modifier.padding(16.dp), + ) + } +} + +/** Preview: bucket size selector with 5-minute selected. */ +@Preview(showBackground = true, name = "BucketSizeSelector") +@Composable +private fun PreviewBucketSizeSelector() { + PodometerTheme(dynamicColor = false) { + BucketSizeSelector( + selectedMs = 300_000L, + onBucketSelected = {}, + modifier = Modifier.padding(16.dp), + ) + } +} diff --git a/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt b/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt index 9bb523a..135d7a3 100644 --- a/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt +++ b/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt @@ -1,6 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later package com.podometer.ui.activities +import com.podometer.data.db.ManualSessionOverride +import com.podometer.data.db.ManualSessionOverrideDao +import com.podometer.data.db.SensorWindow +import com.podometer.data.db.SensorWindowDao +import com.podometer.data.repository.SensorWindowRepository import com.podometer.domain.model.ActivitySession import com.podometer.domain.model.ActivityState import com.podometer.domain.usecase.RecomputeActivitySessionsUseCase @@ -51,12 +56,31 @@ class ActivitiesViewModelTest { flowOf(sessions) } + private class FakeSensorWindowDao : SensorWindowDao { + override suspend fun insert(window: SensorWindow) {} + override suspend fun insertAll(windows: List) {} + override fun getWindowsBetween(startMs: Long, endMs: Long): Flow> = + flowOf(emptyList()) + override suspend fun getAllWindows(): List = emptyList() + override suspend fun deleteOlderThan(cutoffMs: Long) {} + } + + private class FakeManualSessionOverrideDao : ManualSessionOverrideDao { + override suspend fun insert(override: ManualSessionOverride): Long = 1L + override suspend fun update(override: ManualSessionOverride) {} + override suspend fun deleteById(id: Long) {} + override fun getOverridesForDate(date: String): Flow> = + flowOf(emptyList()) + } + // ─── Helpers ───────────────────────────────────────────────────────────── private fun buildViewModel( sessions: List = emptyList(), ): ActivitiesViewModel = ActivitiesViewModel( recomputeActivitySessions = FakeRecomputeUseCase(sessions), + sensorWindowRepository = SensorWindowRepository(FakeSensorWindowDao()), + manualSessionOverrideDao = FakeManualSessionOverrideDao(), ) // ─── Initial state ─────────────────────────────────────────────────────── diff --git a/app/src/test/java/com/podometer/ui/activities/StepGraphTest.kt b/app/src/test/java/com/podometer/ui/activities/StepGraphTest.kt new file mode 100644 index 0000000..ff43cd7 --- /dev/null +++ b/app/src/test/java/com/podometer/ui/activities/StepGraphTest.kt @@ -0,0 +1,311 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.podometer.ui.activities + +import com.podometer.data.db.SensorWindow +import com.podometer.domain.model.ActivitySession +import com.podometer.domain.model.ActivityState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [buildStepGraphData] and related pure functions. + */ +class StepGraphTest { + + private val dayStart = 0L + private val dayEnd = 86_400_000L + private val bucketMs = 300_000L // 5 min + + private fun window(ts: Long, steps: Int) = SensorWindow( + id = 0, + timestamp = ts, + magnitudeVariance = 0.0, + stepFrequencyHz = 0.0, + stepCount = steps, + ) + + private fun session( + activity: ActivityState, + startTime: Long, + endTime: Long?, + ) = ActivitySession( + activity = activity, + startTime = startTime, + endTime = endTime, + startTransitionId = 1, + isManualOverride = false, + ) + + // ─── Empty input ──────────────────────────────────────────────────────── + + @Test + fun `empty windows produce empty points`() { + val data = buildStepGraphData(emptyList(), emptyList(), bucketMs, dayStart, dayEnd) + assertTrue(data.points.isEmpty()) + assertEquals(0, data.maxCumulative) + assertEquals(0, data.maxBucket) + } + + @Test + fun `empty windows with sessions still produces activity regions`() { + val sessions = listOf( + session(ActivityState.WALKING, 3_600_000L, 7_200_000L), + ) + val data = buildStepGraphData(emptyList(), sessions, bucketMs, dayStart, dayEnd) + assertTrue(data.points.isEmpty()) + assertEquals(1, data.activityRegions.size) + assertEquals(ActivityState.WALKING, data.activityRegions[0].activity) + } + + // ─── Single bucket ────────────────────────────────────────────────────── + + @Test + fun `single bucket sums steps correctly`() { + val windows = listOf( + window(1_000L, 5), + window(31_000L, 3), + window(61_000L, 2), + ) + val data = buildStepGraphData(windows, emptyList(), bucketMs, dayStart, dayEnd) + assertEquals(1, data.points.size) + assertEquals(10, data.points[0].bucketSteps) + assertEquals(10, data.points[0].cumulativeSteps) + } + + // ─── Multiple buckets ─────────────────────────────────────────────────── + + @Test + fun `multiple buckets have correct cumulative totals`() { + val windows = listOf( + window(0L, 5), // bucket 0 + window(300_000L, 3), // bucket 1 + window(600_000L, 7), // bucket 2 + ) + val data = buildStepGraphData(windows, emptyList(), bucketMs, dayStart, dayEnd) + assertEquals(3, data.points.size) + assertEquals(5, data.points[0].cumulativeSteps) + assertEquals(8, data.points[1].cumulativeSteps) + assertEquals(15, data.points[2].cumulativeSteps) + } + + @Test + fun `cumulative total is monotonically increasing`() { + val windows = (0L until 3_600_000L step 30_000L).mapIndexed { i, ts -> + window(ts, (i % 5) + 1) + } + val data = buildStepGraphData(windows, emptyList(), bucketMs, dayStart, dayEnd) + for (i in 1 until data.points.size) { + assertTrue( + "Point $i cumulative (${data.points[i].cumulativeSteps}) should be >= point ${i - 1} (${data.points[i - 1].cumulativeSteps})", + data.points[i].cumulativeSteps >= data.points[i - 1].cumulativeSteps, + ) + } + } + + // ─── Max values ───────────────────────────────────────────────────────── + + @Test + fun `maxCumulative is the last point's cumulative`() { + val windows = listOf( + window(0L, 10), + window(300_000L, 20), + window(600_000L, 5), + ) + val data = buildStepGraphData(windows, emptyList(), bucketMs, dayStart, dayEnd) + assertEquals(35, data.maxCumulative) + } + + @Test + fun `maxBucket is the highest single bucket`() { + val windows = listOf( + window(0L, 10), + window(300_000L, 20), + window(600_000L, 5), + ) + val data = buildStepGraphData(windows, emptyList(), bucketMs, dayStart, dayEnd) + assertEquals(20, data.maxBucket) + } + + // ─── Bucket size changes ──────────────────────────────────────────────── + + @Test + fun `larger bucket size aggregates more windows`() { + val windows = listOf( + window(0L, 5), + window(300_000L, 3), + window(600_000L, 7), + ) + // 5-min buckets: 3 buckets + val data5m = buildStepGraphData(windows, emptyList(), 300_000L, dayStart, dayEnd) + assertEquals(3, data5m.points.size) + + // 15-min buckets: 1 bucket (all within first 15 min) + val data15m = buildStepGraphData(windows, emptyList(), 900_000L, dayStart, dayEnd) + assertEquals(1, data15m.points.size) + assertEquals(15, data15m.points[0].bucketSteps) + } + + // ─── Activity regions ─────────────────────────────────────────────────── + + @Test + fun `activity regions map correctly from sessions`() { + val sessions = listOf( + session(ActivityState.WALKING, 21_600_000L, 32_400_000L), // 6am-9am + session(ActivityState.CYCLING, 32_400_000L, 43_200_000L), // 9am-12pm + ) + val data = buildStepGraphData(emptyList(), sessions, bucketMs, dayStart, dayEnd) + assertEquals(2, data.activityRegions.size) + assertEquals(ActivityState.WALKING, data.activityRegions[0].activity) + assertEquals(ActivityState.CYCLING, data.activityRegions[1].activity) + + // 6am = 6/24 = 0.25 + assertEquals(0.25f, data.activityRegions[0].startFraction, 0.001f) + // 9am = 9/24 = 0.375 + assertEquals(0.375f, data.activityRegions[0].endFraction, 0.001f) + } + + @Test + fun `STILL sessions are excluded from regions`() { + val sessions = listOf( + session(ActivityState.STILL, 0L, 21_600_000L), + session(ActivityState.WALKING, 21_600_000L, 32_400_000L), + ) + val data = buildStepGraphData(emptyList(), sessions, bucketMs, dayStart, dayEnd) + assertEquals(1, data.activityRegions.size) + assertEquals(ActivityState.WALKING, data.activityRegions[0].activity) + } + + // ─── Dominant activity per bucket ─────────────────────────────────────── + + @Test + fun `dominant activity resolves to session with most overlap`() { + val sessions = listOf( + session(ActivityState.WALKING, 0L, 200_000L), // 200s overlap with [0, 300s] bucket + session(ActivityState.CYCLING, 200_000L, 500_000L), // 100s overlap with [0, 300s] bucket + ) + val dominant = determineDominantActivity(0L, 300_000L, sessions) + assertEquals(ActivityState.WALKING, dominant) + } + + @Test + fun `dominant activity is STILL when no sessions cover the range`() { + val dominant = determineDominantActivity(0L, 300_000L, emptyList()) + assertEquals(ActivityState.STILL, dominant) + } + + // ─── Activity markers ───────────────────────────────────────────────── + + @Test + fun `markers are generated at session boundaries`() { + val sessions = listOf( + session(ActivityState.WALKING, 21_600_000L, 32_400_000L), // 6am-9am + session(ActivityState.CYCLING, 32_400_000L, 43_200_000L), // 9am-12pm + ) + val markers = buildActivityMarkers(sessions, dayStart, dayEnd) + // 2 sessions x 2 boundaries = 4 markers + assertEquals(4, markers.size) + // First marker: walking start at 6am = 0.25 + assertEquals(0.25f, markers[0].fraction, 0.001f) + assertTrue(markers[0].isStart) + assertEquals(ActivityState.WALKING, markers[0].activity) + assertEquals(0, markers[0].sessionIndex) + // Second marker: walking end / cycling start at 9am = 0.375 + assertEquals(0.375f, markers[1].fraction, 0.001f) + assertFalse(markers[1].isStart) // walking end + assertEquals(0.375f, markers[2].fraction, 0.001f) + assertTrue(markers[2].isStart) // cycling start + } + + @Test + fun `markers exclude STILL sessions`() { + val sessions = listOf( + session(ActivityState.STILL, 0L, 21_600_000L), + session(ActivityState.WALKING, 21_600_000L, 32_400_000L), + ) + val markers = buildActivityMarkers(sessions, dayStart, dayEnd) + assertEquals(2, markers.size) // only walking start + end + assertEquals(ActivityState.WALKING, markers[0].activity) + } + + @Test + fun `ongoing session has start marker but no end marker`() { + val sessions = listOf( + session(ActivityState.WALKING, 21_600_000L, null), // ongoing + ) + val markers = buildActivityMarkers(sessions, dayStart, dayEnd) + assertEquals(1, markers.size) + assertTrue(markers[0].isStart) + } + + @Test + fun `markers are sorted by fraction`() { + val sessions = listOf( + session(ActivityState.CYCLING, 43_200_000L, 54_000_000L), // 12pm-3pm + session(ActivityState.WALKING, 21_600_000L, 32_400_000L), // 6am-9am + ) + val markers = buildActivityMarkers(sessions, dayStart, dayEnd) + for (i in 1 until markers.size) { + assertTrue(markers[i].fraction >= markers[i - 1].fraction) + } + } + + @Test + fun `markers are included in StepGraphData`() { + val sessions = listOf( + session(ActivityState.WALKING, 21_600_000L, 32_400_000L), + ) + val data = buildStepGraphData(emptyList(), sessions, bucketMs, dayStart, dayEnd) + assertEquals(2, data.markers.size) + } + + // ─── findNearestMarker ────────────────────────────────────────────────── + + @Test + fun `findNearestMarker returns marker within threshold`() { + val markers = listOf( + ActivityMarker(0.25f, ActivityState.WALKING, true, 0), + ActivityMarker(0.375f, ActivityState.WALKING, false, 0), + ) + val found = findNearestMarker(markers, 0.26f, 0.02f) + assertEquals(markers[0], found) + } + + @Test + fun `findNearestMarker returns null when no marker within threshold`() { + val markers = listOf( + ActivityMarker(0.25f, ActivityState.WALKING, true, 0), + ) + val found = findNearestMarker(markers, 0.5f, 0.02f) + assertEquals(null, found) + } + + @Test + fun `findNearestMarker returns closest when multiple within threshold`() { + val markers = listOf( + ActivityMarker(0.25f, ActivityState.WALKING, true, 0), + ActivityMarker(0.27f, ActivityState.CYCLING, true, 1), + ) + val found = findNearestMarker(markers, 0.26f, 0.02f) + assertEquals(markers[0], found) // 0.25 is closer to 0.26 than 0.27 + } + + // ─── Dominant activity ────────────────────────────────────────────────── + + @Test + fun `dominant activity assigned to graph points from sessions`() { + val sessions = listOf( + session(ActivityState.WALKING, 0L, 300_000L), + session(ActivityState.CYCLING, 300_000L, 600_000L), + ) + val windows = listOf( + window(100_000L, 5), // in walking session + window(400_000L, 3), // in cycling session + ) + val data = buildStepGraphData(windows, sessions, bucketMs, dayStart, dayEnd) + assertEquals(2, data.points.size) + assertEquals(ActivityState.WALKING, data.points[0].dominantActivity) + assertEquals(ActivityState.CYCLING, data.points[1].dominantActivity) + } +} From 8739c9b8f7ed14d9d55bef7a6c90a8b47fff7933 Mon Sep 17 00:00:00 2001 From: Jan Jetze Date: Fri, 6 Mar 2026 20:14:11 +0100 Subject: [PATCH 2/5] feat: add ManualSessionOverride persistence and edit sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Room entity and DAO for user-edited activity session overrides, with database migration v3→v4. The mergeSessionOverrides() function combines recomputed sessions with manual overrides at the ViewModel level. SessionEditSheet provides a bottom sheet with a zoomed step graph, draggable start/end boundary markers, and activity type selector chips. Co-Authored-By: Claude Opus 4.6 --- .../data/db/ManualSessionOverride.kt | 26 ++ .../data/db/ManualSessionOverrideDao.kt | 32 ++ .../podometer/data/db/PodometerDatabase.kt | 25 +- .../java/com/podometer/di/DatabaseModule.kt | 12 +- .../usecase/MergeSessionOverridesUseCase.kt | 49 +++ .../ui/activities/SessionEditSheet.kt | 343 ++++++++++++++++++ .../MergeSessionOverridesUseCaseTest.kt | 139 +++++++ 7 files changed, 624 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/podometer/data/db/ManualSessionOverride.kt create mode 100644 app/src/main/java/com/podometer/data/db/ManualSessionOverrideDao.kt create mode 100644 app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt create mode 100644 app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt create mode 100644 app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt diff --git a/app/src/main/java/com/podometer/data/db/ManualSessionOverride.kt b/app/src/main/java/com/podometer/data/db/ManualSessionOverride.kt new file mode 100644 index 0000000..eb49d09 --- /dev/null +++ b/app/src/main/java/com/podometer/data/db/ManualSessionOverride.kt @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.podometer.data.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Room entity representing a user-created or user-edited activity session override. + * + * Manual overrides take precedence over recomputed sessions from sensor windows. + * They allow the user to correct activity boundaries and reclassify activity types. + * + * @property id Auto-generated primary key. + * @property startTime Session start in epoch milliseconds. + * @property endTime Session end in epoch milliseconds. + * @property activity Activity type as string ("WALKING", "CYCLING", "STILL"). + * @property date Date string in "yyyy-MM-dd" format for day-based queries. + */ +@Entity(tableName = "manual_session_overrides") +data class ManualSessionOverride( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val startTime: Long, + val endTime: Long, + val activity: String, + val date: String, +) diff --git a/app/src/main/java/com/podometer/data/db/ManualSessionOverrideDao.kt b/app/src/main/java/com/podometer/data/db/ManualSessionOverrideDao.kt new file mode 100644 index 0000000..c77df22 --- /dev/null +++ b/app/src/main/java/com/podometer/data/db/ManualSessionOverrideDao.kt @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.podometer.data.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import kotlinx.coroutines.flow.Flow + +/** + * Data access object for [ManualSessionOverride] entities. + */ +@Dao +interface ManualSessionOverrideDao { + + /** Inserts a new manual session override, returning the generated ID. */ + @Insert + suspend fun insert(override: ManualSessionOverride): Long + + /** Updates an existing manual session override. */ + @Update + suspend fun update(override: ManualSessionOverride) + + /** Deletes a manual session override by ID. */ + @Query("DELETE FROM manual_session_overrides WHERE id = :id") + suspend fun deleteById(id: Long) + + /** Returns all overrides for a given date string, ordered by start time. */ + @Query("SELECT * FROM manual_session_overrides WHERE date = :date ORDER BY startTime") + fun getOverridesForDate(date: String): Flow> +} diff --git a/app/src/main/java/com/podometer/data/db/PodometerDatabase.kt b/app/src/main/java/com/podometer/data/db/PodometerDatabase.kt index 855c3a0..561b5bd 100644 --- a/app/src/main/java/com/podometer/data/db/PodometerDatabase.kt +++ b/app/src/main/java/com/podometer/data/db/PodometerDatabase.kt @@ -24,8 +24,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase DailySummary::class, CyclingSession::class, SensorWindow::class, + ManualSessionOverride::class, ], - version = 3, + version = 4, exportSchema = false, ) abstract class PodometerDatabase : RoomDatabase() { @@ -38,6 +39,8 @@ abstract class PodometerDatabase : RoomDatabase() { abstract fun sensorWindowDao(): SensorWindowDao + abstract fun manualSessionOverrideDao(): ManualSessionOverrideDao + companion object { /** * Migration from version 1 to 2: creates the `sensor_windows` table. @@ -84,5 +87,25 @@ abstract class PodometerDatabase : RoomDatabase() { ) } } + + /** + * Migration from version 3 to 4: creates the `manual_session_overrides` + * table for user-edited activity session boundaries. + */ + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS manual_session_overrides ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + startTime INTEGER NOT NULL, + endTime INTEGER NOT NULL, + activity TEXT NOT NULL, + date TEXT NOT NULL + ) + """.trimIndent(), + ) + } + } } } diff --git a/app/src/main/java/com/podometer/di/DatabaseModule.kt b/app/src/main/java/com/podometer/di/DatabaseModule.kt index 15943f3..4da64f6 100644 --- a/app/src/main/java/com/podometer/di/DatabaseModule.kt +++ b/app/src/main/java/com/podometer/di/DatabaseModule.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.room.Room import com.podometer.data.db.ActivityTransitionDao import com.podometer.data.db.CyclingSessionDao +import com.podometer.data.db.ManualSessionOverrideDao import com.podometer.data.db.PodometerDatabase import com.podometer.data.db.SensorWindowDao import com.podometer.data.db.StepDao @@ -35,7 +36,11 @@ object DatabaseModule { PodometerDatabase::class.java, "podometer.db", ) - .addMigrations(PodometerDatabase.MIGRATION_1_2, PodometerDatabase.MIGRATION_2_3) + .addMigrations( + PodometerDatabase.MIGRATION_1_2, + PodometerDatabase.MIGRATION_2_3, + PodometerDatabase.MIGRATION_3_4, + ) .build() @Provides @@ -57,4 +62,9 @@ object DatabaseModule { @Singleton fun provideSensorWindowDao(database: PodometerDatabase): SensorWindowDao = database.sensorWindowDao() + + @Provides + @Singleton + fun provideManualSessionOverrideDao(database: PodometerDatabase): ManualSessionOverrideDao = + database.manualSessionOverrideDao() } diff --git a/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt b/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt new file mode 100644 index 0000000..36a2a15 --- /dev/null +++ b/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.podometer.domain.usecase + +import com.podometer.data.db.ManualSessionOverride +import com.podometer.domain.model.ActivitySession +import com.podometer.domain.model.ActivityState + +/** + * Merges recomputed activity sessions with manual session overrides. + * + * Manual overrides take precedence: any recomputed session whose time range + * overlaps with a manual override is replaced. The result is a flat list + * of sessions sorted by start time. + * + * This is a pure function with no side effects, suitable for unit testing. + * + * @param recomputed Recomputed sessions from sensor window replay. + * @param overrides User-created manual session overrides. + * @return Merged sessions sorted by start time. + */ +fun mergeSessionOverrides( + recomputed: List, + overrides: List, +): List { + if (overrides.isEmpty()) return recomputed + + // Convert overrides to ActivitySessions + val overrideSessions = overrides.map { override -> + ActivitySession( + activity = ActivityState.fromString(override.activity), + startTime = override.startTime, + endTime = override.endTime, + startTransitionId = -override.id.toInt(), // negative to distinguish + isManualOverride = true, + ) + } + + // Remove recomputed sessions that overlap with any override + val filteredRecomputed = recomputed.filter { session -> + val sessionEnd = session.endTime ?: Long.MAX_VALUE + overrides.none { override -> + // Overlap: session and override share any time + session.startTime < override.endTime && sessionEnd > override.startTime + } + } + + // Merge and sort by start time + return (filteredRecomputed + overrideSessions).sortedBy { it.startTime } +} diff --git a/app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt b/app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt new file mode 100644 index 0000000..2721ef7 --- /dev/null +++ b/app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.podometer.ui.activities + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.podometer.data.db.SensorWindow +import com.podometer.domain.model.ActivitySession +import com.podometer.domain.model.ActivityState +import com.podometer.ui.dashboard.formatActivityTime +import com.podometer.ui.theme.ActivityColors +import com.podometer.ui.theme.LocalActivityColors +import com.podometer.ui.theme.PodometerTheme + +/** Padding in minutes before and after the session for the zoomed view. */ +private const val SESSION_PADDING_MINUTES = 5 + +/** Minimum touch target width for drag handles in dp. */ +private val HANDLE_WIDTH = 48.dp + +/** Height of the zoomed step graph in the edit sheet. */ +private val EDIT_GRAPH_HEIGHT = 150.dp + +/** Alpha for the selected region background. */ +private const val SELECTED_REGION_ALPHA = 0.2f + +/** + * Bottom sheet content for editing an activity session's boundaries and type. + * + * Shows a zoomed step graph for the session's time range with draggable start/end + * markers. Activity type chips allow reclassifying the session. + * + * @param session The session being edited. + * @param windows Sensor windows for the day (used to draw the zoomed graph). + * @param dayStartMillis Start of the day in epoch millis. + * @param dayEndMillis End of the day in epoch millis. + * @param onSave Callback with the edited start time, end time, and activity. + * @param onCancel Callback when the user cancels editing. + * @param onDelete Callback to delete a manual override (null if not deletable). + */ +@Composable +fun SessionEditSheet( + session: ActivitySession, + windows: List, + dayStartMillis: Long, + dayEndMillis: Long, + onSave: (startMs: Long, endMs: Long, activity: ActivityState) -> Unit, + onCancel: () -> Unit, + onDelete: (() -> Unit)? = null, +) { + val paddingMs = SESSION_PADDING_MINUTES * 60_000L + val sessionEnd = session.endTime ?: (session.startTime + 30 * 60_000L) + val viewStart = (session.startTime - paddingMs).coerceAtLeast(dayStartMillis) + val viewEnd = (sessionEnd + paddingMs).coerceAtMost(dayEndMillis) + val viewDuration = (viewEnd - viewStart).toFloat() + + var startFraction by remember { + mutableFloatStateOf(((session.startTime - viewStart) / viewDuration).coerceIn(0f, 1f)) + } + var endFraction by remember { + mutableFloatStateOf(((sessionEnd - viewStart) / viewDuration).coerceIn(0f, 1f)) + } + var selectedActivity by remember { mutableStateOf(session.activity) } + + val activityColors = LocalActivityColors.current + val primaryColor = MaterialTheme.colorScheme.primary + val handleColor = MaterialTheme.colorScheme.primary + val bucketLineColor = MaterialTheme.colorScheme.tertiary + + // Filter windows to the view range + val viewWindows = remember(windows, viewStart, viewEnd) { + windows.filter { it.timestamp in viewStart until viewEnd } + } + + // Compute bucket steps for the zoomed view (30s buckets = raw resolution) + val bucketMs = 30_000L + val maxSteps = remember(viewWindows) { + if (viewWindows.isEmpty()) 1 + else viewWindows.maxOf { it.stepCount }.coerceAtLeast(1) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + ) { + Text( + text = "Edit Activity", + style = MaterialTheme.typography.titleMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Time range display + val editStartMs = viewStart + (startFraction * viewDuration).toLong() + val editEndMs = viewStart + (endFraction * viewDuration).toLong() + Text( + text = "${formatActivityTime(editStartMs)} – ${formatActivityTime(editEndMs)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Zoomed step graph with draggable markers + Box( + modifier = Modifier + .fillMaxWidth() + .height(EDIT_GRAPH_HEIGHT), + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(EDIT_GRAPH_HEIGHT) + .pointerInput(Unit) { + detectHorizontalDragGestures { change, dragAmount -> + change.consume() + val chartWidth = size.width.toFloat() + val dragFraction = dragAmount / chartWidth + val tapX = change.position.x + val tapFraction = tapX / chartWidth + + // Determine which handle to drag (closest) + val distToStart = kotlin.math.abs(tapFraction - startFraction) + val distToEnd = kotlin.math.abs(tapFraction - endFraction) + + if (distToStart < distToEnd) { + startFraction = (startFraction + dragFraction) + .coerceIn(0f, endFraction - 0.01f) + } else { + endFraction = (endFraction + dragFraction) + .coerceIn(startFraction + 0.01f, 1f) + } + } + }, + ) { + val chartWidth = size.width + val chartHeight = size.height + + // Draw selected region background + val regionColor = selectedActivity.regionColor(activityColors) + val x1 = startFraction * chartWidth + val x2 = endFraction * chartWidth + drawRect( + color = regionColor, + topLeft = Offset(x1, 0f), + size = Size(x2 - x1, chartHeight), + alpha = SELECTED_REGION_ALPHA, + ) + + // Draw step data as a line + if (viewWindows.isNotEmpty()) { + val stepPath = Path() + var started = false + for (window in viewWindows) { + val wFraction = (window.timestamp - viewStart) / viewDuration + val wx = wFraction * chartWidth + val wy = chartHeight * (1f - window.stepCount.toFloat() / maxSteps) + if (!started) { + stepPath.moveTo(wx, wy) + started = true + } else { + stepPath.lineTo(wx, wy) + } + } + drawPath( + path = stepPath, + color = bucketLineColor, + style = Stroke(width = 2f), + ) + } + + // Draw start handle + drawLine( + color = handleColor, + start = Offset(x1, 0f), + end = Offset(x1, chartHeight), + strokeWidth = 4f, + ) + // Start handle thumb + drawCircle( + color = handleColor, + radius = 8f, + center = Offset(x1, chartHeight / 2), + ) + + // Draw end handle + drawLine( + color = handleColor, + start = Offset(x2, 0f), + end = Offset(x2, chartHeight), + strokeWidth = 4f, + ) + // End handle thumb + drawCircle( + color = handleColor, + radius = 8f, + center = Offset(x2, chartHeight / 2), + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Activity type selector + Text( + text = "Activity Type", + style = MaterialTheme.typography.labelMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ActivityState.entries.filter { it != ActivityState.STILL }.forEach { activity -> + FilterChip( + selected = selectedActivity == activity, + onClick = { selectedActivity = activity }, + label = { + Text( + when (activity) { + ActivityState.WALKING -> "Walking" + ActivityState.CYCLING -> "Cycling" + ActivityState.STILL -> "Still" + }, + ) + }, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Action buttons + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + if (onDelete != null) { + OutlinedButton( + onClick = onDelete, + ) { + Text("Delete") + } + } + + Spacer(modifier = Modifier.weight(1f)) + + OutlinedButton(onClick = onCancel) { + Text("Cancel") + } + + Button( + onClick = { + val newStartMs = viewStart + (startFraction * viewDuration).toLong() + val newEndMs = viewStart + (endFraction * viewDuration).toLong() + onSave(newStartMs, newEndMs, selectedActivity) + }, + ) { + Text("Save") + } + } + } +} + +/** + * Returns the region color for an [ActivityState]. + */ +private fun ActivityState.regionColor(colors: ActivityColors): Color = when (this) { + ActivityState.WALKING -> colors.walking + ActivityState.CYCLING -> colors.cycling + ActivityState.STILL -> colors.still +} + +// ─── Preview ──────────────────────────────────────────────────────────────── + +/** Preview: session edit sheet with sample walking session. */ +@Preview(showBackground = true, name = "SessionEditSheet — Walking") +@Composable +private fun PreviewSessionEditSheet() { + val hour = 3_600_000L + val session = ActivitySession( + activity = ActivityState.WALKING, + startTime = 9 * hour, + endTime = 10 * hour, + startTransitionId = 1, + isManualOverride = false, + stepCount = 360, + ) + + val windows = (0 until 120).map { i -> + SensorWindow( + id = i.toLong(), + timestamp = 9 * hour - 5 * 60_000L + i * 30_000L, + magnitudeVariance = 0.0, + stepFrequencyHz = 0.0, + stepCount = if (i in 10..110) (i % 5) + 1 else 0, + ) + } + + PodometerTheme(dynamicColor = false) { + SessionEditSheet( + session = session, + windows = windows, + dayStartMillis = 0L, + dayEndMillis = 86_400_000L, + onSave = { _, _, _ -> }, + onCancel = {}, + onDelete = {}, + ) + } +} diff --git a/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt b/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt new file mode 100644 index 0000000..d060e98 --- /dev/null +++ b/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.podometer.domain.usecase + +import com.podometer.data.db.ManualSessionOverride +import com.podometer.domain.model.ActivitySession +import com.podometer.domain.model.ActivityState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for [mergeSessionOverrides]. + */ +class MergeSessionOverridesUseCaseTest { + + private val hour = 3_600_000L + + private fun session( + activity: ActivityState, + startTime: Long, + endTime: Long?, + id: Int = 1, + ) = ActivitySession( + activity = activity, + startTime = startTime, + endTime = endTime, + startTransitionId = id, + isManualOverride = false, + ) + + private fun override( + activity: String, + startTime: Long, + endTime: Long, + id: Long = 1, + ) = ManualSessionOverride( + id = id, + startTime = startTime, + endTime = endTime, + activity = activity, + date = "2026-03-05", + ) + + @Test + fun `no overrides returns recomputed sessions unchanged`() { + val sessions = listOf( + session(ActivityState.WALKING, 9 * hour, 10 * hour), + ) + val result = mergeSessionOverrides(sessions, emptyList()) + assertEquals(sessions, result) + } + + @Test + fun `override replaces overlapping recomputed session`() { + val sessions = listOf( + session(ActivityState.WALKING, 9 * hour, 10 * hour), + ) + val overrides = listOf( + override("CYCLING", 9 * hour, 10 * hour), + ) + val result = mergeSessionOverrides(sessions, overrides) + assertEquals(1, result.size) + assertEquals(ActivityState.CYCLING, result[0].activity) + assertTrue(result[0].isManualOverride) + } + + @Test + fun `override replaces partially overlapping session`() { + val sessions = listOf( + session(ActivityState.WALKING, 9 * hour, 11 * hour), + ) + val overrides = listOf( + override("CYCLING", 10 * hour, 12 * hour), + ) + val result = mergeSessionOverrides(sessions, overrides) + // Walking session is removed because it overlaps + assertEquals(1, result.size) + assertEquals(ActivityState.CYCLING, result[0].activity) + } + + @Test + fun `non-overlapping sessions are preserved`() { + val sessions = listOf( + session(ActivityState.WALKING, 9 * hour, 10 * hour, id = 1), + session(ActivityState.WALKING, 14 * hour, 15 * hour, id = 2), + ) + val overrides = listOf( + override("CYCLING", 11 * hour, 12 * hour), + ) + val result = mergeSessionOverrides(sessions, overrides) + assertEquals(3, result.size) + assertEquals(ActivityState.WALKING, result[0].activity) + assertEquals(ActivityState.CYCLING, result[1].activity) + assertEquals(ActivityState.WALKING, result[2].activity) + } + + @Test + fun `result is sorted by start time`() { + val sessions = listOf( + session(ActivityState.WALKING, 14 * hour, 15 * hour), + ) + val overrides = listOf( + override("CYCLING", 9 * hour, 10 * hour), + ) + val result = mergeSessionOverrides(sessions, overrides) + assertEquals(2, result.size) + assertTrue(result[0].startTime < result[1].startTime) + } + + @Test + fun `multiple overrides replace multiple sessions`() { + val sessions = listOf( + session(ActivityState.WALKING, 9 * hour, 10 * hour, id = 1), + session(ActivityState.CYCLING, 11 * hour, 12 * hour, id = 2), + ) + val overrides = listOf( + override("CYCLING", 9 * hour, 10 * hour, id = 1), + override("WALKING", 11 * hour, 12 * hour, id = 2), + ) + val result = mergeSessionOverrides(sessions, overrides) + assertEquals(2, result.size) + assertEquals(ActivityState.CYCLING, result[0].activity) // was walking + assertEquals(ActivityState.WALKING, result[1].activity) // was cycling + assertTrue(result.all { it.isManualOverride }) + } + + @Test + fun `override for ongoing session works`() { + val sessions = listOf( + session(ActivityState.WALKING, 14 * hour, null), + ) + val overrides = listOf( + override("CYCLING", 14 * hour, 15 * hour), + ) + val result = mergeSessionOverrides(sessions, overrides) + assertEquals(1, result.size) + assertEquals(ActivityState.CYCLING, result[0].activity) + } +} From 36b4b704b193f22850d80e3e1ec9f988fbbf4f6d Mon Sep 17 00:00:00 2001 From: Jan Jetze Date: Fri, 6 Mar 2026 21:07:47 +0100 Subject: [PATCH 3/5] feat: add developer toggle for test data in debug builds Adds a "Use test data" switch in Settings > Developer (debug only) that replaces real sensor data with generated test data on both the Dashboard and Activities screens. Each date produces a unique pattern using date-seeded randomization. Co-Authored-By: Claude Opus 4.6 --- app/build.gradle.kts | 1 + .../main/java/com/podometer/MainActivity.kt | 1 + .../data/repository/PreferencesManager.kt | 21 ++ .../ui/activities/ActivitiesViewModel.kt | 59 +++- .../ui/activities/TestDataGenerator.kt | 305 ++++++++++++++++++ .../ui/dashboard/DashboardViewModel.kt | 76 +++-- .../podometer/ui/settings/SettingsScreen.kt | 15 + .../ui/settings/SettingsViewModel.kt | 24 +- .../ui/activities/ActivitiesViewModelTest.kt | 17 + .../ui/dashboard/DashboardViewModelTest.kt | 18 ++ 10 files changed, 496 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/podometer/ui/activities/TestDataGenerator.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 95010a1..9343009 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,7 @@ android { buildFeatures { compose = true + buildConfig = true } testOptions { diff --git a/app/src/main/java/com/podometer/MainActivity.kt b/app/src/main/java/com/podometer/MainActivity.kt index 3379951..bb76587 100644 --- a/app/src/main/java/com/podometer/MainActivity.kt +++ b/app/src/main/java/com/podometer/MainActivity.kt @@ -158,6 +158,7 @@ class MainActivity : ComponentActivity() { ) startActivity(intent) }, + onSetUseTestData = viewModel::setUseTestData, ) } diff --git a/app/src/main/java/com/podometer/data/repository/PreferencesManager.kt b/app/src/main/java/com/podometer/data/repository/PreferencesManager.kt index 06ea045..fcc1a78 100644 --- a/app/src/main/java/com/podometer/data/repository/PreferencesManager.kt +++ b/app/src/main/java/com/podometer/data/repository/PreferencesManager.kt @@ -60,6 +60,11 @@ class PreferencesManager @Inject constructor( /** Default onboarding complete: false (show onboarding on first launch). */ const val DEFAULT_ONBOARDING_COMPLETE = false + + val KEY_USE_TEST_DATA = booleanPreferencesKey("use_test_data") + + /** Default use-test-data: false (use real sensor data). */ + const val DEFAULT_USE_TEST_DATA = false } // ─── Read ──────────────────────────────────────────────────────────────── @@ -101,6 +106,15 @@ class PreferencesManager @Inject constructor( prefs[KEY_NOTIFICATION_STYLE] ?: DEFAULT_NOTIFICATION_STYLE } + /** + * Emits whether the debug test-data mode is enabled. + * Defaults to `false` (use real sensor data). + */ + fun useTestData(): Flow = + dataStore.data.map { prefs -> + prefs[KEY_USE_TEST_DATA] ?: DEFAULT_USE_TEST_DATA + } + /** * Emits whether the user has completed the onboarding flow. * Defaults to `false` on first launch so the onboarding screen is shown. @@ -163,6 +177,13 @@ class PreferencesManager @Inject constructor( } } + /** Persists the given [enabled] flag for the debug test-data mode. */ + suspend fun setUseTestData(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_USE_TEST_DATA] = enabled + } + } + /** * Persists the given [complete] flag as the onboarding completion status. * diff --git a/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt b/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt index 7559f92..d24cc9a 100644 --- a/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt +++ b/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.podometer.data.db.ManualSessionOverride import com.podometer.data.db.ManualSessionOverrideDao import com.podometer.data.db.SensorWindow +import com.podometer.data.repository.PreferencesManager import com.podometer.data.repository.SensorWindowRepository import com.podometer.domain.model.ActivitySession import com.podometer.domain.model.ActivityState @@ -18,6 +19,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.LocalDate @@ -56,6 +58,7 @@ class ActivitiesViewModel @Inject constructor( private val recomputeActivitySessions: RecomputeActivitySessionsUseCase, private val sensorWindowRepository: SensorWindowRepository, private val manualSessionOverrideDao: ManualSessionOverrideDao, + private val preferencesManager: PreferencesManager, ) : ViewModel() { companion object { @@ -70,25 +73,47 @@ class ActivitiesViewModel @Inject constructor( /** Combined UI state emitted to the Activities screen. */ @OptIn(ExperimentalCoroutinesApi::class) - val uiState: StateFlow = _selectedDate.flatMapLatest { date -> + val uiState: StateFlow = combine( + _selectedDate, + preferencesManager.useTestData(), + ) { date, useTest -> date to useTest }.flatMapLatest { (date, useTestData) -> val nowMillis = System.currentTimeMillis() val dateStr = date.format(DateTimeFormatter.ISO_LOCAL_DATE) - combine( - recomputeActivitySessions(date, nowMillis), - sensorWindowRepository.getWindowsForDay(date), - manualSessionOverrideDao.getOverridesForDate(dateStr), - _bucketSizeMs, - ) { recomputedSessions, windows, overrides, bucketSizeMs -> - val sessions = mergeSessionOverrides(recomputedSessions, overrides) - ActivitiesUiState( - selectedDate = date, - sessions = sessions, - windows = windows, - bucketSizeMs = bucketSizeMs, - isToday = date == LocalDate.now(), - dateLabel = formatDateLabel(date), - isLoading = false, - ) + + if (useTestData) { + combine( + flowOf(TestDataGenerator.generateSessions(date)), + flowOf(TestDataGenerator.generateWindows(date)), + _bucketSizeMs, + ) { sessions, windows, bucketSizeMs -> + ActivitiesUiState( + selectedDate = date, + sessions = sessions, + windows = windows, + bucketSizeMs = bucketSizeMs, + isToday = date == LocalDate.now(), + dateLabel = formatDateLabel(date), + isLoading = false, + ) + } + } else { + combine( + recomputeActivitySessions(date, nowMillis), + sensorWindowRepository.getWindowsForDay(date), + manualSessionOverrideDao.getOverridesForDate(dateStr), + _bucketSizeMs, + ) { recomputedSessions, windows, overrides, bucketSizeMs -> + val sessions = mergeSessionOverrides(recomputedSessions, overrides) + ActivitiesUiState( + selectedDate = date, + sessions = sessions, + windows = windows, + bucketSizeMs = bucketSizeMs, + isToday = date == LocalDate.now(), + dateLabel = formatDateLabel(date), + isLoading = false, + ) + } } }.stateIn( scope = viewModelScope, diff --git a/app/src/main/java/com/podometer/ui/activities/TestDataGenerator.kt b/app/src/main/java/com/podometer/ui/activities/TestDataGenerator.kt new file mode 100644 index 0000000..72b993e --- /dev/null +++ b/app/src/main/java/com/podometer/ui/activities/TestDataGenerator.kt @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.podometer.ui.activities + +import com.podometer.data.db.CyclingSession +import com.podometer.data.db.SensorWindow +import com.podometer.domain.model.ActivitySession +import com.podometer.domain.model.ActivityState +import com.podometer.domain.model.DaySummary +import com.podometer.domain.model.StepData +import com.podometer.domain.model.TransitionEvent +import com.podometer.util.DateTimeUtils +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import kotlin.random.Random + +/** + * Generates realistic fake sensor windows and activity sessions for a given date. + * + * Used in debug builds to visualize the step graph without needing real sensor data. + * Each date produces a unique but deterministic pattern — the same date always + * generates the same data, while different dates look visibly different. + */ +object TestDataGenerator { + + private const val WINDOW_DURATION_MS = 30_000L + + /** + * Returns a deterministic [Random] seeded from the given [date]. + * + * Ensures repeatable output per date while varying across days. + */ + private fun seededRandom(date: LocalDate): Random = + Random(date.toEpochDay()) + + /** + * A template for one activity block within a day. + * + * @property startHour Hour-of-day (fractional) when the block starts. + * @property durationHours Duration in hours. + * @property activity The activity type for this block. + * @property stepsPerWindow Approximate steps per 30-second window (0 for cycling). + */ + private data class ActivityBlock( + val startHour: Double, + val durationHours: Double, + val activity: ActivityState, + val stepsPerWindow: Int, + ) + + /** + * Generates a date-specific schedule of activity blocks. + * + * The number and timing of activities vary by day-of-week and a date seed. + * Possible patterns: commute days (cycling + walks), active days (many walks), + * rest days (few short walks), long-run days (one extended walk). + */ + private fun generateSchedule(date: LocalDate, rng: Random): List { + val dayType = (date.toEpochDay() % 7).toInt().let { if (it < 0) it + 7 else it } + + // Pool of possible activity blocks with jittered start times + fun jitter(base: Double): Double = base + rng.nextDouble(-0.25, 0.25) + fun walkDuration(): Double = 0.25 + rng.nextDouble() * 0.75 // 15–60 min + fun cycleDuration(): Double = 0.5 + rng.nextDouble() * 0.5 // 30–60 min + fun walkIntensity(): Int = 25 + rng.nextInt(30) // 25–54 steps per window + + return when (dayType) { + // Commute day: cycling morning + evening, short walks + 0 -> listOf( + ActivityBlock(jitter(7.5), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(9.0), cycleDuration(), ActivityState.CYCLING, 0), + ActivityBlock(jitter(12.5), walkDuration() * 0.6, ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(17.5), cycleDuration(), ActivityState.CYCLING, 0), + ActivityBlock(jitter(19.5), walkDuration() * 0.5, ActivityState.WALKING, walkIntensity()), + ) + // Active day: many walks throughout the day + 1 -> listOf( + ActivityBlock(jitter(6.5), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(9.0), walkDuration() * 0.4, ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(11.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(14.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(16.5), walkDuration() * 0.5, ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(19.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ) + // Rest day: just a couple short walks + 2 -> listOf( + ActivityBlock(jitter(10.0), walkDuration() * 0.5, ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(16.0), walkDuration() * 0.5, ActivityState.WALKING, walkIntensity()), + ) + // Long run day: one extended walk + short evening walk + 3 -> listOf( + ActivityBlock(jitter(8.0), 1.0 + rng.nextDouble() * 0.5, ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(18.0), walkDuration() * 0.4, ActivityState.WALKING, walkIntensity()), + ) + // Mixed day: walk + cycle + walk + 4 -> listOf( + ActivityBlock(jitter(7.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(10.5), cycleDuration(), ActivityState.CYCLING, 0), + ActivityBlock(jitter(13.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(17.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ) + // Afternoon-heavy day: quiet morning, active afternoon + 5 -> listOf( + ActivityBlock(jitter(12.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(14.5), cycleDuration(), ActivityState.CYCLING, 0), + ActivityBlock(jitter(16.5), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(19.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ) + // Morning-heavy day: early start, quiet evening + else -> listOf( + ActivityBlock(jitter(6.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(8.0), cycleDuration(), ActivityState.CYCLING, 0), + ActivityBlock(jitter(10.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(12.5), walkDuration() * 0.5, ActivityState.WALKING, walkIntensity()), + ) + }.map { block -> + // Clamp start hours to valid range + block.copy(startHour = block.startHour.coerceIn(6.0, 21.0)) + }.sortedBy { it.startHour } + } + + /** + * Generates fake [SensorWindow]s for the given [date]. + * + * Step counts vary based on the date-specific activity schedule. + * + * @param date The date to generate windows for. + * @return Chronologically ordered list of sensor windows. + */ + fun generateWindows(date: LocalDate): List { + val rng = seededRandom(date) + val schedule = generateSchedule(date, seededRandom(date)) + val dayStart = DateTimeUtils.startOfDayMillis(date) + val windows = mutableListOf() + var id = 1L + + val startMs = dayStart + 7 * 3_600_000L + val endMs = dayStart + 22 * 3_600_000L + + var ts = startMs + while (ts < endMs) { + val hourFraction = (ts - dayStart).toDouble() / 3_600_000.0 + val activeBlock = schedule.firstOrNull { block -> + hourFraction >= block.startHour && + hourFraction < block.startHour + block.durationHours + } + + val steps = when { + activeBlock != null && activeBlock.activity == ActivityState.WALKING -> { + val base = activeBlock.stepsPerWindow + (base + rng.nextInt(-8, 9)).coerceAtLeast(5) + } + activeBlock != null && activeBlock.activity == ActivityState.CYCLING -> { + rng.nextInt(0, 3) + } + // Random idle steps + else -> if (rng.nextInt(10) < 2) rng.nextInt(1, 6) else 0 + } + + val variance = if (steps > 0) 2.0 + steps * 0.5 else 0.1 + val frequency = if (steps > 0) 1.5 + steps * 0.1 else 0.0 + + windows.add( + SensorWindow( + id = id++, + timestamp = ts, + magnitudeVariance = variance, + stepFrequencyHz = frequency, + stepCount = steps, + ), + ) + ts += WINDOW_DURATION_MS + } + + return windows + } + + /** + * Generates fake [ActivitySession]s for the given [date]. + * + * The number, timing, and type of sessions vary per date. + * + * @param date The date to generate sessions for. + * @return Chronologically ordered list of activity sessions. + */ + fun generateSessions(date: LocalDate): List { + val rng = seededRandom(date) + val schedule = generateSchedule(date, seededRandom(date)) + val dayStart = DateTimeUtils.startOfDayMillis(date) + fun h(hour: Double): Long = dayStart + (hour * 3_600_000).toLong() + + return schedule.mapIndexed { index, block -> + val steps = if (block.activity == ActivityState.WALKING) { + (block.stepsPerWindow * block.durationHours * 120).toInt() + } else { + 0 + } + ActivitySession( + activity = block.activity, + startTime = h(block.startHour), + endTime = h(block.startHour + block.durationHours), + startTransitionId = index + 1, + isManualOverride = false, + stepCount = steps, + ) + } + } + + /** + * Generates fake [StepData] for the dashboard today card. + * + * Uses today's date to vary the step count. + * + * @param goal The user's daily step goal. + * @param strideKm The user's stride length in km. + * @return A [StepData] with date-varying step progress. + */ + fun generateTodaySteps(goal: Int = 10_000, strideKm: Float = 0.00075f): StepData { + val rng = seededRandom(LocalDate.now()) + val steps = 2_000 + rng.nextInt(8_000) // 2,000–10,000 + return StepData( + steps = steps, + goal = goal, + progressPercent = steps.toFloat() / goal * 100f, + distanceKm = steps * strideKm, + ) + } + + /** + * Generates fake [TransitionEvent]s for the dashboard transition log. + * + * Derived from the same date-specific schedule as [generateSessions]. + * + * @param date The date to generate transitions for. + * @return Chronologically ordered list of transition events. + */ + fun generateTransitions(date: LocalDate): List { + val schedule = generateSchedule(date, seededRandom(date)) + val dayStart = DateTimeUtils.startOfDayMillis(date) + fun h(hour: Double): Long = dayStart + (hour * 3_600_000).toLong() + + val transitions = mutableListOf() + var id = 1 + for (block in schedule) { + transitions.add( + TransitionEvent(id++, h(block.startHour), ActivityState.STILL, block.activity, false), + ) + transitions.add( + TransitionEvent(id++, h(block.startHour + block.durationHours), block.activity, ActivityState.STILL, false), + ) + } + return transitions + } + + /** + * Generates fake [DaySummary] entries for the weekly step chart. + * + * Each day of the week gets a different step count derived from its date seed. + */ + fun generateWeeklySummaries(): List { + val today = LocalDate.now() + val startOfWeek = today.minusDays((today.dayOfWeek.value - 1).toLong()) + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + return (0..today.dayOfWeek.value - 1).map { i -> + val day = startOfWeek.plusDays(i.toLong()) + val rng = seededRandom(day) + val steps = 3_000 + rng.nextInt(9_000) // 3,000–12,000 + val hasCycling = generateSchedule(day, seededRandom(day)).any { + it.activity == ActivityState.CYCLING + } + DaySummary( + date = day.format(formatter), + totalSteps = steps, + totalDistanceKm = steps * 0.00075f, + walkingMinutes = steps / 100, + cyclingMinutes = if (hasCycling) 30 + rng.nextInt(60) else 0, + ) + } + } + + /** + * Generates fake [CyclingSession]s for the dashboard cycling section. + * + * Only produces sessions for dates whose schedule includes cycling blocks. + * + * @param date The date to generate sessions for. + * @return Cycling sessions derived from the date-specific schedule. + */ + fun generateCyclingSessions(date: LocalDate): List { + val schedule = generateSchedule(date, seededRandom(date)) + val dayStart = DateTimeUtils.startOfDayMillis(date) + fun h(hour: Double): Long = dayStart + (hour * 3_600_000).toLong() + + return schedule + .filter { it.activity == ActivityState.CYCLING } + .mapIndexed { index, block -> + CyclingSession( + id = index + 1, + startTime = h(block.startHour), + endTime = h(block.startHour + block.durationHours), + durationMinutes = (block.durationHours * 60).toInt(), + ) + } + } +} diff --git a/app/src/main/java/com/podometer/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/podometer/ui/dashboard/DashboardViewModel.kt index 3cd3d65..f310ade 100644 --- a/app/src/main/java/com/podometer/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/podometer/ui/dashboard/DashboardViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.podometer.data.db.ActivityTransition import com.podometer.data.db.CyclingSession +import com.podometer.data.repository.PreferencesManager import com.podometer.domain.model.ActivityState import com.podometer.domain.model.DaySummary import com.podometer.domain.model.TransitionEvent @@ -14,14 +15,19 @@ import com.podometer.domain.usecase.GetTodayStepsUseCase import com.podometer.domain.usecase.GetTodayTransitionsUseCase import com.podometer.domain.usecase.GetWeeklyStepsUseCase import com.podometer.domain.usecase.OverrideActivityUseCase +import com.podometer.ui.activities.TestDataGenerator import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.LocalDate import javax.inject.Inject /** @@ -84,6 +90,7 @@ class DashboardViewModel @Inject constructor( getTodayTransitions: GetTodayTransitionsUseCase, getTodayCyclingSessions: GetTodayCyclingSessionsUseCase, private val overrideActivityUseCase: OverrideActivityUseCase, + preferencesManager: PreferencesManager, ) : ViewModel() { companion object { @@ -95,26 +102,55 @@ class DashboardViewModel @Inject constructor( private val _permissionsDenied = MutableStateFlow(false) /** Combined UI state emitted to the Dashboard Compose screen. */ - val uiState: StateFlow = combine( - getTodaySteps(), - getWeeklySteps(), - getTodayTransitions(), - getTodayCyclingSessions(), - _permissionsDenied, - ) { stepData, weekly, transitions, cycling, permissionsDenied -> - DashboardUiState( - todaySteps = stepData.steps, - dailyGoal = stepData.goal, - progressPercent = stepData.progressPercent, - distanceKm = stepData.distanceKm, - currentActivity = transitions.lastOrNull()?.toActivity ?: ActivityState.STILL, - transitions = transitions, - weeklySteps = weekly, - cyclingSessions = cycling, - isLoading = false, - permissionsDenied = permissionsDenied, - ) - }.stateIn( + @OptIn(ExperimentalCoroutinesApi::class) + val uiState: StateFlow = preferencesManager.useTestData() + .flatMapLatest { useTestData -> + if (useTestData) { + val today = LocalDate.now() + val stepData = TestDataGenerator.generateTodaySteps() + val transitions = TestDataGenerator.generateTransitions(today) + val weekly = TestDataGenerator.generateWeeklySummaries() + val cycling = TestDataGenerator.generateCyclingSessions(today) + combine( + flowOf(Unit), + _permissionsDenied, + ) { _, permissionsDenied -> + DashboardUiState( + todaySteps = stepData.steps, + dailyGoal = stepData.goal, + progressPercent = stepData.progressPercent, + distanceKm = stepData.distanceKm, + currentActivity = transitions.lastOrNull()?.toActivity ?: ActivityState.STILL, + transitions = transitions, + weeklySteps = weekly, + cyclingSessions = cycling, + isLoading = false, + permissionsDenied = permissionsDenied, + ) + } + } else { + combine( + getTodaySteps(), + getWeeklySteps(), + getTodayTransitions(), + getTodayCyclingSessions(), + _permissionsDenied, + ) { stepData, weekly, transitions, cycling, permissionsDenied -> + DashboardUiState( + todaySteps = stepData.steps, + dailyGoal = stepData.goal, + progressPercent = stepData.progressPercent, + distanceKm = stepData.distanceKm, + currentActivity = transitions.lastOrNull()?.toActivity ?: ActivityState.STILL, + transitions = transitions, + weeklySteps = weekly, + cyclingSessions = cycling, + isLoading = false, + permissionsDenied = permissionsDenied, + ) + } + } + }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), initialValue = DashboardUiState(), diff --git a/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt b/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt index 6d9856f..1c32750 100644 --- a/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.podometer.BuildConfig import com.podometer.R import com.podometer.ui.theme.PodometerTheme @@ -91,6 +92,7 @@ fun SettingsScreen( onResetExportState: () -> Unit, onNavigateToDonate: () -> Unit = {}, onOpenFeedbackUrl: () -> Unit = {}, + onSetUseTestData: (Boolean) -> Unit = {}, modifier: Modifier = Modifier, ) { val snackbarHostState = remember { SnackbarHostState() } @@ -306,6 +308,19 @@ fun SettingsScreen( onClick = onNavigateToDonate, ) + // ── Developer section (debug builds only) ─────────────────────── + if (BuildConfig.DEBUG) { + Spacer(modifier = Modifier.height(16.dp)) + SettingsSectionHeader(text = "Developer") + + SettingRowWithSwitch( + title = "Use test data", + description = "Replace real sensor data with generated test data for the step graph", + checked = uiState.useTestData, + onCheckedChange = onSetUseTestData, + ) + } + Spacer(modifier = Modifier.height(24.dp)) } } diff --git a/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt index 6eac780..a5b6e6c 100644 --- a/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt @@ -55,6 +55,7 @@ data class SettingsUiState( val autoStartEnabled: Boolean = true, val notificationStyle: String = "minimal", val exportState: ExportState = ExportState.Idle, + val useTestData: Boolean = false, ) /** @@ -89,18 +90,22 @@ class SettingsViewModel @Inject constructor( * Starts with default values; updates reactively as preferences or export state change. */ val uiState: StateFlow = combine( - preferencesManager.dailyStepGoal(), - preferencesManager.strideLengthKm(), - preferencesManager.isAutoStartEnabled(), + combine( + preferencesManager.dailyStepGoal(), + preferencesManager.strideLengthKm(), + preferencesManager.isAutoStartEnabled(), + ) { goal, stride, autoStart -> Triple(goal, stride, autoStart) }, preferencesManager.notificationStyle(), _exportState, - ) { dailyStepGoal, strideLengthKm, autoStart, notifStyle, exportState -> + preferencesManager.useTestData(), + ) { (dailyStepGoal, strideLengthKm, autoStart), notifStyle, exportState, useTestData -> SettingsUiState( dailyStepGoal = dailyStepGoal, strideLengthCm = strideLengthKmToCm(strideLengthKm), autoStartEnabled = autoStart, notificationStyle = notifStyle, exportState = exportState, + useTestData = useTestData, ) }.stateIn( scope = viewModelScope, @@ -154,6 +159,17 @@ class SettingsViewModel @Inject constructor( } } + /** + * Persists the given [enabled] flag for the debug test-data mode. + * + * @param enabled `true` to use generated test data; `false` to use real sensor data. + */ + fun setUseTestData(enabled: Boolean) { + viewModelScope.launch { + preferencesManager.setUseTestData(enabled) + } + } + // ─── Export ─────────────────────────────────────────────────────────────── /** diff --git a/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt b/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt index 135d7a3..30980ea 100644 --- a/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt +++ b/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt @@ -1,10 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later package com.podometer.ui.activities +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences import com.podometer.data.db.ManualSessionOverride import com.podometer.data.db.ManualSessionOverrideDao import com.podometer.data.db.SensorWindow import com.podometer.data.db.SensorWindowDao +import com.podometer.data.repository.PreferencesManager import com.podometer.data.repository.SensorWindowRepository import com.podometer.domain.model.ActivitySession import com.podometer.domain.model.ActivityState @@ -23,7 +27,9 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import java.time.LocalDate /** @@ -37,6 +43,9 @@ class ActivitiesViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() + @get:Rule + val tmpFolder = TemporaryFolder() + @Before fun setUpDispatcher() { Dispatchers.setMain(testDispatcher) @@ -75,12 +84,20 @@ class ActivitiesViewModelTest { // ─── Helpers ───────────────────────────────────────────────────────────── + private fun buildPreferencesManager(): PreferencesManager { + val dataStore: DataStore = PreferenceDataStoreFactory.create { + tmpFolder.newFile("test_prefs.preferences_pb") + } + return PreferencesManager(dataStore) + } + private fun buildViewModel( sessions: List = emptyList(), ): ActivitiesViewModel = ActivitiesViewModel( recomputeActivitySessions = FakeRecomputeUseCase(sessions), sensorWindowRepository = SensorWindowRepository(FakeSensorWindowDao()), manualSessionOverrideDao = FakeManualSessionOverrideDao(), + preferencesManager = buildPreferencesManager(), ) // ─── Initial state ─────────────────────────────────────────────────────── diff --git a/app/src/test/java/com/podometer/ui/dashboard/DashboardViewModelTest.kt b/app/src/test/java/com/podometer/ui/dashboard/DashboardViewModelTest.kt index f6d888c..185873e 100644 --- a/app/src/test/java/com/podometer/ui/dashboard/DashboardViewModelTest.kt +++ b/app/src/test/java/com/podometer/ui/dashboard/DashboardViewModelTest.kt @@ -1,8 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later package com.podometer.ui.dashboard +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences import com.podometer.data.db.ActivityTransition import com.podometer.data.db.CyclingSession +import com.podometer.data.repository.PreferencesManager import com.podometer.domain.model.ActivityState import com.podometer.domain.model.DaySummary import com.podometer.domain.model.StepData @@ -27,7 +31,9 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder /** * Unit tests for [DashboardViewModel]. @@ -43,6 +49,16 @@ class DashboardViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() + @get:Rule + val tmpFolder = TemporaryFolder() + + private fun buildPreferencesManager(): PreferencesManager { + val dataStore: DataStore = PreferenceDataStoreFactory.create { + tmpFolder.newFile("test_prefs.preferences_pb") + } + return PreferencesManager(dataStore) + } + @Before fun setUpDispatcher() { Dispatchers.setMain(testDispatcher) @@ -103,6 +119,7 @@ class DashboardViewModelTest { getTodayTransitions = FakeGetTodayTransitionsUseCase(flowOf(transitions)), getTodayCyclingSessions = FakeGetTodayCyclingSessionsUseCase(flowOf(cyclingSessions)), overrideActivityUseCase = overrideActivityUseCase, + preferencesManager = buildPreferencesManager(), ) // ─── DashboardUiState default state ────────────────────────────────────── @@ -290,6 +307,7 @@ class DashboardViewModelTest { getTodayTransitions = FakeGetTodayTransitionsUseCase(), getTodayCyclingSessions = FakeGetTodayCyclingSessionsUseCase(), overrideActivityUseCase = FakeOverrideActivityUseCase(), + preferencesManager = buildPreferencesManager(), ) val firstState = viewModel.uiState.first { !it.isLoading } From 6c8a45a889ae27280fb4da24c5d8c1bb539455f5 Mon Sep 17 00:00:00 2001 From: Jan Jetze Date: Fri, 6 Mar 2026 21:28:43 +0100 Subject: [PATCH 4/5] feat: improve step graph UX with Y-axis labels, zoom, and legend fixes Add YAxisLabels composable with nice rounded numbers (niceAxisMax), fix centroid-anchored pinch zoom, use fractional positioning for time labels, and give legend line samples explicit width so they're visible. Co-Authored-By: Claude Opus 4.6 --- .../com/podometer/ui/activities/StepGraph.kt | 546 +++++++++++------- 1 file changed, 329 insertions(+), 217 deletions(-) diff --git a/app/src/main/java/com/podometer/ui/activities/StepGraph.kt b/app/src/main/java/com/podometer/ui/activities/StepGraph.kt index cfe16e9..681ba56 100644 --- a/app/src/main/java/com/podometer/ui/activities/StepGraph.kt +++ b/app/src/main/java/com/podometer/ui/activities/StepGraph.kt @@ -6,11 +6,15 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -30,10 +34,15 @@ import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlin.math.pow import com.podometer.data.db.SensorWindow import com.podometer.domain.model.ActivitySession import com.podometer.domain.model.ActivityState @@ -308,11 +317,14 @@ internal fun findNearestMarker( // ─── Composable ─────────────────────────────────────────────────────────────── -/** Left/right padding for Y-axis labels in dp. */ -private val Y_AXIS_WIDTH = 48.dp +/** Width reserved for left Y-axis labels. */ +private val Y_AXIS_LEFT_WIDTH = 40.dp + +/** Width reserved for right Y-axis labels. */ +private val Y_AXIS_RIGHT_WIDTH = 36.dp /** Bottom padding for X-axis labels in dp. */ -private val X_AXIS_HEIGHT = 20.dp +private val X_AXIS_HEIGHT = 24.dp /** Height of the chart canvas area. */ private val CHART_HEIGHT = 200.dp @@ -332,22 +344,65 @@ private const val MARKER_ALPHA = 0.5f /** Fraction of the visible range within which a tap snaps to a marker. */ private const val MARKER_TAP_THRESHOLD = 0.02f -/** Time labels for the X-axis. */ -private val TIME_LABELS = listOf( - "6am" to 6f / 24f, - "9am" to 9f / 24f, - "12pm" to 12f / 24f, - "3pm" to 15f / 24f, - "6pm" to 18f / 24f, - "9pm" to 21f / 24f, +/** Number of Y-axis grid divisions. */ +private const val Y_GRID_DIVISIONS = 4 + +/** + * Rounds a value up to a "nice" number for axis labeling (1, 2, 5 multiples). + */ +internal fun niceAxisMax(value: Int): Int { + if (value <= 0) return 100 + val magnitude = 10.0.pow(kotlin.math.floor(kotlin.math.log10(value.toDouble()))).toInt() + val normalized = value.toDouble() / magnitude + val niceNorm = when { + normalized <= 1.0 -> 1.0 + normalized <= 2.0 -> 2.0 + normalized <= 5.0 -> 5.0 + else -> 10.0 + } + return (niceNorm * magnitude).toInt().coerceAtLeast(1) +} + +/** + * Formats a step count for Y-axis labels: "1.2k" for thousands, plain number otherwise. + */ +private fun formatAxisLabel(value: Int): String = when { + value >= 10_000 -> "${value / 1_000}k" + value >= 1_000 -> { + val thousands = value / 1_000 + val hundreds = (value % 1_000) / 100 + if (hundreds > 0) "${thousands}.${hundreds}k" else "${thousands}k" + } + else -> value.toString() +} + +/** Time labels for the X-axis at various zoom levels. */ +private val TIME_LABELS_COARSE = listOf( + "6a" to 6f / 24f, + "9a" to 9f / 24f, + "12p" to 12f / 24f, + "3p" to 15f / 24f, + "6p" to 18f / 24f, + "9p" to 21f / 24f, +) + +private val TIME_LABELS_FINE = listOf( + "12a" to 0f / 24f, + "3a" to 3f / 24f, + "6a" to 6f / 24f, + "9a" to 9f / 24f, + "12p" to 12f / 24f, + "3p" to 15f / 24f, + "6p" to 18f / 24f, + "9p" to 21f / 24f, ) /** * Dual-axis step line graph showing cumulative and per-bucket step counts. * * Features: - * - Left Y-axis: cumulative step count (monotonically increasing line) - * - Right Y-axis: per-bucket step count (spiky intensity line) + * - Left Y-axis: cumulative step count with labeled grid lines + * - Right Y-axis: per-bucket step count with labeled grid lines * - Activity session colored background regions * - Tap to show crosshair with tooltip * - Horizontal pan and pinch-to-zoom on the time axis @@ -390,227 +445,225 @@ fun StepGraph( val dayDuration = (dayEndMillis - dayStartMillis).toFloat() + // Compute nice axis maximums + val niceMaxCumulative = niceAxisMax(graphData.maxCumulative) + val niceMaxBucket = niceAxisMax(graphData.maxBucket) + Column(modifier = modifier) { - Box { - Canvas( + // Y-axis labels + chart in a Row + Row(modifier = Modifier.fillMaxWidth()) { + // Left Y-axis labels (cumulative) + YAxisLabels( + maxValue = niceMaxCumulative, + color = cumulativeLineColor, + alignment = Alignment.End, modifier = Modifier - .fillMaxWidth() - .height(CHART_HEIGHT) - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - // Update scale (horizontal zoom only) - val newScale = (scale * zoom).coerceIn(1f, 10f) - // Adjust offset to keep center stable during zoom - val panFraction = pan.x / size.width / scale - val newOffset = (offsetFraction - panFraction) - .coerceIn(0f, 1f - 1f / newScale) - scale = newScale - offsetFraction = newOffset - // Dismiss crosshair on pan/zoom - crosshairFraction = null + .width(Y_AXIS_LEFT_WIDTH) + .height(CHART_HEIGHT), + ) + + // Chart area + Box( + modifier = Modifier + .weight(1f) + .height(CHART_HEIGHT), + ) { + Canvas( + modifier = Modifier + .matchParentSize() + .pointerInput(Unit) { + detectTransformGestures { centroid, pan, zoom, _ -> + val oldScale = scale + val newScale = (scale * zoom).coerceIn(1f, 10f) + // Anchor zoom to the pinch centroid + val centroidFraction = centroid.x / size.width + val centroidDay = offsetFraction + centroidFraction / oldScale + val newOffset = (centroidDay - centroidFraction / newScale) + .coerceIn(0f, (1f - 1f / newScale).coerceAtLeast(0f)) + // Apply pan + val panFraction = pan.x / size.width / newScale + val finalOffset = (newOffset - panFraction) + .coerceIn(0f, (1f - 1f / newScale).coerceAtLeast(0f)) + scale = newScale + offsetFraction = finalOffset + crosshairFraction = null + } + } + .pointerInput(Unit) { + detectTapGestures( + onTap = { offset -> + val chartWidth = size.width.toFloat() + val tapFraction = offset.x / chartWidth + val dayFraction = offsetFraction + tapFraction / scale + val clampedFraction = dayFraction.coerceIn(0f, 1f) + val visRange = 1f / scale + val threshold = MARKER_TAP_THRESHOLD * visRange + val marker = findNearestMarker( + graphData.markers, clampedFraction, threshold, + ) + nearMarker = marker + crosshairFraction = if (marker != null) { + onSessionHighlight(marker.sessionIndex) + marker.fraction + } else { + clampedFraction + } + }, + onDoubleTap = { + scale = 1f + offsetFraction = 0f + crosshairFraction = null + nearMarker = null + }, + ) + }, + ) { + val chartWidth = size.width + val chartHeight = size.height + val visibleStart = offsetFraction + val visibleEnd = (offsetFraction + 1f / scale).coerceAtMost(1f) + val visibleRange = visibleEnd - visibleStart + if (visibleRange <= 0f) return@Canvas + + fun fractionToX(f: Float): Float = + ((f - visibleStart) / visibleRange) * chartWidth + + // Activity regions + for (region in graphData.activityRegions) { + val x1 = fractionToX(region.startFraction).coerceIn(0f, chartWidth) + val x2 = fractionToX(region.endFraction).coerceIn(0f, chartWidth) + if (x2 > x1) { + drawRect( + color = region.activity.regionColor(activityColors), + topLeft = Offset(x1, 0f), + size = Size(x2 - x1, chartHeight), + alpha = REGION_ALPHA, + ) } } - .pointerInput(Unit) { - detectTapGestures( - onTap = { offset -> - // Convert tap position to day fraction - val chartWidth = size.width.toFloat() - val tapFraction = offset.x / chartWidth - val dayFraction = offsetFraction + tapFraction / scale - val clampedFraction = dayFraction.coerceIn(0f, 1f) - - // Check if tap is near a marker - val visRange = 1f / scale - val threshold = MARKER_TAP_THRESHOLD * visRange - val marker = findNearestMarker( - graphData.markers, clampedFraction, threshold, - ) - nearMarker = marker - crosshairFraction = if (marker != null) { - onSessionHighlight(marker.sessionIndex) - marker.fraction - } else { - clampedFraction - } - }, - onDoubleTap = { - // Reset zoom - scale = 1f - offsetFraction = 0f - crosshairFraction = null - nearMarker = null - }, - ) - }, - ) { - val chartWidth = size.width - val chartHeight = size.height - - // Visible range in day fractions - val visibleStart = offsetFraction - val visibleEnd = (offsetFraction + 1f / scale).coerceAtMost(1f) - val visibleRange = visibleEnd - visibleStart - - if (visibleRange <= 0f) return@Canvas - - // Helper: day fraction to x pixel - fun fractionToX(f: Float): Float = - ((f - visibleStart) / visibleRange) * chartWidth - - // Draw activity regions - for (region in graphData.activityRegions) { - val x1 = fractionToX(region.startFraction).coerceIn(0f, chartWidth) - val x2 = fractionToX(region.endFraction).coerceIn(0f, chartWidth) - if (x2 > x1) { - drawRect( - color = region.activity.regionColor(activityColors), - topLeft = Offset(x1, 0f), - size = Size(x2 - x1, chartHeight), - alpha = REGION_ALPHA, - ) + + // Highlighted session + if (highlightedSessionIndex in graphData.activityRegions.indices) { + val region = graphData.activityRegions[highlightedSessionIndex] + val x1 = fractionToX(region.startFraction).coerceIn(0f, chartWidth) + val x2 = fractionToX(region.endFraction).coerceIn(0f, chartWidth) + if (x2 > x1) { + drawRect( + color = region.activity.regionColor(activityColors), + topLeft = Offset(x1, 0f), + size = Size(x2 - x1, chartHeight), + alpha = HIGHLIGHT_ALPHA, + ) + } } - } - // Draw highlighted session region - if (highlightedSessionIndex in graphData.activityRegions.indices) { - val region = graphData.activityRegions[highlightedSessionIndex] - val x1 = fractionToX(region.startFraction).coerceIn(0f, chartWidth) - val x2 = fractionToX(region.endFraction).coerceIn(0f, chartWidth) - if (x2 > x1) { - drawRect( - color = region.activity.regionColor(activityColors), - topLeft = Offset(x1, 0f), - size = Size(x2 - x1, chartHeight), - alpha = HIGHLIGHT_ALPHA, - ) + // Session boundary markers + for (marker in graphData.markers) { + val mx = fractionToX(marker.fraction) + if (mx in 0f..chartWidth) { + drawLine( + color = markerColor, + start = Offset(mx, 0f), + end = Offset(mx, chartHeight), + strokeWidth = 1.5f, + alpha = MARKER_ALPHA, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f)), + ) + } } - } - // Draw session boundary markers - for (marker in graphData.markers) { - val mx = fractionToX(marker.fraction) - if (mx in 0f..chartWidth) { + // Grid lines with Y-axis labels + val gridDash = PathEffect.dashPathEffect(floatArrayOf(8f, 4f)) + for (i in 1 until Y_GRID_DIVISIONS) { + val y = chartHeight * (1f - i.toFloat() / Y_GRID_DIVISIONS) drawLine( - color = markerColor, - start = Offset(mx, 0f), - end = Offset(mx, chartHeight), - strokeWidth = 1.5f, - alpha = MARKER_ALPHA, - pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f)), + color = gridLineColor, + start = Offset(0f, y), + end = Offset(chartWidth, y), + strokeWidth = 1f, + pathEffect = gridDash, ) } - } - // Draw horizontal grid lines (3 lines for each axis) - val gridDash = PathEffect.dashPathEffect(floatArrayOf(8f, 4f)) - for (i in 1..3) { - val y = chartHeight * (1f - i / 4f) - drawLine( - color = gridLineColor, - start = Offset(0f, y), - end = Offset(chartWidth, y), - strokeWidth = 1f, - pathEffect = gridDash, - ) - } + if (graphData.points.isEmpty()) return@Canvas + + // Cumulative line (left axis) + if (niceMaxCumulative > 0) { + val path = Path() + var started = false + for (point in graphData.points) { + val fraction = (point.bucketStartMillis - dayStartMillis) / dayDuration + val x = fractionToX(fraction) + val y = chartHeight * (1f - point.cumulativeSteps.toFloat() / niceMaxCumulative) + if (!started) { path.moveTo(x, y); started = true } else path.lineTo(x, y) + } + drawPath(path, cumulativeLineColor, style = Stroke(width = 2.5f)) + } - if (graphData.points.isEmpty()) return@Canvas - - // Draw cumulative line (left axis) - if (graphData.maxCumulative > 0) { - val cumulativePath = Path() - var started = false - for (point in graphData.points) { - val fraction = (point.bucketStartMillis - dayStartMillis) / dayDuration - val x = fractionToX(fraction) - val y = chartHeight * (1f - point.cumulativeSteps.toFloat() / graphData.maxCumulative) - if (!started) { - cumulativePath.moveTo(x, y) - started = true - } else { - cumulativePath.lineTo(x, y) + // Bucket line (right axis) + if (niceMaxBucket > 0) { + val path = Path() + var started = false + for (point in graphData.points) { + val fraction = (point.bucketStartMillis - dayStartMillis) / dayDuration + val x = fractionToX(fraction) + val y = chartHeight * (1f - point.bucketSteps.toFloat() / niceMaxBucket) + if (!started) { path.moveTo(x, y); started = true } else path.lineTo(x, y) } + drawPath(path, bucketLineColor, style = Stroke(width = 1.5f), alpha = 0.8f) } - drawPath( - path = cumulativePath, - color = cumulativeLineColor, - style = Stroke(width = 3f), - ) - } - // Draw bucket line (right axis) - if (graphData.maxBucket > 0) { - val bucketPath = Path() - var started = false - for (point in graphData.points) { - val fraction = (point.bucketStartMillis - dayStartMillis) / dayDuration - val x = fractionToX(fraction) - val y = chartHeight * (1f - point.bucketSteps.toFloat() / graphData.maxBucket) - if (!started) { - bucketPath.moveTo(x, y) - started = true - } else { - bucketPath.lineTo(x, y) + // Crosshair + val cf = crosshairFraction + if (cf != null) { + val cx = fractionToX(cf) + if (cx in 0f..chartWidth) { + drawLine(crosshairColor, Offset(cx, 0f), Offset(cx, chartHeight), 1.5f, alpha = CROSSHAIR_ALPHA) } } - drawPath( - path = bucketPath, - color = bucketLineColor, - style = Stroke(width = 2f), - ) } - // Draw crosshair + // Tooltip overlay val cf = crosshairFraction if (cf != null) { - val cx = fractionToX(cf) - if (cx in 0f..chartWidth) { - drawLine( - color = crosshairColor, - start = Offset(cx, 0f), - end = Offset(cx, chartHeight), - strokeWidth = 2f, - alpha = CROSSHAIR_ALPHA, - ) + val markerSession = nearMarker?.let { m -> sessions.getOrNull(m.sessionIndex) } + if (markerSession != null) { + MarkerTooltip(session = markerSession, modifier = Modifier.align(Alignment.TopCenter)) + } else if (graphData.points.isNotEmpty()) { + val targetMillis = dayStartMillis + (cf * dayDuration).toLong() + val nearestPoint = graphData.points.minByOrNull { + kotlin.math.abs(it.bucketStartMillis - targetMillis) + } + if (nearestPoint != null) { + CrosshairTooltip(point = nearestPoint, modifier = Modifier.align(Alignment.TopCenter)) + } } } } - // Tooltip overlay - val cf = crosshairFraction - if (cf != null) { - val markerSession = nearMarker?.let { m -> - sessions.getOrNull(m.sessionIndex) - } - if (markerSession != null) { - // Show session detail tooltip when tapping near a marker - MarkerTooltip( - session = markerSession, - modifier = Modifier.align(Alignment.TopCenter), - ) - } else if (graphData.points.isNotEmpty()) { - // Show regular crosshair tooltip - val targetMillis = dayStartMillis + (cf * dayDuration).toLong() - val nearestPoint = graphData.points.minByOrNull { - kotlin.math.abs(it.bucketStartMillis - targetMillis) - } - if (nearestPoint != null) { - CrosshairTooltip( - point = nearestPoint, - modifier = Modifier.align(Alignment.TopCenter), - ) - } - } - } + // Right Y-axis labels (per bucket) + YAxisLabels( + maxValue = niceMaxBucket, + color = bucketLineColor, + alignment = Alignment.Start, + modifier = Modifier + .width(Y_AXIS_RIGHT_WIDTH) + .height(CHART_HEIGHT), + ) } - // X-axis time labels - StepGraphTimeLabels( - scale = scale, - offsetFraction = offsetFraction, - ) + // X-axis time labels (indented to match chart area) + Row(modifier = Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.width(Y_AXIS_LEFT_WIDTH)) + StepGraphTimeLabels( + scale = scale, + offsetFraction = offsetFraction, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(Y_AXIS_RIGHT_WIDTH)) + } - // Y-axis legend + // Legend StepGraphLegend( maxCumulative = graphData.maxCumulative, maxBucket = graphData.maxBucket, @@ -620,6 +673,52 @@ fun StepGraph( } } +/** + * Y-axis labels drawn vertically alongside the chart. + * + * Shows [Y_GRID_DIVISIONS] evenly spaced labels from 0 to [maxValue]. + * + * @param maxValue The maximum value on this axis. + * @param color Color for the label text. + * @param alignment Horizontal alignment of labels within the column. + * @param modifier Modifier (must include width and height constraints). + */ +@Composable +private fun YAxisLabels( + maxValue: Int, + color: Color, + alignment: Alignment.Horizontal, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val labelStyle = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp) + val textMeasurer = rememberTextMeasurer() + + // Measure label height so we can center each label on its grid line + val sampleResult = textMeasurer.measure("0", labelStyle) + val labelHeightPx = sampleResult.size.height + + BoxWithConstraints(modifier = modifier) { + val heightPx = with(density) { maxHeight.toPx() } + for (i in 0..Y_GRID_DIVISIONS) { + val value = maxValue * i / Y_GRID_DIVISIONS + val yFraction = 1f - i.toFloat() / Y_GRID_DIVISIONS + val yOffsetPx = (yFraction * heightPx - labelHeightPx / 2f).toInt() + .coerceIn(0, (heightPx - labelHeightPx).toInt()) + + Text( + text = formatAxisLabel(value), + style = labelStyle, + color = color, + textAlign = if (alignment == Alignment.End) TextAlign.End else TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + .offset { IntOffset(0, yOffsetPx) }, + ) + } + } +} + /** * Tooltip card shown when the crosshair is active. * @@ -700,6 +799,8 @@ private fun MarkerTooltip( /** * Row of time labels below the graph, adjusting for zoom/pan state. + * + * Uses [TIME_LABELS_COARSE] at default zoom and [TIME_LABELS_FINE] when zoomed in. */ @Composable private fun StepGraphTimeLabels( @@ -710,25 +811,26 @@ private fun StepGraphTimeLabels( val visibleStart = offsetFraction val visibleEnd = (offsetFraction + 1f / scale).coerceAtMost(1f) val visibleRange = visibleEnd - visibleStart + val labels = if (scale > 2f) TIME_LABELS_FINE else TIME_LABELS_COARSE + val density = LocalDensity.current - Box( + BoxWithConstraints( modifier = modifier .fillMaxWidth() .height(X_AXIS_HEIGHT), ) { - for ((label, fraction) in TIME_LABELS) { + val widthPx = with(density) { maxWidth.toPx() } + + for ((label, fraction) in labels) { if (fraction in visibleStart..visibleEnd) { val xFraction = (fraction - visibleStart) / visibleRange + val xOffsetPx = (xFraction * widthPx).toInt() + Text( text = label, style = MaterialTheme.typography.labelSmall, textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = (xFraction * 300).dp.coerceAtMost(280.dp)), - // Note: using fillMaxWidth with a fraction offset would be - // more precise, but this simple approach works well enough - // for the fixed set of labels. + modifier = Modifier.offset { IntOffset(xOffsetPx - 12, 0) }, ) } } @@ -753,11 +855,16 @@ private fun StepGraphLegend( .padding(top = 4.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { - Canvas(modifier = Modifier.padding(end = 4.dp).height(2.dp).padding(horizontal = 0.dp)) { + Canvas( + modifier = Modifier + .padding(end = 4.dp) + .width(16.dp) + .height(3.dp), + ) { drawLine( color = cumulativeColor, start = Offset(0f, size.height / 2), - end = Offset(12.dp.toPx(), size.height / 2), + end = Offset(size.width, size.height / 2), strokeWidth = 3f, ) } @@ -768,11 +875,16 @@ private fun StepGraphLegend( ) } Row(verticalAlignment = Alignment.CenterVertically) { - Canvas(modifier = Modifier.padding(end = 4.dp).height(2.dp)) { + Canvas( + modifier = Modifier + .padding(end = 4.dp) + .width(16.dp) + .height(3.dp), + ) { drawLine( color = bucketColor, start = Offset(0f, size.height / 2), - end = Offset(12.dp.toPx(), size.height / 2), + end = Offset(size.width, size.height / 2), strokeWidth = 2f, ) } From 4489ee76ced64333449ec50abb4b1474bba1645d Mon Sep 17 00:00:00 2001 From: Jan Jetze Date: Fri, 6 Mar 2026 21:32:13 +0100 Subject: [PATCH 5/5] test: add tests for niceAxisMax, formatAxisLabel, and TestDataGenerator - niceAxisMax: edge cases (0, negative), 1-2-5 rounding, always >= input - formatAxisLabel: plain numbers, thousands with decimal, 10k+ shorthand - TestDataGenerator: determinism, date variation, invariants (ordering, non-negative steps, chronological sessions), transitions, weekly summaries, cycling sessions, all 7 day types Co-Authored-By: Claude Opus 4.6 --- .../com/podometer/ui/activities/StepGraph.kt | 2 +- .../podometer/ui/activities/StepGraphTest.kt | 76 ++++++ .../ui/activities/TestDataGeneratorTest.kt | 228 ++++++++++++++++++ 3 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/podometer/ui/activities/TestDataGeneratorTest.kt diff --git a/app/src/main/java/com/podometer/ui/activities/StepGraph.kt b/app/src/main/java/com/podometer/ui/activities/StepGraph.kt index 681ba56..9e493bc 100644 --- a/app/src/main/java/com/podometer/ui/activities/StepGraph.kt +++ b/app/src/main/java/com/podometer/ui/activities/StepGraph.kt @@ -366,7 +366,7 @@ internal fun niceAxisMax(value: Int): Int { /** * Formats a step count for Y-axis labels: "1.2k" for thousands, plain number otherwise. */ -private fun formatAxisLabel(value: Int): String = when { +internal fun formatAxisLabel(value: Int): String = when { value >= 10_000 -> "${value / 1_000}k" value >= 1_000 -> { val thousands = value / 1_000 diff --git a/app/src/test/java/com/podometer/ui/activities/StepGraphTest.kt b/app/src/test/java/com/podometer/ui/activities/StepGraphTest.kt index ff43cd7..733c584 100644 --- a/app/src/test/java/com/podometer/ui/activities/StepGraphTest.kt +++ b/app/src/test/java/com/podometer/ui/activities/StepGraphTest.kt @@ -293,6 +293,82 @@ class StepGraphTest { // ─── Dominant activity ────────────────────────────────────────────────── + // ─── niceAxisMax ───────────────────────────────────────────────────── + + @Test + fun `niceAxisMax returns 100 for zero`() { + assertEquals(100, niceAxisMax(0)) + } + + @Test + fun `niceAxisMax returns 100 for negative`() { + assertEquals(100, niceAxisMax(-5)) + } + + @Test + fun `niceAxisMax rounds small values to 1-2-5 multiples`() { + assertEquals(1, niceAxisMax(1)) + assertEquals(2, niceAxisMax(2)) + assertEquals(5, niceAxisMax(3)) + assertEquals(5, niceAxisMax(5)) + assertEquals(10, niceAxisMax(6)) + assertEquals(10, niceAxisMax(9)) + assertEquals(10, niceAxisMax(10)) + } + + @Test + fun `niceAxisMax rounds hundreds correctly`() { + assertEquals(100, niceAxisMax(100)) + assertEquals(200, niceAxisMax(150)) + assertEquals(200, niceAxisMax(200)) + assertEquals(500, niceAxisMax(300)) + assertEquals(500, niceAxisMax(500)) + assertEquals(1000, niceAxisMax(600)) + } + + @Test + fun `niceAxisMax rounds thousands correctly`() { + assertEquals(1000, niceAxisMax(1000)) + assertEquals(2000, niceAxisMax(1500)) + assertEquals(5000, niceAxisMax(3000)) + assertEquals(10000, niceAxisMax(8000)) + assertEquals(20000, niceAxisMax(15000)) + } + + @Test + fun `niceAxisMax result is always gte input`() { + val testValues = listOf(1, 7, 42, 99, 123, 567, 1234, 5678, 9999, 15000) + for (v in testValues) { + assertTrue("niceAxisMax($v) = ${niceAxisMax(v)} should be >= $v", niceAxisMax(v) >= v) + } + } + + // ─── formatAxisLabel ───────────────────────────────────────────────── + + @Test + fun `formatAxisLabel shows plain number below 1000`() { + assertEquals("0", formatAxisLabel(0)) + assertEquals("500", formatAxisLabel(500)) + assertEquals("999", formatAxisLabel(999)) + } + + @Test + fun `formatAxisLabel shows thousands with decimal`() { + assertEquals("1k", formatAxisLabel(1000)) + assertEquals("1.5k", formatAxisLabel(1500)) + assertEquals("2.5k", formatAxisLabel(2500)) + assertEquals("9.9k", formatAxisLabel(9900)) + } + + @Test + fun `formatAxisLabel shows plain k for 10000 and above`() { + assertEquals("10k", formatAxisLabel(10000)) + assertEquals("15k", formatAxisLabel(15000)) + assertEquals("20k", formatAxisLabel(20000)) + } + + // ─── Dominant activity ────────────────────────────────────────────── + @Test fun `dominant activity assigned to graph points from sessions`() { val sessions = listOf( diff --git a/app/src/test/java/com/podometer/ui/activities/TestDataGeneratorTest.kt b/app/src/test/java/com/podometer/ui/activities/TestDataGeneratorTest.kt new file mode 100644 index 0000000..bd92a8b --- /dev/null +++ b/app/src/test/java/com/podometer/ui/activities/TestDataGeneratorTest.kt @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.podometer.ui.activities + +import com.podometer.domain.model.ActivityState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** + * Unit tests for [TestDataGenerator]. + * + * Verifies determinism, basic invariants, and that different dates produce + * different data patterns. + */ +class TestDataGeneratorTest { + + private val date1 = LocalDate.of(2026, 3, 1) // Sunday + private val date2 = LocalDate.of(2026, 3, 2) // Monday + + // ─── Determinism ──────────────────────────────────────────────────── + + @Test + fun `generateWindows is deterministic for same date`() { + val a = TestDataGenerator.generateWindows(date1) + val b = TestDataGenerator.generateWindows(date1) + assertEquals(a.size, b.size) + a.zip(b).forEach { (wa, wb) -> + assertEquals(wa.timestamp, wb.timestamp) + assertEquals(wa.stepCount, wb.stepCount) + } + } + + @Test + fun `generateSessions is deterministic for same date`() { + val a = TestDataGenerator.generateSessions(date1) + val b = TestDataGenerator.generateSessions(date1) + assertEquals(a.size, b.size) + a.zip(b).forEach { (sa, sb) -> + assertEquals(sa.activity, sb.activity) + assertEquals(sa.startTime, sb.startTime) + assertEquals(sa.endTime, sb.endTime) + } + } + + // ─── Variation across dates ───────────────────────────────────────── + + @Test + fun `different dates produce different sessions`() { + val s1 = TestDataGenerator.generateSessions(date1) + val s2 = TestDataGenerator.generateSessions(date2) + // Different dates should have different session counts or timings + val timings1 = s1.map { it.startTime } + val timings2 = s2.map { it.startTime } + assertFalse( + "Sessions for different dates should differ", + s1.size == s2.size && timings1 == timings2, + ) + } + + @Test + fun `different dates produce different window step patterns`() { + val w1 = TestDataGenerator.generateWindows(date1) + val w2 = TestDataGenerator.generateWindows(date2) + val steps1 = w1.map { it.stepCount } + val steps2 = w2.map { it.stepCount } + assertFalse( + "Windows for different dates should have different step patterns", + steps1 == steps2, + ) + } + + // ─── Basic invariants ─────────────────────────────────────────────── + + @Test + fun `generateWindows produces non-empty output`() { + val windows = TestDataGenerator.generateWindows(date1) + assertTrue("Should produce windows", windows.isNotEmpty()) + } + + @Test + fun `generateWindows timestamps are chronologically ordered`() { + val windows = TestDataGenerator.generateWindows(date1) + for (i in 1 until windows.size) { + assertTrue( + "Window $i timestamp should be >= window ${i - 1}", + windows[i].timestamp >= windows[i - 1].timestamp, + ) + } + } + + @Test + fun `generateWindows step counts are non-negative`() { + val windows = TestDataGenerator.generateWindows(date1) + windows.forEach { w -> + assertTrue("Step count should be >= 0, got ${w.stepCount}", w.stepCount >= 0) + } + } + + @Test + fun `generateSessions produces non-empty output`() { + val sessions = TestDataGenerator.generateSessions(date1) + assertTrue("Should produce sessions", sessions.isNotEmpty()) + } + + @Test + fun `generateSessions are chronologically ordered`() { + val sessions = TestDataGenerator.generateSessions(date1) + for (i in 1 until sessions.size) { + assertTrue( + "Session $i should start after session ${i - 1}", + sessions[i].startTime >= sessions[i - 1].startTime, + ) + } + } + + @Test + fun `generateSessions all have end times`() { + val sessions = TestDataGenerator.generateSessions(date1) + sessions.forEach { s -> + assertTrue("All test sessions should have an endTime", s.endTime != null) + } + } + + @Test + fun `generateSessions endTime is after startTime`() { + val sessions = TestDataGenerator.generateSessions(date1) + sessions.forEach { s -> + assertTrue( + "endTime (${s.endTime}) should be > startTime (${s.startTime})", + s.endTime!! > s.startTime, + ) + } + } + + @Test + fun `generateSessions walking sessions have positive step counts`() { + val sessions = TestDataGenerator.generateSessions(date1) + sessions.filter { it.activity == ActivityState.WALKING }.forEach { s -> + assertTrue("Walking session should have steps > 0, got ${s.stepCount}", s.stepCount > 0) + } + } + + // ─── Transitions ──────────────────────────────────────────────────── + + @Test + fun `generateTransitions is deterministic`() { + val a = TestDataGenerator.generateTransitions(date1) + val b = TestDataGenerator.generateTransitions(date1) + assertEquals(a.size, b.size) + a.zip(b).forEach { (ta, tb) -> + assertEquals(ta.timestamp, tb.timestamp) + assertEquals(ta.fromActivity, tb.fromActivity) + assertEquals(ta.toActivity, tb.toActivity) + } + } + + @Test + fun `generateTransitions has pairs per session`() { + val sessions = TestDataGenerator.generateSessions(date1) + val transitions = TestDataGenerator.generateTransitions(date1) + // Each session produces a start and end transition + assertEquals(sessions.size * 2, transitions.size) + } + + // ─── Weekly summaries ─────────────────────────────────────────────── + + @Test + fun `generateWeeklySummaries has entries up to today`() { + val summaries = TestDataGenerator.generateWeeklySummaries() + val today = LocalDate.now() + val expectedDays = today.dayOfWeek.value + assertEquals(expectedDays, summaries.size) + } + + @Test + fun `generateWeeklySummaries have positive step counts`() { + val summaries = TestDataGenerator.generateWeeklySummaries() + summaries.forEach { s -> + assertTrue("Step count should be > 0, got ${s.totalSteps}", s.totalSteps > 0) + } + } + + // ─── Cycling sessions ─────────────────────────────────────────────── + + @Test + fun `generateCyclingSessions only contains cycling`() { + // Test across several dates to find one with cycling + val dates = (0L..6L).map { LocalDate.of(2026, 3, 1).plusDays(it) } + val allCycling = dates.flatMap { TestDataGenerator.generateCyclingSessions(it) } + allCycling.forEach { cs -> + assertTrue("Cycling session endTime should be > startTime", cs.endTime!! > cs.startTime) + assertTrue("Duration should be positive", cs.durationMinutes > 0) + } + } + + @Test + fun `generateCyclingSessions matches cycling blocks in sessions`() { + // Test a date that has cycling (dayType 0 = commute day) + val dates = (0L..6L).map { LocalDate.of(2026, 3, 1).plusDays(it) } + for (date in dates) { + val sessions = TestDataGenerator.generateSessions(date) + val cyclingBlocks = sessions.filter { it.activity == ActivityState.CYCLING } + val cyclingSessions = TestDataGenerator.generateCyclingSessions(date) + assertEquals( + "Cycling session count should match cycling blocks for $date", + cyclingBlocks.size, + cyclingSessions.size, + ) + } + } + + // ─── Day type coverage ────────────────────────────────────────────── + + @Test + fun `all 7 day types produce valid data`() { + // 7 consecutive days covers all day types + val baseDate = LocalDate.of(2026, 1, 1) + for (i in 0L..6L) { + val date = baseDate.plusDays(i) + val sessions = TestDataGenerator.generateSessions(date) + val windows = TestDataGenerator.generateWindows(date) + assertTrue("Day type ${i}: should have sessions", sessions.isNotEmpty()) + assertTrue("Day type ${i}: should have windows", windows.isNotEmpty()) + } + } +}