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
26 changes: 26 additions & 0 deletions app/src/main/java/com/podometer/data/db/ManualSessionOverride.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package com.podometer.data.db

import androidx.room.Entity
import androidx.room.PrimaryKey

/**
* Room entity representing a user-created or user-edited activity session override.
*
* Manual overrides take precedence over recomputed sessions from sensor windows.
* They allow the user to correct activity boundaries and reclassify activity types.
*
* @property id Auto-generated primary key.
* @property startTime Session start in epoch milliseconds.
* @property endTime Session end in epoch milliseconds.
* @property activity Activity type as string ("WALKING", "CYCLING", "STILL").
* @property date Date string in "yyyy-MM-dd" format for day-based queries.
*/
@Entity(tableName = "manual_session_overrides")
data class ManualSessionOverride(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val startTime: Long,
val endTime: Long,
val activity: String,
val date: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package com.podometer.data.db

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow

/**
* Data access object for [ManualSessionOverride] entities.
*/
@Dao
interface ManualSessionOverrideDao {

/** Inserts a new manual session override, returning the generated ID. */
@Insert
suspend fun insert(override: ManualSessionOverride): Long

/** Updates an existing manual session override. */
@Update
suspend fun update(override: ManualSessionOverride)

/** Deletes a manual session override by ID. */
@Query("DELETE FROM manual_session_overrides WHERE id = :id")
suspend fun deleteById(id: Long)

/** Returns all overrides for a given date string, ordered by start time. */
@Query("SELECT * FROM manual_session_overrides WHERE date = :date ORDER BY startTime")
fun getOverridesForDate(date: String): Flow<List<ManualSessionOverride>>
}
25 changes: 24 additions & 1 deletion app/src/main/java/com/podometer/data/db/PodometerDatabase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
DailySummary::class,
CyclingSession::class,
SensorWindow::class,
ManualSessionOverride::class,
],
version = 3,
version = 4,
exportSchema = false,
)
abstract class PodometerDatabase : RoomDatabase() {
Expand All @@ -38,6 +39,8 @@ abstract class PodometerDatabase : RoomDatabase() {

abstract fun sensorWindowDao(): SensorWindowDao

abstract fun manualSessionOverrideDao(): ManualSessionOverrideDao

companion object {
/**
* Migration from version 1 to 2: creates the `sensor_windows` table.
Expand Down Expand Up @@ -84,5 +87,25 @@ abstract class PodometerDatabase : RoomDatabase() {
)
}
}

/**
* Migration from version 3 to 4: creates the `manual_session_overrides`
* table for user-edited activity session boundaries.
*/
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"""
CREATE TABLE IF NOT EXISTS manual_session_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
startTime INTEGER NOT NULL,
endTime INTEGER NOT NULL,
activity TEXT NOT NULL,
date TEXT NOT NULL
)
""".trimIndent(),
)
}
}
}
}
12 changes: 11 additions & 1 deletion app/src/main/java/com/podometer/di/DatabaseModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.content.Context
import androidx.room.Room
import com.podometer.data.db.ActivityTransitionDao
import com.podometer.data.db.CyclingSessionDao
import com.podometer.data.db.ManualSessionOverrideDao
import com.podometer.data.db.PodometerDatabase
import com.podometer.data.db.SensorWindowDao
import com.podometer.data.db.StepDao
Expand Down Expand Up @@ -35,7 +36,11 @@ object DatabaseModule {
PodometerDatabase::class.java,
"podometer.db",
)
.addMigrations(PodometerDatabase.MIGRATION_1_2, PodometerDatabase.MIGRATION_2_3)
.addMigrations(
PodometerDatabase.MIGRATION_1_2,
PodometerDatabase.MIGRATION_2_3,
PodometerDatabase.MIGRATION_3_4,
)
.build()

@Provides
Expand All @@ -57,4 +62,9 @@ object DatabaseModule {
@Singleton
fun provideSensorWindowDao(database: PodometerDatabase): SensorWindowDao =
database.sensorWindowDao()

@Provides
@Singleton
fun provideManualSessionOverrideDao(database: PodometerDatabase): ManualSessionOverrideDao =
database.manualSessionOverrideDao()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package com.podometer.domain.usecase

import com.podometer.data.db.ManualSessionOverride
import com.podometer.domain.model.ActivitySession
import com.podometer.domain.model.ActivityState

/**
* Merges recomputed activity sessions with manual session overrides.
*
* Manual overrides take precedence: any recomputed session whose time range
* overlaps with a manual override is replaced. The result is a flat list
* of sessions sorted by start time.
*
* This is a pure function with no side effects, suitable for unit testing.
*
* @param recomputed Recomputed sessions from sensor window replay.
* @param overrides User-created manual session overrides.
* @return Merged sessions sorted by start time.
*/
fun mergeSessionOverrides(
recomputed: List<ActivitySession>,
overrides: List<ManualSessionOverride>,
): List<ActivitySession> {
if (overrides.isEmpty()) return recomputed

// Convert overrides to ActivitySessions
val overrideSessions = overrides.map { override ->
ActivitySession(
activity = ActivityState.fromString(override.activity),
startTime = override.startTime,
endTime = override.endTime,
startTransitionId = -override.id.toInt(), // negative to distinguish
isManualOverride = true,
)
}

// Remove recomputed sessions that overlap with any override
val filteredRecomputed = recomputed.filter { session ->
val sessionEnd = session.endTime ?: Long.MAX_VALUE
overrides.none { override ->
// Overlap: session and override share any time
session.startTime < override.endTime && sessionEnd > override.startTime
}
}

// Merge and sort by start time
return (filteredRecomputed + overrideSessions).sortedBy { it.startTime }
}
156 changes: 96 additions & 60 deletions app/src/main/java/com/podometer/ui/activities/ActivitiesScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@ import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
Expand All @@ -35,10 +38,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.podometer.R
import com.podometer.domain.model.TransitionEvent
import com.podometer.ui.dashboard.ActivityLog
import com.podometer.ui.dashboard.ActivityTimeline
import com.podometer.ui.dashboard.buildTimelineSegments
import com.podometer.util.DateTimeUtils
import java.time.Instant
import java.time.LocalDate
Expand All @@ -62,6 +62,8 @@ fun ActivitiesScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var showDatePicker by remember { mutableStateOf(false) }
var highlightedSessionIndex by remember { mutableIntStateOf(-1) }
var editingSession by remember { mutableStateOf<com.podometer.domain.model.ActivitySession?>(null) }

Scaffold(
topBar = {
Expand Down Expand Up @@ -101,39 +103,68 @@ fun ActivitiesScreen(

Spacer(modifier = Modifier.height(16.dp))

if (uiState.sessions.isEmpty()) {
Text(
text = stringResource(R.string.activities_no_data),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
} else {
// Activity Timeline
val dayStartMillis = DateTimeUtils.startOfDayMillis(uiState.selectedDate)
val dayEndMillis = dayStartMillis + 86_400_000L
val nowMillis = System.currentTimeMillis()

// Build transitions from sessions for the timeline.
// The recomputed sessions already have correct timestamps.
val transitions = sessionsToTransitions(uiState.sessions)

ActivityTimeline(
segments = buildTimelineSegments(
transitions = transitions,
// Step Graph (shown even when sessions are empty, as long as windows exist)
val dayStartMillis = DateTimeUtils.startOfDayMillis(uiState.selectedDate)
val dayEndMillis = dayStartMillis + 86_400_000L
val nowMillis = System.currentTimeMillis()

if (uiState.windows.isNotEmpty()) {
val graphData = remember(uiState.windows, uiState.sessions, uiState.bucketSizeMs) {
buildStepGraphData(
windows = uiState.windows,
sessions = uiState.sessions,
bucketSizeMs = uiState.bucketSizeMs,
dayStartMillis = dayStartMillis,
dayEndMillis = dayEndMillis,
nowMillis = if (uiState.isToday) nowMillis else dayEndMillis,
),
)
}

StepGraph(
graphData = graphData,
sessions = uiState.sessions,
dayStartMillis = dayStartMillis,
dayEndMillis = dayEndMillis,
highlightedSessionIndex = highlightedSessionIndex,
onSessionHighlight = { index ->
highlightedSessionIndex = if (highlightedSessionIndex == index) -1 else index
},
modifier = Modifier.fillMaxWidth(),
)

Spacer(modifier = Modifier.height(8.dp))

BucketSizeSelector(
selectedMs = uiState.bucketSizeMs,
onBucketSelected = { viewModel.setBucketSize(it) },
)

Spacer(modifier = Modifier.height(16.dp))
}

// Activity Log (read-only on past days — override is disabled)
if (uiState.sessions.isEmpty() && uiState.windows.isEmpty()) {
Text(
text = stringResource(R.string.activities_no_data),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp),
)
}

if (uiState.sessions.isNotEmpty()) {
ActivityLog(
sessions = uiState.sessions,
onOverride = { _, _ -> }, // overrides not supported on recomputed data
onOverride = { transitionId, _ ->
// Open edit sheet for the tapped session
val session = uiState.sessions.find {
it.startTransitionId == transitionId
}
if (session != null && uiState.windows.isNotEmpty()) {
editingSession = session
// Also highlight the session on the graph
val idx = uiState.sessions.indexOf(session)
highlightedSessionIndex = idx
}
},
snackbarHostState = snackbarHostState,
onUndo = {},
nowMillis = if (uiState.isToday) nowMillis else dayEndMillis,
Expand Down Expand Up @@ -175,41 +206,46 @@ fun ActivitiesScreen(
DatePicker(state = datePickerState)
}
}
}

/**
* Converts a list of [com.podometer.domain.model.ActivitySession]s into
* [TransitionEvent]s suitable for [buildTimelineSegments].
*
* Each session produces a start transition (STILL → activity) and, if closed,
* an end transition (activity → STILL).
*/
private fun sessionsToTransitions(
sessions: List<com.podometer.domain.model.ActivitySession>,
): List<TransitionEvent> {
val transitions = mutableListOf<TransitionEvent>()
var id = 1
for (session in sessions) {
transitions.add(
TransitionEvent(
id = id++,
timestamp = session.startTime,
fromActivity = com.podometer.domain.model.ActivityState.STILL,
toActivity = session.activity,
isManualOverride = false,
),
)
if (session.endTime != null) {
transitions.add(
TransitionEvent(
id = id++,
timestamp = session.endTime,
fromActivity = session.activity,
toActivity = com.podometer.domain.model.ActivityState.STILL,
isManualOverride = false,
),
// Session edit bottom sheet
if (editingSession != null) {
val session = editingSession!!
val dayStartMillis = DateTimeUtils.startOfDayMillis(uiState.selectedDate)
val dayEndMillis = dayStartMillis + 86_400_000L
val isManualOverride = session.isManualOverride

ModalBottomSheet(
onDismissRequest = {
editingSession = null
highlightedSessionIndex = -1
},
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
SessionEditSheet(
session = session,
windows = uiState.windows,
dayStartMillis = dayStartMillis,
dayEndMillis = dayEndMillis,
onSave = { startMs, endMs, activity ->
viewModel.saveSessionOverride(startMs, endMs, activity)
editingSession = null
highlightedSessionIndex = -1
},
onCancel = {
editingSession = null
highlightedSessionIndex = -1
},
onDelete = if (isManualOverride) {
{
// startTransitionId is negated override ID
viewModel.deleteSessionOverride(-session.startTransitionId.toLong())
editingSession = null
highlightedSessionIndex = -1
}
} else {
null
},
)
}
}
return transitions
}
Loading
Loading