From e584f96cb90df2ae55cc2b2165cbc786dd9c70ac Mon Sep 17 00:00:00 2001 From: Jan Jetze Date: Fri, 6 Mar 2026 21:09:58 +0100 Subject: [PATCH] feat: add developer toggle for test data in debug builds Adds a "Use test data" switch in Settings > Developer (debug only) that replaces real sensor data with generated test data on both the Dashboard and Activities screens. Each date produces a unique pattern using date-seeded randomization with 7 day-type archetypes. Co-Authored-By: Claude Opus 4.6 --- app/build.gradle.kts | 1 + .../main/java/com/podometer/MainActivity.kt | 1 + .../data/repository/PreferencesManager.kt | 21 ++ .../ui/activities/ActivitiesViewModel.kt | 44 ++- .../ui/activities/TestDataGenerator.kt | 305 ++++++++++++++++++ .../ui/dashboard/DashboardViewModel.kt | 76 +++-- .../podometer/ui/settings/SettingsScreen.kt | 15 + .../ui/settings/SettingsViewModel.kt | 24 +- .../ui/activities/ActivitiesViewModelTest.kt | 17 + .../ui/dashboard/DashboardViewModelTest.kt | 18 ++ 10 files changed, 487 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/com/podometer/ui/activities/TestDataGenerator.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 95010a1..9343009 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,7 @@ android { buildFeatures { compose = true + buildConfig = true } testOptions { diff --git a/app/src/main/java/com/podometer/MainActivity.kt b/app/src/main/java/com/podometer/MainActivity.kt index 3379951..bb76587 100644 --- a/app/src/main/java/com/podometer/MainActivity.kt +++ b/app/src/main/java/com/podometer/MainActivity.kt @@ -158,6 +158,7 @@ class MainActivity : ComponentActivity() { ) startActivity(intent) }, + onSetUseTestData = viewModel::setUseTestData, ) } diff --git a/app/src/main/java/com/podometer/data/repository/PreferencesManager.kt b/app/src/main/java/com/podometer/data/repository/PreferencesManager.kt index 06ea045..fcc1a78 100644 --- a/app/src/main/java/com/podometer/data/repository/PreferencesManager.kt +++ b/app/src/main/java/com/podometer/data/repository/PreferencesManager.kt @@ -60,6 +60,11 @@ class PreferencesManager @Inject constructor( /** Default onboarding complete: false (show onboarding on first launch). */ const val DEFAULT_ONBOARDING_COMPLETE = false + + val KEY_USE_TEST_DATA = booleanPreferencesKey("use_test_data") + + /** Default use-test-data: false (use real sensor data). */ + const val DEFAULT_USE_TEST_DATA = false } // ─── Read ──────────────────────────────────────────────────────────────── @@ -101,6 +106,15 @@ class PreferencesManager @Inject constructor( prefs[KEY_NOTIFICATION_STYLE] ?: DEFAULT_NOTIFICATION_STYLE } + /** + * Emits whether the debug test-data mode is enabled. + * Defaults to `false` (use real sensor data). + */ + fun useTestData(): Flow = + dataStore.data.map { prefs -> + prefs[KEY_USE_TEST_DATA] ?: DEFAULT_USE_TEST_DATA + } + /** * Emits whether the user has completed the onboarding flow. * Defaults to `false` on first launch so the onboarding screen is shown. @@ -163,6 +177,13 @@ class PreferencesManager @Inject constructor( } } + /** Persists the given [enabled] flag for the debug test-data mode. */ + suspend fun setUseTestData(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_USE_TEST_DATA] = enabled + } + } + /** * Persists the given [complete] flag as the onboarding completion status. * 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 a2dff98..849c22b 100644 --- a/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt +++ b/app/src/main/java/com/podometer/ui/activities/ActivitiesViewModel.kt @@ -3,6 +3,7 @@ package com.podometer.ui.activities import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.podometer.data.repository.PreferencesManager import com.podometer.domain.model.ActivitySession import com.podometer.domain.model.TransitionEvent import com.podometer.domain.usecase.RecomputeActivitySessionsUseCase @@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import java.time.LocalDate import javax.inject.Inject @@ -43,6 +45,7 @@ data class ActivitiesUiState( @HiltViewModel class ActivitiesViewModel @Inject constructor( private val recomputeActivitySessions: RecomputeActivitySessionsUseCase, + private val preferencesManager: PreferencesManager, ) : ViewModel() { companion object { @@ -56,18 +59,37 @@ class ActivitiesViewModel @Inject constructor( /** Combined UI state emitted to the Activities screen. */ @OptIn(ExperimentalCoroutinesApi::class) - val uiState: StateFlow = _selectedDate.flatMapLatest { date -> + val uiState: StateFlow = combine( + _selectedDate, + preferencesManager.useTestData(), + ) { date, useTest -> date to useTest }.flatMapLatest { (date, useTestData) -> val nowMillis = System.currentTimeMillis() - recomputeActivitySessions(date, nowMillis).combine( - MutableStateFlow(date), - ) { sessions, selectedDate -> - ActivitiesUiState( - selectedDate = selectedDate, - sessions = sessions, - isToday = selectedDate == LocalDate.now(), - dateLabel = formatDateLabel(selectedDate), - isLoading = false, - ) + + if (useTestData) { + combine( + flowOf(TestDataGenerator.generateSessions(date)), + flowOf(Unit), + ) { sessions, _ -> + ActivitiesUiState( + selectedDate = date, + sessions = sessions, + isToday = date == LocalDate.now(), + dateLabel = formatDateLabel(date), + isLoading = false, + ) + } + } else { + recomputeActivitySessions(date, nowMillis).combine( + MutableStateFlow(date), + ) { sessions, selectedDate -> + ActivitiesUiState( + selectedDate = selectedDate, + sessions = sessions, + isToday = selectedDate == LocalDate.now(), + dateLabel = formatDateLabel(selectedDate), + isLoading = false, + ) + } } }.stateIn( scope = viewModelScope, diff --git a/app/src/main/java/com/podometer/ui/activities/TestDataGenerator.kt b/app/src/main/java/com/podometer/ui/activities/TestDataGenerator.kt new file mode 100644 index 0000000..72b993e --- /dev/null +++ b/app/src/main/java/com/podometer/ui/activities/TestDataGenerator.kt @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.podometer.ui.activities + +import com.podometer.data.db.CyclingSession +import com.podometer.data.db.SensorWindow +import com.podometer.domain.model.ActivitySession +import com.podometer.domain.model.ActivityState +import com.podometer.domain.model.DaySummary +import com.podometer.domain.model.StepData +import com.podometer.domain.model.TransitionEvent +import com.podometer.util.DateTimeUtils +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import kotlin.random.Random + +/** + * Generates realistic fake sensor windows and activity sessions for a given date. + * + * Used in debug builds to visualize the step graph without needing real sensor data. + * Each date produces a unique but deterministic pattern — the same date always + * generates the same data, while different dates look visibly different. + */ +object TestDataGenerator { + + private const val WINDOW_DURATION_MS = 30_000L + + /** + * Returns a deterministic [Random] seeded from the given [date]. + * + * Ensures repeatable output per date while varying across days. + */ + private fun seededRandom(date: LocalDate): Random = + Random(date.toEpochDay()) + + /** + * A template for one activity block within a day. + * + * @property startHour Hour-of-day (fractional) when the block starts. + * @property durationHours Duration in hours. + * @property activity The activity type for this block. + * @property stepsPerWindow Approximate steps per 30-second window (0 for cycling). + */ + private data class ActivityBlock( + val startHour: Double, + val durationHours: Double, + val activity: ActivityState, + val stepsPerWindow: Int, + ) + + /** + * Generates a date-specific schedule of activity blocks. + * + * The number and timing of activities vary by day-of-week and a date seed. + * Possible patterns: commute days (cycling + walks), active days (many walks), + * rest days (few short walks), long-run days (one extended walk). + */ + private fun generateSchedule(date: LocalDate, rng: Random): List { + val dayType = (date.toEpochDay() % 7).toInt().let { if (it < 0) it + 7 else it } + + // Pool of possible activity blocks with jittered start times + fun jitter(base: Double): Double = base + rng.nextDouble(-0.25, 0.25) + fun walkDuration(): Double = 0.25 + rng.nextDouble() * 0.75 // 15–60 min + fun cycleDuration(): Double = 0.5 + rng.nextDouble() * 0.5 // 30–60 min + fun walkIntensity(): Int = 25 + rng.nextInt(30) // 25–54 steps per window + + return when (dayType) { + // Commute day: cycling morning + evening, short walks + 0 -> listOf( + ActivityBlock(jitter(7.5), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(9.0), cycleDuration(), ActivityState.CYCLING, 0), + ActivityBlock(jitter(12.5), walkDuration() * 0.6, ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(17.5), cycleDuration(), ActivityState.CYCLING, 0), + ActivityBlock(jitter(19.5), walkDuration() * 0.5, ActivityState.WALKING, walkIntensity()), + ) + // Active day: many walks throughout the day + 1 -> listOf( + ActivityBlock(jitter(6.5), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(9.0), walkDuration() * 0.4, ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(11.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(14.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(16.5), walkDuration() * 0.5, ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(19.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ) + // Rest day: just a couple short walks + 2 -> listOf( + ActivityBlock(jitter(10.0), walkDuration() * 0.5, ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(16.0), walkDuration() * 0.5, ActivityState.WALKING, walkIntensity()), + ) + // Long run day: one extended walk + short evening walk + 3 -> listOf( + ActivityBlock(jitter(8.0), 1.0 + rng.nextDouble() * 0.5, ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(18.0), walkDuration() * 0.4, ActivityState.WALKING, walkIntensity()), + ) + // Mixed day: walk + cycle + walk + 4 -> listOf( + ActivityBlock(jitter(7.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(10.5), cycleDuration(), ActivityState.CYCLING, 0), + ActivityBlock(jitter(13.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(17.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ) + // Afternoon-heavy day: quiet morning, active afternoon + 5 -> listOf( + ActivityBlock(jitter(12.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(14.5), cycleDuration(), ActivityState.CYCLING, 0), + ActivityBlock(jitter(16.5), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(19.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ) + // Morning-heavy day: early start, quiet evening + else -> listOf( + ActivityBlock(jitter(6.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(8.0), cycleDuration(), ActivityState.CYCLING, 0), + ActivityBlock(jitter(10.0), walkDuration(), ActivityState.WALKING, walkIntensity()), + ActivityBlock(jitter(12.5), walkDuration() * 0.5, ActivityState.WALKING, walkIntensity()), + ) + }.map { block -> + // Clamp start hours to valid range + block.copy(startHour = block.startHour.coerceIn(6.0, 21.0)) + }.sortedBy { it.startHour } + } + + /** + * Generates fake [SensorWindow]s for the given [date]. + * + * Step counts vary based on the date-specific activity schedule. + * + * @param date The date to generate windows for. + * @return Chronologically ordered list of sensor windows. + */ + fun generateWindows(date: LocalDate): List { + val rng = seededRandom(date) + val schedule = generateSchedule(date, seededRandom(date)) + val dayStart = DateTimeUtils.startOfDayMillis(date) + val windows = mutableListOf() + var id = 1L + + val startMs = dayStart + 7 * 3_600_000L + val endMs = dayStart + 22 * 3_600_000L + + var ts = startMs + while (ts < endMs) { + val hourFraction = (ts - dayStart).toDouble() / 3_600_000.0 + val activeBlock = schedule.firstOrNull { block -> + hourFraction >= block.startHour && + hourFraction < block.startHour + block.durationHours + } + + val steps = when { + activeBlock != null && activeBlock.activity == ActivityState.WALKING -> { + val base = activeBlock.stepsPerWindow + (base + rng.nextInt(-8, 9)).coerceAtLeast(5) + } + activeBlock != null && activeBlock.activity == ActivityState.CYCLING -> { + rng.nextInt(0, 3) + } + // Random idle steps + else -> if (rng.nextInt(10) < 2) rng.nextInt(1, 6) else 0 + } + + val variance = if (steps > 0) 2.0 + steps * 0.5 else 0.1 + val frequency = if (steps > 0) 1.5 + steps * 0.1 else 0.0 + + windows.add( + SensorWindow( + id = id++, + timestamp = ts, + magnitudeVariance = variance, + stepFrequencyHz = frequency, + stepCount = steps, + ), + ) + ts += WINDOW_DURATION_MS + } + + return windows + } + + /** + * Generates fake [ActivitySession]s for the given [date]. + * + * The number, timing, and type of sessions vary per date. + * + * @param date The date to generate sessions for. + * @return Chronologically ordered list of activity sessions. + */ + fun generateSessions(date: LocalDate): List { + val rng = seededRandom(date) + val schedule = generateSchedule(date, seededRandom(date)) + val dayStart = DateTimeUtils.startOfDayMillis(date) + fun h(hour: Double): Long = dayStart + (hour * 3_600_000).toLong() + + return schedule.mapIndexed { index, block -> + val steps = if (block.activity == ActivityState.WALKING) { + (block.stepsPerWindow * block.durationHours * 120).toInt() + } else { + 0 + } + ActivitySession( + activity = block.activity, + startTime = h(block.startHour), + endTime = h(block.startHour + block.durationHours), + startTransitionId = index + 1, + isManualOverride = false, + stepCount = steps, + ) + } + } + + /** + * Generates fake [StepData] for the dashboard today card. + * + * Uses today's date to vary the step count. + * + * @param goal The user's daily step goal. + * @param strideKm The user's stride length in km. + * @return A [StepData] with date-varying step progress. + */ + fun generateTodaySteps(goal: Int = 10_000, strideKm: Float = 0.00075f): StepData { + val rng = seededRandom(LocalDate.now()) + val steps = 2_000 + rng.nextInt(8_000) // 2,000–10,000 + return StepData( + steps = steps, + goal = goal, + progressPercent = steps.toFloat() / goal * 100f, + distanceKm = steps * strideKm, + ) + } + + /** + * Generates fake [TransitionEvent]s for the dashboard transition log. + * + * Derived from the same date-specific schedule as [generateSessions]. + * + * @param date The date to generate transitions for. + * @return Chronologically ordered list of transition events. + */ + fun generateTransitions(date: LocalDate): List { + val schedule = generateSchedule(date, seededRandom(date)) + val dayStart = DateTimeUtils.startOfDayMillis(date) + fun h(hour: Double): Long = dayStart + (hour * 3_600_000).toLong() + + val transitions = mutableListOf() + var id = 1 + for (block in schedule) { + transitions.add( + TransitionEvent(id++, h(block.startHour), ActivityState.STILL, block.activity, false), + ) + transitions.add( + TransitionEvent(id++, h(block.startHour + block.durationHours), block.activity, ActivityState.STILL, false), + ) + } + return transitions + } + + /** + * Generates fake [DaySummary] entries for the weekly step chart. + * + * Each day of the week gets a different step count derived from its date seed. + */ + fun generateWeeklySummaries(): List { + val today = LocalDate.now() + val startOfWeek = today.minusDays((today.dayOfWeek.value - 1).toLong()) + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + + return (0..today.dayOfWeek.value - 1).map { i -> + val day = startOfWeek.plusDays(i.toLong()) + val rng = seededRandom(day) + val steps = 3_000 + rng.nextInt(9_000) // 3,000–12,000 + val hasCycling = generateSchedule(day, seededRandom(day)).any { + it.activity == ActivityState.CYCLING + } + DaySummary( + date = day.format(formatter), + totalSteps = steps, + totalDistanceKm = steps * 0.00075f, + walkingMinutes = steps / 100, + cyclingMinutes = if (hasCycling) 30 + rng.nextInt(60) else 0, + ) + } + } + + /** + * Generates fake [CyclingSession]s for the dashboard cycling section. + * + * Only produces sessions for dates whose schedule includes cycling blocks. + * + * @param date The date to generate sessions for. + * @return Cycling sessions derived from the date-specific schedule. + */ + fun generateCyclingSessions(date: LocalDate): List { + val schedule = generateSchedule(date, seededRandom(date)) + val dayStart = DateTimeUtils.startOfDayMillis(date) + fun h(hour: Double): Long = dayStart + (hour * 3_600_000).toLong() + + return schedule + .filter { it.activity == ActivityState.CYCLING } + .mapIndexed { index, block -> + CyclingSession( + id = index + 1, + startTime = h(block.startHour), + endTime = h(block.startHour + block.durationHours), + durationMinutes = (block.durationHours * 60).toInt(), + ) + } + } +} diff --git a/app/src/main/java/com/podometer/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/podometer/ui/dashboard/DashboardViewModel.kt index 3cd3d65..f310ade 100644 --- a/app/src/main/java/com/podometer/ui/dashboard/DashboardViewModel.kt +++ b/app/src/main/java/com/podometer/ui/dashboard/DashboardViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.podometer.data.db.ActivityTransition import com.podometer.data.db.CyclingSession +import com.podometer.data.repository.PreferencesManager import com.podometer.domain.model.ActivityState import com.podometer.domain.model.DaySummary import com.podometer.domain.model.TransitionEvent @@ -14,14 +15,19 @@ import com.podometer.domain.usecase.GetTodayStepsUseCase import com.podometer.domain.usecase.GetTodayTransitionsUseCase import com.podometer.domain.usecase.GetWeeklyStepsUseCase import com.podometer.domain.usecase.OverrideActivityUseCase +import com.podometer.ui.activities.TestDataGenerator import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.LocalDate import javax.inject.Inject /** @@ -84,6 +90,7 @@ class DashboardViewModel @Inject constructor( getTodayTransitions: GetTodayTransitionsUseCase, getTodayCyclingSessions: GetTodayCyclingSessionsUseCase, private val overrideActivityUseCase: OverrideActivityUseCase, + preferencesManager: PreferencesManager, ) : ViewModel() { companion object { @@ -95,26 +102,55 @@ class DashboardViewModel @Inject constructor( private val _permissionsDenied = MutableStateFlow(false) /** Combined UI state emitted to the Dashboard Compose screen. */ - val uiState: StateFlow = combine( - getTodaySteps(), - getWeeklySteps(), - getTodayTransitions(), - getTodayCyclingSessions(), - _permissionsDenied, - ) { stepData, weekly, transitions, cycling, permissionsDenied -> - DashboardUiState( - todaySteps = stepData.steps, - dailyGoal = stepData.goal, - progressPercent = stepData.progressPercent, - distanceKm = stepData.distanceKm, - currentActivity = transitions.lastOrNull()?.toActivity ?: ActivityState.STILL, - transitions = transitions, - weeklySteps = weekly, - cyclingSessions = cycling, - isLoading = false, - permissionsDenied = permissionsDenied, - ) - }.stateIn( + @OptIn(ExperimentalCoroutinesApi::class) + val uiState: StateFlow = preferencesManager.useTestData() + .flatMapLatest { useTestData -> + if (useTestData) { + val today = LocalDate.now() + val stepData = TestDataGenerator.generateTodaySteps() + val transitions = TestDataGenerator.generateTransitions(today) + val weekly = TestDataGenerator.generateWeeklySummaries() + val cycling = TestDataGenerator.generateCyclingSessions(today) + combine( + flowOf(Unit), + _permissionsDenied, + ) { _, permissionsDenied -> + DashboardUiState( + todaySteps = stepData.steps, + dailyGoal = stepData.goal, + progressPercent = stepData.progressPercent, + distanceKm = stepData.distanceKm, + currentActivity = transitions.lastOrNull()?.toActivity ?: ActivityState.STILL, + transitions = transitions, + weeklySteps = weekly, + cyclingSessions = cycling, + isLoading = false, + permissionsDenied = permissionsDenied, + ) + } + } else { + combine( + getTodaySteps(), + getWeeklySteps(), + getTodayTransitions(), + getTodayCyclingSessions(), + _permissionsDenied, + ) { stepData, weekly, transitions, cycling, permissionsDenied -> + DashboardUiState( + todaySteps = stepData.steps, + dailyGoal = stepData.goal, + progressPercent = stepData.progressPercent, + distanceKm = stepData.distanceKm, + currentActivity = transitions.lastOrNull()?.toActivity ?: ActivityState.STILL, + transitions = transitions, + weeklySteps = weekly, + cyclingSessions = cycling, + isLoading = false, + permissionsDenied = permissionsDenied, + ) + } + } + }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), initialValue = DashboardUiState(), diff --git a/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt b/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt index 6d9856f..1c32750 100644 --- a/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/podometer/ui/settings/SettingsScreen.kt @@ -52,6 +52,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.podometer.BuildConfig import com.podometer.R import com.podometer.ui.theme.PodometerTheme @@ -91,6 +92,7 @@ fun SettingsScreen( onResetExportState: () -> Unit, onNavigateToDonate: () -> Unit = {}, onOpenFeedbackUrl: () -> Unit = {}, + onSetUseTestData: (Boolean) -> Unit = {}, modifier: Modifier = Modifier, ) { val snackbarHostState = remember { SnackbarHostState() } @@ -306,6 +308,19 @@ fun SettingsScreen( onClick = onNavigateToDonate, ) + // ── Developer section (debug builds only) ─────────────────────── + if (BuildConfig.DEBUG) { + Spacer(modifier = Modifier.height(16.dp)) + SettingsSectionHeader(text = "Developer") + + SettingRowWithSwitch( + title = "Use test data", + description = "Replace real sensor data with generated test data for the step graph", + checked = uiState.useTestData, + onCheckedChange = onSetUseTestData, + ) + } + Spacer(modifier = Modifier.height(24.dp)) } } diff --git a/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt index 6eac780..a5b6e6c 100644 --- a/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/podometer/ui/settings/SettingsViewModel.kt @@ -55,6 +55,7 @@ data class SettingsUiState( val autoStartEnabled: Boolean = true, val notificationStyle: String = "minimal", val exportState: ExportState = ExportState.Idle, + val useTestData: Boolean = false, ) /** @@ -89,18 +90,22 @@ class SettingsViewModel @Inject constructor( * Starts with default values; updates reactively as preferences or export state change. */ val uiState: StateFlow = combine( - preferencesManager.dailyStepGoal(), - preferencesManager.strideLengthKm(), - preferencesManager.isAutoStartEnabled(), + combine( + preferencesManager.dailyStepGoal(), + preferencesManager.strideLengthKm(), + preferencesManager.isAutoStartEnabled(), + ) { goal, stride, autoStart -> Triple(goal, stride, autoStart) }, preferencesManager.notificationStyle(), _exportState, - ) { dailyStepGoal, strideLengthKm, autoStart, notifStyle, exportState -> + preferencesManager.useTestData(), + ) { (dailyStepGoal, strideLengthKm, autoStart), notifStyle, exportState, useTestData -> SettingsUiState( dailyStepGoal = dailyStepGoal, strideLengthCm = strideLengthKmToCm(strideLengthKm), autoStartEnabled = autoStart, notificationStyle = notifStyle, exportState = exportState, + useTestData = useTestData, ) }.stateIn( scope = viewModelScope, @@ -154,6 +159,17 @@ class SettingsViewModel @Inject constructor( } } + /** + * Persists the given [enabled] flag for the debug test-data mode. + * + * @param enabled `true` to use generated test data; `false` to use real sensor data. + */ + fun setUseTestData(enabled: Boolean) { + viewModelScope.launch { + preferencesManager.setUseTestData(enabled) + } + } + // ─── Export ─────────────────────────────────────────────────────────────── /** diff --git a/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt b/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt index 9bb523a..d0a9bf0 100644 --- a/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt +++ b/app/src/test/java/com/podometer/ui/activities/ActivitiesViewModelTest.kt @@ -1,6 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later package com.podometer.ui.activities +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import com.podometer.data.repository.PreferencesManager import com.podometer.domain.model.ActivitySession import com.podometer.domain.model.ActivityState import com.podometer.domain.usecase.RecomputeActivitySessionsUseCase @@ -18,7 +22,9 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder import java.time.LocalDate /** @@ -32,6 +38,9 @@ class ActivitiesViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() + @get:Rule + val tmpFolder = TemporaryFolder() + @Before fun setUpDispatcher() { Dispatchers.setMain(testDispatcher) @@ -53,10 +62,18 @@ class ActivitiesViewModelTest { // ─── Helpers ───────────────────────────────────────────────────────────── + private fun buildPreferencesManager(): PreferencesManager { + val dataStore: DataStore = PreferenceDataStoreFactory.create { + tmpFolder.newFile("test_prefs.preferences_pb") + } + return PreferencesManager(dataStore) + } + private fun buildViewModel( sessions: List = emptyList(), ): ActivitiesViewModel = ActivitiesViewModel( recomputeActivitySessions = FakeRecomputeUseCase(sessions), + preferencesManager = buildPreferencesManager(), ) // ─── Initial state ─────────────────────────────────────────────────────── diff --git a/app/src/test/java/com/podometer/ui/dashboard/DashboardViewModelTest.kt b/app/src/test/java/com/podometer/ui/dashboard/DashboardViewModelTest.kt index f6d888c..185873e 100644 --- a/app/src/test/java/com/podometer/ui/dashboard/DashboardViewModelTest.kt +++ b/app/src/test/java/com/podometer/ui/dashboard/DashboardViewModelTest.kt @@ -1,8 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later package com.podometer.ui.dashboard +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences import com.podometer.data.db.ActivityTransition import com.podometer.data.db.CyclingSession +import com.podometer.data.repository.PreferencesManager import com.podometer.domain.model.ActivityState import com.podometer.domain.model.DaySummary import com.podometer.domain.model.StepData @@ -27,7 +31,9 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test +import org.junit.rules.TemporaryFolder /** * Unit tests for [DashboardViewModel]. @@ -43,6 +49,16 @@ class DashboardViewModelTest { private val testDispatcher = UnconfinedTestDispatcher() + @get:Rule + val tmpFolder = TemporaryFolder() + + private fun buildPreferencesManager(): PreferencesManager { + val dataStore: DataStore = PreferenceDataStoreFactory.create { + tmpFolder.newFile("test_prefs.preferences_pb") + } + return PreferencesManager(dataStore) + } + @Before fun setUpDispatcher() { Dispatchers.setMain(testDispatcher) @@ -103,6 +119,7 @@ class DashboardViewModelTest { getTodayTransitions = FakeGetTodayTransitionsUseCase(flowOf(transitions)), getTodayCyclingSessions = FakeGetTodayCyclingSessionsUseCase(flowOf(cyclingSessions)), overrideActivityUseCase = overrideActivityUseCase, + preferencesManager = buildPreferencesManager(), ) // ─── DashboardUiState default state ────────────────────────────────────── @@ -290,6 +307,7 @@ class DashboardViewModelTest { getTodayTransitions = FakeGetTodayTransitionsUseCase(), getTodayCyclingSessions = FakeGetTodayCyclingSessionsUseCase(), overrideActivityUseCase = FakeOverrideActivityUseCase(), + preferencesManager = buildPreferencesManager(), ) val firstState = viewModel.uiState.first { !it.isLoading }