From 7145bb59f66410d4309b93129c99801fe7d65b4c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 22:30:34 +0000 Subject: [PATCH] Add period detail screen: one episode expanded into its individual days Tapping a period card in History now opens a read-only detail view built from the logging-redesign component library: a ToneHero words the range ("Mar 3 to Mar 8", or "Started Mar 3, ongoing") with length and cycle context, and a single ListCard lists each logged day (from period_days) as "Day N" with its date, the day's flow as a word in the Flow category's role colour, the day's symptoms, and a count of other logged categories. Tapping a day opens the unified day screen for that date; the top bar's Edit action opens the existing period editor, so the previous History tap behaviour stays one tap away. The ViewModel observes the episode row reactively, loads the day range's tracking logs in a fixed number of queries (one for logs, one for their values), and pops back when the episode no longer exists. Day-level data refreshes when the screen returns to composition after an edit. Claude-Session: https://claude.ai/code/session_01PZJLynVBkgLtehJFXffnfg Co-authored-by: Claude --- .../java/com/mapgie/goflo/MainActivity.kt | 20 ++ .../com/mapgie/goflo/ui/navigation/Screen.kt | 11 + .../goflo/ui/screens/history/HistoryScreen.kt | 4 +- .../ui/screens/history/PeriodDetailScreen.kt | 295 ++++++++++++++++++ .../screens/history/PeriodDetailViewModel.kt | 193 ++++++++++++ changelog/unreleased/period-detail-view.json | 9 + .../subsystem-maps/01-logging-screens.md | 2 + 7 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailScreen.kt create mode 100644 app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailViewModel.kt create mode 100644 changelog/unreleased/period-detail-view.json diff --git a/app/src/main/java/com/mapgie/goflo/MainActivity.kt b/app/src/main/java/com/mapgie/goflo/MainActivity.kt index 11ddf73..33c8b0e 100644 --- a/app/src/main/java/com/mapgie/goflo/MainActivity.kt +++ b/app/src/main/java/com/mapgie/goflo/MainActivity.kt @@ -59,6 +59,8 @@ import com.mapgie.goflo.ui.screens.categories.ManageCategoryValuesScreen import com.mapgie.goflo.ui.screens.categories.ManageCategoryValuesViewModel import com.mapgie.goflo.ui.screens.history.HistoryScreen import com.mapgie.goflo.ui.screens.history.HistoryViewModel +import com.mapgie.goflo.ui.screens.history.PeriodDetailScreen +import com.mapgie.goflo.ui.screens.history.PeriodDetailViewModel import com.mapgie.goflo.ui.screens.home.HomeScreen import com.mapgie.goflo.ui.screens.home.HomeViewModel import com.mapgie.goflo.ui.screens.log.LogCategoryScreen @@ -306,6 +308,24 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa HistoryScreen(viewModel = vm, onNavigate = { navController.navigate(it) }) } + composable( + route = Screen.PeriodDetail.route, + arguments = listOf(navArgument("periodId") { type = NavType.LongType }) + ) { backStack -> + val periodId = backStack.arguments?.getLong("periodId") ?: return@composable + val vm: PeriodDetailViewModel = viewModel( + key = "period_detail_$periodId", + factory = PeriodDetailViewModel.Factory( + periodId, app.repository, app.trackingRepository, app.preferencesStore + ) + ) + PeriodDetailScreen( + viewModel = vm, + onBack = { navController.popBackStack() }, + onNavigate = { navController.navigate(it) }, + ) + } + composable(Screen.Stats.route) { val vm: StatsViewModel = viewModel(factory = StatsViewModel.Factory(app.trackingRepository, app.preferencesStore, app.repository)) StatsScreen( 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 600efb4..1d7b750 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 @@ -79,6 +79,17 @@ sealed class Screen(val route: String) { "log_category/$categoryId?logId=$logId" } + // ── Period detail (History drill-in) ─────────────────────────────────────── + + /** + * Read-only view of one period episode expanded into its individual days. + * Opened from a History card; day rows continue to [LogDay] and the Edit + * action continues to [LogPeriod]. + */ + data object PeriodDetail : Screen("period_detail/{periodId}") { + fun forPeriod(periodId: Long) = "period_detail/$periodId" + } + // ── Unified day logging (logging redesign Phase 5) ───────────────────────── /** diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt index ea9e4be..0928e88 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt @@ -173,7 +173,9 @@ fun HistoryScreen( } } }, - onClick = { onNavigate(Screen.LogPeriod.withId(period.id)) }, + // Opens the read-only period detail (day-by-day) view; + // the editor stays reachable from its Edit action. + onClick = { onNavigate(Screen.PeriodDetail.forPeriod(period.id)) }, modifier = Modifier, ) } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailScreen.kt new file mode 100644 index 0000000..5d3bd5b --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailScreen.kt @@ -0,0 +1,295 @@ +package com.mapgie.goflo.ui.screens.history + +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mapgie.goflo.ui.components.HairlineDivider +import com.mapgie.goflo.ui.components.ListCard +import com.mapgie.goflo.ui.components.SectionHeader +import com.mapgie.goflo.ui.components.ToneHero +import com.mapgie.goflo.ui.navigation.Screen +import com.mapgie.goflo.ui.util.effectiveColorToken +import com.mapgie.goflo.ui.util.toCategoryColor +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +private val monthDay = DateTimeFormatter.ofPattern("MMM d") +private val monthDayYear = DateTimeFormatter.ofPattern("MMM d, yyyy") + +/** + * One period episode expanded into its individual days. + * + * A tonal hero words the episode's range and length; a single list card holds + * one row per logged day (day number, date, the day's flow as a word in the + * Flow category's role colour, and a compact line for symptoms and other + * logged categories). Tapping a day opens the unified day screen for that + * date; the top bar's Edit action opens the existing period editor. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PeriodDetailScreen( + viewModel: PeriodDetailViewModel, + onBack: () -> Unit, + onNavigate: (String) -> Unit, +) { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(state.notFound) { + if (state.notFound) onBack() + } + + // Day-level data is a one-shot read: refresh whenever the screen returns + // to composition after a day or the episode was edited underneath it. + var composedBefore by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(Unit) { + if (composedBefore) viewModel.refresh() else composedBefore = true + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Period") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + state.period?.let { period -> + IconButton(onClick = { onNavigate(Screen.LogPeriod.withId(period.id)) }) { + Icon(Icons.Outlined.Edit, contentDescription = "Edit period") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + ) + } + ) { padding -> + val period = state.period + val start = state.startDate + if (period == null || start == null) { + Box( + modifier = Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + if (state.isLoading) CircularProgressIndicator() + } + return@Scaffold + } + + val flowToken = state.flowCategory?.effectiveColorToken(state.groups) ?: "primary" + val flowRole = flowToken.toCategoryColor() + + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Spacer(Modifier.height(4.dp)) + + ToneHero( + word = rangeWording(start, state.endDate), + role = flowRole, + caption = summaryCaption( + lengthDays = state.lengthDays, + ongoing = state.endDate == null, + cycleLengthDays = state.cycleLengthDays, + ), + ) + + SectionHeader( + label = "Day by day", + value = if (state.days.size == 1) "1 day logged" else "${state.days.size} days logged", + ) + ListCard { + state.days.forEachIndexed { index, day -> + if (index > 0) HairlineDivider() + PeriodDayRow( + day = day, + dateText = formatDayDate(day.date), + flowRole = flowRole, + onClick = { onNavigate(Screen.LogDay.forDate(day.date)) }, + ) + } + } + + if (period.notes.isNotBlank()) { + SectionHeader(label = "Notes") + ListCard { + Text( + text = period.notes, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(16.dp), + ) + } + } + + Spacer(Modifier.height(8.dp)) + } + } +} + +// ── Day row ─────────────────────────────────────────────────────────────────── + +/** + * One logged day: "Day N" with its date, the day's flow as a word in the Flow + * category's role colour (the word carries the meaning; the colour reinforces + * it), and a muted second line for symptoms and other logged categories. + */ +@Composable +private fun PeriodDayRow( + day: PeriodDayDetail, + dateText: String, + flowRole: Color, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) + .semantics { this.role = Role.Button } + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "Day ${day.dayNumber}", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = dateText, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + // Tabular figures so the date column aligns across rows. + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } + val secondary = buildList { + if (day.symptoms.isNotEmpty()) add(day.symptoms.joinToString(", ")) + if (day.otherLoggedCount > 0) { + add( + if (day.otherLoggedCount == 1) "1 more logged" + else "${day.otherLoggedCount} more logged" + ) + } + }.joinToString(" · ") + if (secondary.isNotEmpty()) { + Text( + text = secondary, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = day.flowLabel ?: "Not logged", + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = if (day.flowLabel != null) flowRole + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// ── Wording helpers ─────────────────────────────────────────────────────────── + +/** "Mar 3 to Mar 8", "Mar 3 to Mar 8, 2025", or "Started Mar 3, ongoing". */ +private fun rangeWording(start: LocalDate, end: LocalDate?): String { + val currentYear = LocalDate.now().year + if (end == null) { + val startText = + if (start.year == currentYear) monthDay.format(start) else monthDayYear.format(start) + return "Started $startText, ongoing" + } + return when { + start.year != end.year -> + "${monthDayYear.format(start)} to ${monthDayYear.format(end)}" + start.year != currentYear -> + "${monthDay.format(start)} to ${monthDay.format(end)}, ${end.year}" + else -> + "${monthDay.format(start)} to ${monthDay.format(end)}" + } +} + +/** "6 days", "4 days so far", with " · 28-day cycle" appended when known. */ +private fun summaryCaption(lengthDays: Int, ongoing: Boolean, cycleLengthDays: Int?): String { + val length = when { + ongoing && lengthDays == 1 -> "1 day so far" + ongoing -> "$lengthDays days so far" + lengthDays == 1 -> "1 day" + else -> "$lengthDays days" + } + return if (cycleLengthDays != null) "$length · $cycleLengthDays-day cycle" else length +} + +/** "Mar 5" for current-year dates, "Mar 5, 2025" otherwise. */ +private fun formatDayDate(date: LocalDate): String = + if (date.year == LocalDate.now().year) monthDay.format(date) else monthDayYear.format(date) diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailViewModel.kt new file mode 100644 index 0000000..58616a3 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/history/PeriodDetailViewModel.kt @@ -0,0 +1,193 @@ +package com.mapgie.goflo.ui.screens.history + +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.preferences.AppPreferencesStore +import com.mapgie.goflo.data.repository.PeriodRepository +import com.mapgie.goflo.data.repository.TrackingRepository +import com.mapgie.goflo.ui.screens.log.PeriodDaySync +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +/** + * One logged day of a period episode, resolved for display. + * + * [flowLabel] is always a word ("Medium"), never a number: numeric_slider flow + * values are mapped back through [PeriodDaySync.flowLabelForSliderValue], and a + * raw label that does not parse as a number (a legacy chip-mode value) is shown + * as-is. Null when the day has no flow log. + */ +data class PeriodDayDetail( + val date: LocalDate, + /** 1-based day number within the episode (from its start date). */ + val dayNumber: Int, + val flowLabel: String?, + val symptoms: List, + /** Distinct categories (beyond flow and symptoms) logged on this day. */ + val otherLoggedCount: Int, +) + +data class PeriodDetailUiState( + val isLoading: Boolean = true, + /** The episode no longer exists; the screen should pop back. */ + val notFound: Boolean = false, + val period: PeriodEntry? = null, + val startDate: LocalDate? = null, + /** Explicit episode end; null while the episode is ongoing. */ + val endDate: LocalDate? = null, + /** Whole-episode length in days (through today while ongoing). */ + val lengthDays: Int = 0, + /** Days from this episode's start to the next episode's start, when plausible. */ + val cycleLengthDays: Int? = null, + val days: List = emptyList(), + /** The Flow system category, for resolving the day rows' role colour. */ + val flowCategory: TrackingCategory? = null, + val groups: List = emptyList(), +) + +/** + * ViewModel for [PeriodDetailScreen]: loads one episode, its period_days rows, + * and the range's tracking logs (flow word, symptoms, and a count of other + * logged categories per day) in a fixed number of queries. + * + * The episode row is observed reactively; day-level tracking logs are one-shot + * reads, so the screen calls [refresh] when it re-enters composition after a + * day was edited through the unified day screen. + */ +class PeriodDetailViewModel( + private val periodId: Long, + private val repository: PeriodRepository, + private val trackingRepository: TrackingRepository, + private val preferencesStore: AppPreferencesStore? = null, +) : ViewModel() { + + private val _uiState = MutableStateFlow(PeriodDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + /** Bumped by [refresh] to re-run the day-level loads. */ + private val refreshTick = MutableStateFlow(0) + + init { + viewModelScope.launch { + combine(repository.getPeriodById(periodId), refreshTick) { period, _ -> period } + .collect { period -> + if (period == null) { + _uiState.update { it.copy(isLoading = false, notFound = true) } + } else { + runCatching { loadDetails(period) } + _uiState.update { it.copy(isLoading = false) } + } + } + } + } + + /** Reloads the day list; called when the screen returns from a day or the editor. */ + fun refresh() = refreshTick.update { it + 1 } + + private suspend fun loadDetails(period: PeriodEntry) { + val tolerance = preferencesStore?.preferences?.first()?.periodGapToleranceDays + ?: PeriodRepository.DEFAULT_GAP_TOLERANCE_DAYS + val start = LocalDate.parse(period.startDate) + val end = period.endDate?.let { LocalDate.parse(it) } + + // The episode's logged days (period_days is the per-day source of + // truth). Defensive: an episode should always have at least its start + // day logged, but fall back to it rather than rendering nothing. + val dayDates = repository.getDaysForEpisode(period, tolerance) + .mapNotNull { runCatching { LocalDate.parse(it) }.getOrNull() } + .sorted() + .ifEmpty { listOf(start) } + + // One range query for the logs, one for their values (exportTrackingLogs + // batches both), then group by date in memory. + val categories = trackingRepository.getAllCategoriesOnce() + val flowCat = categories.firstOrNull { it.systemKey == "flow" } + val symptomsCat = categories.firstOrNull { it.systemKey == "symptoms" } + val logsByDate = trackingRepository + .exportTrackingLogs(categories.map { it.id }, start, dayDates.last()) + .groupBy { it.log.date } + val groups = trackingRepository.getAllGroupsOnce() + + val days = dayDates.map { date -> + val dayLogs = logsByDate[date.toString()].orEmpty() + val flowRaw = dayLogs + .firstOrNull { it.log.categoryId == flowCat?.id } + ?.values?.firstOrNull() + val flowLabel = flowRaw?.let { raw -> + if (flowCat?.categoryType == "numeric_slider") { + // Slider mode stores the numeric step; a value that does not + // parse is a legacy chip-mode label and stands on its own. + raw.toFloatOrNull() + ?.let { PeriodDaySync.flowLabelForSliderValue(it.toInt()) } + ?: raw + } else raw + } + val symptoms = dayLogs + .firstOrNull { it.log.categoryId == symptomsCat?.id } + ?.values.orEmpty() + val otherCount = dayLogs + .filter { it.log.categoryId != flowCat?.id && it.log.categoryId != symptomsCat?.id } + .distinctBy { it.log.categoryId } + .count() + PeriodDayDetail( + date = date, + dayNumber = ChronoUnit.DAYS.between(start, date).toInt() + 1, + flowLabel = flowLabel, + symptoms = symptoms, + otherLoggedCount = otherCount, + ) + } + + // Cycle context: days to the next episode's start, same plausibility + // bounds as the History list. + val allPeriods = repository.getAllPeriodsOnce().sortedBy { it.startDate } + val nextStart = allPeriods + .firstOrNull { LocalDate.parse(it.startDate).isAfter(start) } + ?.let { LocalDate.parse(it.startDate) } + val cycleLength = nextStart + ?.let { ChronoUnit.DAYS.between(start, it).toInt() } + ?.takeIf { it in 15..60 } + + val lengthEnd = end ?: maxOf(LocalDate.now(), dayDates.last()) + val lengthDays = (ChronoUnit.DAYS.between(start, lengthEnd).toInt() + 1) + .coerceAtLeast(1) + + _uiState.update { + it.copy( + isLoading = false, + notFound = false, + period = period, + startDate = start, + endDate = end, + lengthDays = lengthDays, + cycleLengthDays = cycleLength, + days = days, + flowCategory = flowCat, + groups = groups, + ) + } + } + + class Factory( + private val periodId: Long, + private val repository: PeriodRepository, + private val trackingRepository: TrackingRepository, + private val preferencesStore: AppPreferencesStore? = null, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return PeriodDetailViewModel(periodId, repository, trackingRepository, preferencesStore) as T + } + } +} diff --git a/changelog/unreleased/period-detail-view.json b/changelog/unreleased/period-detail-view.json new file mode 100644 index 0000000..e76cd97 --- /dev/null +++ b/changelog/unreleased/period-detail-view.json @@ -0,0 +1,9 @@ +{ + "bump": "minor", + "added": [ + "New period detail screen: tap a period in History to see it day by day, with each day's flow, symptoms, and other logged entries; tap a day to open its log, or use Edit to change the period's dates and notes" + ], + "changed": [ + "Tapping a period in History now opens the new detail view first; the period editor is one tap away via its Edit action" + ] +} 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 43fe83b..feb9eaf 100644 --- a/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md +++ b/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md @@ -11,6 +11,8 @@ > **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). +> **Period detail drift (branch `claude/period-detail-view`, outside the phased plan):** a read-only `PeriodDetailScreen` (`ui/screens/history/`) at route `period_detail/{periodId}` now sits between a History card tap and the destinations below — its day rows open `Screen.LogDay.forDate(date)` and its Edit action opens `Screen.LogPeriod.withId(id)`; no logging screen or save path changed. + ## 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`.