diff --git a/LESSONS.md b/LESSONS.md index 27c53e7..c70f074 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -35,6 +35,9 @@ The constructor has no default values in this version — passing only `shouldDi **A classification defined by negation ("anything but X") silently misclassifies new variants** `TrackingCategory.isNumeric` was `categoryType != "default"`, which was correct while every non-default type happened to store numbers. Adding the label-valued "yes_no" and "time" types would have silently routed "Yes"/"HH:mm" strings into numeric chart math (`toFloatOrNull()` returning null everywhere) with no compile error, because a negated predicate auto-includes every future variant. When a derived property gates behaviour, define membership positively (enumerate the types that ARE numeric); then a new variant defaults to the safe side and the property's KDoc records why. Grep for `!=` against discriminator fields whenever adding a variant to a string-keyed or enum type. +**A batch save surface must re-derive per-entry rules from the single-entry screen it replaces — "block the save" becomes "skip the entry", and "untouched" must be distinguished from "empty"** +A screen that saves one entry can block its Save button on invalid input (empty numeric field, zero count). A unified surface that saves many entries at once cannot block the whole save on one bad entry — each single-entry blocking rule must be translated to "skip this entry, leave any stored log untouched". The batch surface also introduces a state the single screen never had: an entry the user never interacted with. Saving those with their displayed defaults fabricates logs for every category on every save; track a per-entry `touched` flag and only persist entries that are touched or already stored. Exception: preserve any existing always-save semantics verbatim (GoFlo's pinned-category period fan-out deliberately saves untouched pinned entries), or the two surfaces silently produce different data for the same user action. + **Parallel write paths must each respect every category setting** When two code paths write to the same store (e.g. `LogPeriodViewModel.syncSymptomsToTrackingLog` and `LogCategoryViewModel.save` both writing to `tracking_logs`), each path must independently read and apply every relevant category flag. If a new flag is added (like `trackAgainstTime`) and only one path is updated, the other silently ignores the setting. When adding a per-category behaviour flag, grep for all call sites of the underlying `saveLog` / `updateLogInPlace` and confirm they all handle the new flag. diff --git a/app/src/main/java/com/mapgie/goflo/MainActivity.kt b/app/src/main/java/com/mapgie/goflo/MainActivity.kt index 94bebbf..e943b50 100644 --- a/app/src/main/java/com/mapgie/goflo/MainActivity.kt +++ b/app/src/main/java/com/mapgie/goflo/MainActivity.kt @@ -590,6 +590,35 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa onNavigateBack = { navController.popBackStack() } ) } + + // ── Unified day logging (logging redesign Phase 5) ─────────────────── + // Additive route: the LogPeriod and LogCategory destinations above + // stay registered and reachable until parity sign-off (Phase 8). + + composable( + route = Screen.LogDay.route, + arguments = listOf( + navArgument("date") { type = NavType.StringType; nullable = true; defaultValue = null } + ) + ) { backStack -> + val dateStr = backStack.arguments?.getString("date") + val date = dateStr?.let { runCatching { java.time.LocalDate.parse(it) }.getOrNull() } + ?: java.time.LocalDate.now() + val vm: com.mapgie.goflo.ui.screens.log.LogViewModel = viewModel( + key = "log_day_$dateStr", + factory = com.mapgie.goflo.ui.screens.log.LogViewModel.Factory( + repository = app.repository, + trackingRepository = app.trackingRepository, + date = date, + application = app, + preferencesStore = app.preferencesStore, + ) + ) + com.mapgie.goflo.ui.screens.log.LogScreen( + viewModel = vm, + onBack = { navController.popBackStack() } + ) + } } } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/DayLogSheet.kt b/app/src/main/java/com/mapgie/goflo/ui/components/DayLogSheet.kt index 7e28029..8c31a50 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/DayLogSheet.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/DayLogSheet.kt @@ -68,6 +68,11 @@ fun DayLogSheet( onEditPeriod: (Long) -> Unit, onEditTrackingLog: (categoryId: Long, logId: Long) -> Unit, onLogMore: () -> Unit, + /** + * Optional entry to the unified day screen (logging redesign Phase 5). + * Null hides the row; the classic per-screen actions above are unaffected. + */ + onOpenDayLog: (() -> Unit)? = null, ) { val sheetState = rememberModalBottomSheetState() @@ -221,6 +226,15 @@ fun DayLogSheet( Text("Log more for this day…") } + if (onOpenDayLog != null) { + TextButton( + onClick = { onDismiss(); onOpenDayLog() }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Try the new day log (preview)") + } + } + Spacer(Modifier.height(8.dp)) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt b/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt index fde49d8..3b2a84f 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt @@ -66,4 +66,18 @@ sealed class Screen(val route: String) { fun editEntry(categoryId: Long, logId: Long) = "log_category/$categoryId?logId=$logId" } + + // ── Unified day logging (logging redesign Phase 5) ───────────────────────── + + /** + * Route for the unified day screen, where a running period is a state of + * the day rather than a separate destination. + * + * Additive: [LogPeriod] and [LogCategory] stay registered and reachable + * until the parity sign-off (removal is Phase 8 of the logging redesign). + * - [date] — ISO 8601 date string; omit to default to today + */ + data object LogDay : Screen("log_day?date={date}") { + fun forDate(date: LocalDate) = "log_day?date=$date" + } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt index 1600cb6..e02c25d 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/home/HomeScreen.kt @@ -170,6 +170,10 @@ fun HomeScreen( viewModel.clearSelectedDay() openLogMenuFor(data.date) }, + onOpenDayLog = { + viewModel.clearSelectedDay() + onNavigate(Screen.LogDay.forDate(data.date)) + }, ) } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt index ec8ef18..9429939 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt @@ -129,8 +129,12 @@ private fun DatePickerDialogWrapper( } } -/** Builds the [MetricConfig] the [MetricInput] facade renders from a category row. */ -private fun metricConfigFor( +/** + * Builds the [MetricConfig] the [MetricInput] facade renders from a category + * row. Internal so the unified day screen ([LogScreen]) shares the exact same + * mapping instead of a copy that could drift. + */ +internal fun metricConfigFor( category: TrackingCategory, availableValues: List, ): MetricConfig = MetricConfig( @@ -175,9 +179,12 @@ private fun metricValueFor( * timestamped log immediately, so the day renders as a running total plus a * [Timeline] of today's entries with per-entry delete. There is deliberately * no notes field or Save button on this path. + * + * Internal so the unified day screen ([LogScreen]) renders the identical + * timed-increment surface. */ @Composable -private fun TimedIncrementTimeline( +internal fun TimedIncrementTimeline( category: TrackingCategory, entries: List, onAddOne: () -> Unit, diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt index 9353dfd..6d43922 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt @@ -18,9 +18,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate -import java.time.LocalTime -import java.time.format.DateTimeFormatter -import java.time.temporal.ChronoUnit /** * UI state for per-day period logging. @@ -298,12 +295,7 @@ class LogPeriodViewModel( fun setFlowSliderValue(value: Float) = _uiState.update { state -> // Map slider position to the nearest built-in label for storage. - val label = when (value.toInt()) { - 1 -> "Spotting" - 2 -> "Light" - 4 -> "Heavy" - else -> "Medium" - } + val label = PeriodDaySync.flowLabelForSliderValue(value.toInt()) state.copy(flowSliderValue = value, selectedFlowLabel = label, hasChanges = true) } @@ -429,47 +421,15 @@ class LogPeriodViewModel( * Mirrors this day's flow level into the TrackingLog system. * This ensures logged days appear in the Stats screen under the Flow category. * No-op if [trackingRepository] was not provided (e.g. in tests or legacy callers). + * Logic lives in [PeriodDaySync], shared with the unified day screen. */ - private suspend fun syncFlowToTrackingLog(state: LogPeriodUiState) { - val tr = trackingRepository ?: return - val flowCategory = tr.getSystemCategoryByKey("flow") ?: return - if (flowCategory.isArchived) return - val flowLabel = if (flowCategory.categoryType == "numeric_slider") { - val v = state.flowSliderValue ?: flowLabelToSliderValue(state.selectedFlowLabel) - v.toInt().toString() - } else { - state.selectedFlowLabel - } - tr.saveLog( - date = state.date, - categoryId = flowCategory.id, - selectedValues = setOf(flowLabel), - notes = "", - allowMultiple = false, + private suspend fun syncFlowToTrackingLog(state: LogPeriodUiState) = + PeriodDaySync.syncFlowToTrackingLog( + trackingRepository, state.date, state.selectedFlowLabel, state.flowSliderValue, ) - } - private suspend fun syncSymptomsToTrackingLog(state: LogPeriodUiState) { - val tr = trackingRepository ?: return - val symptomsCategory = tr.getSystemCategoryByKey("symptoms") ?: return - if (symptomsCategory.isArchived) return - if (state.symptoms.isEmpty()) { - val existing = tr.getExistingLog(state.date, symptomsCategory.id) ?: return - tr.deleteLog(existing.log) - } else { - val loggedAt = if (symptomsCategory.trackAgainstTime) { - LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) - } else "" - tr.saveLog( - date = state.date, - categoryId = symptomsCategory.id, - selectedValues = state.symptoms, - notes = "", - allowMultiple = false, - loggedAt = loggedAt, - ) - } - } + private suspend fun syncSymptomsToTrackingLog(state: LogPeriodUiState) = + PeriodDaySync.syncSymptomsToTrackingLog(trackingRepository, state.date, state.symptoms) /** Saves each pinned category's current selection as a tracking log for the day being logged. */ private suspend fun syncPinnedCategoryLogs(state: LogPeriodUiState) { @@ -488,27 +448,12 @@ class LogPeriodViewModel( } private fun computePinnedValues(cat: TrackingCategory, state: LogPeriodUiState): Set? = - when (cat.categoryType) { - "numeric_slider" -> { - // Fall back to numericMin so the slider's displayed position is always saved. - val v = state.pinnedNumericValues[cat.id] ?: cat.numericMin - setOf(if (cat.allowDecimals) "%.1f".format(v) else v.toInt().toString()) - } - "numeric_free" -> { - val text = (state.pinnedFreeTextValues[cat.id] ?: "").trim() - if (text.isEmpty()) null else setOf(text) - } - "increment" -> { - // Always save, including 0 — a zero count is meaningful data for a - // category the user chose to track alongside periods. - val count = state.pinnedNumericValues[cat.id]?.toInt() ?: 0 - setOf(count.toString()) - } - else -> { - val selected = state.pinnedCategorySelections[cat.id] ?: emptySet() - if (selected.isEmpty()) null else selected - } - } + PeriodDaySync.computePinnedValues( + cat = cat, + numericValue = state.pinnedNumericValues[cat.id], + freeText = state.pinnedFreeTextValues[cat.id] ?: "", + selections = state.pinnedCategorySelections[cat.id] ?: emptySet(), + ) fun disablePeriodTracking() { viewModelScope.launch { preferencesStore?.setPeriodTrackingEnabled(false) } @@ -552,17 +497,11 @@ class LogPeriodViewModel( } companion object { - private fun flowLabelToSliderValue(label: String): Float = when (label) { - "Spotting" -> 1f - "Light" -> 2f - "Heavy" -> 4f - else -> 3f // "Medium" and any custom label default to the middle - } + private fun flowLabelToSliderValue(label: String): Float = + PeriodDaySync.flowLabelToSliderValue(label) /** 1-based day number of [date] within an episode starting at [start], or null when before it. */ - private fun dayNumber(start: LocalDate, date: LocalDate): Int? { - val n = ChronoUnit.DAYS.between(start, date).toInt() + 1 - return if (n >= 1) n else null - } + private fun dayNumber(start: LocalDate, date: LocalDate): Int? = + PeriodDaySync.dayNumber(start, date) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt new file mode 100644 index 0000000..4d00b17 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogScreen.kt @@ -0,0 +1,1410 @@ +package com.mapgie.goflo.ui.screens.log + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.ui.components.ChipRow +import com.mapgie.goflo.ui.components.HairlineDivider +import com.mapgie.goflo.ui.components.ListCard +import com.mapgie.goflo.ui.components.ListRow +import com.mapgie.goflo.ui.components.MetricConfig +import com.mapgie.goflo.ui.components.MetricInput +import com.mapgie.goflo.ui.components.MetricValue +import com.mapgie.goflo.ui.components.PrimarySaveBar +import com.mapgie.goflo.ui.components.SectionHeader +import com.mapgie.goflo.ui.components.SelectableChip +import com.mapgie.goflo.ui.components.ToneHero +import com.mapgie.goflo.ui.components.roleContainerTint +import com.mapgie.goflo.ui.components.usesStepScale +import com.mapgie.goflo.ui.util.CategoryType +import com.mapgie.goflo.ui.util.effectiveColorToken +import com.mapgie.goflo.ui.util.toCategoryColor +import com.mapgie.goflo.ui.util.toCategoryOnColor +import com.mapgie.goflo.ui.util.toCategoryType +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +private val displayFormat = DateTimeFormatter.ofPattern("MMM d, yyyy") + +// Sentinels for the switch sheet: closed / opened from the title (jump) / +// opened from a metric header (re-file, value = source category id). +private const val SHEET_CLOSED = 0L +private const val SHEET_JUMP = -1L + +/** + * The unified day screen: one screen logs a day, and a running period is a + * state of that day rather than a separate destination. + * + * Off-period, the first tracked category leads as a tonal hero and the footer + * is a quiet "Period started today" row. On-period, the Flow group slots in at + * the top, the lead category compresses into the tracked list, and the footer + * becomes a filled status row with an End action. Everything between renders + * identically in both states. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun LogScreen( + viewModel: LogViewModel, + onBack: () -> Unit, +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(state.saved, state.deleted) { + if (state.saved || state.deleted) onBack() + } + + var showDayPicker by rememberSaveable { mutableStateOf(false) } + var showStartPicker by rememberSaveable { mutableStateOf(false) } + var showEndPicker by rememberSaveable { mutableStateOf(false) } + var showDeleteConfirm by rememberSaveable { mutableStateOf(false) } + var showRemoveDayConfirm by rememberSaveable { mutableStateOf(false) } + var showAddSymptomDialog by rememberSaveable { mutableStateOf(false) } + var showUnsavedChangesDialog by rememberSaveable { mutableStateOf(false) } + var showOverflowMenu by rememberSaveable { mutableStateOf(false) } + /** SHEET_CLOSED, SHEET_JUMP, or the category id a re-file was opened from. */ + var switchSheetMode by rememberSaveable { mutableStateOf(SHEET_CLOSED) } + /** Category id awaiting delete-entry confirmation, or 0 when none. */ + var pendingDeleteEntryId by rememberSaveable { mutableStateOf(0L) } + /** Day picked while unsaved changes exist, awaiting discard confirmation. */ + var pendingDaySwitch by rememberSaveable { mutableStateOf(null) } + + val handleBack: () -> Unit = { + if (state.hasChanges) showUnsavedChangesDialog = true else onBack() + } + BackHandler(enabled = state.hasChanges) { showUnsavedChangesDialog = true } + + // ── Dialogs ─────────────────────────────────────────────────────────────── + + if (showDayPicker && !state.isLoading) { + DatePickerDialogWrapper( + initial = state.date, + onConfirm = { picked -> + showDayPicker = false + if (picked != state.date) { + if (state.hasChanges) pendingDaySwitch = picked.toString() + else viewModel.setDate(picked) + } + }, + onDismiss = { showDayPicker = false }, + ) + } + + if (showStartPicker && !state.isLoading) { + DatePickerDialogWrapper( + initial = state.episodeStart ?: state.date, + onConfirm = { viewModel.setStartDate(it); showStartPicker = false }, + onDismiss = { showStartPicker = false }, + ) + } + + if (showEndPicker && !state.isLoading) { + DatePickerDialogWrapper( + initial = state.endDate ?: state.date, + minDate = state.episodeStart ?: state.date, + onConfirm = { viewModel.setEndDate(it); showEndPicker = false }, + onDismiss = { showEndPicker = false }, + ) + } + + pendingDaySwitch?.let { pendingIso -> + AlertDialog( + onDismissRequest = { pendingDaySwitch = null }, + title = { Text("Switch day?") }, + text = { Text("This day has unsaved changes. Switching to another day discards them.") }, + confirmButton = { + TextButton( + onClick = { + val target = runCatching { LocalDate.parse(pendingIso) }.getOrNull() + pendingDaySwitch = null + target?.let { viewModel.setDate(it) } + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Discard and switch") } + }, + dismissButton = { + TextButton(onClick = { pendingDaySwitch = null }) { Text("Cancel") } + }, + ) + } + + if (showDeleteConfirm && !state.isLoading) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete period?") }, + text = { Text("This will permanently remove this entire period, including every logged day in it.") }, + confirmButton = { + TextButton( + onClick = { showDeleteConfirm = false; viewModel.deleteEpisode() }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Delete") } + }, + dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } }, + ) + } + + if (showRemoveDayConfirm && !state.isLoading) { + AlertDialog( + onDismissRequest = { showRemoveDayConfirm = false }, + title = { Text("Remove this day?") }, + text = { Text( + "${state.date.format(displayFormat)} will no longer count as a period day. " + + "Anything else logged for this day is kept." + ) }, + confirmButton = { + TextButton( + onClick = { showRemoveDayConfirm = false; viewModel.removeDay() }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Remove day") } + }, + dismissButton = { TextButton(onClick = { showRemoveDayConfirm = false }) { Text("Cancel") } }, + ) + } + + if (showUnsavedChangesDialog) { + AlertDialog( + onDismissRequest = { showUnsavedChangesDialog = false }, + title = { Text("Unsaved changes") }, + text = { Text("Do you want to save this entry before going back?") }, + confirmButton = { + Button(onClick = { showUnsavedChangesDialog = false; viewModel.save() }) { + Text("Save") + } + }, + dismissButton = { + TextButton( + onClick = { showUnsavedChangesDialog = false; onBack() }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Discard") } + }, + ) + } + + if (showAddSymptomDialog) { + AddSymptomDialog( + existingLabels = state.symptomOptions.map { it.label }, + selectedLabels = state.symptoms, + onAdd = { name -> + viewModel.addNewSymptomToLibrary(name) + showAddSymptomDialog = false + }, + onDismiss = { showAddSymptomDialog = false }, + ) + } + + if (pendingDeleteEntryId != 0L) { + val cat = state.categories.firstOrNull { it.id == pendingDeleteEntryId } + AlertDialog( + onDismissRequest = { pendingDeleteEntryId = 0L }, + title = { Text("Delete this entry?") }, + text = { Text( + "The ${cat?.name ?: "category"} entry for " + + "${state.date.format(displayFormat)} will be permanently removed." + ) }, + confirmButton = { + TextButton( + onClick = { + viewModel.deleteEntry(pendingDeleteEntryId) + pendingDeleteEntryId = 0L + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { Text("Delete") } + }, + dismissButton = { TextButton(onClick = { pendingDeleteEntryId = 0L }) { Text("Cancel") } }, + ) + } + + if (switchSheetMode != SHEET_CLOSED) { + DaySwitchSheet( + refileSourceId = switchSheetMode.takeIf { it > 0L }, + state = state, + onPickDay = { + switchSheetMode = SHEET_CLOSED + showDayPicker = true + }, + onPickCategory = { categoryId -> + val mode = switchSheetMode + switchSheetMode = SHEET_CLOSED + if (mode > 0L) viewModel.refileEntry(mode, categoryId) + else viewModel.setActiveCategory(categoryId) + }, + onDismiss = { switchSheetMode = SHEET_CLOSED }, + ) + } + + // ── Scaffold ────────────────────────────────────────────────────────────── + + Scaffold( + topBar = { + LogDayTopBar( + state = state, + onBack = handleBack, + onTitleClick = { switchSheetMode = SHEET_JUMP }, + showOverflowMenu = showOverflowMenu, + onOverflowChange = { showOverflowMenu = it }, + onDisablePeriodTracking = { + viewModel.disablePeriodTracking() + onBack() + }, + ) + } + ) { padding -> + if (state.isLoading) { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator() } + return@Scaffold + } + + Box(Modifier.fillMaxSize().padding(padding)) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 104.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + DaySection(state, onPickDay = { showDayPicker = true }) + + if (state.periodActive) { + PeriodDatesSection( + state = state, + onPickStart = { showStartPicker = true }, + onPickEnd = { showEndPicker = true }, + onClearEnd = { viewModel.setEndDate(null) }, + ) + FlowSection(state, viewModel) + } + + // Pinned ("Log with period") categories render in the flow + // context while the day is on-period. + val pinned = if (state.periodActive) { + state.categories.filter { it.showInLogPeriod } + } else emptyList() + pinned.forEach { cat -> + CategoryMetricSection( + category = cat, + state = state, + viewModel = viewModel, + onSwitchCategory = { switchSheetMode = cat.id }, + onDeleteEntry = { pendingDeleteEntryId = cat.id }, + ) + } + + // Off-period the first tracked category leads as the hero. + val lead = if (!state.periodActive) { + state.categories.firstOrNull() + } else null + lead?.let { cat -> + CategoryMetricSection( + category = cat, + state = state, + viewModel = viewModel, + hero = true, + onSwitchCategory = { switchSheetMode = cat.id }, + onDeleteEntry = { pendingDeleteEntryId = cat.id }, + ) + } + + SymptomsSection(state, viewModel, onAddSymptom = { showAddSymptomDialog = true }) + + TrackingSections( + state = state, + viewModel = viewModel, + excludeIds = (pinned.map { it.id } + listOfNotNull(lead?.id)).toSet(), + onSwitchCategory = { switchSheetMode = it }, + onDeleteEntry = { pendingDeleteEntryId = it }, + ) + + if (state.periodActive) { + SectionHeader(label = "Notes", value = "Optional") + OutlinedTextField( + value = state.periodNotes, + onValueChange = { if (it.length <= 500) viewModel.setPeriodNotes(it) }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("How are you feeling? Any other details…") }, + minLines = 3, + maxLines = 6, + supportingText = { Text("${state.periodNotes.length}/500") }, + colors = OutlinedTextFieldDefaults.colors( + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + ), + ) + } + + PeriodFooter( + state = state, + onStartPeriod = viewModel::startPeriodToday, + onUndoStart = viewModel::undoStartPeriod, + onEndPeriod = viewModel::endPeriodOnThisDay, + onUndoEnd = viewModel::undoEndPeriod, + ) + + if (state.isPeriodDay || (state.episodeId != null && state.dayInEpisode)) { + OutlinedButton( + onClick = { showRemoveDayConfirm = true }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Remove this day from period") } + if (state.episodeId != null) { + OutlinedButton( + onClick = { showDeleteConfirm = true }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { Text("Delete Entire Period") } + } + } + + state.error?.let { + Text( + text = "Error: $it", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Assertive }, + ) + } + } + + PrimarySaveBar( + label = if (state.date == LocalDate.now()) "Save today" else "Save day", + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onClick = viewModel::save, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding(), + ) + } + } +} + +// ── Top bar ─────────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogDayTopBar( + state: LogUiState, + onBack: () -> Unit, + onTitleClick: () -> Unit, + showOverflowMenu: Boolean, + onOverflowChange: (Boolean) -> Unit, + onDisablePeriodTracking: () -> Unit, +) { + val subtitle = if (state.isLoading) null else buildString { + append(state.date.format(displayFormat)) + val dayNo = state.episodeDayNumber + if (state.periodActive && dayNo != null) append(" · period day $dayNo") + } + TopAppBar( + title = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .semantics { this.role = Role.Button } + .clickable(onClick = onTitleClick) + .padding(horizontal = 4.dp, vertical = 2.dp), + ) { + Column { + Text(if (state.date == LocalDate.now()) "Log today" else "Log day") + if (subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } + } + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = "Switch day or category", + modifier = Modifier.padding(start = 4.dp).size(20.dp), + ) + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + if (state.periodTrackingEnabled) { + IconButton(onClick = { onOverflowChange(true) }) { + Icon(Icons.Default.MoreVert, contentDescription = "More options") + } + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { onOverflowChange(false) }, + ) { + DropdownMenuItem( + text = { Text("Disable period logging") }, + onClick = { + onOverflowChange(false) + onDisablePeriodTracking() + }, + ) + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) +} + +// ── Day + period dates ──────────────────────────────────────────────────────── + +@Composable +private fun DaySection(state: LogUiState, onPickDay: () -> Unit) { + SectionHeader(label = "Day") + ListCard { + ListRow( + key = "Date", + value = state.date.format(displayFormat), + valueEmphasis = true, + onClick = onPickDay, + ) + } + // Continuation context changes as the user picks days and toggles the + // period state, so announce it politely to screen readers. + if (state.periodActive) { + val text = when { + state.startPeriodToday && state.continuesEpisodeStart != null -> { + val dayNo = state.episodeDayNumber + if (dayNo != null && dayNo > 1) { + "Day $dayNo of the period started ${state.continuesEpisodeStart.format(displayFormat)}" + } else { + "Continues the period started ${state.continuesEpisodeStart.format(displayFormat)}" + } + } + state.startPeriodToday -> "Starts a new period" + state.episodeDayNumber != null -> "Day ${state.episodeDayNumber} of this period" + else -> null + } + if (text != null) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + } +} + +@Composable +private fun PeriodDatesSection( + state: LogUiState, + onPickStart: () -> Unit, + onPickEnd: () -> Unit, + onClearEnd: () -> Unit, +) { + if (state.episodeId != null) { + SectionHeader(label = "Period dates") + ListCard { + ListRow( + key = "Started", + value = (state.episodeStart ?: state.date).format(displayFormat), + valueEmphasis = true, + onClick = onPickStart, + ) + HairlineDivider() + ListRow( + key = "Ended", + value = state.endDate?.format(displayFormat) ?: "Still ongoing", + valueEmphasis = true, + valueColor = if (state.endDate == null) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + onClick = onPickEnd, + ) + } + if (state.endDate != null) { + TextButton(onClick = onClearEnd) { Text("Clear end date (leave open)") } + } + } else { + SectionHeader(label = "End date", value = "Optional") + ListCard { + ListRow( + key = "Ends", + value = state.endDate?.let { "Until ${it.format(displayFormat)}" } ?: "No end date", + valueEmphasis = state.endDate != null, + onClick = onPickEnd, + ) + } + if (state.endDate != null) { + TextButton(onClick = onClearEnd) { Text("Clear end date") } + } + Text( + "Without an end date, the period ends on its own after " + + "${state.toleranceDays + 1} days with no period day logged. " + + "Log each day to record how it changes.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// ── Flow ────────────────────────────────────────────────────────────────────── + +@Composable +private fun FlowSection(state: LogUiState, viewModel: LogViewModel) { + val flowCat = state.flowCategory ?: return + if (flowCat.isArchived) return + val token = flowCat.effectiveColorToken(state.groups) + val role = token.toCategoryColor() + val onRole = token.toCategoryOnColor() + + if (flowCat.categoryType == "numeric_slider") { + val config = metricConfigFor(flowCat, emptyList()) + val current = state.flowSliderValue?.toInt() + val word = current?.let { config.stepLabels[it] } ?: state.selectedFlowLabel + SectionHeader(label = state.flowCategoryName, value = word, valueColor = role) + MetricInput( + type = CategoryType.NUMERIC_SLIDER, + config = config, + value = if (config.usesStepScale()) MetricValue.Scale(current) + else MetricValue.Continuous(state.flowSliderValue), + role = role, + onRole = onRole, + onChange = { v -> + when (v) { + is MetricValue.Scale -> v.step?.let { viewModel.setFlowSliderValue(it.toFloat()) } + is MetricValue.Continuous -> v.value?.let { viewModel.setFlowSliderValue(it) } + else -> {} + } + }, + ) + } else { + SectionHeader( + label = state.flowCategoryName, + value = state.selectedFlowLabel, + valueColor = role, + ) + if (state.flowOptions.isNotEmpty()) { + ChipRow( + options = state.flowOptions.map { it.label }, + selected = setOf(state.selectedFlowLabel), + role = role, + onToggle = { viewModel.setFlowLevel(it) }, + ) + } else { + Text( + "No flow levels configured. Add levels in Settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ── Symptoms ────────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SymptomsSection( + state: LogUiState, + viewModel: LogViewModel, + onAddSymptom: () -> Unit, +) { + val symptomsCat = state.symptomsCategory ?: return + if (symptomsCat.isArchived) return + val token = symptomsCat.effectiveColorToken(state.groups) + val role = token.toCategoryColor() + + SectionHeader( + label = state.symptomsCategoryName, + value = state.symptoms.size.takeIf { it > 0 }?.let { "$it today" }, + valueColor = role, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.symptomOptions.forEach { option -> + SelectableChip( + label = option.label, + selected = option.label in state.symptoms, + onClick = { viewModel.toggleSymptom(option.label) }, + ) + } + AssistChip( + onClick = onAddSymptom, + label = { Text("Add") }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add symptom", + modifier = Modifier.size(AssistChipDefaults.IconSize), + ) + }, + border = AssistChipDefaults.assistChipBorder( + enabled = true, + borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + ), + ) + } +} + +// ── Tracked metric sections ─────────────────────────────────────────────────── + +/** + * Renders every remaining tracked category, organised by group: a group of + * two or more categories renders as one card of rows (tap a row to open its + * input below the card); a group of one, and lone ungrouped categories, + * render as their own always-open section. + */ +@Composable +private fun TrackingSections( + state: LogUiState, + viewModel: LogViewModel, + excludeIds: Set, + onSwitchCategory: (Long) -> Unit, + onDeleteEntry: (Long) -> Unit, +) { + val shown = state.categories.filter { it.id !in excludeIds } + if (shown.isEmpty()) return + + val groupIds = state.groups.map { it.id }.toSet() + val grouped = shown + .filter { cat -> cat.groupId.let { it != null && it in groupIds } } + .groupBy { it.groupId } + val ungrouped = shown.filter { cat -> cat.groupId.let { it == null || it !in groupIds } } + + state.groups.forEach { group -> + val members = grouped[group.id] ?: return@forEach + if (members.size >= 2) { + GroupCardSection( + title = group.name, + members = members, + state = state, + viewModel = viewModel, + onSwitchCategory = onSwitchCategory, + onDeleteEntry = onDeleteEntry, + ) + } else { + members.forEach { cat -> + CategoryMetricSection( + category = cat, + state = state, + viewModel = viewModel, + onSwitchCategory = { onSwitchCategory(cat.id) }, + onDeleteEntry = { onDeleteEntry(cat.id) }, + ) + } + } + } + + if (ungrouped.size >= 2) { + GroupCardSection( + title = "Tracking", + members = ungrouped, + state = state, + viewModel = viewModel, + onSwitchCategory = onSwitchCategory, + onDeleteEntry = onDeleteEntry, + ) + } else { + ungrouped.forEach { cat -> + CategoryMetricSection( + category = cat, + state = state, + viewModel = viewModel, + onSwitchCategory = { onSwitchCategory(cat.id) }, + onDeleteEntry = { onDeleteEntry(cat.id) }, + ) + } + } +} + +/** One card of rows for a multi-category group, plus the active row's input. */ +@Composable +private fun GroupCardSection( + title: String, + members: List, + state: LogUiState, + viewModel: LogViewModel, + onSwitchCategory: (Long) -> Unit, + onDeleteEntry: (Long) -> Unit, +) { + SectionHeader(label = title, value = "${members.size} metrics") + ListCard { + members.forEachIndexed { index, cat -> + val entry = state.entries[cat.id] ?: DayMetricEntry() + val config = metricConfigFor(cat, state.categoryValues[cat.id] ?: emptyList()) + val summary = entrySummary(cat, entry, config) + val token = cat.effectiveColorToken(state.groups) + ListRow( + key = cat.name, + value = summary ?: "Add", + valueEmphasis = summary != null, + valueColor = if (summary != null) token.toCategoryColor() + else MaterialTheme.colorScheme.onSurfaceVariant, + onClick = { + viewModel.setActiveCategory( + if (state.activeCategoryId == cat.id) null else cat.id + ) + }, + ) + if (index < members.lastIndex) HairlineDivider() + } + } + members.firstOrNull { it.id == state.activeCategoryId }?.let { active -> + CategoryMetricSection( + category = active, + state = state, + viewModel = viewModel, + onSwitchCategory = { onSwitchCategory(active.id) }, + onDeleteEntry = { onDeleteEntry(active.id) }, + ) + } +} + +/** + * One tracked category's full input surface: header (the category name is a + * button that opens the re-file sheet), the [MetricInput] for its type (or the + * timed-increment timeline), "previously recorded" chips for stored labels no + * longer in the catalog, the track-against-time checkbox, per-entry notes, and + * a delete action when an entry already exists. As the off-period [hero], the + * input nests inside a [ToneHero] that shows the current reading as words. + */ +@Composable +private fun CategoryMetricSection( + category: TrackingCategory, + state: LogUiState, + viewModel: LogViewModel, + onSwitchCategory: () -> Unit, + onDeleteEntry: () -> Unit, + hero: Boolean = false, +) { + val entry = state.entries[category.id] ?: DayMetricEntry() + val availableValues = state.categoryValues[category.id] ?: emptyList() + val config = metricConfigFor(category, availableValues) + val token = category.effectiveColorToken(state.groups) + val role = token.toCategoryColor() + val onRole = token.toCategoryOnColor() + val summary = entrySummary(category, entry, config) + + if (hero) { + MetricHeaderButton( + name = category.name, + value = null, + valueColor = role, + onClick = onSwitchCategory, + ) + ToneHero( + word = summary ?: "Not logged yet", + role = role, + ) { + MetricSectionBody(category, entry, availableValues, config, role, onRole, viewModel, onDeleteEntry) + } + } else { + MetricHeaderButton( + name = category.name, + value = summary, + valueColor = role, + onClick = onSwitchCategory, + ) + MetricSectionBody(category, entry, availableValues, config, role, onRole, viewModel, onDeleteEntry) + } +} + +/** + * The category-name header row: the name is a button opening the re-file + * sheet ("logged the wrong thing?"), with the current value right-aligned. + */ +@Composable +private fun MetricHeaderButton( + name: String, + value: String?, + valueColor: Color, + onClick: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .semantics { this.role = Role.Button } + .clickable(onClick = onClick) + .heightIn(min = 44.dp), + ) { + Text( + text = name.uppercase(), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.11.em, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = "File under another category", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 2.dp).size(16.dp), + ) + } + if (value != null) { + Text( + text = value, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = valueColor, + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun MetricSectionBody( + category: TrackingCategory, + entry: DayMetricEntry, + availableValues: List, + config: MetricConfig, + role: Color, + onRole: Color, + viewModel: LogViewModel, + onDeleteEntry: () -> Unit, +) { + val type = category.categoryType.toCategoryType() + val isTimedIncrement = type == CategoryType.INCREMENT && category.trackAgainstTime + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (isTimedIncrement) { + // Per-tap immediate saves with a timeline, exactly as the category + // screen renders it. + TimedIncrementTimeline( + category = category, + entries = entry.timedEntries, + onAddOne = { viewModel.addTimedIncrement(category.id) }, + onDeleteEntry = { viewModel.deleteTimedEntry(category.id, it) }, + ) + return@Column + } + + if (type == CategoryType.DEFAULT && availableValues.isEmpty()) { + Text( + "No values defined for this category yet. You can add values in " + + "Settings → Tracking Categories.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + MetricInput( + type = type, + config = config, + value = metricValueForEntry(type, config, entry), + role = role, + onRole = onRole, + onChange = { v -> + when (v) { + is MetricValue.Choice -> viewModel.setEntrySelection(category.id, v.selected) + is MetricValue.Scale -> + v.step?.let { viewModel.setEntryNumeric(category.id, it.toFloat()) } + is MetricValue.Continuous -> + v.value?.let { viewModel.setEntryNumeric(category.id, it) } + is MetricValue.FreeNumber -> viewModel.setEntryFreeText(category.id, v.text) + is MetricValue.Count -> + viewModel.setEntryNumeric(category.id, v.count.toFloat()) + is MetricValue.YesNo -> v.value?.let { + viewModel.setEntrySelection(category.id, setOf(if (it) "Yes" else "No")) + } + is MetricValue.TimeOfDay -> v.time?.let { + viewModel.setEntrySelection(category.id, setOf(it)) + } + } + }, + ) + } + + // Stored labels no longer offered by the catalog stay visible and + // deselectable, exactly as on the category screen. + if (type == CategoryType.DEFAULT) { + val removedValues = entry.selectedValues.filter { it !in availableValues } + if (removedValues.isNotEmpty()) { + Text( + "Previously recorded (removed from options):", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + removedValues.forEach { label -> + SelectableChip( + label = "$label (removed)", + selected = true, + onClick = { viewModel.toggleEntryValue(category.id, label) }, + ) + } + } + } + } + + if (category.trackAgainstTime) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Checkbox( + checked = entry.trackTime, + onCheckedChange = { viewModel.setEntryTrackTime(category.id, it) }, + ) + Text("Track against time", style = MaterialTheme.typography.bodyMedium) + } + } + + // Per-entry notes: shown when the entry already carries notes, or on + // demand, so a dozen categories never means a dozen empty text boxes. + var noteOpen by rememberSaveable(category.id) { mutableStateOf(false) } + if (entry.notes.isNotEmpty() || noteOpen) { + OutlinedTextField( + value = entry.notes, + onValueChange = { if (it.length <= 500) viewModel.setEntryNotes(category.id, it) }, + label = { Text("Notes (optional)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4, + supportingText = { + if (entry.notes.isNotEmpty()) Text("${entry.notes.length}/500") + }, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (entry.notes.isEmpty() && !noteOpen) { + TextButton(onClick = { noteOpen = true }) { Text("Add note") } + } + if (entry.existingLog != null) { + TextButton( + onClick = onDeleteEntry, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { Text("Delete entry") } + } + } + } +} + +// ── Period footer ───────────────────────────────────────────────────────────── + +/** + * Off-period: a quiet hairline row that starts (or continues) a period today. + * On-period: a filled status row naming the period state, with End/Undo. + */ +@Composable +private fun PeriodFooter( + state: LogUiState, + onStartPeriod: () -> Unit, + onUndoStart: () -> Unit, + onEndPeriod: () -> Unit, + onUndoEnd: () -> Unit, +) { + if (!state.periodActive) { + if (!state.periodTrackingEnabled) return + val continues = state.continuesEpisodeStart + ListCard { + ListRow( + key = if (continues != null) "Log as a period day" else "Period started today", + onClick = onStartPeriod, + ) + } + if (continues != null) { + Text( + "Continues the period started ${continues.format(displayFormat)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + + val pendingStart = state.startPeriodToday && state.episodeId == null + val endedToday = state.endDate != null && state.endDate == state.date && + state.loadedEndDate != state.endDate + val title = when { + pendingStart -> "Period starts today" + state.startPeriodToday -> "Period day added" + endedToday -> "Period ends today" + state.endDate == null -> "Period ongoing" + else -> "Period recorded" + } + val since = (state.episodeStart ?: state.date).format(displayFormat) + val subtitle = when { + pendingStart -> state.endDate?.let { "Until ${it.format(displayFormat)}" } ?: "Save to log it" + state.startPeriodToday -> "Continues the period started $since" + else -> "Since $since" + } + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Row( + modifier = Modifier + .heightIn(min = 56.dp) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column( + modifier = Modifier + .weight(1f) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text( + text = subtitle, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } + when { + state.startPeriodToday -> TextButton(onClick = onUndoStart) { Text("Undo") } + endedToday -> TextButton(onClick = onUndoEnd) { Text("Undo") } + state.endDate == null -> TextButton(onClick = onEndPeriod) { Text("End") } + } + } + } +} + +// ── Switch sheet ────────────────────────────────────────────────────────────── + +/** + * The title/header switcher: every category organised by group and tinted by + * its role, so the colour you're about to log in is visible before you commit. + * + * Opened from the screen title it jumps between sections and offers a day + * change; opened from a metric header ([refileSourceId] set) it re-files the + * entered value under the picked category, keeping the value. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DaySwitchSheet( + refileSourceId: Long?, + state: LogUiState, + onPickDay: () -> Unit, + onPickCategory: (Long) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + val selectedId = refileSourceId ?: state.activeCategoryId + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .navigationBarsPadding() + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = if (refileSourceId != null) "File this entry under…" else "Switch day or category", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = if (refileSourceId != null) { + "The value you entered is kept; only the category it is filed under changes." + } else { + "Jump to a category, or pick another day to log." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (refileSourceId == null) { + ListCard { + ListRow( + key = "Change day", + value = state.date.format(displayFormat), + onClick = onPickDay, + ) + } + } + + val groupIds = state.groups.map { it.id }.toSet() + val byGroup = state.categories + .filter { cat -> cat.groupId.let { it != null && it in groupIds } } + .groupBy { it.groupId } + val ungrouped = state.categories + .filter { cat -> cat.groupId.let { it == null || it !in groupIds } } + + state.groups.forEach { group -> + val members = byGroup[group.id] ?: return@forEach + SheetGroupLabel(name = group.name, token = group.colorRole) + members.forEach { cat -> + SheetCategoryRow( + category = cat, + state = state, + selected = cat.id == selectedId, + onPick = { onPickCategory(cat.id) }, + ) + } + } + if (ungrouped.isNotEmpty()) { + if (byGroup.isNotEmpty()) SheetGroupLabel(name = "Other", token = null) + ungrouped.forEach { cat -> + SheetCategoryRow( + category = cat, + state = state, + selected = cat.id == selectedId, + onPick = { onPickCategory(cat.id) }, + ) + } + } + } + } +} + +@Composable +private fun SheetGroupLabel(name: String, token: String?) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp), + ) { + if (token != null) { + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(token.toCategoryColor()), + ) + } + Text( + text = name.uppercase(), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.11.em, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun SheetCategoryRow( + category: TrackingCategory, + state: LogUiState, + selected: Boolean, + onPick: () -> Unit, +) { + val token = category.effectiveColorToken(state.groups) + val roleColor = token.toCategoryColor() + val container = if (selected) { + roleContainerTint(roleColor, MaterialTheme.colorScheme.surface) + } else Color.Transparent + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(container) + .semantics { + this.role = Role.RadioButton + this.selected = selected + } + .clickable(onClick = onPick) + .heightIn(min = 48.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + RadioButton(selected = selected, onClick = null) + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(roleColor), + ) + Text( + text = category.name, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } +} + +// ── Value mapping helpers ───────────────────────────────────────────────────── + +/** Maps a [DayMetricEntry] onto the [MetricValue] variant [MetricInput] expects. */ +private fun metricValueForEntry( + type: CategoryType, + config: MetricConfig, + entry: DayMetricEntry, +): MetricValue = when (type) { + CategoryType.DEFAULT -> MetricValue.Choice(entry.selectedValues) + CategoryType.NUMERIC_SLIDER -> + if (config.usesStepScale()) MetricValue.Scale(entry.numericValue?.toInt()) + else MetricValue.Continuous(entry.numericValue) + CategoryType.NUMERIC_FREE -> MetricValue.FreeNumber(entry.freeText) + CategoryType.INCREMENT -> MetricValue.Count(entry.numericValue?.toInt() ?: 0) + CategoryType.YES_NO -> MetricValue.YesNo( + when { + "Yes" in entry.selectedValues -> true + "No" in entry.selectedValues -> false + else -> null + } + ) + CategoryType.TIME -> MetricValue.TimeOfDay(entry.selectedValues.firstOrNull()) +} + +/** Words for the current reading, or null when nothing is set for the day. */ +private fun entrySummary( + category: TrackingCategory, + entry: DayMetricEntry, + config: MetricConfig, +): String? { + fun withUnit(text: String): String = + if (config.unit.isNullOrBlank()) text else "$text ${config.unit}" + + val type = category.categoryType.toCategoryType() + if (type == CategoryType.INCREMENT && category.trackAgainstTime) { + val n = entry.timedEntries.size + return if (n > 0) withUnit(n.toString()) else null + } + return when (type) { + CategoryType.NUMERIC_SLIDER -> entry.numericValue?.let { v -> + if (!category.allowDecimals) { + config.stepLabels[v.toInt()] ?: withUnit(v.toInt().toString()) + } else { + withUnit("%.1f".format(v)) + } + } + CategoryType.NUMERIC_FREE -> + entry.freeText.trim().takeIf { it.isNotEmpty() }?.let { withUnit(it) } + CategoryType.INCREMENT -> + entry.numericValue?.toInt()?.takeIf { it > 0 }?.let { withUnit(it.toString()) } + CategoryType.YES_NO -> when { + "Yes" in entry.selectedValues -> "Yes" + "No" in entry.selectedValues -> "No" + else -> null + } + CategoryType.TIME -> entry.selectedValues.firstOrNull() + CategoryType.DEFAULT -> when { + entry.selectedValues.isEmpty() -> null + entry.selectedValues.size > 2 -> "${entry.selectedValues.size} selected" + else -> entry.selectedValues.joinToString(", ") + } + } +} + +// ── Date picker ─────────────────────────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun DatePickerDialogWrapper( + initial: LocalDate, + minDate: LocalDate? = null, + onConfirm: (LocalDate) -> Unit, + onDismiss: () -> Unit, +) { + val initialMillis = initial.atStartOfDay(ZoneId.of("UTC")).toInstant().toEpochMilli() + val pickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis) + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + val millis = pickerState.selectedDateMillis ?: return@TextButton + val picked = Instant.ofEpochMilli(millis).atZone(ZoneId.of("UTC")).toLocalDate() + if (minDate == null || !picked.isBefore(minDate)) { + onConfirm(picked) + } + }) { Text("OK") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) { + DatePicker(state = pickerState) + } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt new file mode 100644 index 0000000..1f0438e --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogViewModel.kt @@ -0,0 +1,752 @@ +package com.mapgie.goflo.ui.screens.log + +import android.app.Application +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.mapgie.goflo.data.database.entities.Group +import com.mapgie.goflo.data.database.entities.PeriodEntry +import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.data.database.entities.TrackingLog +import com.mapgie.goflo.data.database.entities.TrackingValue +import com.mapgie.goflo.data.preferences.AppPreferencesStore +import com.mapgie.goflo.data.repository.PeriodRepository +import com.mapgie.goflo.data.repository.TrackingLogWithValues +import com.mapgie.goflo.data.repository.TrackingRepository +import com.mapgie.goflo.notifications.ReminderScheduler +import com.mapgie.goflo.widget.GoFloWidget +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * The per-day state of one tracked category on the unified day screen. + * + * Mirrors the fields LogCategoryViewModel keeps for its single category, held + * once per category here. [touched] records whether the user changed anything + * this session: untouched entries are skipped on save, so the screen neither + * fabricates logs for ignored categories nor rewrites stored entries (which + * would re-stamp or clear their recorded time). Pinned categories are the + * exception while the day is on-period: they keep the period screen's + * always-save fan-out semantics. + */ +data class DayMetricEntry( + val selectedValues: Set = emptySet(), + /** Slider position or running count, per the category type. */ + val numericValue: Float? = null, + /** Text entry for numeric_free categories. */ + val freeText: String = "", + /** Per-log notes (500-char cap enforced by the screen). */ + val notes: String = "", + /** Whether to stamp the save with the current time (pre-set from trackAgainstTime). */ + val trackTime: Boolean = false, + val existingLog: TrackingLog? = null, + /** Timed entries already logged this day (increment + trackAgainstTime only). */ + val timedEntries: List = emptyList(), + val touched: Boolean = false, +) + +/** + * UI state for the unified day screen: one day, with an active period as a + * state of that day rather than a separate destination. + */ +data class LogUiState( + val isLoading: Boolean = true, + /** The day being logged or edited. */ + val date: LocalDate = LocalDate.now(), + val toleranceDays: Int = PeriodRepository.DEFAULT_GAP_TOLERANCE_DAYS, + val periodTrackingEnabled: Boolean = true, + + // ── Period state of the day ─────────────────────────────────────────────── + /** The episode covering (or within tolerance reach of) [date], if any. */ + val episodeId: Long? = null, + val episodeStart: LocalDate? = null, + /** Editable explicit episode end ("until"), null = open. */ + val endDate: LocalDate? = null, + /** The end date as loaded, so the End action can be undone before saving. */ + val loadedEndDate: LocalDate? = null, + /** True when [date] itself is a logged period day. */ + val isPeriodDay: Boolean = false, + /** True when [date] falls inside its episode's start..end span. */ + val dayInEpisode: Boolean = false, + /** + * Start of the episode [date] would continue (within gap tolerance) when + * the day is not itself on-period; null when logging would start fresh. + */ + val continuesEpisodeStart: LocalDate? = null, + /** 1-based day number of [date] within its episode, when known. */ + val episodeDayNumber: Int? = null, + /** The user tapped "Period started today"; applied on save. */ + val startPeriodToday: Boolean = false, + /** Episode-level notes (the period screen's Notes field). */ + val periodNotes: String = "", + + // ── Flow (rendered only while the day is on-period) ────────────────────── + val flowCategory: TrackingCategory? = null, + val flowCategoryName: String = "Flow", + val flowOptions: List = emptyList(), + val selectedFlowLabel: String = "Medium", + val flowSliderValue: Float? = null, + + // ── Symptoms ───────────────────────────────────────────────────────────── + val symptomsCategory: TrackingCategory? = null, + val symptomsCategoryName: String = "Symptoms", + val symptomOptions: List = emptyList(), + val symptoms: Set = emptySet(), + val symptomsTouched: Boolean = false, + + // ── Tracked categories (active, non-system) ────────────────────────────── + val groups: List = emptyList(), + val categories: List = emptyList(), + /** Catalog value labels per category id. */ + val categoryValues: Map> = emptyMap(), + /** Per-category day entries, keyed by category id. */ + val entries: Map = emptyMap(), + + // ── Screen state ───────────────────────────────────────────────────────── + /** The category whose input is currently expanded from a grouped card row. */ + val activeCategoryId: Long? = null, + val hasChanges: Boolean = false, + val saved: Boolean = false, + val deleted: Boolean = false, + val error: String? = null, +) { + /** Whether the day renders in its on-period arrangement. */ + val periodActive: Boolean + get() = isPeriodDay || dayInEpisode || startPeriodToday +} + +/** + * ViewModel for the unified `LogScreen(date)`. + * + * Period behaviour (episode continuation, the flow slider mapping, the save + * fan-out into the tracking system, widget and reminder refreshes) delegates + * to [PeriodDaySync] and [PeriodRepository], the same code paths + * [LogPeriodViewModel] uses, so the two surfaces cannot drift. Generic + * category behaviour mirrors [LogCategoryViewModel]'s save rules per entry. + */ +class LogViewModel( + private val repository: PeriodRepository, + private val trackingRepository: TrackingRepository, + private val initialDate: LocalDate, + private val application: Application? = null, + private val preferencesStore: AppPreferencesStore? = null, +) : ViewModel() { + + private val _uiState = MutableStateFlow(LogUiState(date = initialDate)) + val uiState: StateFlow = _uiState.asStateFlow() + + private var optionSubscriptionsStarted = false + + init { + viewModelScope.launch { + val prefs = preferencesStore?.preferences?.first() + _uiState.update { + it.copy( + toleranceDays = prefs?.periodGapToleranceDays + ?: PeriodRepository.DEFAULT_GAP_TOLERANCE_DAYS, + periodTrackingEnabled = prefs?.periodTrackingEnabled ?: true, + ) + } + loadDay(initialDate) + _uiState.update { it.copy(isLoading = false) } + } + } + + // ── Loading ─────────────────────────────────────────────────────────────── + + /** + * Loads (or reloads) everything the screen shows for [date]: the period + * context, the day's stored flow/symptom values, and one [DayMetricEntry] + * per active non-system category. Leaves the form pristine + * (hasChanges = false) because after a load it matches the stored state. + */ + private suspend fun loadDay(date: LocalDate) { + val tolerance = _uiState.value.toleranceDays + + // Period context: episode covering or within tolerance reach of the day. + val periods = repository.getAllPeriodsOnce() + val isPeriodDay = repository.isPeriodDay(date) + val episode = PeriodRepository.periodForDate(periods, date, tolerance) + val epStart = episode?.let { LocalDate.parse(it.startDate) } + val epEndStored = episode?.endDate?.let { LocalDate.parse(it) } + val dayInEpisode = epStart != null && + !date.isBefore(epStart) && + (epEndStored == null || !date.isAfter(epEndStored)) + // Opening a day just past the stored end (within tolerance) extends the + // end to that day, so saving continues the period instead of trimming + // the new day away — same rule as LogPeriodViewModel's init. + val effectiveEnd = + if (epEndStored != null && date.isAfter(epEndStored)) date else epEndStored + + // Flow + symptoms stored values for this day. + val flowCat = trackingRepository.getSystemCategoryByKey("flow") + val symptomsCat = trackingRepository.getSystemCategoryByKey("symptoms") + var editFlowLabel: String? = null + var editFlowSlider: Float? = null + if (flowCat != null) { + val raw = trackingRepository.getExistingLog(date, flowCat.id)?.values?.firstOrNull() + if (raw != null) { + if (flowCat.categoryType == "numeric_slider") { + editFlowSlider = raw.toFloatOrNull() + editFlowLabel = PeriodDaySync.flowLabelForSliderValue(editFlowSlider?.toInt() ?: 3) + } else { + editFlowLabel = raw + } + } + } + val editSymptoms = symptomsCat?.let { + trackingRepository.getExistingLog(date, it.id)?.values?.toSet() + } + val selectedFlow = editFlowLabel ?: "Medium" + val sliderValue = editFlowSlider ?: if (flowCat?.categoryType == "numeric_slider") { + PeriodDaySync.flowLabelToSliderValue(selectedFlow) + } else null + + // Tracked categories, their groups, catalogs, and this day's entries. + val groups = trackingRepository.getAllGroupsOnce() + val categories = trackingRepository.getActiveCategories().first().filter { !it.isSystem } + val valuesMap = mutableMapOf>() + val entriesMap = mutableMapOf() + for (cat in categories) { + valuesMap[cat.id] = trackingRepository.getValuesForCategoryOnce(cat.id).map { it.label } + entriesMap[cat.id] = loadEntry(cat, date) + } + + _uiState.update { state -> + state.copy( + date = date, + episodeId = episode?.id, + episodeStart = epStart, + endDate = effectiveEnd, + loadedEndDate = effectiveEnd, + isPeriodDay = isPeriodDay, + dayInEpisode = dayInEpisode, + continuesEpisodeStart = + if (epStart != null && !isPeriodDay && !dayInEpisode) epStart else null, + episodeDayNumber = epStart?.let { PeriodDaySync.dayNumber(minOf(it, date), date) }, + startPeriodToday = false, + periodNotes = episode?.notes ?: "", + flowCategory = flowCat, + flowCategoryName = flowCat?.name ?: state.flowCategoryName, + selectedFlowLabel = selectedFlow, + flowSliderValue = sliderValue, + symptomsCategory = symptomsCat, + symptomsCategoryName = symptomsCat?.name ?: state.symptomsCategoryName, + symptoms = editSymptoms ?: emptySet(), + symptomsTouched = false, + groups = groups, + categories = categories, + categoryValues = valuesMap, + entries = entriesMap, + activeCategoryId = null, + hasChanges = false, + ) + } + + // Keep the flow/symptom option chips live after catalog edits + // (e.g. the inline Add Symptom dialog). + if (!optionSubscriptionsStarted) { + optionSubscriptionsStarted = true + if (flowCat != null) { + viewModelScope.launch { + trackingRepository.getValuesForCategory(flowCat.id).collect { values -> + _uiState.update { it.copy(flowOptions = values) } + } + } + } + if (symptomsCat != null) { + viewModelScope.launch { + trackingRepository.getValuesForCategory(symptomsCat.id).collect { values -> + _uiState.update { it.copy(symptomOptions = values) } + } + } + } + } + } + + /** Loads one category's stored entry for [date] into a [DayMetricEntry]. */ + private suspend fun loadEntry(cat: TrackingCategory, date: LocalDate): DayMetricEntry { + val timed = cat.categoryType == "increment" && cat.trackAgainstTime + val timedEntries = + if (timed) trackingRepository.getLogsForDateAndCategory(date, cat.id) else emptyList() + // allowMultiple categories always start a fresh entry, matching + // LogCategoryViewModel's new-entry behaviour. + val existing = if (timed || cat.allowMultiple) null + else trackingRepository.getExistingLog(date, cat.id) + val numeric = + if (cat.categoryType == "numeric_slider" || cat.categoryType == "increment") + existing?.values?.firstOrNull()?.toFloatOrNull() + else null + val freeText = if (cat.categoryType == "numeric_free") + existing?.values?.firstOrNull() ?: "" else "" + return DayMetricEntry( + selectedValues = existing?.values?.toSet() ?: emptySet(), + numericValue = numeric, + freeText = freeText, + notes = existing?.log?.notes ?: "", + trackTime = cat.trackAgainstTime, + existingLog = existing?.log, + timedEntries = timedEntries, + ) + } + + private suspend fun reloadEntry(categoryId: Long) { + val state = _uiState.value + val cat = state.categories.firstOrNull { it.id == categoryId } ?: return + val fresh = loadEntry(cat, state.date) + _uiState.update { it.copy(entries = it.entries + (categoryId to fresh)) } + } + + // ── Day switching ───────────────────────────────────────────────────────── + + /** + * Changes the day being shown and reloads every section's stored values + * for it, so the form always reflects the selected day (the same rule as + * LogCategoryViewModel.setDate). Unsaved edits are guarded by the screen + * before this is called. + */ + fun setDate(newDate: LocalDate) { + if (newDate == _uiState.value.date) return + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + loadDay(newDate) + _uiState.update { it.copy(isLoading = false) } + } + } + + // ── Period actions ──────────────────────────────────────────────────────── + + /** Marks the day to be logged as a period day (starting or continuing one) on save. */ + fun startPeriodToday() = _uiState.update { + it.copy(startPeriodToday = true, hasChanges = true) + } + + fun undoStartPeriod() = _uiState.update { + it.copy(startPeriodToday = false, hasChanges = true) + } + + /** Moves the episode start (existing episodes only). */ + fun setStartDate(date: LocalDate) = _uiState.update { state -> + if (state.episodeId == null) return@update state + val end = if (state.endDate != null && date.isAfter(state.endDate)) null else state.endDate + state.copy( + episodeStart = date, + endDate = end, + episodeDayNumber = PeriodDaySync.dayNumber(date, state.date), + hasChanges = true, + ) + } + + fun setEndDate(date: LocalDate?) = _uiState.update { + it.copy(endDate = date, hasChanges = true) + } + + /** The footer's End action: close the period on the day being logged. */ + fun endPeriodOnThisDay() = _uiState.update { + it.copy(endDate = it.date, hasChanges = true) + } + + /** Undoes the End action, restoring the end date as loaded. */ + fun undoEndPeriod() = _uiState.update { + it.copy(endDate = it.loadedEndDate, hasChanges = true) + } + + fun setFlowLevel(label: String) = _uiState.update { + it.copy(selectedFlowLabel = label, hasChanges = true) + } + + fun setFlowSliderValue(value: Float) = _uiState.update { state -> + state.copy( + flowSliderValue = value, + selectedFlowLabel = PeriodDaySync.flowLabelForSliderValue(value.toInt()), + hasChanges = true, + ) + } + + fun toggleSymptom(label: String) = _uiState.update { state -> + val updated = if (label in state.symptoms) state.symptoms - label else state.symptoms + label + state.copy(symptoms = updated, symptomsTouched = true, hasChanges = true) + } + + /** + * Adds [name] as a new option in the symptoms catalog and selects it for + * this day. The catalog insert is fire-and-forget; the selection is + * immediate. Same behaviour as the period screen's Add Symptom dialog. + */ + fun addNewSymptomToLibrary(name: String) { + val trimmed = name.trim() + if (trimmed.isBlank()) return + viewModelScope.launch { + val sympCat = trackingRepository.getSystemCategoryByKey("symptoms") ?: return@launch + trackingRepository.addValueToCategory(sympCat.id, trimmed) + } + _uiState.update { state -> + state.copy(symptoms = state.symptoms + trimmed, symptomsTouched = true, hasChanges = true) + } + } + + fun setPeriodNotes(notes: String) = _uiState.update { + it.copy(periodNotes = notes, hasChanges = true) + } + + fun disablePeriodTracking() { + viewModelScope.launch { preferencesStore?.setPeriodTrackingEnabled(false) } + } + + // ── Category entry mutators ─────────────────────────────────────────────── + + private fun updateEntry(categoryId: Long, transform: (DayMetricEntry) -> DayMetricEntry) = + _uiState.update { state -> + val entry = state.entries[categoryId] ?: DayMetricEntry() + state.copy( + entries = state.entries + (categoryId to transform(entry)), + hasChanges = true, + ) + } + + fun toggleEntryValue(categoryId: Long, label: String) = updateEntry(categoryId) { entry -> + val selected = if (label in entry.selectedValues) entry.selectedValues - label + else entry.selectedValues + label + entry.copy(selectedValues = selected, touched = true) + } + + /** Replaces the whole selection set (chip sets, and yes_no/time single labels). */ + fun setEntrySelection(categoryId: Long, values: Set) = updateEntry(categoryId) { + it.copy(selectedValues = values, touched = true) + } + + fun setEntryNumeric(categoryId: Long, value: Float) = updateEntry(categoryId) { + it.copy(numericValue = value, touched = true) + } + + fun setEntryFreeText(categoryId: Long, text: String) = updateEntry(categoryId) { + it.copy(freeText = text, touched = true) + } + + fun setEntryNotes(categoryId: Long, notes: String) = updateEntry(categoryId) { + it.copy(notes = notes, touched = true) + } + + fun setEntryTrackTime(categoryId: Long, track: Boolean) = updateEntry(categoryId) { + it.copy(trackTime = track, touched = true) + } + + fun setActiveCategory(categoryId: Long?) = _uiState.update { + it.copy(activeCategoryId = categoryId) + } + + /** Deletes a category's existing log for this day and reloads its entry. */ + fun deleteEntry(categoryId: Long) { + val log = _uiState.value.entries[categoryId]?.existingLog ?: return + viewModelScope.launch { + runCatching { + trackingRepository.deleteLog(log) + reloadEntry(categoryId) + }.onFailure { + _uiState.update { s -> s.copy(error = "Could not delete the entry. Please try again.") } + } + } + } + + /** Adds a new time-stamped increment entry immediately (increment + trackAgainstTime). */ + fun addTimedIncrement(categoryId: Long) { + val state = _uiState.value + if (state.isLoading) return + val time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) + viewModelScope.launch { + runCatching { + trackingRepository.saveLog( + date = state.date, + categoryId = categoryId, + selectedValues = setOf("1"), + notes = "", + allowMultiple = true, + loggedAt = time, + ) + reloadEntry(categoryId) + }.onFailure { + _uiState.update { s -> s.copy(error = "Could not log the entry. Please try again.") } + } + } + } + + /** Deletes a specific timed entry (increment + trackAgainstTime undo). */ + fun deleteTimedEntry(categoryId: Long, log: TrackingLog) { + viewModelScope.launch { + runCatching { + trackingRepository.deleteLog(log) + reloadEntry(categoryId) + }.onFailure { + _uiState.update { s -> s.copy(error = "Could not delete the entry. Please try again.") } + } + } + } + + // ── Re-filing (the header switcher) ────────────────────────────────────── + + /** + * Re-files the value entered under [fromId] to the category [toId]: the + * entered (unsaved) value is carried over, serialised through the same + * rules a save would use, and the source entry reverts to its stored + * state. Switching only changes what the entry is filed under; nothing is + * saved until Save. When the source has no unsaved edits this is a plain + * focus switch. + */ + fun refileEntry(fromId: Long, toId: Long) { + val state = _uiState.value + if (fromId == toId) { + _uiState.update { it.copy(activeCategoryId = toId) } + return + } + val fromCat = state.categories.firstOrNull { it.id == fromId } + val toCat = state.categories.firstOrNull { it.id == toId } + val fromEntry = state.entries[fromId] + if (fromCat == null || toCat == null || fromEntry == null || !fromEntry.touched) { + _uiState.update { it.copy(activeCategoryId = toId) } + return + } + val labels = serialisedValues(fromCat, fromEntry) + viewModelScope.launch { + val reset = loadEntry(fromCat, state.date) + _uiState.update { s -> + val target = s.entries[toId] ?: DayMetricEntry(trackTime = toCat.trackAgainstTime) + val refiled = if (labels.isNullOrEmpty()) target else hydrateEntry(toCat, labels, target) + s.copy( + entries = s.entries + (fromId to reset) + (toId to refiled), + activeCategoryId = toId, + hasChanges = true, + ) + } + } + } + + /** Serialises an entry's current value to the labels a save would store. */ + private fun serialisedValues(cat: TrackingCategory, entry: DayMetricEntry): Set? = + when (cat.categoryType) { + "numeric_slider" -> entry.numericValue?.let { + setOf(formatNumericValue(it, cat.allowDecimals)) + } + "numeric_free" -> entry.freeText.trim().takeIf { it.isNotEmpty() }?.let { setOf(it) } + "increment" -> entry.numericValue?.toInt()?.takeIf { it > 0 }?.let { setOf(it.toString()) } + else -> entry.selectedValues.takeIf { it.isNotEmpty() } + } + + /** Hydrates stored-shape labels into the state fields [cat]'s input reads. */ + private fun hydrateEntry( + cat: TrackingCategory, + labels: Set, + base: DayMetricEntry, + ): DayMetricEntry = when (cat.categoryType) { + "numeric_slider", "increment" -> + base.copy(numericValue = labels.firstOrNull()?.toFloatOrNull(), touched = true) + "numeric_free" -> + base.copy(freeText = labels.firstOrNull() ?: "", touched = true) + else -> + base.copy(selectedValues = labels, touched = true) + } + + // ── Saving ──────────────────────────────────────────────────────────────── + + /** + * Saves the day. When the day is on-period (or being started), the period + * path mirrors LogPeriodViewModel.save(): mark the day, apply episode + * boundary edits, write episode meta, then fan the day's flow out to the + * tracking system, and refresh widgets and prediction reminders. Symptoms + * and every tracked category then save through the shared per-day rules. + */ + fun save() { + val state = _uiState.value + if (state.isLoading) return + viewModelScope.launch { + try { + val tolerance = state.toleranceDays + val periodSave = state.periodActive + if (periodSave) { + val episode: PeriodEntry? = if (state.episodeId != null) { + repository.logPeriodDay(state.date, tolerance) + repository.updateEpisode( + id = state.episodeId, + start = state.episodeStart ?: state.date, + end = state.endDate, + notes = state.periodNotes, + toleranceDays = tolerance, + ) + } else if (state.endDate != null && !state.endDate.isBefore(state.date)) { + repository.logPeriodRange(state.date, state.endDate, tolerance) + } else { + repository.logPeriodDay(state.date, tolerance) + } + if (episode != null) { + repository.updateEpisodeMeta( + id = episode.id, + notes = state.periodNotes, + flowLevel = state.selectedFlowLabel, + ) + } + PeriodDaySync.syncFlowToTrackingLog( + trackingRepository, state.date, state.selectedFlowLabel, state.flowSliderValue, + ) + } + + // Symptoms: period saves always mirror the set (parity with the + // period screen, where an emptied set deletes the day's log); + // otherwise only when the user touched them, so an off-period + // save never rewrites an untouched symptoms log. + if (periodSave || state.symptomsTouched) { + PeriodDaySync.syncSymptomsToTrackingLog( + trackingRepository, state.date, state.symptoms, + ) + } + + saveCategoryEntries(state, periodSave) + + if (periodSave) { + application?.let { GoFloWidget.updateAllWidgets(it) } + // Saving a period day changes the cycle predictions; failure + // must not report the (already successful) save as failed. + application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } } + } + _uiState.update { it.copy(saved = true) } + } catch (e: Exception) { + _uiState.update { it.copy(error = "Could not save entry. Please try again.") } + } + } + } + + private suspend fun saveCategoryEntries(state: LogUiState, periodSave: Boolean) { + for (cat in state.categories) { + // Timed increments save per tap; never through the day save. + if (cat.categoryType == "increment" && cat.trackAgainstTime) continue + val entry = state.entries[cat.id] ?: continue + val pinnedContext = periodSave && cat.showInLogPeriod + val values: Set? = if (pinnedContext) { + // Exact parity with the period screen's pinned fan-out + // (slider falls back to min, count saves including 0). + PeriodDaySync.computePinnedValues( + cat, entry.numericValue, entry.freeText, entry.selectedValues, + ) + } else { + // Only touched entries save: an ignored category must neither + // gain a fabricated log nor have its stored entry rewritten + // (rewriting would re-stamp or clear its recorded time). + if (!entry.touched) null + else entryValuesToSave(cat, entry) + } + if (values == null) continue + val loggedAt = if (entry.trackTime) { + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) + } else "" + val existing = entry.existingLog + if (existing != null) { + trackingRepository.updateLogInPlace(existing, values, entry.notes, loggedAt) + } else { + trackingRepository.saveLog( + date = state.date, + categoryId = cat.id, + selectedValues = values, + notes = entry.notes, + // The period screen's pinned fan-out always upserts the + // day's single log (allowMultiple forced off) — keep that, + // so repeated period-day saves never stack duplicates. + allowMultiple = if (pinnedContext) false else cat.allowMultiple, + loggedAt = loggedAt, + ) + } + } + } + + /** + * LogCategoryViewModel.save()'s per-type rules, applied per entry: an + * unset slider falls back to its displayed minimum, empty free numeric + * input and a count of zero or less record nothing (the old screen blocks + * those saves; here the category is skipped and any existing log is left + * untouched), and label types persist the selection set. + */ + private fun entryValuesToSave(cat: TrackingCategory, entry: DayMetricEntry): Set? = + when (cat.categoryType) { + "numeric_slider" -> { + val v = entry.numericValue ?: cat.numericMin + setOf(formatNumericValue(v, cat.allowDecimals)) + } + "numeric_free" -> { + val text = entry.freeText.trim() + if (text.isEmpty()) null else setOf(text) + } + "increment" -> { + val count = entry.numericValue?.toInt() ?: 0 + if (count <= 0) null else setOf(count.toString()) + } + // default chips, yes_no ("Yes"/"No") and time ("HH:mm") persist + // their labels straight from the selection set. + else -> entry.selectedValues + } + + private fun formatNumericValue(v: Float, allowDecimals: Boolean): String = + if (allowDecimals) "%.1f".format(v) else v.toInt().toString() + + // ── Period day removal and episode deletion ─────────────────────────────── + + /** + * Removes this day from the period without touching the day's own tracking + * logs — a flow or symptom logged on a day that turns out not to be a + * period day is still a valid, dated record. + */ + fun removeDay() { + val state = _uiState.value + viewModelScope.launch { + try { + repository.unlogPeriodDay(state.date, state.toleranceDays) + application?.let { GoFloWidget.updateAllWidgets(it) } + application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } } + _uiState.update { it.copy(deleted = true) } + } catch (e: Exception) { + _uiState.update { it.copy(error = "Could not remove this day. Please try again.") } + } + } + } + + /** Deletes the entire episode: its days, its row, and its per-day logs. */ + fun deleteEpisode() { + val state = _uiState.value + val id = state.episodeId ?: return + viewModelScope.launch { + try { + val period = repository.getPeriodById(id).first() ?: return@launch + val days = repository.getDaysForEpisode(period, state.toleranceDays) + trackingRepository.deleteLogsForPeriod( + LocalDate.parse(period.startDate), + period.endDate?.let { LocalDate.parse(it) } + ?: days.lastOrNull()?.let { LocalDate.parse(it) }, + ) + repository.deletePeriod(period, state.toleranceDays) + application?.let { GoFloWidget.updateAllWidgets(it) } + application?.let { runCatching { ReminderScheduler.refreshPredictionReminders(it) } } + _uiState.update { it.copy(deleted = true) } + } catch (e: Exception) { + _uiState.update { it.copy(error = "Could not delete entry. Please try again.") } + } + } + } + + fun clearError() = _uiState.update { it.copy(error = null) } + + class Factory( + private val repository: PeriodRepository, + private val trackingRepository: TrackingRepository, + private val date: LocalDate, + private val application: Application? = null, + private val preferencesStore: AppPreferencesStore? = null, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return LogViewModel(repository, trackingRepository, date, application, preferencesStore) as T + } + } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/PeriodDaySync.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/PeriodDaySync.kt new file mode 100644 index 0000000..f016f66 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/PeriodDaySync.kt @@ -0,0 +1,138 @@ +package com.mapgie.goflo.ui.screens.log + +import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.data.repository.TrackingRepository +import java.time.LocalDate +import java.time.LocalTime +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit + +/** + * Period-day logic shared between [LogPeriodViewModel] (the standalone period + * screen) and [LogViewModel] (the unified day screen). + * + * Extracted rather than duplicated so the flow slider mapping and the save + * fan-out into the tracking system (which make period data appear under + * Flow/Symptoms/pinned categories in Stats) cannot drift between the two + * surfaces. Behaviour is byte-for-byte the pre-extraction LogPeriodViewModel + * logic. + */ +internal object PeriodDaySync { + + /** + * Maps a flow slider position to the built-in label stored for the day: + * 1 = Spotting, 2 = Light, 4 = Heavy, anything else = Medium. + */ + fun flowLabelForSliderValue(value: Int): String = when (value) { + 1 -> "Spotting" + 2 -> "Light" + 4 -> "Heavy" + else -> "Medium" + } + + /** Inverse mapping; "Medium" and any custom label default to the middle. */ + fun flowLabelToSliderValue(label: String): Float = when (label) { + "Spotting" -> 1f + "Light" -> 2f + "Heavy" -> 4f + else -> 3f + } + + /** 1-based day number of [date] within an episode starting at [start], or null when before it. */ + fun dayNumber(start: LocalDate, date: LocalDate): Int? { + val n = ChronoUnit.DAYS.between(start, date).toInt() + 1 + return if (n >= 1) n else null + } + + /** + * Mirrors the day's flow level into the TrackingLog system so logged days + * appear in the Stats screen under the Flow category. + * No-op if [trackingRepository] is null (e.g. in tests or legacy callers). + */ + suspend fun syncFlowToTrackingLog( + trackingRepository: TrackingRepository?, + date: LocalDate, + selectedFlowLabel: String, + flowSliderValue: Float?, + ) { + val tr = trackingRepository ?: return + val flowCategory = tr.getSystemCategoryByKey("flow") ?: return + if (flowCategory.isArchived) return + val flowLabel = if (flowCategory.categoryType == "numeric_slider") { + val v = flowSliderValue ?: flowLabelToSliderValue(selectedFlowLabel) + v.toInt().toString() + } else { + selectedFlowLabel + } + tr.saveLog( + date = date, + categoryId = flowCategory.id, + selectedValues = setOf(flowLabel), + notes = "", + allowMultiple = false, + ) + } + + /** + * Mirrors the day's symptom set into the TrackingLog system. An empty set + * deletes the day's existing symptoms log (deselecting everything clears + * the record rather than leaving a stale one). + */ + suspend fun syncSymptomsToTrackingLog( + trackingRepository: TrackingRepository?, + date: LocalDate, + symptoms: Set, + ) { + val tr = trackingRepository ?: return + val symptomsCategory = tr.getSystemCategoryByKey("symptoms") ?: return + if (symptomsCategory.isArchived) return + if (symptoms.isEmpty()) { + val existing = tr.getExistingLog(date, symptomsCategory.id) ?: return + tr.deleteLog(existing.log) + } else { + val loggedAt = if (symptomsCategory.trackAgainstTime) { + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) + } else "" + tr.saveLog( + date = date, + categoryId = symptomsCategory.id, + selectedValues = symptoms, + notes = "", + allowMultiple = false, + loggedAt = loggedAt, + ) + } + } + + /** + * The value set a pinned ("Log with period") category saves for the day, + * or null when there is nothing to record: + * - slider: falls back to numericMin so the displayed position always saves + * - free numeric: skipped while empty + * - count: always saves, including 0 (a zero count is meaningful data for a + * category the user chose to track alongside periods) + * - everything else: the selection set, skipped while empty + */ + fun computePinnedValues( + cat: TrackingCategory, + numericValue: Float?, + freeText: String, + selections: Set, + ): Set? = when (cat.categoryType) { + "numeric_slider" -> { + val v = numericValue ?: cat.numericMin + setOf(if (cat.allowDecimals) "%.1f".format(v) else v.toInt().toString()) + } + "numeric_free" -> { + val text = freeText.trim() + if (text.isEmpty()) null else setOf(text) + } + "increment" -> { + val count = numericValue?.toInt() ?: 0 + setOf(count.toString()) + } + else -> { + if (selections.isEmpty()) null else selections + } + } +} diff --git a/changelog/unreleased/unified-day-log-screen.json b/changelog/unreleased/unified-day-log-screen.json new file mode 100644 index 0000000..be7454b --- /dev/null +++ b/changelog/unreleased/unified-day-log-screen.json @@ -0,0 +1,7 @@ +{ + "bump": "minor", + "added": [ + "New unified day log screen (preview): period, flow, symptoms, and every tracked category for a day in one place, opened from the day sheet on the calendar", + "Re-file an entry from the day log: tap a category's name to file the value you entered under a different category, organised by group" + ] +} diff --git a/docs/design/logging-redesign/PLAN.md b/docs/design/logging-redesign/PLAN.md index e41dcf8..092d527 100644 --- a/docs/design/logging-redesign/PLAN.md +++ b/docs/design/logging-redesign/PLAN.md @@ -207,7 +207,7 @@ Each phase is a shippable PR. Order is deliberate: additive foundations first (r | 2 — Group data model | Done | `claude/logging-redesign-phase-2-nuaywv` | 24 | Colour-inheritance deviation (keep existing colours, opt-in `"inherit"`) confirmed with owner. `"inherit"` is a sentinel constant, not a `CategoryColor` entry, so the Phase 1 picker does not offer it. Group methods live in `TrackingRepository` (new nullable `groupDao` ctor param) rather than a separate repository. No FK on `groupId`; `deleteGroup` unfiles members first. Migration test is a JVM test (`Migration23To24Test`) driving the real migration through sqlite-jdbc, since the project has no instrumented tests and `exportSchema = false`. | | 3 — Component library | Done | `claude/logging-redesign-phase-3` | 24 (unchanged) | 12 primitives + `MetricInput` stub in `ui/components/`, previews only — no screen consumes them yet. Deviations: `RolePicker` built standalone (visuals mirrored from the Phase 1 picker; `ManageCategoriesScreen` left untouched per this phase's "existing screens compile unchanged" rule — rewire in Phase 6/7); tonal container fills for arbitrary role colours derived via `roleContainerTint` (lerp toward surface, house `ordinalShade` pattern) since M3 `ColorScheme` has no containers for derived/fixed roles; `ToneHero` word uses onSurface on the tint (contrast-safe in every palette) with the role carried by the container; `StepScale` gained a `name` param for its single-control announcement plus per-step `customActions` so TalkBack can still operate it. `MetricValue`/`MetricConfig` value types defined now, incl. YesNo/TimeOfDay variants for Phase 4. | | 4 — MetricInput + Yes/No + Time | Done | `claude/logging-redesign-phase-4` | 24 (unchanged) | §8 decision #3 resolved by the owner: Yes/No and Time store value-label strings ("Yes"/"No"; 24h "HH:mm") in `tracking_log_values` — no new columns, no migration. `LogCategoryScreen` renders every non-timed type through `MetricInput` (timed increment stays screen-driven, now rendering the `Timeline` primitive); rating scales ≤10 whole steps render as `StepScale` (plan §2 rule 1), wider/decimal ranges keep the parity slider incl. stepped whole-number behaviour. `TrackingCategory.isNumeric` re-defined from "not default" to an explicit numeric-type list so yes_no/time chart as label categories in Stats. `PinnedCategoryInput` gained additive yes_no/time branches delegating to `MetricInput` (existing four branches untouched; full replacement stays Phase 5). New `TimeField` primitive added to `ui/components/`. Editing a yes_no/time category still opens the default value-catalog editor (harmless; redesigned in Phase 7). | -| 5 — Unified LogScreen | Not started | | | Consider sub-PRs. | +| 5 — Unified LogScreen | Done | `claude/logging-redesign-phase-5` | 24 (unchanged) | New `LogScreen(date)` + `LogViewModel` behind additive route `log_day?date={date}`; LogPeriod/LogCategory routes untouched and all entry points still use them — the only new entry is an opt-in "Try the new day log (preview)" row in `DayLogSheet` (5d entry-point flip deliberately deferred). Period logic shared with `LogPeriodViewModel` via extracted `PeriodDaySync` (flow mapping, flow/symptom sync, pinned-value rules) rather than copied. Deviations: (1) re-file opens from the metric's own header (name is the button) as well as the screen title — on a whole-day surface the title sheet is day-switch + jump, and re-filing from an entry's own name is unambiguous; incompatible input shapes transfer via the serialised value labels. (2) Day-level Notes bind to episode notes and so render only while the day is on-period; per-log notes are editable per metric ("Add note"). (3) Off-period saves only write categories the user touched (no fabricated logs); pinned categories keep the exact period-screen fan-out semantics while on-period. (4) allowMultiple (non-timed) categories always start a fresh entry on the day screen (matching LogCategoryViewModel new-entry behaviour); editing a specific one of several same-day logs stays on LogCategory via the day sheet. | | 6 — What You Track home | Not started | | | | | 7 — Create/edit + scale + alarms | Not started | | | Decide categoryType mutability. | | 8 — Cleanup & removal | Not started | | | Gate on parity checklist. | diff --git a/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md b/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md index 2b96c0d..43fe83b 100644 --- a/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md +++ b/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md @@ -9,6 +9,8 @@ > > **Phase 4 drift (branch `claude/logging-redesign-phase-4`):** `LogCategoryScreen` no longer contains the per-type section composables described in §2 — every non-timed input renders through the `MetricInput` facade (`ui/components/MetricInput.kt`), and the timed-increment path renders the `Timeline` primitive via a screen-level `TimedIncrementTimeline`. The screen keeps a small `when` only to map `LogCategoryUiState` onto a `MetricValue` (`metricValueFor`) and to frame card vs bare-chip layouts. Two new `categoryType` strings exist: `"yes_no"` (stores "Yes"/"No" value labels) and `"time"` (stores 24h "HH:mm" value labels); both flow through `LogCategoryUiState.selectedValues` as a single-label set, and `LogCategoryViewModel.save()`'s else-branch persists them. `PinnedCategoryInput` in `LogPeriodScreen` gained additive `"yes_no"`/`"time"` branches delegating to `MetricInput` (its four pre-existing branches and `LogPeriodViewModel.computePinnedValues` are unchanged; the new types save through the existing else/selection-set path plus a new `setPinnedSingleValue`). Line numbers below refer to the pre-Phase-4 files; the save-flow description in §4 remains accurate. +> **Phase 5 drift (branch `claude/logging-redesign-phase-5`):** a third, additive destination now exists: the unified day screen `LogScreen` (`ui/screens/log/LogScreen.kt`) + `LogViewModel`, route `log_day?date={date}` (`Screen.LogDay`), reached only via an opt-in "Try the new day log (preview)" row in `DayLogSheet` — every pre-existing entry point still targets the two screens below, and both remain registered and byte-for-byte functional. `LogViewModel` holds one `DayMetricEntry` per active non-system category plus the period-day state (episode continuation, flow, symptoms, episode notes) and reuses the period logic through `PeriodDaySync` (`ui/screens/log/PeriodDaySync.kt`), an extraction of `LogPeriodViewModel`'s former private helpers: the 1→Spotting/2→Light/4→Heavy/else-Medium flow mapping, `syncFlowToTrackingLog`, `syncSymptomsToTrackingLog`, and the pinned-category value rules (`computePinnedValues`). `LogPeriodViewModel` now delegates to that object; its public behaviour is unchanged. `LogCategoryScreen`'s `metricConfigFor` and `TimedIncrementTimeline` were widened from `private` to `internal` so `LogScreen` renders the identical config mapping and timed-increment surface. The §4 save-flow description applies to the unified screen as follows: on-period saves run the LogPeriodViewModel sequence (day + episode + meta + fan-out + widget/reminder refresh) and pinned categories keep `computePinnedValues` semantics; off-period saves write only touched categories using `LogCategoryViewModel.save()`'s per-type rules (empty free text / count ≤ 0 skip that category instead of blocking the day). + ## Overview: two truly separate destinations "Log Period" and "Log Category" are **fully separate screens, routes, ViewModels, and repositories**. They share only two small helpers: the `LogEntryTopBar` composable and a private (duplicated) `DatePickerDialogWrapper`. Period logging is a bespoke multi-section day editor on `PeriodRepository`; category logging is a single generic input on `TrackingRepository`.