From 9e3c360a641f0dc446bfcb101250bb1e8c116c71 Mon Sep 17 00:00:00 2001 From: Jan Jetze Date: Fri, 6 Mar 2026 22:05:47 +0100 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20resolve=20session=20editing=20bugs?= =?UTF-8?q?=20=E2=80=94=20duplicates,=20data=20leak,=20and=20UX=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix save creating duplicates by passing existingOverrideId to update instead of always inserting. Isolate test data overrides with "test:" date key prefix. Simplify editing flow by removing intermediate ActivityOverrideSheet and override badge. Add FAB for creating new activities. STILL overrides now suppress sessions without appearing in results. Add delete button for all sessions. Co-Authored-By: Claude Opus 4.6 --- .../usecase/MergeSessionOverridesUseCase.kt | 6 +- .../ui/activities/ActivitiesScreen.kt | 72 ++++--- .../ui/activities/ActivitiesViewModel.kt | 53 +++-- .../ui/activities/SessionEditSheet.kt | 9 +- .../com/podometer/ui/dashboard/ActivityLog.kt | 188 +----------------- .../MergeSessionOverridesUseCaseTest.kt | 26 +++ 6 files changed, 134 insertions(+), 220 deletions(-) diff --git a/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt b/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt index 36a2a15..2cac797 100644 --- a/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt +++ b/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt @@ -44,6 +44,8 @@ fun mergeSessionOverrides( } } - // Merge and sort by start time - return (filteredRecomputed + overrideSessions).sortedBy { it.startTime } + // Merge, exclude STILL overrides (they only suppress detected sessions), + // and sort by start time + val activeOverrides = overrideSessions.filter { it.activity != ActivityState.STILL } + return (filteredRecomputed + activeOverrides).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 deb1bac..55b48dd 100644 --- a/app/src/main/java/com/podometer/ui/activities/ActivitiesScreen.kt +++ b/app/src/main/java/com/podometer/ui/activities/ActivitiesScreen.kt @@ -11,15 +11,17 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon 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 import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -38,6 +40,8 @@ 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.ActivitySession +import com.podometer.domain.model.ActivityState import com.podometer.ui.dashboard.ActivityLog import com.podometer.util.DateTimeUtils import java.time.Instant @@ -60,10 +64,9 @@ fun ActivitiesScreen( viewModel: ActivitiesViewModel = hiltViewModel(), ) { 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) } + var editingSession by remember { mutableStateOf(null) } Scaffold( topBar = { @@ -71,7 +74,25 @@ fun ActivitiesScreen( title = { Text(text = stringResource(R.string.screen_activities)) }, ) }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + floatingActionButton = { + if (!uiState.isLoading && uiState.windows.isNotEmpty()) { + FloatingActionButton( + onClick = { + val dayStart = DateTimeUtils.startOfDayMillis(uiState.selectedDate) + val noon = dayStart + 12 * 3_600_000L + editingSession = ActivitySession( + activity = ActivityState.WALKING, + startTime = noon, + endTime = noon + 30 * 60_000L, + startTransitionId = 0, + isManualOverride = false, + ) + }, + ) { + Icon(Icons.Default.Add, contentDescription = "Add activity") + } + } + }, modifier = modifier, ) { innerPadding -> if (uiState.isLoading) { @@ -153,20 +174,11 @@ fun ActivitiesScreen( if (uiState.sessions.isNotEmpty()) { ActivityLog( sessions = uiState.sessions, - 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 - } + onSessionClick = { session -> + editingSession = session + val idx = uiState.sessions.indexOf(session) + highlightedSessionIndex = idx }, - snackbarHostState = snackbarHostState, - onUndo = {}, nowMillis = if (uiState.isToday) nowMillis else dayEndMillis, ) @@ -212,7 +224,7 @@ fun ActivitiesScreen( val session = editingSession!! val dayStartMillis = DateTimeUtils.startOfDayMillis(uiState.selectedDate) val dayEndMillis = dayStartMillis + 86_400_000L - val isManualOverride = session.isManualOverride + val isNew = session.startTransitionId == 0 ModalBottomSheet( onDismissRequest = { @@ -227,7 +239,12 @@ fun ActivitiesScreen( dayStartMillis = dayStartMillis, dayEndMillis = dayEndMillis, onSave = { startMs, endMs, activity -> - viewModel.saveSessionOverride(startMs, endMs, activity) + val overrideId = if (session.isManualOverride) { + -session.startTransitionId.toLong() + } else { + 0L + } + viewModel.saveSessionOverride(startMs, endMs, activity, overrideId) editingSession = null highlightedSessionIndex = -1 }, @@ -235,10 +252,19 @@ fun ActivitiesScreen( editingSession = null highlightedSessionIndex = -1 }, - onDelete = if (isManualOverride) { + onDelete = if (!isNew) { { - // startTransitionId is negated override ID - viewModel.deleteSessionOverride(-session.startTransitionId.toLong()) + if (session.isManualOverride) { + // Delete the manual override directly + viewModel.deleteSessionOverride(-session.startTransitionId.toLong()) + } else { + // "Delete" a detected session by overriding it as STILL + viewModel.saveSessionOverride( + session.startTime, + session.endTime ?: (session.startTime + 30 * 60_000L), + ActivityState.STILL, + ) + } editingSession = null highlightedSessionIndex = -1 } 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 d24cc9a..9ea5cf1 100644 --- a/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt +++ b/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt @@ -67,25 +67,35 @@ class ActivitiesViewModel @Inject constructor( private val _selectedDate = MutableStateFlow(LocalDate.now()) private val _bucketSizeMs = MutableStateFlow(300_000L) + private val _useTestData = MutableStateFlow(false) /** The currently selected date. */ val selectedDate: StateFlow = _selectedDate + /** Returns the override date key, prefixed for test data to avoid leaking. */ + private fun overrideDateKey(date: LocalDate, isTestData: Boolean): String { + val dateStr = date.format(DateTimeFormatter.ISO_LOCAL_DATE) + return if (isTestData) "test:$dateStr" else dateStr + } + /** Combined UI state emitted to the Activities screen. */ @OptIn(ExperimentalCoroutinesApi::class) val uiState: StateFlow = combine( _selectedDate, preferencesManager.useTestData(), ) { date, useTest -> date to useTest }.flatMapLatest { (date, useTestData) -> + _useTestData.value = useTestData val nowMillis = System.currentTimeMillis() - val dateStr = date.format(DateTimeFormatter.ISO_LOCAL_DATE) + val dateKey = overrideDateKey(date, useTestData) if (useTestData) { combine( flowOf(TestDataGenerator.generateSessions(date)), flowOf(TestDataGenerator.generateWindows(date)), + manualSessionOverrideDao.getOverridesForDate(dateKey), _bucketSizeMs, - ) { sessions, windows, bucketSizeMs -> + ) { generatedSessions, windows, overrides, bucketSizeMs -> + val sessions = mergeSessionOverrides(generatedSessions, overrides) ActivitiesUiState( selectedDate = date, sessions = sessions, @@ -100,7 +110,7 @@ class ActivitiesViewModel @Inject constructor( combine( recomputeActivitySessions(date, nowMillis), sensorWindowRepository.getWindowsForDay(date), - manualSessionOverrideDao.getOverridesForDate(dateStr), + manualSessionOverrideDao.getOverridesForDate(dateKey), _bucketSizeMs, ) { recomputedSessions, windows, overrides, bucketSizeMs -> val sessions = mergeSessionOverrides(recomputedSessions, overrides) @@ -127,17 +137,34 @@ class ActivitiesViewModel @Inject constructor( } /** 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) + fun saveSessionOverride( + startMs: Long, + endMs: Long, + activity: ActivityState, + existingOverrideId: Long = 0, + ) { viewModelScope.launch { - manualSessionOverrideDao.insert( - ManualSessionOverride( - startTime = startMs, - endTime = endMs, - activity = activity.name, - date = dateStr, - ), - ) + val dateKey = overrideDateKey(_selectedDate.value, _useTestData.value) + if (existingOverrideId > 0) { + manualSessionOverrideDao.update( + ManualSessionOverride( + id = existingOverrideId, + startTime = startMs, + endTime = endMs, + activity = activity.name, + date = dateKey, + ), + ) + } else { + manualSessionOverrideDao.insert( + ManualSessionOverride( + startTime = startMs, + endTime = endMs, + activity = activity.name, + date = dateKey, + ), + ) + } } } diff --git a/app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt b/app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt index 2721ef7..f39352b 100644 --- a/app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt +++ b/app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt @@ -54,18 +54,18 @@ private val EDIT_GRAPH_HEIGHT = 150.dp private const val SELECTED_REGION_ALPHA = 0.2f /** - * Bottom sheet content for editing an activity session's boundaries and type. + * Bottom sheet content for editing or creating an activity session. * * 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 session The session being edited (or a template for a new session). * @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). + * @param onDelete Callback to delete the session (null hides the button). */ @Composable fun SessionEditSheet( @@ -77,6 +77,7 @@ fun SessionEditSheet( onCancel: () -> Unit, onDelete: (() -> Unit)? = null, ) { + val isNew = session.startTransitionId == 0 val paddingMs = SESSION_PADDING_MINUTES * 60_000L val sessionEnd = session.endTime ?: (session.startTime + 30 * 60_000L) val viewStart = (session.startTime - paddingMs).coerceAtLeast(dayStartMillis) @@ -115,7 +116,7 @@ fun SessionEditSheet( .padding(bottom = 24.dp), ) { Text( - text = "Edit Activity", + text = if (isNew) "New Activity" else "Edit Activity", style = MaterialTheme.typography.titleMedium, ) diff --git a/app/src/main/java/com/podometer/ui/dashboard/ActivityLog.kt b/app/src/main/java/com/podometer/ui/dashboard/ActivityLog.kt index ff6cf8b..9d76000 100644 --- a/app/src/main/java/com/podometer/ui/dashboard/ActivityLog.kt +++ b/app/src/main/java/com/podometer/ui/dashboard/ActivityLog.kt @@ -10,23 +10,10 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material3.AssistChip -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -38,16 +25,10 @@ import com.podometer.R import com.podometer.domain.model.ActivitySession import com.podometer.domain.model.ActivityState import com.podometer.ui.theme.PodometerTheme -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone -/** Snackbar auto-dismiss timeout in milliseconds (5 seconds per spec). */ -private const val SNACKBAR_TIMEOUT_MS = 5_000L - // ─── Pure helper functions (unit-testable) ──────────────────────────────────── /** @@ -122,40 +103,23 @@ fun activityLabel(activity: ActivityState): String = when (activity) { /** * Renders a consolidated list of today's activity sessions. * - * Replaces both [TransitionLog] and [CyclingSessionList] with a unified view. * Each row shows an icon, activity label, time range, and duration. - * - * Tapping a row opens a [ModalBottomSheet] with reclassification options. - * On override, [onOverride] is called and a snackbar is shown with an "Undo" action. + * Tapping a row invokes [onSessionClick] so the parent can open an edit sheet. * * **Empty state**: When [sessions] is empty, shows "No activities detected today". * - * @param sessions List of [ActivitySession]s to display. - * @param onOverride Callback invoked when the user confirms an override. - * Receives the transition id and the new [ActivityState]. - * @param snackbarHostState [SnackbarHostState] used to show the "Activity overridden" snackbar. - * @param onUndo Callback invoked when the user taps "Undo" in the snackbar. - * @param nowMillis Current wall-clock time for calculating ongoing session duration. - * @param modifier Optional [Modifier] applied to the root [Column]. + * @param sessions List of [ActivitySession]s to display. + * @param onSessionClick Callback invoked when the user taps a session row. + * @param nowMillis Current wall-clock time for calculating ongoing session duration. + * @param modifier Optional [Modifier] applied to the root [Column]. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ActivityLog( sessions: List, - onOverride: (transitionId: Int, newActivity: ActivityState) -> Unit, - snackbarHostState: SnackbarHostState, - onUndo: () -> Unit, + onSessionClick: (ActivitySession) -> Unit, nowMillis: Long = System.currentTimeMillis(), modifier: Modifier = Modifier, ) { - val overriddenLabel = stringResource(R.string.transition_overridden) - val undoLabel = stringResource(R.string.transition_undo) - val scope = rememberCoroutineScope() - - var selectedSession by remember { mutableStateOf(null) } - var snackbarJob by remember { mutableStateOf(null) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - Column(modifier = modifier) { if (sessions.isEmpty()) { Text( @@ -168,45 +132,11 @@ fun ActivityLog( ActivityLogItem( session = session, nowMillis = nowMillis, - onClick = { selectedSession = session }, + onClick = { onSessionClick(session) }, ) } } } - - // Bottom sheet — rendered outside the Column so it overlays the full screen - if (selectedSession != null) { - val session = selectedSession!! - ModalBottomSheet( - onDismissRequest = { selectedSession = null }, - sheetState = sheetState, - ) { - ActivityOverrideSheet( - session = session, - onOptionSelected = { newActivity -> - selectedSession = null - onOverride(session.startTransitionId, newActivity) - snackbarJob?.cancel() - snackbarJob = scope.launch { - try { - val result = withTimeoutOrNull(SNACKBAR_TIMEOUT_MS) { - snackbarHostState.showSnackbar( - message = overriddenLabel, - actionLabel = undoLabel, - duration = SnackbarDuration.Indefinite, - ) - } - if (result == SnackbarResult.ActionPerformed) { - onUndo() - } - } finally { - snackbarHostState.currentSnackbarData?.dismiss() - } - } - }, - ) - } - } } /** @@ -239,7 +169,6 @@ private fun ActivityLogItem( } else { stringResource(R.string.activity_session_ongoing) } - val overrideBadgeLabel = stringResource(R.string.transition_override_badge) val itemContentDescription = stringResource( R.string.cd_activity_session_item, label, @@ -295,97 +224,6 @@ private fun ActivityLogItem( ) } - Spacer(modifier = Modifier.weight(1f)) - - if (session.isManualOverride) { - Spacer(modifier = Modifier.width(8.dp)) - AssistChip( - onClick = onClick, - label = { - Text( - text = overrideBadgeLabel, - style = MaterialTheme.typography.labelSmall, - ) - }, - ) - } - } -} - -/** - * Content of the override bottom sheet for an activity session. - * - * Shows buttons for each [ActivityState] that differs from the session's current activity. - * - * @param session The [ActivitySession] being overridden. - * @param onOptionSelected Callback invoked with the chosen [ActivityState]. - */ -@Composable -private fun ActivityOverrideSheet( - session: ActivitySession, - onOptionSelected: (ActivityState) -> Unit, -) { - val sheetCdLabel = stringResource( - R.string.cd_activity_override_options, - formatActivityTime(session.startTime), - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .padding(bottom = 24.dp) - .semantics { contentDescription = sheetCdLabel }, - ) { - if (session.activity != ActivityState.WALKING) { - TextButton( - onClick = { onOptionSelected(ActivityState.WALKING) }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp), - ) { - Icon( - imageVector = ActivityState.WALKING.icon(), - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = stringResource(R.string.transition_mark_as_walking)) - } - } - - if (session.activity != ActivityState.CYCLING) { - TextButton( - onClick = { onOptionSelected(ActivityState.CYCLING) }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp), - ) { - Icon( - imageVector = ActivityState.CYCLING.icon(), - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = stringResource(R.string.transition_mark_as_cycling)) - } - } - - if (session.activity != ActivityState.STILL) { - TextButton( - onClick = { onOptionSelected(ActivityState.STILL) }, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 48.dp), - ) { - Icon( - imageVector = ActivityState.STILL.icon(), - contentDescription = null, - modifier = Modifier.size(20.dp), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = stringResource(R.string.transition_mark_as_still)) - } - } } } @@ -398,9 +236,7 @@ private fun PreviewActivityLogEmpty() { PodometerTheme { ActivityLog( sessions = emptyList(), - onOverride = { _, _ -> }, - snackbarHostState = remember { SnackbarHostState() }, - onUndo = {}, + onSessionClick = {}, modifier = Modifier.padding(16.dp), ) } @@ -439,9 +275,7 @@ private fun PreviewActivityLogMultiple() { PodometerTheme { ActivityLog( sessions = sessions, - onOverride = { _, _ -> }, - snackbarHostState = remember { SnackbarHostState() }, - onUndo = {}, + onSessionClick = {}, nowMillis = 14L * hour + 30L * 60_000L, modifier = Modifier.padding(16.dp), ) @@ -472,9 +306,7 @@ private fun PreviewActivityLogWithOverride() { PodometerTheme { ActivityLog( sessions = sessions, - onOverride = { _, _ -> }, - snackbarHostState = remember { SnackbarHostState() }, - onUndo = {}, + onSessionClick = {}, 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 index d060e98..eb67ba7 100644 --- a/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt +++ b/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt @@ -136,4 +136,30 @@ class MergeSessionOverridesUseCaseTest { assertEquals(1, result.size) assertEquals(ActivityState.CYCLING, result[0].activity) } + + @Test + fun `STILL override suppresses detected session without appearing in result`() { + val sessions = listOf( + session(ActivityState.WALKING, 9 * hour, 10 * hour), + ) + val overrides = listOf( + override("STILL", 9 * hour, 10 * hour), + ) + val result = mergeSessionOverrides(sessions, overrides) + assertTrue("STILL override should suppress the session entirely", result.isEmpty()) + } + + @Test + fun `STILL override only suppresses overlapping sessions`() { + 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("STILL", 9 * hour, 10 * hour), + ) + val result = mergeSessionOverrides(sessions, overrides) + assertEquals(1, result.size) + assertEquals(14 * hour, result[0].startTime) + } } From 2be2524394c833b724da03306fa59ad1c3442d82 Mon Sep 17 00:00:00 2001 From: Jan Jetze Date: Fri, 6 Mar 2026 22:12:51 +0100 Subject: [PATCH 2/3] fix: compute step counts for override and new sessions from sensor windows Override and manually created sessions now get their stepCount computed from sensor windows that fall within their time range, matching the behavior of detected sessions. Co-Authored-By: Claude Opus 4.6 --- .../usecase/MergeSessionOverridesUseCase.kt | 14 +++-- .../ui/activities/ActivitiesViewModel.kt | 4 +- .../MergeSessionOverridesUseCaseTest.kt | 52 +++++++++++++++++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt b/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt index 2cac797..7f70404 100644 --- a/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt +++ b/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt @@ -2,6 +2,7 @@ package com.podometer.domain.usecase import com.podometer.data.db.ManualSessionOverride +import com.podometer.data.db.SensorWindow import com.podometer.domain.model.ActivitySession import com.podometer.domain.model.ActivityState @@ -9,29 +10,36 @@ 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. + * overlaps with a manual override is replaced. Override sessions get their + * step counts computed from the sensor [windows] that fall within their + * time range. 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. + * @param windows Sensor windows used to compute step counts for overrides. * @return Merged sessions sorted by start time. */ fun mergeSessionOverrides( recomputed: List, overrides: List, + windows: List = emptyList(), ): List { if (overrides.isEmpty()) return recomputed - // Convert overrides to ActivitySessions + // Convert overrides to ActivitySessions with step counts from sensor windows val overrideSessions = overrides.map { override -> + val steps = windows + .filter { it.timestamp in override.startTime until override.endTime } + .sumOf { it.stepCount } ActivitySession( activity = ActivityState.fromString(override.activity), startTime = override.startTime, endTime = override.endTime, startTransitionId = -override.id.toInt(), // negative to distinguish isManualOverride = true, + stepCount = steps, ) } 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 9ea5cf1..11b13a0 100644 --- a/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt +++ b/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt @@ -95,7 +95,7 @@ class ActivitiesViewModel @Inject constructor( manualSessionOverrideDao.getOverridesForDate(dateKey), _bucketSizeMs, ) { generatedSessions, windows, overrides, bucketSizeMs -> - val sessions = mergeSessionOverrides(generatedSessions, overrides) + val sessions = mergeSessionOverrides(generatedSessions, overrides, windows) ActivitiesUiState( selectedDate = date, sessions = sessions, @@ -113,7 +113,7 @@ class ActivitiesViewModel @Inject constructor( manualSessionOverrideDao.getOverridesForDate(dateKey), _bucketSizeMs, ) { recomputedSessions, windows, overrides, bucketSizeMs -> - val sessions = mergeSessionOverrides(recomputedSessions, overrides) + val sessions = mergeSessionOverrides(recomputedSessions, overrides, windows) ActivitiesUiState( selectedDate = date, sessions = sessions, diff --git a/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt b/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt index eb67ba7..c0f21dc 100644 --- a/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt +++ b/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt @@ -2,6 +2,7 @@ package com.podometer.domain.usecase import com.podometer.data.db.ManualSessionOverride +import com.podometer.data.db.SensorWindow import com.podometer.domain.model.ActivitySession import com.podometer.domain.model.ActivityState import org.junit.Assert.assertEquals @@ -162,4 +163,55 @@ class MergeSessionOverridesUseCaseTest { assertEquals(1, result.size) assertEquals(14 * hour, result[0].startTime) } + + private fun window(timestamp: Long, stepCount: Int) = SensorWindow( + timestamp = timestamp, + magnitudeVariance = 0.0, + stepFrequencyHz = 0.0, + stepCount = stepCount, + ) + + @Test + fun `override session gets step count from sensor windows`() { + val sessions = listOf( + session(ActivityState.WALKING, 9 * hour, 10 * hour), + ) + val overrides = listOf( + override("CYCLING", 9 * hour, 10 * hour), + ) + val windows = listOf( + window(9 * hour, 50), + window(9 * hour + 30_000L, 60), + window(10 * hour, 100), // outside override range + ) + val result = mergeSessionOverrides(sessions, overrides, windows) + assertEquals(1, result.size) + assertEquals(110, result[0].stepCount) + } + + @Test + fun `new activity gets step count from sensor windows in range`() { + val overrides = listOf( + override("WALKING", 12 * hour, 13 * hour), + ) + val windows = listOf( + window(11 * hour, 30), // outside range + window(12 * hour, 40), + window(12 * hour + 30_000L, 50), + window(13 * hour, 20), // outside range (at endTime, exclusive) + ) + val result = mergeSessionOverrides(emptyList(), overrides, windows) + assertEquals(1, result.size) + assertEquals(90, result[0].stepCount) + } + + @Test + fun `override without windows has zero step count`() { + val overrides = listOf( + override("WALKING", 9 * hour, 10 * hour), + ) + val result = mergeSessionOverrides(emptyList(), overrides) + assertEquals(1, result.size) + assertEquals(0, result[0].stepCount) + } } From cb775578dbfca53f6ae239066409f1eaa2d25387 Mon Sep 17 00:00:00 2001 From: Jan Jetze Date: Fri, 6 Mar 2026 22:24:19 +0100 Subject: [PATCH 3/3] refactor: eliminate DRY violations and improve test coverage - Extract shared step counting to `sumStepsInRange()` extension on `List`, used by both RecomputeActivitySessionsUseCase and MergeSessionOverridesUseCase - Add `isNew` property and `effectiveEndTime()` to ActivitySession, removing magic `startTransitionId == 0` checks and repeated `endTime ?: (startTime + 30 * 60_000L)` fallbacks - Deduplicate ViewModel's two `combine` blocks into a single block with conditional session/window flows - Remove `_useTestData` MutableStateFlow mutation inside reactive pipeline; read preference directly in `saveSessionOverride` - Move duplicate `regionColor()` extension to `ActivityColors.colorFor()` - Use shared `activityLabel()` in SessionEditSheet instead of inline when - Extract `closeEditSheet` lambda in ActivitiesScreen - Add tests: sumStepsInRange boundaries, window edge cases, overlapping overrides, step count preservation, isNew, effectiveEndTime Co-Authored-By: Claude Opus 4.6 --- .../com/podometer/data/db/SensorWindow.kt | 11 +++ .../podometer/domain/model/ActivitySession.kt | 13 +++- .../usecase/MergeSessionOverridesUseCase.kt | 6 +- .../RecomputeActivitySessionsUseCase.kt | 6 +- .../ui/activities/ActivitiesScreen.kt | 29 +++----- .../ui/activities/ActivitiesViewModel.kt | 73 ++++++++----------- .../ui/activities/SessionEditSheet.kt | 28 ++----- .../com/podometer/ui/activities/StepGraph.kt | 13 +--- .../main/java/com/podometer/ui/theme/Theme.kt | 10 ++- .../java/com/podometer/data/db/EntityTest.kt | 33 +++++++++ .../domain/model/ActivitySessionTest.kt | 62 ++++++++++++++++ .../MergeSessionOverridesUseCaseTest.kt | 65 +++++++++++++++++ 12 files changed, 246 insertions(+), 103 deletions(-) diff --git a/app/src/main/java/com/podometer/data/db/SensorWindow.kt b/app/src/main/java/com/podometer/data/db/SensorWindow.kt index 21ba2b8..93ff827 100644 --- a/app/src/main/java/com/podometer/data/db/SensorWindow.kt +++ b/app/src/main/java/com/podometer/data/db/SensorWindow.kt @@ -25,3 +25,14 @@ data class SensorWindow( val stepFrequencyHz: Double, val stepCount: Int, ) + +/** + * Sums [SensorWindow.stepCount] for windows whose [SensorWindow.timestamp] + * falls within the half-open range `[startTime, endTime)`. + * + * @param startTime Inclusive start of the time range in epoch milliseconds. + * @param endTime Exclusive end of the time range in epoch milliseconds. + * @return Total step count for the range. + */ +fun List.sumStepsInRange(startTime: Long, endTime: Long): Int = + filter { it.timestamp in startTime until endTime }.sumOf { it.stepCount } diff --git a/app/src/main/java/com/podometer/domain/model/ActivitySession.kt b/app/src/main/java/com/podometer/domain/model/ActivitySession.kt index 2d2f5c7..f397435 100644 --- a/app/src/main/java/com/podometer/domain/model/ActivitySession.kt +++ b/app/src/main/java/com/podometer/domain/model/ActivitySession.kt @@ -25,7 +25,18 @@ data class ActivitySession( val startTransitionId: Int, val isManualOverride: Boolean, val stepCount: Int = 0, -) +) { + companion object { + /** Default session duration used for new/ongoing sessions (30 minutes). */ + const val DEFAULT_DURATION_MS = 30 * 60_000L + } + + /** True when this session was created manually and has no backing transition. */ + val isNew: Boolean get() = startTransitionId == 0 + + /** Returns [endTime] or a default end time (start + 30 min) for ongoing sessions. */ + fun effectiveEndTime(): Long = endTime ?: (startTime + DEFAULT_DURATION_MS) +} /** * Builds a list of [ActivitySession]s from chronologically ordered [transitions]. diff --git a/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt b/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt index 7f70404..6f9d49d 100644 --- a/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt +++ b/app/src/main/java/com/podometer/domain/usecase/MergeSessionOverridesUseCase.kt @@ -3,6 +3,7 @@ package com.podometer.domain.usecase import com.podometer.data.db.ManualSessionOverride import com.podometer.data.db.SensorWindow +import com.podometer.data.db.sumStepsInRange import com.podometer.domain.model.ActivitySession import com.podometer.domain.model.ActivityState @@ -30,16 +31,13 @@ fun mergeSessionOverrides( // Convert overrides to ActivitySessions with step counts from sensor windows val overrideSessions = overrides.map { override -> - val steps = windows - .filter { it.timestamp in override.startTime until override.endTime } - .sumOf { it.stepCount } ActivitySession( activity = ActivityState.fromString(override.activity), startTime = override.startTime, endTime = override.endTime, startTransitionId = -override.id.toInt(), // negative to distinguish isManualOverride = true, - stepCount = steps, + stepCount = windows.sumStepsInRange(override.startTime, override.endTime), ) } diff --git a/app/src/main/java/com/podometer/domain/usecase/RecomputeActivitySessionsUseCase.kt b/app/src/main/java/com/podometer/domain/usecase/RecomputeActivitySessionsUseCase.kt index 1f7e551..d486211 100644 --- a/app/src/main/java/com/podometer/domain/usecase/RecomputeActivitySessionsUseCase.kt +++ b/app/src/main/java/com/podometer/domain/usecase/RecomputeActivitySessionsUseCase.kt @@ -2,6 +2,7 @@ package com.podometer.domain.usecase import com.podometer.data.db.SensorWindow +import com.podometer.data.db.sumStepsInRange import com.podometer.data.repository.SensorWindowRepository import com.podometer.data.sensor.AccelerometerSampleBuffer import com.podometer.data.sensor.CyclingClassifier @@ -109,10 +110,7 @@ class RecomputeActivitySessionsUseCaseImpl @Inject constructor( if (sessions.isEmpty() || windows.isEmpty()) return sessions return sessions.map { session -> val endTime = session.endTime ?: Long.MAX_VALUE - val steps = windows - .filter { it.timestamp in session.startTime until endTime } - .sumOf { it.stepCount } - session.copy(stepCount = steps) + session.copy(stepCount = windows.sumStepsInRange(session.startTime, endTime)) } } } 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 55b48dd..7d6e7ee 100644 --- a/app/src/main/java/com/podometer/ui/activities/ActivitiesScreen.kt +++ b/app/src/main/java/com/podometer/ui/activities/ActivitiesScreen.kt @@ -83,7 +83,7 @@ fun ActivitiesScreen( editingSession = ActivitySession( activity = ActivityState.WALKING, startTime = noon, - endTime = noon + 30 * 60_000L, + endTime = noon + ActivitySession.DEFAULT_DURATION_MS, startTransitionId = 0, isManualOverride = false, ) @@ -224,13 +224,13 @@ fun ActivitiesScreen( val session = editingSession!! val dayStartMillis = DateTimeUtils.startOfDayMillis(uiState.selectedDate) val dayEndMillis = dayStartMillis + 86_400_000L - val isNew = session.startTransitionId == 0 + val closeEditSheet = { + editingSession = null + highlightedSessionIndex = -1 + } ModalBottomSheet( - onDismissRequest = { - editingSession = null - highlightedSessionIndex = -1 - }, + onDismissRequest = closeEditSheet, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { SessionEditSheet( @@ -245,28 +245,21 @@ fun ActivitiesScreen( 0L } viewModel.saveSessionOverride(startMs, endMs, activity, overrideId) - editingSession = null - highlightedSessionIndex = -1 - }, - onCancel = { - editingSession = null - highlightedSessionIndex = -1 + closeEditSheet() }, - onDelete = if (!isNew) { + onCancel = closeEditSheet, + onDelete = if (!session.isNew) { { if (session.isManualOverride) { - // Delete the manual override directly viewModel.deleteSessionOverride(-session.startTransitionId.toLong()) } else { - // "Delete" a detected session by overriding it as STILL viewModel.saveSessionOverride( session.startTime, - session.endTime ?: (session.startTime + 30 * 60_000L), + session.effectiveEndTime(), ActivityState.STILL, ) } - editingSession = null - highlightedSessionIndex = -1 + closeEditSheet() } } else { null 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 11b13a0..941df91 100644 --- a/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt +++ b/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn @@ -67,10 +68,6 @@ class ActivitiesViewModel @Inject constructor( private val _selectedDate = MutableStateFlow(LocalDate.now()) private val _bucketSizeMs = MutableStateFlow(300_000L) - private val _useTestData = MutableStateFlow(false) - - /** The currently selected date. */ - val selectedDate: StateFlow = _selectedDate /** Returns the override date key, prefixed for test data to avoid leaking. */ private fun overrideDateKey(date: LocalDate, isTestData: Boolean): String { @@ -78,52 +75,45 @@ class ActivitiesViewModel @Inject constructor( return if (isTestData) "test:$dateStr" else dateStr } + /** The currently selected date. */ + val selectedDate: StateFlow = _selectedDate + /** Combined UI state emitted to the Activities screen. */ @OptIn(ExperimentalCoroutinesApi::class) val uiState: StateFlow = combine( _selectedDate, preferencesManager.useTestData(), ) { date, useTest -> date to useTest }.flatMapLatest { (date, useTestData) -> - _useTestData.value = useTestData val nowMillis = System.currentTimeMillis() val dateKey = overrideDateKey(date, useTestData) - if (useTestData) { - combine( - flowOf(TestDataGenerator.generateSessions(date)), - flowOf(TestDataGenerator.generateWindows(date)), - manualSessionOverrideDao.getOverridesForDate(dateKey), - _bucketSizeMs, - ) { generatedSessions, windows, overrides, bucketSizeMs -> - val sessions = mergeSessionOverrides(generatedSessions, overrides, windows) - ActivitiesUiState( - selectedDate = date, - sessions = sessions, - windows = windows, - bucketSizeMs = bucketSizeMs, - isToday = date == LocalDate.now(), - dateLabel = formatDateLabel(date), - isLoading = false, - ) - } + val sessionFlow = if (useTestData) { + flowOf(TestDataGenerator.generateSessions(date)) } else { - combine( - recomputeActivitySessions(date, nowMillis), - sensorWindowRepository.getWindowsForDay(date), - manualSessionOverrideDao.getOverridesForDate(dateKey), - _bucketSizeMs, - ) { recomputedSessions, windows, overrides, bucketSizeMs -> - val sessions = mergeSessionOverrides(recomputedSessions, overrides, windows) - ActivitiesUiState( - selectedDate = date, - sessions = sessions, - windows = windows, - bucketSizeMs = bucketSizeMs, - isToday = date == LocalDate.now(), - dateLabel = formatDateLabel(date), - isLoading = false, - ) - } + recomputeActivitySessions(date, nowMillis) + } + val windowFlow = if (useTestData) { + flowOf(TestDataGenerator.generateWindows(date)) + } else { + sensorWindowRepository.getWindowsForDay(date) + } + + combine( + sessionFlow, + windowFlow, + manualSessionOverrideDao.getOverridesForDate(dateKey), + _bucketSizeMs, + ) { sessions, windows, overrides, bucketSizeMs -> + val merged = mergeSessionOverrides(sessions, overrides, windows) + ActivitiesUiState( + selectedDate = date, + sessions = merged, + windows = windows, + bucketSizeMs = bucketSizeMs, + isToday = date == LocalDate.now(), + dateLabel = formatDateLabel(date), + isLoading = false, + ) } }.stateIn( scope = viewModelScope, @@ -144,7 +134,8 @@ class ActivitiesViewModel @Inject constructor( existingOverrideId: Long = 0, ) { viewModelScope.launch { - val dateKey = overrideDateKey(_selectedDate.value, _useTestData.value) + val useTestData = preferencesManager.useTestData().first() + val dateKey = overrideDateKey(_selectedDate.value, useTestData) if (existingOverrideId > 0) { manualSessionOverrideDao.update( ManualSessionOverride( diff --git a/app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt b/app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt index f39352b..f28a1e6 100644 --- a/app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt +++ b/app/src/main/java/com/podometer/ui/activities/SessionEditSheet.kt @@ -27,7 +27,6 @@ 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 @@ -36,8 +35,8 @@ 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.formatActivityTime -import com.podometer.ui.theme.ActivityColors import com.podometer.ui.theme.LocalActivityColors import com.podometer.ui.theme.PodometerTheme @@ -77,9 +76,8 @@ fun SessionEditSheet( onCancel: () -> Unit, onDelete: (() -> Unit)? = null, ) { - val isNew = session.startTransitionId == 0 val paddingMs = SESSION_PADDING_MINUTES * 60_000L - val sessionEnd = session.endTime ?: (session.startTime + 30 * 60_000L) + val sessionEnd = session.effectiveEndTime() val viewStart = (session.startTime - paddingMs).coerceAtLeast(dayStartMillis) val viewEnd = (sessionEnd + paddingMs).coerceAtMost(dayEndMillis) val viewDuration = (viewEnd - viewStart).toFloat() @@ -116,7 +114,7 @@ fun SessionEditSheet( .padding(bottom = 24.dp), ) { Text( - text = if (isNew) "New Activity" else "Edit Activity", + text = if (session.isNew) "New Activity" else "Edit Activity", style = MaterialTheme.typography.titleMedium, ) @@ -169,7 +167,7 @@ fun SessionEditSheet( val chartHeight = size.height // Draw selected region background - val regionColor = selectedActivity.regionColor(activityColors) + val regionColor = activityColors.colorFor(selectedActivity) val x1 = startFraction * chartWidth val x2 = endFraction * chartWidth drawRect( @@ -248,15 +246,7 @@ fun SessionEditSheet( FilterChip( selected = selectedActivity == activity, onClick = { selectedActivity = activity }, - label = { - Text( - when (activity) { - ActivityState.WALKING -> "Walking" - ActivityState.CYCLING -> "Cycling" - ActivityState.STILL -> "Still" - }, - ) - }, + label = { Text(activityLabel(activity)) }, ) } } @@ -295,14 +285,6 @@ fun SessionEditSheet( } } -/** - * 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 ──────────────────────────────────────────────────────────────── 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 9e493bc..e468c77 100644 --- a/app/src/main/java/com/podometer/ui/activities/StepGraph.kt +++ b/app/src/main/java/com/podometer/ui/activities/StepGraph.kt @@ -534,7 +534,7 @@ fun StepGraph( val x2 = fractionToX(region.endFraction).coerceIn(0f, chartWidth) if (x2 > x1) { drawRect( - color = region.activity.regionColor(activityColors), + color = activityColors.colorFor(region.activity), topLeft = Offset(x1, 0f), size = Size(x2 - x1, chartHeight), alpha = REGION_ALPHA, @@ -549,7 +549,7 @@ fun StepGraph( val x2 = fractionToX(region.endFraction).coerceIn(0f, chartWidth) if (x2 > x1) { drawRect( - color = region.activity.regionColor(activityColors), + color = activityColors.colorFor(region.activity), topLeft = Offset(x1, 0f), size = Size(x2 - x1, chartHeight), alpha = HIGHLIGHT_ALPHA, @@ -897,15 +897,6 @@ private fun StepGraphLegend( } } -/** - * 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 ───────────────────────────────────────────────────── /** diff --git a/app/src/main/java/com/podometer/ui/theme/Theme.kt b/app/src/main/java/com/podometer/ui/theme/Theme.kt index b5c499d..f534c73 100644 --- a/app/src/main/java/com/podometer/ui/theme/Theme.kt +++ b/app/src/main/java/com/podometer/ui/theme/Theme.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import com.podometer.domain.model.ActivityState // ─── Activity colour container ──────────────────────────────────────────────── @@ -33,7 +34,14 @@ data class ActivityColors( val cycling: Color, val still: Color, val contentColor: Color = Color.White, -) +) { + /** Returns the background color for the given [ActivityState]. */ + fun colorFor(activity: ActivityState): Color = when (activity) { + ActivityState.WALKING -> walking + ActivityState.CYCLING -> cycling + ActivityState.STILL -> still + } +} /** * Default [ActivityColors] for the **light** theme. diff --git a/app/src/test/java/com/podometer/data/db/EntityTest.kt b/app/src/test/java/com/podometer/data/db/EntityTest.kt index ccd39ae..d6459bd 100644 --- a/app/src/test/java/com/podometer/data/db/EntityTest.kt +++ b/app/src/test/java/com/podometer/data/db/EntityTest.kt @@ -248,4 +248,37 @@ class EntityTest { val b = SensorWindow(id = 1, timestamp = 1000L, magnitudeVariance = 1.0, stepFrequencyHz = 0.0, stepCount = 0) assertEquals(a, b) } + + // ─── sumStepsInRange ───────────────────────────────────────────────────── + + private fun window(timestamp: Long, stepCount: Int) = SensorWindow( + timestamp = timestamp, + magnitudeVariance = 0.0, + stepFrequencyHz = 0.0, + stepCount = stepCount, + ) + + @Test + fun `sumStepsInRange sums windows within range`() { + val windows = listOf(window(100, 10), window(200, 20), window(300, 30)) + assertEquals(60, windows.sumStepsInRange(100, 400)) + } + + @Test + fun `sumStepsInRange includes start boundary and excludes end boundary`() { + val windows = listOf(window(100, 10), window(200, 20), window(300, 30)) + assertEquals(10, windows.sumStepsInRange(100, 200)) // only 100 included + assertEquals(20, windows.sumStepsInRange(200, 300)) // only 200 included + } + + @Test + fun `sumStepsInRange returns zero for empty list`() { + assertEquals(0, emptyList().sumStepsInRange(0, 1000)) + } + + @Test + fun `sumStepsInRange returns zero when no windows in range`() { + val windows = listOf(window(100, 10), window(200, 20)) + assertEquals(0, windows.sumStepsInRange(300, 400)) + } } diff --git a/app/src/test/java/com/podometer/domain/model/ActivitySessionTest.kt b/app/src/test/java/com/podometer/domain/model/ActivitySessionTest.kt index 5a25ec6..b6b7f38 100644 --- a/app/src/test/java/com/podometer/domain/model/ActivitySessionTest.kt +++ b/app/src/test/java/com/podometer/domain/model/ActivitySessionTest.kt @@ -189,4 +189,66 @@ class ActivitySessionTest { assertEquals(20_000L, result[1].startTime) assertNull(result[1].endTime) } + + // ─── isNew property ───────────────────────────────────────────────────── + + @Test + fun `isNew returns true when startTransitionId is zero`() { + val session = ActivitySession( + activity = ActivityState.WALKING, + startTime = 1000L, + endTime = 2000L, + startTransitionId = 0, + isManualOverride = false, + ) + assertTrue(session.isNew) + } + + @Test + fun `isNew returns false for detected or override sessions`() { + assertFalse( + ActivitySession( + activity = ActivityState.WALKING, + startTime = 1000L, + endTime = 2000L, + startTransitionId = 5, + isManualOverride = false, + ).isNew, + ) + assertFalse( + ActivitySession( + activity = ActivityState.CYCLING, + startTime = 1000L, + endTime = 2000L, + startTransitionId = -3, + isManualOverride = true, + ).isNew, + ) + } + + // ─── effectiveEndTime ─────────────────────────────────────────────────── + + @Test + fun `effectiveEndTime returns endTime when present`() { + val session = ActivitySession( + activity = ActivityState.WALKING, + startTime = 1000L, + endTime = 5000L, + startTransitionId = 1, + isManualOverride = false, + ) + assertEquals(5000L, session.effectiveEndTime()) + } + + @Test + fun `effectiveEndTime returns startTime plus 30 min when endTime is null`() { + val session = ActivitySession( + activity = ActivityState.WALKING, + startTime = 1000L, + endTime = null, + startTransitionId = 1, + isManualOverride = false, + ) + assertEquals(1000L + ActivitySession.DEFAULT_DURATION_MS, session.effectiveEndTime()) + } } diff --git a/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt b/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt index c0f21dc..da75126 100644 --- a/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt +++ b/app/src/test/java/com/podometer/domain/usecase/MergeSessionOverridesUseCaseTest.kt @@ -214,4 +214,69 @@ class MergeSessionOverridesUseCaseTest { assertEquals(1, result.size) assertEquals(0, result[0].stepCount) } + + @Test + fun `window at exact start boundary is included in step count`() { + val overrides = listOf( + override("WALKING", 9 * hour, 10 * hour), + ) + val windows = listOf( + window(9 * hour, 100), // at startTime, inclusive + ) + val result = mergeSessionOverrides(emptyList(), overrides, windows) + assertEquals(100, result[0].stepCount) + } + + @Test + fun `window at exact end boundary is excluded from step count`() { + val overrides = listOf( + override("WALKING", 9 * hour, 10 * hour), + ) + val windows = listOf( + window(10 * hour, 100), // at endTime, exclusive + ) + val result = mergeSessionOverrides(emptyList(), overrides, windows) + assertEquals(0, result[0].stepCount) + } + + @Test + fun `override replacing multiple sessions sums all windows in range`() { + val sessions = listOf( + session(ActivityState.WALKING, 9 * hour, 10 * hour, id = 1), + session(ActivityState.WALKING, 10 * hour, 11 * hour, id = 2), + ) + val overrides = listOf( + override("CYCLING", 9 * hour, 11 * hour), + ) + val windows = listOf( + window(9 * hour, 30), + window(10 * hour, 40), + window(10 * hour + 30_000L, 50), + ) + val result = mergeSessionOverrides(sessions, overrides, windows) + assertEquals(1, result.size) + assertEquals(120, result[0].stepCount) + } + + @Test + fun `non-overlapping sessions preserve their original step counts`() { + val sessions = listOf( + session(ActivityState.WALKING, 9 * hour, 10 * hour, id = 1) + .copy(stepCount = 500), + session(ActivityState.WALKING, 14 * hour, 15 * hour, id = 2) + .copy(stepCount = 300), + ) + val overrides = listOf( + override("CYCLING", 11 * hour, 12 * hour), + ) + val windows = listOf( + window(11 * hour, 25), + window(11 * hour + 30_000L, 35), + ) + val result = mergeSessionOverrides(sessions, overrides, windows) + assertEquals(3, result.size) + assertEquals(500, result[0].stepCount) // original preserved + assertEquals(60, result[1].stepCount) // override computed from windows + assertEquals(300, result[2].stepCount) // original preserved + } }