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 36a2a15..6f9d49d 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,8 @@ 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 @@ -9,22 +11,25 @@ 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 -> ActivitySession( activity = ActivityState.fromString(override.activity), @@ -32,6 +37,7 @@ fun mergeSessionOverrides( endTime = override.endTime, startTransitionId = -override.id.toInt(), // negative to distinguish isManualOverride = true, + stepCount = windows.sumStepsInRange(override.startTime, override.endTime), ) } @@ -44,6 +50,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/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 deb1bac..7d6e7ee 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 + ActivitySession.DEFAULT_DURATION_MS, + 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,13 +224,13 @@ fun ActivitiesScreen( val session = editingSession!! val dayStartMillis = DateTimeUtils.startOfDayMillis(uiState.selectedDate) val dayEndMillis = dayStartMillis + 86_400_000L - val isManualOverride = session.isManualOverride + val closeEditSheet = { + editingSession = null + highlightedSessionIndex = -1 + } ModalBottomSheet( - onDismissRequest = { - editingSession = null - highlightedSessionIndex = -1 - }, + onDismissRequest = closeEditSheet, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { SessionEditSheet( @@ -227,20 +239,27 @@ fun ActivitiesScreen( dayStartMillis = dayStartMillis, dayEndMillis = dayEndMillis, onSave = { startMs, endMs, activity -> - viewModel.saveSessionOverride(startMs, endMs, activity) - editingSession = null - highlightedSessionIndex = -1 - }, - onCancel = { - editingSession = null - highlightedSessionIndex = -1 + val overrideId = if (session.isManualOverride) { + -session.startTransitionId.toLong() + } else { + 0L + } + viewModel.saveSessionOverride(startMs, endMs, activity, overrideId) + closeEditSheet() }, - onDelete = if (isManualOverride) { + onCancel = closeEditSheet, + onDelete = if (!session.isNew) { { - // startTransitionId is negated override ID - viewModel.deleteSessionOverride(-session.startTransitionId.toLong()) - editingSession = null - highlightedSessionIndex = -1 + if (session.isManualOverride) { + viewModel.deleteSessionOverride(-session.startTransitionId.toLong()) + } else { + viewModel.saveSessionOverride( + session.startTime, + session.effectiveEndTime(), + ActivityState.STILL, + ) + } + 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 d24cc9a..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 @@ -68,6 +69,12 @@ class ActivitiesViewModel @Inject constructor( private val _selectedDate = MutableStateFlow(LocalDate.now()) private val _bucketSizeMs = MutableStateFlow(300_000L) + /** 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 + } + /** The currently selected date. */ val selectedDate: StateFlow = _selectedDate @@ -78,42 +85,35 @@ 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) + val dateKey = overrideDateKey(date, useTestData) - if (useTestData) { - combine( - flowOf(TestDataGenerator.generateSessions(date)), - flowOf(TestDataGenerator.generateWindows(date)), - _bucketSizeMs, - ) { sessions, windows, bucketSizeMs -> - ActivitiesUiState( - selectedDate = date, - sessions = sessions, - windows = windows, - bucketSizeMs = bucketSizeMs, - isToday = date == LocalDate.now(), - dateLabel = formatDateLabel(date), - isLoading = false, - ) - } + val sessionFlow = if (useTestData) { + flowOf(TestDataGenerator.generateSessions(date)) } else { - combine( - recomputeActivitySessions(date, nowMillis), - sensorWindowRepository.getWindowsForDay(date), - manualSessionOverrideDao.getOverridesForDate(dateStr), - _bucketSizeMs, - ) { recomputedSessions, windows, overrides, bucketSizeMs -> - val sessions = mergeSessionOverrides(recomputedSessions, overrides) - ActivitiesUiState( - selectedDate = date, - sessions = sessions, - windows = windows, - bucketSizeMs = bucketSizeMs, - isToday = date == LocalDate.now(), - dateLabel = formatDateLabel(date), - isLoading = false, - ) - } + 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, @@ -127,17 +127,35 @@ 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 useTestData = preferencesManager.useTestData().first() + val dateKey = overrideDateKey(_selectedDate.value, useTestData) + 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..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 @@ -54,18 +53,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( @@ -78,7 +77,7 @@ fun SessionEditSheet( onDelete: (() -> Unit)? = null, ) { 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() @@ -115,7 +114,7 @@ fun SessionEditSheet( .padding(bottom = 24.dp), ) { Text( - text = "Edit Activity", + text = if (session.isNew) "New Activity" else "Edit Activity", style = MaterialTheme.typography.titleMedium, ) @@ -168,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( @@ -247,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)) }, ) } } @@ -294,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/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/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 d060e98..da75126 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 @@ -136,4 +137,146 @@ 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) + } + + 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) + } + + @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 + } }