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/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 849c22b..d24cc9a 100644 --- a/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt +++ b/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt @@ -3,10 +3,15 @@ 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.PreferencesManager +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 @@ -16,7 +21,9 @@ 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 +import java.time.format.DateTimeFormatter import javax.inject.Inject /** @@ -24,6 +31,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. @@ -31,6 +40,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, @@ -45,6 +56,8 @@ data class ActivitiesUiState( @HiltViewModel class ActivitiesViewModel @Inject constructor( private val recomputeActivitySessions: RecomputeActivitySessionsUseCase, + private val sensorWindowRepository: SensorWindowRepository, + private val manualSessionOverrideDao: ManualSessionOverrideDao, private val preferencesManager: PreferencesManager, ) : ViewModel() { @@ -53,6 +66,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 @@ -64,29 +78,39 @@ class ActivitiesViewModel @Inject constructor( preferencesManager.useTestData(), ) { date, useTest -> date to useTest }.flatMapLatest { (date, useTestData) -> val nowMillis = System.currentTimeMillis() + val dateStr = date.format(DateTimeFormatter.ISO_LOCAL_DATE) if (useTestData) { combine( flowOf(TestDataGenerator.generateSessions(date)), - flowOf(Unit), - ) { sessions, _ -> + 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 { - recomputeActivitySessions(date, nowMillis).combine( - MutableStateFlow(date), - ) { sessions, selectedDate -> + 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, ) } @@ -97,6 +121,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/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/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..9e493bc --- /dev/null +++ b/app/src/main/java/com/podometer/ui/activities/StepGraph.kt @@ -0,0 +1,1050 @@ +// 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.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 +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.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 +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 ─────────────────────────────────────────────────────────────── + +/** 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 = 24.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 + +/** 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. + */ +internal 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 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 + * - 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() + + // Compute nice axis maximums + val niceMaxCumulative = niceAxisMax(graphData.maxCumulative) + val niceMaxBucket = niceAxisMax(graphData.maxBucket) + + Column(modifier = modifier) { + // 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 + .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, + ) + } + } + + // 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, + ) + } + } + + // 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)), + ) + } + } + + // 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 = 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)) + } + + // 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) + } + + // 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) + } + } + } + + // Tooltip overlay + val cf = crosshairFraction + if (cf != null) { + 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)) + } + } + } + } + + // 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 (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)) + } + + // Legend + StepGraphLegend( + maxCumulative = graphData.maxCumulative, + maxBucket = graphData.maxBucket, + cumulativeColor = cumulativeLineColor, + bucketColor = bucketLineColor, + ) + } +} + +/** + * 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. + * + * 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. + * + * Uses [TIME_LABELS_COARSE] at default zoom and [TIME_LABELS_FINE] when zoomed in. + */ +@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 + val labels = if (scale > 2f) TIME_LABELS_FINE else TIME_LABELS_COARSE + val density = LocalDensity.current + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .height(X_AXIS_HEIGHT), + ) { + 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.offset { IntOffset(xOffsetPx - 12, 0) }, + ) + } + } + } +} + +/** + * 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) + .width(16.dp) + .height(3.dp), + ) { + drawLine( + color = cumulativeColor, + start = Offset(0f, size.height / 2), + end = Offset(size.width, 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) + .width(16.dp) + .height(3.dp), + ) { + drawLine( + color = bucketColor, + start = Offset(0f, size.height / 2), + end = Offset(size.width, 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/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) + } +} 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 d0a9bf0..30980ea 100644 --- a/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt +++ b/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt @@ -4,7 +4,12 @@ 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 import com.podometer.domain.usecase.RecomputeActivitySessionsUseCase @@ -60,6 +65,23 @@ 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 buildPreferencesManager(): PreferencesManager { @@ -73,6 +95,8 @@ class ActivitiesViewModelTest { sessions: List = emptyList(), ): ActivitiesViewModel = ActivitiesViewModel( recomputeActivitySessions = FakeRecomputeUseCase(sessions), + sensorWindowRepository = SensorWindowRepository(FakeSensorWindowDao()), + manualSessionOverrideDao = FakeManualSessionOverrideDao(), preferencesManager = buildPreferencesManager(), ) 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..733c584 --- /dev/null +++ b/app/src/test/java/com/podometer/ui/activities/StepGraphTest.kt @@ -0,0 +1,387 @@ +// 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 ────────────────────────────────────────────────── + + // ─── 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( + 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) + } +} 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()) + } + } +}