Skip to content

Commit f761bdd

Browse files
mapgieclaude
andauthored
Add period detail screen: one episode expanded into its individual days (#186)
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 <noreply@anthropic.com>
1 parent 327ea3e commit f761bdd

7 files changed

Lines changed: 533 additions & 1 deletion

File tree

app/src/main/java/com/mapgie/goflo/MainActivity.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ import com.mapgie.goflo.ui.screens.categories.ManageCategoryValuesScreen
5959
import com.mapgie.goflo.ui.screens.categories.ManageCategoryValuesViewModel
6060
import com.mapgie.goflo.ui.screens.history.HistoryScreen
6161
import com.mapgie.goflo.ui.screens.history.HistoryViewModel
62+
import com.mapgie.goflo.ui.screens.history.PeriodDetailScreen
63+
import com.mapgie.goflo.ui.screens.history.PeriodDetailViewModel
6264
import com.mapgie.goflo.ui.screens.home.HomeScreen
6365
import com.mapgie.goflo.ui.screens.home.HomeViewModel
6466
import com.mapgie.goflo.ui.screens.log.LogCategoryScreen
@@ -306,6 +308,24 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa
306308
HistoryScreen(viewModel = vm, onNavigate = { navController.navigate(it) })
307309
}
308310

311+
composable(
312+
route = Screen.PeriodDetail.route,
313+
arguments = listOf(navArgument("periodId") { type = NavType.LongType })
314+
) { backStack ->
315+
val periodId = backStack.arguments?.getLong("periodId") ?: return@composable
316+
val vm: PeriodDetailViewModel = viewModel(
317+
key = "period_detail_$periodId",
318+
factory = PeriodDetailViewModel.Factory(
319+
periodId, app.repository, app.trackingRepository, app.preferencesStore
320+
)
321+
)
322+
PeriodDetailScreen(
323+
viewModel = vm,
324+
onBack = { navController.popBackStack() },
325+
onNavigate = { navController.navigate(it) },
326+
)
327+
}
328+
309329
composable(Screen.Stats.route) {
310330
val vm: StatsViewModel = viewModel(factory = StatsViewModel.Factory(app.trackingRepository, app.preferencesStore, app.repository))
311331
StatsScreen(

app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,17 @@ sealed class Screen(val route: String) {
7979
"log_category/$categoryId?logId=$logId"
8080
}
8181

82+
// ── Period detail (History drill-in) ───────────────────────────────────────
83+
84+
/**
85+
* Read-only view of one period episode expanded into its individual days.
86+
* Opened from a History card; day rows continue to [LogDay] and the Edit
87+
* action continues to [LogPeriod].
88+
*/
89+
data object PeriodDetail : Screen("period_detail/{periodId}") {
90+
fun forPeriod(periodId: Long) = "period_detail/$periodId"
91+
}
92+
8293
// ── Unified day logging (logging redesign Phase 5) ─────────────────────────
8394

8495
/**

app/src/main/java/com/mapgie/goflo/ui/screens/history/HistoryScreen.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,9 @@ fun HistoryScreen(
173173
}
174174
}
175175
},
176-
onClick = { onNavigate(Screen.LogPeriod.withId(period.id)) },
176+
// Opens the read-only period detail (day-by-day) view;
177+
// the editor stays reachable from its Edit action.
178+
onClick = { onNavigate(Screen.PeriodDetail.forPeriod(period.id)) },
177179
modifier = Modifier,
178180
)
179181
}
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
package com.mapgie.goflo.ui.screens.history
2+
3+
import androidx.compose.foundation.clickable
4+
import androidx.compose.foundation.layout.Arrangement
5+
import androidx.compose.foundation.layout.Box
6+
import androidx.compose.foundation.layout.Column
7+
import androidx.compose.foundation.layout.Row
8+
import androidx.compose.foundation.layout.Spacer
9+
import androidx.compose.foundation.layout.fillMaxSize
10+
import androidx.compose.foundation.layout.fillMaxWidth
11+
import androidx.compose.foundation.layout.height
12+
import androidx.compose.foundation.layout.heightIn
13+
import androidx.compose.foundation.layout.padding
14+
import androidx.compose.foundation.rememberScrollState
15+
import androidx.compose.foundation.verticalScroll
16+
import androidx.compose.material.icons.Icons
17+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
18+
import androidx.compose.material.icons.filled.ChevronRight
19+
import androidx.compose.material.icons.outlined.Edit
20+
import androidx.compose.material3.CircularProgressIndicator
21+
import androidx.compose.material3.ExperimentalMaterial3Api
22+
import androidx.compose.material3.Icon
23+
import androidx.compose.material3.IconButton
24+
import androidx.compose.material3.MaterialTheme
25+
import androidx.compose.material3.Scaffold
26+
import androidx.compose.material3.Text
27+
import androidx.compose.material3.TopAppBar
28+
import androidx.compose.material3.TopAppBarDefaults
29+
import androidx.compose.runtime.Composable
30+
import androidx.compose.runtime.LaunchedEffect
31+
import androidx.compose.runtime.collectAsState
32+
import androidx.compose.runtime.getValue
33+
import androidx.compose.runtime.mutableStateOf
34+
import androidx.compose.runtime.saveable.rememberSaveable
35+
import androidx.compose.runtime.setValue
36+
import androidx.compose.ui.Alignment
37+
import androidx.compose.ui.Modifier
38+
import androidx.compose.ui.graphics.Color
39+
import androidx.compose.ui.semantics.Role
40+
import androidx.compose.ui.semantics.role
41+
import androidx.compose.ui.semantics.semantics
42+
import androidx.compose.ui.text.TextStyle
43+
import androidx.compose.ui.text.font.FontWeight
44+
import androidx.compose.ui.text.style.TextOverflow
45+
import androidx.compose.ui.unit.dp
46+
import androidx.compose.ui.unit.sp
47+
import com.mapgie.goflo.ui.components.HairlineDivider
48+
import com.mapgie.goflo.ui.components.ListCard
49+
import com.mapgie.goflo.ui.components.SectionHeader
50+
import com.mapgie.goflo.ui.components.ToneHero
51+
import com.mapgie.goflo.ui.navigation.Screen
52+
import com.mapgie.goflo.ui.util.effectiveColorToken
53+
import com.mapgie.goflo.ui.util.toCategoryColor
54+
import java.time.LocalDate
55+
import java.time.format.DateTimeFormatter
56+
57+
private val monthDay = DateTimeFormatter.ofPattern("MMM d")
58+
private val monthDayYear = DateTimeFormatter.ofPattern("MMM d, yyyy")
59+
60+
/**
61+
* One period episode expanded into its individual days.
62+
*
63+
* A tonal hero words the episode's range and length; a single list card holds
64+
* one row per logged day (day number, date, the day's flow as a word in the
65+
* Flow category's role colour, and a compact line for symptoms and other
66+
* logged categories). Tapping a day opens the unified day screen for that
67+
* date; the top bar's Edit action opens the existing period editor.
68+
*/
69+
@OptIn(ExperimentalMaterial3Api::class)
70+
@Composable
71+
fun PeriodDetailScreen(
72+
viewModel: PeriodDetailViewModel,
73+
onBack: () -> Unit,
74+
onNavigate: (String) -> Unit,
75+
) {
76+
val state by viewModel.uiState.collectAsState()
77+
78+
LaunchedEffect(state.notFound) {
79+
if (state.notFound) onBack()
80+
}
81+
82+
// Day-level data is a one-shot read: refresh whenever the screen returns
83+
// to composition after a day or the episode was edited underneath it.
84+
var composedBefore by rememberSaveable { mutableStateOf(false) }
85+
LaunchedEffect(Unit) {
86+
if (composedBefore) viewModel.refresh() else composedBefore = true
87+
}
88+
89+
Scaffold(
90+
topBar = {
91+
TopAppBar(
92+
title = { Text("Period") },
93+
navigationIcon = {
94+
IconButton(onClick = onBack) {
95+
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
96+
}
97+
},
98+
actions = {
99+
state.period?.let { period ->
100+
IconButton(onClick = { onNavigate(Screen.LogPeriod.withId(period.id)) }) {
101+
Icon(Icons.Outlined.Edit, contentDescription = "Edit period")
102+
}
103+
}
104+
},
105+
colors = TopAppBarDefaults.topAppBarColors(
106+
containerColor = MaterialTheme.colorScheme.primaryContainer,
107+
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
108+
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
109+
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
110+
)
111+
)
112+
}
113+
) { padding ->
114+
val period = state.period
115+
val start = state.startDate
116+
if (period == null || start == null) {
117+
Box(
118+
modifier = Modifier.fillMaxSize().padding(padding),
119+
contentAlignment = Alignment.Center,
120+
) {
121+
if (state.isLoading) CircularProgressIndicator()
122+
}
123+
return@Scaffold
124+
}
125+
126+
val flowToken = state.flowCategory?.effectiveColorToken(state.groups) ?: "primary"
127+
val flowRole = flowToken.toCategoryColor()
128+
129+
Column(
130+
modifier = Modifier
131+
.fillMaxSize()
132+
.padding(padding)
133+
.verticalScroll(rememberScrollState())
134+
.padding(horizontal = 16.dp),
135+
verticalArrangement = Arrangement.spacedBy(12.dp),
136+
) {
137+
Spacer(Modifier.height(4.dp))
138+
139+
ToneHero(
140+
word = rangeWording(start, state.endDate),
141+
role = flowRole,
142+
caption = summaryCaption(
143+
lengthDays = state.lengthDays,
144+
ongoing = state.endDate == null,
145+
cycleLengthDays = state.cycleLengthDays,
146+
),
147+
)
148+
149+
SectionHeader(
150+
label = "Day by day",
151+
value = if (state.days.size == 1) "1 day logged" else "${state.days.size} days logged",
152+
)
153+
ListCard {
154+
state.days.forEachIndexed { index, day ->
155+
if (index > 0) HairlineDivider()
156+
PeriodDayRow(
157+
day = day,
158+
dateText = formatDayDate(day.date),
159+
flowRole = flowRole,
160+
onClick = { onNavigate(Screen.LogDay.forDate(day.date)) },
161+
)
162+
}
163+
}
164+
165+
if (period.notes.isNotBlank()) {
166+
SectionHeader(label = "Notes")
167+
ListCard {
168+
Text(
169+
text = period.notes,
170+
style = MaterialTheme.typography.bodyMedium,
171+
color = MaterialTheme.colorScheme.onSurface,
172+
modifier = Modifier.padding(16.dp),
173+
)
174+
}
175+
}
176+
177+
Spacer(Modifier.height(8.dp))
178+
}
179+
}
180+
}
181+
182+
// ── Day row ───────────────────────────────────────────────────────────────────
183+
184+
/**
185+
* One logged day: "Day N" with its date, the day's flow as a word in the Flow
186+
* category's role colour (the word carries the meaning; the colour reinforces
187+
* it), and a muted second line for symptoms and other logged categories.
188+
*/
189+
@Composable
190+
private fun PeriodDayRow(
191+
day: PeriodDayDetail,
192+
dateText: String,
193+
flowRole: Color,
194+
onClick: () -> Unit,
195+
) {
196+
Row(
197+
modifier = Modifier
198+
.fillMaxWidth()
199+
.heightIn(min = 56.dp)
200+
.semantics { this.role = Role.Button }
201+
.clickable(onClick = onClick)
202+
.padding(horizontal = 16.dp, vertical = 10.dp),
203+
verticalAlignment = Alignment.CenterVertically,
204+
horizontalArrangement = Arrangement.spacedBy(8.dp),
205+
) {
206+
Column(
207+
modifier = Modifier.weight(1f),
208+
verticalArrangement = Arrangement.spacedBy(2.dp),
209+
) {
210+
Row(
211+
verticalAlignment = Alignment.CenterVertically,
212+
horizontalArrangement = Arrangement.spacedBy(8.dp),
213+
) {
214+
Text(
215+
text = "Day ${day.dayNumber}",
216+
fontSize = 14.sp,
217+
fontWeight = FontWeight.SemiBold,
218+
color = MaterialTheme.colorScheme.onSurface,
219+
)
220+
Text(
221+
text = dateText,
222+
fontSize = 12.sp,
223+
color = MaterialTheme.colorScheme.onSurfaceVariant,
224+
// Tabular figures so the date column aligns across rows.
225+
style = TextStyle(fontFeatureSettings = "tnum"),
226+
)
227+
}
228+
val secondary = buildList {
229+
if (day.symptoms.isNotEmpty()) add(day.symptoms.joinToString(", "))
230+
if (day.otherLoggedCount > 0) {
231+
add(
232+
if (day.otherLoggedCount == 1) "1 more logged"
233+
else "${day.otherLoggedCount} more logged"
234+
)
235+
}
236+
}.joinToString(" · ")
237+
if (secondary.isNotEmpty()) {
238+
Text(
239+
text = secondary,
240+
fontSize = 12.sp,
241+
color = MaterialTheme.colorScheme.onSurfaceVariant,
242+
maxLines = 2,
243+
overflow = TextOverflow.Ellipsis,
244+
)
245+
}
246+
}
247+
Text(
248+
text = day.flowLabel ?: "Not logged",
249+
fontSize = 14.sp,
250+
fontWeight = FontWeight.SemiBold,
251+
color = if (day.flowLabel != null) flowRole
252+
else MaterialTheme.colorScheme.onSurfaceVariant,
253+
)
254+
Icon(
255+
imageVector = Icons.Default.ChevronRight,
256+
contentDescription = null,
257+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
258+
)
259+
}
260+
}
261+
262+
// ── Wording helpers ───────────────────────────────────────────────────────────
263+
264+
/** "Mar 3 to Mar 8", "Mar 3 to Mar 8, 2025", or "Started Mar 3, ongoing". */
265+
private fun rangeWording(start: LocalDate, end: LocalDate?): String {
266+
val currentYear = LocalDate.now().year
267+
if (end == null) {
268+
val startText =
269+
if (start.year == currentYear) monthDay.format(start) else monthDayYear.format(start)
270+
return "Started $startText, ongoing"
271+
}
272+
return when {
273+
start.year != end.year ->
274+
"${monthDayYear.format(start)} to ${monthDayYear.format(end)}"
275+
start.year != currentYear ->
276+
"${monthDay.format(start)} to ${monthDay.format(end)}, ${end.year}"
277+
else ->
278+
"${monthDay.format(start)} to ${monthDay.format(end)}"
279+
}
280+
}
281+
282+
/** "6 days", "4 days so far", with " · 28-day cycle" appended when known. */
283+
private fun summaryCaption(lengthDays: Int, ongoing: Boolean, cycleLengthDays: Int?): String {
284+
val length = when {
285+
ongoing && lengthDays == 1 -> "1 day so far"
286+
ongoing -> "$lengthDays days so far"
287+
lengthDays == 1 -> "1 day"
288+
else -> "$lengthDays days"
289+
}
290+
return if (cycleLengthDays != null) "$length · $cycleLengthDays-day cycle" else length
291+
}
292+
293+
/** "Mar 5" for current-year dates, "Mar 5, 2025" otherwise. */
294+
private fun formatDayDate(date: LocalDate): String =
295+
if (date.year == LocalDate.now().year) monthDay.format(date) else monthDayYear.format(date)

0 commit comments

Comments
 (0)