Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions app/src/main/java/com/podometer/data/db/SensorWindow.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<SensorWindow>.sumStepsInRange(startTime: Long, endTime: Long): Int =
filter { it.timestamp in startTime until endTime }.sumOf { it.stepCount }
13 changes: 12 additions & 1 deletion app/src/main/java/com/podometer/domain/model/ActivitySession.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,42 @@
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

/**
* 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<ActivitySession>,
overrides: List<ManualSessionOverride>,
windows: List<SensorWindow> = emptyList(),
): List<ActivitySession> {
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),
startTime = override.startTime,
endTime = override.endTime,
startTransitionId = -override.id.toInt(), // negative to distinguish
isManualOverride = true,
stepCount = windows.sumStepsInRange(override.startTime, override.endTime),
)
}

Expand All @@ -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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}
}
Expand Down
89 changes: 54 additions & 35 deletions app/src/main/java/com/podometer/ui/activities/ActivitiesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -60,18 +64,35 @@ 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<com.podometer.domain.model.ActivitySession?>(null) }
var editingSession by remember { mutableStateOf<ActivitySession?>(null) }

Scaffold(
topBar = {
TopAppBar(
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) {
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
Loading