Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
29 changes: 29 additions & 0 deletions app/src/main/java/com/mapgie/goflo/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
)
}
}
}
}
14 changes: 14 additions & 0 deletions app/src/main/java/com/mapgie/goflo/ui/components/DayLogSheet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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))
}
}
Expand Down
14 changes: 14 additions & 0 deletions app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ fun HomeScreen(
viewModel.clearSelectedDay()
openLogMenuFor(data.date)
},
onOpenDayLog = {
viewModel.clearSelectedDay()
onNavigate(Screen.LogDay.forDate(data.date))
},
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
): MetricConfig = MetricConfig(
Expand Down Expand Up @@ -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<com.mapgie.goflo.data.repository.TrackingLogWithValues>,
onAddOne: () -> Unit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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) {
Expand All @@ -488,27 +448,12 @@ class LogPeriodViewModel(
}

private fun computePinnedValues(cat: TrackingCategory, state: LogPeriodUiState): Set<String>? =
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) }
Expand Down Expand Up @@ -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)
}
}
Loading
Loading