diff --git a/LESSONS.md b/LESSONS.md index 27281a4..c56a175 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -184,6 +184,12 @@ State that captures the user's entire current configuration (e.g. "X axis = Cate ### Code Quality / Review +**Text-based unused-import scrubbing wrongly flags Compose's `getValue`/`setValue` delegate imports** +When trimming imports without a compiler (e.g. after deleting half a file in a build-less environment), a "does the identifier appear in the body?" check is right for almost every import but wrong for `androidx.compose.runtime.getValue`/`setValue`: property delegation (`var x by remember { mutableStateOf(...) }`, `val s by flow.collectAsState()`) uses those operators without their names ever appearing in the source. Removing them fails only at compile time. Whitelist the delegate-operator imports in any mechanical scrub, and treat `by` in a file as proof they are needed. + +**Stage a deletion: supersede-and-annotate in one phase, delete against the manifest in the next** +When a new surface replaces old code, cutting the last references and deleting the code in the same change makes the diff unreviewable and the parity argument untestable. The pattern that worked here: the phase that ships the replacement leaves the old code compiled but unreferenced, annotated `@Suppress("unused")` with a comment naming its replacement (a removal manifest in the code itself). The deletion phase then verifies each annotation still holds, deletes, and proves the removal with greps for every deleted symbol and route string. The annotations make the dead set explicit to both reviewers and later sessions, and anything that regrew a reference in between fails the grep instead of silently surviving. + **Branch protection blocks force push — use merge, not rebase, for conflict resolution** When a branch is protected against force push and upstream has moved on, `git rebase origin/main` rewrites local history that can no longer be pushed. The only forward path is `git merge origin/main`, which creates a merge commit but preserves the existing remote history. If both branches claimed the same version string, resolve by bumping the lower-priority branch's version upward in the same merge commit — don't leave the version collision for the reviewer to spot. diff --git a/app/src/main/java/com/mapgie/goflo/MainActivity.kt b/app/src/main/java/com/mapgie/goflo/MainActivity.kt index 33c8b0e..dcf83f2 100644 --- a/app/src/main/java/com/mapgie/goflo/MainActivity.kt +++ b/app/src/main/java/com/mapgie/goflo/MainActivity.kt @@ -63,8 +63,6 @@ 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 -import com.mapgie.goflo.ui.screens.log.LogCategoryViewModel import com.mapgie.goflo.ui.screens.dashboard.DashboardScreen import com.mapgie.goflo.ui.screens.dashboard.DashboardViewModel import com.mapgie.goflo.ui.screens.settings.PrivacyPolicyScreen @@ -206,11 +204,12 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa val appPrefs by app.preferencesStore.preferences.collectAsState(initial = AppPreferences()) val dashboardEnabled = appPrefs.dashboardEnabled - // Deep-link from the Quick Log widget: navigate to the category log screen for today. + // Deep-link from the Quick Log widget: open today's unified day screen + // with that category's input focused. LaunchedEffect(pendingCategoryId) { if (pendingCategoryId != -1L) { navController.navigate( - Screen.LogCategory.newEntry(pendingCategoryId, java.time.LocalDate.now()) + Screen.LogDay.forCategory(java.time.LocalDate.now(), pendingCategoryId) ) } } @@ -377,23 +376,6 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa ) } - composable( - route = Screen.LogPeriod.route, - arguments = listOf( - navArgument("periodId") { type = NavType.LongType; defaultValue = -1L }, - navArgument("startDate") { type = NavType.StringType; nullable = true; defaultValue = null } - ) - ) { backStack -> - val periodId = backStack.arguments?.getLong("periodId") ?: -1L - val startDateStr = backStack.arguments?.getString("startDate") - val prefilledDate = startDateStr?.let { runCatching { java.time.LocalDate.parse(it) }.getOrNull() } - val vm: com.mapgie.goflo.ui.screens.log.LogPeriodViewModel = viewModel( - key = "log_${periodId}_${startDateStr}", - factory = com.mapgie.goflo.ui.screens.log.LogPeriodViewModel.Factory(app.repository, periodId, prefilledDate, app.trackingRepository, app, app.preferencesStore) - ) - com.mapgie.goflo.ui.screens.log.LogPeriodScreen(viewModel = vm, onBack = { navController.popBackStack() }) - } - composable( route = Screen.PinSetup.route, arguments = listOf(navArgument("changing") { @@ -632,56 +614,34 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa ) } - // ── Per-day category logging ───────────────────────────────────────── - - composable( - route = Screen.LogCategory.route, - arguments = listOf( - navArgument("categoryId") { type = NavType.LongType }, - navArgument("date") { type = NavType.StringType; nullable = true; defaultValue = null }, - navArgument("logId") { type = NavType.LongType; defaultValue = -1L } - ) - ) { backStack -> - val categoryId = backStack.arguments?.getLong("categoryId") ?: return@composable - val dateStr = backStack.arguments?.getString("date") - val logId = backStack.arguments?.getLong("logId")?.takeIf { it != -1L } - val prefilledDate = dateStr?.let { runCatching { java.time.LocalDate.parse(it) }.getOrNull() } - val vm: LogCategoryViewModel = viewModel( - key = "log_cat_${categoryId}_${dateStr}_${logId}", - factory = LogCategoryViewModel.Factory( - categoryId = categoryId, - prefilledDate = prefilledDate, - existingLogId = logId, - repository = app.trackingRepository - ) - ) - LogCategoryScreen( - viewModel = vm, - 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). + // ── Unified day logging ────────────────────────────────────────────── + // The one logging destination: a running period is a state of the + // day. Optional deep links focus one category's input or load one + // specific log for in-place editing. composable( route = Screen.LogDay.route, arguments = listOf( - navArgument("date") { type = NavType.StringType; nullable = true; defaultValue = null } + navArgument("date") { type = NavType.StringType; nullable = true; defaultValue = null }, + navArgument("categoryId") { type = NavType.LongType; defaultValue = -1L }, + navArgument("logId") { type = NavType.LongType; defaultValue = -1L }, ) ) { backStack -> val dateStr = backStack.arguments?.getString("date") val date = dateStr?.let { runCatching { java.time.LocalDate.parse(it) }.getOrNull() } ?: java.time.LocalDate.now() + val focusCategoryId = backStack.arguments?.getLong("categoryId")?.takeIf { it != -1L } + val editLogId = backStack.arguments?.getLong("logId")?.takeIf { it != -1L } val vm: com.mapgie.goflo.ui.screens.log.LogViewModel = viewModel( - key = "log_day_$dateStr", + key = "log_day_${dateStr}_${focusCategoryId}_$editLogId", factory = com.mapgie.goflo.ui.screens.log.LogViewModel.Factory( repository = app.repository, trackingRepository = app.trackingRepository, date = date, application = app, preferencesStore = app.preferencesStore, + focusCategoryId = focusCategoryId, + editLogId = editLogId, ) ) com.mapgie.goflo.ui.screens.log.LogScreen( diff --git a/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt b/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt index e7c14eb..a91c513 100644 --- a/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt +++ b/app/src/main/java/com/mapgie/goflo/data/preferences/ReminderPreferences.kt @@ -42,7 +42,7 @@ data class AppPreferences( /** * The tracking category ID to open when the user taps the FAB (Quick Log). * -1L means "Log Period" (the default). Any other value is a TrackingCategory.id - * and opens LogCategoryScreen for that category. + * and opens the unified day screen with that category focused. */ val quickLogCategoryId: Long = -1L, /** Whether to show predicted future period days on the calendar. */ diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/DatePickerDialogWrapper.kt b/app/src/main/java/com/mapgie/goflo/ui/components/DatePickerDialogWrapper.kt new file mode 100644 index 0000000..5c1aefc --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/DatePickerDialogWrapper.kt @@ -0,0 +1,49 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * The one shared Material 3 date-picker dialog (logging redesign Phase 8 + * consolidated the per-screen private copies into this component). + * + * Dates are converted through UTC in both directions so the picked calendar + * day never shifts with the device time zone. [minDate], when set, makes OK a + * no-op for earlier picks (used for period end dates, which cannot precede the + * start). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +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/components/DayLogSheet.kt b/app/src/main/java/com/mapgie/goflo/ui/components/DayLogSheet.kt index 8c31a50..94dcb1a 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 @@ -67,12 +67,10 @@ fun DayLogSheet( onDismiss: () -> Unit, onEditPeriod: (Long) -> Unit, onEditTrackingLog: (categoryId: Long, logId: Long) -> Unit, + /** Opens the full log menu so one category can be picked directly. */ 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, + /** Opens the unified day screen for this day, the standard logging surface. */ + onOpenDayLog: () -> Unit, ) { val sheetState = rememberModalBottomSheetState() @@ -142,7 +140,7 @@ fun DayLogSheet( HorizontalDivider() // Split tracked categories into those logged with the period (the - // ones that appear on the Log Period screen) and everything else. + // ones pinned into the day screen's flow context) and everything else. val periodLinkedCats = if (period != null) { categoryOrder.filter { catId -> logsByCategory[catId]?.firstOrNull()?.category?.showInLogPeriod == true @@ -217,22 +215,20 @@ fun DayLogSheet( HorizontalDivider() } - // ── Log more ────────────────────────────────────────────────────── + // ── Open the day / log more ─────────────────────────────────────── OutlinedButton( - onClick = { onDismiss(); onLogMore() }, + onClick = { onDismiss(); onOpenDayLog() }, modifier = Modifier.fillMaxWidth() ) { - Text("Log more for this day…") + Text("Open day log") } - if (onOpenDayLog != null) { - TextButton( - onClick = { onDismiss(); onOpenDayLog() }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Try the new day log (preview)") - } + TextButton( + onClick = { onDismiss(); onLogMore() }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Log more for this day…") } 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 1d7b750..84d1cf4 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 @@ -9,12 +9,6 @@ sealed class Screen(val route: String) { data object Stats : Screen("stats") data object StatsGrid : Screen("stats_grid") data object Settings : Screen("settings") - data object LogPeriod : Screen("log_period?periodId={periodId}&startDate={startDate}") { - fun withId(periodId: Long, targetDate: LocalDate? = null) = - if (targetDate != null) "log_period?periodId=$periodId&startDate=$targetDate" else "log_period?periodId=$periodId" - val newEntry = "log_period?periodId=-1" - fun newEntryForDate(date: LocalDate) = "log_period?periodId=-1&startDate=$date" - } data object PinSetup : Screen("pin_setup?changing={changing}") { val newPin = "pin_setup?changing=false" val changePin = "pin_setup?changing=true" @@ -61,46 +55,32 @@ sealed class Screen(val route: String) { fun newForCategory(categoryId: Long) = "edit_alarm?alarmId=-1&categoryId=$categoryId" } - // ── Per-day category logging ──────────────────────────────────────────────── - - /** - * Route for logging or editing a tracking category entry. - * - [categoryId] — the TrackingCategory.id to log - * - [date] — ISO 8601 date string; omit to default to today - * - [logId] — the existing TrackingLog.id when editing; omit for a new entry - */ - data object LogCategory : Screen( - "log_category/{categoryId}?date={date}&logId={logId}" - ) { - fun newEntry(categoryId: Long, date: LocalDate) = - "log_category/$categoryId?date=$date" - - fun editEntry(categoryId: Long, logId: Long) = - "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]. + * Opened from a History card; day rows and the top-bar action both + * continue to [LogDay]. */ data object PeriodDetail : Screen("period_detail/{periodId}") { fun forPeriod(periodId: Long) = "period_detail/$periodId" } - // ── Unified day logging (logging redesign Phase 5) ───────────────────────── + // ── Unified day logging ──────────────────────────────────────────────────── /** * 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). + * the day rather than a separate destination. Since Phase 8 of the logging + * redesign this is the only logging destination. * - [date] — ISO 8601 date string; omit to default to today + * - [categoryId] — optional category to focus (expand and scroll context to) + * - [logId] — optional specific TrackingLog.id to load for in-place editing; + * the way to edit one particular log of an allow-multiple category */ - data object LogDay : Screen("log_day?date={date}") { + data object LogDay : Screen("log_day?date={date}&categoryId={categoryId}&logId={logId}") { fun forDate(date: LocalDate) = "log_day?date=$date" + fun forCategory(date: LocalDate, categoryId: Long) = + "log_day?date=$date&categoryId=$categoryId" + fun forLog(date: LocalDate, logId: Long) = "log_day?date=$date&logId=$logId" } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoryColorPickerDialog.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoryColorPickerDialog.kt new file mode 100644 index 0000000..88d8ced --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoryColorPickerDialog.kt @@ -0,0 +1,274 @@ +package com.mapgie.goflo.ui.screens.categories + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.mapgie.goflo.ui.util.CATEGORY_COLOR_OPTIONS +import com.mapgie.goflo.ui.util.CategoryColor +import com.mapgie.goflo.ui.util.toHexColorKey + +// The full HSV colour picker and the colour-token classification helpers, +// shared by ManageCategoriesScreen (adopt-colour rules) and +// CategoryEditScreen's custom fixed-colour slot. Relocated here in Phase 8 +// when the superseded add/edit-appearance dialogs left +// ManageCategoriesScreen.kt. + +/** + * True when the token is a stored hex colour, i.e. any deliberately chosen + * non-theme colour: a fixed swatch or a custom picker colour. Unlike + * [isCustomColorToken] this includes the fixed swatches. The length check is + * not sufficient on its own because "tertiary" is also 8 characters. + */ +internal fun isFixedColorToken(token: String): Boolean { + if (token.length != 8) return false + return CategoryColor.entries.none { it.key == token } +} + +internal fun isCustomColorToken(token: String): Boolean { + if (token.length != 8) return false + val categoryColorKeys = CategoryColor.entries.map { it.key }.toSet() + if (token in categoryColorKeys) return false + val extendedHexKeys = CATEGORY_COLOR_OPTIONS.map { it.toHexColorKey() }.toSet() + return token !in extendedHexKeys +} + +// ── Full HSV colour picker dialog ───────────────────────────────────────────── + +@Composable +internal fun FullColorPickerDialog( + initialColor: Int, + onDismiss: () -> Unit, + onColorSelected: (String) -> Unit +) { + val initHsv = FloatArray(3) + android.graphics.Color.colorToHSV(initialColor, initHsv) + + var hue by remember { mutableStateOf(initHsv[0]) } + var saturation by remember { mutableStateOf(initHsv[1]) } + var value by remember { mutableStateOf(initHsv[2]) } + + val currentArgb by remember(hue, saturation, value) { + derivedStateOf { + android.graphics.Color.HSVToColor(floatArrayOf(hue, saturation, value)) + } + } + var hexInput by remember(currentArgb) { + mutableStateOf("%06X".format(currentArgb and 0xFFFFFF)) + } + var hexError by remember { mutableStateOf(false) } + + fun applyHexInput(input: String) { + hexInput = input.uppercase().filter { it.isLetterOrDigit() }.take(6) + if (hexInput.length == 6) { + runCatching { + val parsed = android.graphics.Color.parseColor("#$hexInput") + val hsv = FloatArray(3) + android.graphics.Color.colorToHSV(parsed, hsv) + hue = hsv[0] + saturation = hsv[1] + value = hsv[2] + hexError = false + }.onFailure { hexError = true } + } else { + hexError = hexInput.isNotEmpty() + } + } + + val previewColor = Color(currentArgb or (0xFF shl 24)) + + Dialog(onDismissRequest = onDismiss) { + Card( + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column( + modifier = Modifier + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Custom Colour", style = MaterialTheme.typography.headlineSmall) + + SaturationValuePanel( + hue = hue, + saturation = saturation, + value = value, + onChanged = { s, v -> saturation = s; value = v } + ) + + HueSlider(hue = hue, onChanged = { hue = it }) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(previewColor) + ) + OutlinedTextField( + value = hexInput, + onValueChange = { applyHexInput(it) }, + label = { Text("Hex") }, + prefix = { Text("#") }, + singleLine = true, + isError = hexError, + modifier = Modifier.weight(1f) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { Text("Cancel") } + Spacer(Modifier.width(8.dp)) + Button(onClick = { + val argb = currentArgb + val hexKey = "FF%06X".format(argb and 0xFFFFFF) + onColorSelected(hexKey) + }) { Text("Done") } + } + } + } + } +} + +// ── Saturation/Value panel ──────────────────────────────────────────────────── + +@Composable +private fun SaturationValuePanel( + hue: Float, + saturation: Float, + value: Float, + onChanged: (saturation: Float, value: Float) -> Unit +) { + val hueColor = Color(android.graphics.Color.HSVToColor(floatArrayOf(hue, 1f, 1f))) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(8.dp)) + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + val w = size.width.toFloat() + val h = size.height.toFloat() + fun updateFromOffset(offset: Offset) { + val s = (offset.x / w).coerceIn(0f, 1f) + val v = (1f - offset.y / h).coerceIn(0f, 1f) + onChanged(s, v) + } + updateFromOffset(down.position) + drag(down.id) { change -> updateFromOffset(change.position) } + } + } + ) { + Box( + modifier = Modifier + .matchParentSize() + .background(Brush.horizontalGradient(listOf(Color.White, hueColor))) + ) + Box( + modifier = Modifier + .matchParentSize() + .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black))) + ) + val thumbX = saturation + val thumbY = 1f - value + Canvas(modifier = Modifier.matchParentSize()) { + val cx = thumbX * size.width + val cy = thumbY * size.height + drawCircle(color = Color.White, radius = 10.dp.toPx(), center = Offset(cx, cy), + style = Stroke(width = 2.dp.toPx())) + drawCircle(color = Color.Black, radius = 12.dp.toPx(), center = Offset(cx, cy), + style = Stroke(width = 1.dp.toPx())) + } + } +} + +// ── Hue slider ──────────────────────────────────────────────────────────────── + +@Composable +private fun HueSlider(hue: Float, onChanged: (Float) -> Unit) { + val hueColors = remember { + listOf( + Color(0xFFFF0000), Color(0xFFFF8000), Color(0xFFFFFF00), Color(0xFF80FF00), + Color(0xFF00FF00), Color(0xFF00FF80), Color(0xFF00FFFF), Color(0xFF0080FF), + Color(0xFF0000FF), Color(0xFF8000FF), Color(0xFFFF00FF), Color(0xFFFF0080), + Color(0xFFFF0000), + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(24.dp) + .clip(CircleShape) + .background(Brush.horizontalGradient(hueColors)) + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + fun updateFromOffset(offset: Offset) { + val h = (offset.x / size.width.toFloat()).coerceIn(0f, 1f) * 360f + onChanged(h) + } + updateFromOffset(down.position) + drag(down.id) { change -> updateFromOffset(change.position) } + } + } + ) { + val thumbX = hue / 360f + Canvas(modifier = Modifier.matchParentSize()) { + val cx = thumbX * size.width + val cy = size.height / 2f + drawCircle(color = Color.White, radius = 10.dp.toPx(), center = Offset(cx, cy), + style = Stroke(width = 2.dp.toPx())) + drawCircle(color = Color.Black, radius = 12.dp.toPx(), center = Offset(cx, cy), + style = Stroke(width = 1.dp.toPx())) + } + } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt index 8760fa8..dd203ef 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesScreen.kt @@ -1,19 +1,9 @@ package com.mapgie.goflo.ui.screens.categories -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.gestures.drag import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -36,7 +26,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -44,7 +33,6 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Archive import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DragHandle @@ -70,7 +58,6 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.SwipeToDismissBox import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text @@ -82,7 +69,6 @@ import androidx.compose.material3.rememberSwipeToDismissBoxState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableFloatStateOf @@ -95,11 +81,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.Role @@ -109,8 +91,6 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import com.mapgie.goflo.data.database.entities.Group @@ -122,15 +102,12 @@ import com.mapgie.goflo.ui.components.RolePicker import com.mapgie.goflo.ui.components.SegmentedToggle import com.mapgie.goflo.ui.components.SwitchRow import com.mapgie.goflo.ui.components.roleContainerTint -import com.mapgie.goflo.ui.util.CATEGORY_COLOR_OPTIONS import com.mapgie.goflo.ui.util.CategoryColor -import com.mapgie.goflo.ui.util.CategoryIcon 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.toCategoryIcon import com.mapgie.goflo.ui.util.toCategoryOnColor -import com.mapgie.goflo.ui.util.toHexColorKey /** * The "What You Track" management home (logging redesign Phase 6). @@ -1470,820 +1447,3 @@ private fun buildCategorySubtitle(category: TrackingCategory): String = buildStr else -> append("Tap to manage values") } } - -// ── Add category dialog ─────────────────────────────────────────────────────── -// Superseded by CategoryEditScreen (logging redesign Phase 7): every create -// entry point now navigates to the 2-step flow instead of opening this dialog. -// Kept unreferenced until Phase 8 removes it against the parity checklist. - -@Suppress("unused") -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun AddCategoryDialog( - onAdd: (name: String, iconName: String, colorToken: String, - categoryType: String, numericMin: Float, numericMax: Float, - allowDecimals: Boolean, numericUnit: String, allowMultiple: Boolean, - showInLogPeriod: Boolean) -> Unit, - onDismiss: () -> Unit, - initialType: String = CategoryType.DEFAULT.key, -) { - var name by rememberSaveable { mutableStateOf("") } - var selectedType by rememberSaveable { mutableStateOf(initialType) } - var numericUnit by rememberSaveable { mutableStateOf("") } - var selectedIconKey by rememberSaveable { mutableStateOf(CategoryIcon.CATEGORY.key) } - var selectedToken by rememberSaveable { mutableStateOf(CategoryColor.SECONDARY.key) } - var minText by rememberSaveable { mutableStateOf("1") } - var maxText by rememberSaveable { mutableStateOf("5") } - var allowDecimals by rememberSaveable { mutableStateOf(false) } - var allowMultiple by rememberSaveable { mutableStateOf(false) } - var showInLogPeriod by rememberSaveable { mutableStateOf(false) } - - // Only the numeric family carries a unit; Yes/No and Time store fixed - // labels ("Yes"/"No", "HH:mm") and need no extra configuration at all. - val isNumericType = selectedType == CategoryType.NUMERIC_SLIDER.key || - selectedType == CategoryType.NUMERIC_FREE.key || - selectedType == CategoryType.INCREMENT.key - // Only the slider type uses a min/max range — free input and increment do not. - val isSliderType = selectedType == CategoryType.NUMERIC_SLIDER.key - - val canAdd by remember(name, isSliderType, minText, maxText) { - derivedStateOf { - name.isNotBlank() && (!isSliderType || ( - minText.toFloatOrNull() != null && maxText.toFloatOrNull() != null && - (minText.toFloatOrNull() ?: 0f) < (maxText.toFloatOrNull() ?: 10f) - )) - } - } - - Dialog(onDismissRequest = onDismiss) { - Card( - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - ) { - Column( - modifier = Modifier - .padding(24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("New Category", style = MaterialTheme.typography.headlineSmall) - - // Name - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - placeholder = { Text("e.g. Mood, Sleep, Exercise…") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - // Type selector - Text( - "Type", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth() - ) { - CategoryType.entries.forEach { type -> - FilterChip( - selected = selectedType == type.key, - onClick = { selectedType = type.key }, - label = { Text(type.displayName, style = MaterialTheme.typography.labelSmall) } - ) - } - } - - // Unit field — shown for any numeric/counter type - AnimatedVisibility( - visible = isNumericType, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - OutlinedTextField( - value = numericUnit, - onValueChange = { numericUnit = it }, - label = { Text("Unit / Key (optional)") }, - placeholder = { Text("e.g. °C, bpm, coffees…") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - } - - // Icon - Text("Icon", style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant) - CategoryIconGrid(selectedKey = selectedIconKey, onSelect = { selectedIconKey = it }) - - // Colour - Text("Colour", style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant) - CategoryColorPicker(selectedToken = selectedToken, onSelect = { selectedToken = it }) - - // ── Numeric range settings (slider only) ────────────────────── - AnimatedVisibility( - visible = isSliderType, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - HorizontalDivider() - } - AnimatedVisibility( - visible = isSliderType, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - NumericSettingsSection( - minText = minText, - onMinChange = { minText = it }, - maxText = maxText, - onMaxChange = { maxText = it }, - allowDecimals = allowDecimals, - onDecimalsToggle = { allowDecimals = it } - ) - } - - // Allow multiple per day (not applicable to Plus One — its counter always uses a single daily log) - if (selectedType != CategoryType.INCREMENT.key) { - HorizontalDivider() - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text("Allow multiple per day", style = MaterialTheme.typography.titleSmall) - Text( - "Log this category more than once on the same day", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch(checked = allowMultiple, onCheckedChange = { allowMultiple = it }) - } - } - - // Log with period - HorizontalDivider() - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text("Log with period", style = MaterialTheme.typography.titleSmall) - Text( - "Show this category on the Log Period screen", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch(checked = showInLogPeriod, onCheckedChange = { showInLogPeriod = it }) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onDismiss) { Text("Cancel") } - Spacer(Modifier.width(8.dp)) - Button( - onClick = { - if (canAdd) onAdd( - name, selectedIconKey, selectedToken, - selectedType, - minText.toFloatOrNull() ?: 0f, - maxText.toFloatOrNull() ?: 10f, - allowDecimals, - numericUnit.trim(), - allowMultiple && selectedType != CategoryType.INCREMENT.key, - showInLogPeriod - ) - }, - enabled = canAdd - ) { Text("Add") } - } - } - } - } -} - -// ── Edit appearance dialog ──────────────────────────────────────────────────── -// Superseded by CategoryEditScreen (logging redesign Phase 7), which covers icon -// and colour editing. Kept unreferenced until Phase 8 removes it against the -// parity checklist. - -@Suppress("unused") -@Composable -internal fun EditAppearanceDialog( - category: TrackingCategory, - onSave: (iconName: String, colorToken: String) -> Unit, - onDismiss: () -> Unit -) { - var selectedIconKey by rememberSaveable { mutableStateOf(category.iconName) } - var selectedToken by rememberSaveable { mutableStateOf(category.colorToken) } - - val previewBubble = selectedToken.toCategoryColor() - val previewIcon = selectedToken.toCategoryOnColor() - - Dialog(onDismissRequest = onDismiss) { - Card( - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - ) { - Column( - modifier = Modifier - .padding(24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // Live preview - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Box( - modifier = Modifier - .size(52.dp) - .clip(CircleShape) - .background(previewBubble), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = selectedIconKey.toCategoryIcon().vector, - contentDescription = null, - tint = previewIcon, - modifier = Modifier.size(28.dp) - ) - } - Column { - Text( - text = "Appearance", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = category.name, - style = MaterialTheme.typography.headlineSmall - ) - } - } - - HorizontalDivider() - - Text("Icon", style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant) - CategoryIconGrid(selectedKey = selectedIconKey, onSelect = { selectedIconKey = it }) - - Text("Colour", style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant) - CategoryColorPicker(selectedToken = selectedToken, onSelect = { selectedToken = it }) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onDismiss) { Text("Cancel") } - Spacer(Modifier.width(8.dp)) - Button(onClick = { onSave(selectedIconKey, selectedToken) }) { Text("Save") } - } - } - } - } -} - -// ── Numeric settings section ────────────────────────────────────────────────── - -/** - * Reusable block shown in both the Add and Edit dialogs for configuring numeric mode. - * Shows min/max/decimal fields directly; the caller's AnimatedVisibility handles - * section visibility based on the selected category type. - */ -@Composable -private fun NumericSettingsSection( - minText: String, onMinChange: (String) -> Unit, - maxText: String, onMaxChange: (String) -> Unit, - allowDecimals: Boolean, onDecimalsToggle: (Boolean) -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - // Min / Max side-by-side - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = minText, - onValueChange = { onMinChange(it) }, - label = { Text("Min") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = maxText, - onValueChange = { onMaxChange(it) }, - label = { Text("Max") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.weight(1f) - ) - } - // Decimal toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text("Allow decimals", style = MaterialTheme.typography.titleSmall) - Text( - "Slider snaps to 0.1 steps instead of whole numbers", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch(checked = allowDecimals, onCheckedChange = onDecimalsToggle) - } - } -} - -// ── Shared picker components ────────────────────────────────────────────────── - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun CategoryIconGrid(selectedKey: String, onSelect: (String) -> Unit) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - maxItemsInEachRow = 5, - ) { - CategoryIcon.entries.forEach { icon -> - val isSelected = icon.key == selectedKey - Box( - modifier = Modifier - .size(52.dp) - .clip(RoundedCornerShape(12.dp)) - .background( - if (isSelected) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceVariant - ) - .clickable { onSelect(icon.key) } - .semantics { - role = Role.RadioButton - selected = isSelected - contentDescription = icon.displayName - }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon.vector, - contentDescription = null, - tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer - else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(26.dp) - ) - } - } - } -} - -/** - * True when the token is a stored hex colour, i.e. any deliberately chosen - * non-theme colour: a fixed swatch or a custom picker colour. Unlike - * [isCustomColorToken] this includes the fixed swatches. The length check is - * not sufficient on its own because "tertiary" is also 8 characters. - */ -internal fun isFixedColorToken(token: String): Boolean { - if (token.length != 8) return false - return CategoryColor.entries.none { it.key == token } -} - -internal fun isCustomColorToken(token: String): Boolean { - if (token.length != 8) return false - val categoryColorKeys = CategoryColor.entries.map { it.key }.toSet() - if (token in categoryColorKeys) return false - val extendedHexKeys = CATEGORY_COLOR_OPTIONS.map { it.toHexColorKey() }.toSet() - return token !in extendedHexKeys -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun CategoryColorPicker(selectedToken: String, onSelect: (String) -> Unit) { - var showFullPicker by rememberSaveable { mutableStateOf(false) } - - val hasCustomColor by remember(selectedToken) { - derivedStateOf { isCustomColorToken(selectedToken) } - } - - if (showFullPicker) { - val initialColor = if (hasCustomColor) { - runCatching { android.graphics.Color.parseColor("#$selectedToken") } - .getOrDefault(android.graphics.Color.RED) - } else { - android.graphics.Color.RED - } - FullColorPickerDialog( - initialColor = initialColor, - onDismiss = { showFullPicker = false }, - onColorSelected = { hexKey -> - onSelect(hexKey) - showFullPicker = false - } - ) - } - - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - - // In-theme roles: pill chips that re-theme with the active palette. - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "In-theme roles", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Text( - text = "Re-theme automatically", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - CategoryColor.entries.forEach { colorOption -> - val isSelected = colorOption.key == selectedToken - val roleColor = colorOption.key.toCategoryColor() - val onRoleColor = colorOption.key.toCategoryOnColor() - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier - .heightIn(min = 48.dp) - .clip(RoundedCornerShape(50)) - .then( - if (isSelected) Modifier.background(roleColor) - else Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(50)) - ) - .clickable { onSelect(colorOption.key) } - .semantics { - role = Role.RadioButton - selected = isSelected - contentDescription = colorOption.displayName - } - .padding(horizontal = 14.dp), - ) { - if (isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = onRoleColor, - modifier = Modifier.size(16.dp) - ) - } else { - Box( - modifier = Modifier - .size(12.dp) - .clip(CircleShape) - .background(roleColor) - ) - } - Text( - text = colorOption.displayName, - style = MaterialTheme.typography.labelLarge, - fontWeight = if (isSelected) FontWeight.Bold else null, - color = if (isSelected) onRoleColor else MaterialTheme.colorScheme.onSurface, - ) - } - } - } - - // Fixed colours: deliberately exempt from theme changes. - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "Fixed colour", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - Text( - text = "Stays put on theme change", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - maxItemsInEachRow = 6, - ) { - CATEGORY_COLOR_OPTIONS.forEach { argb -> - val hexKey = argb.toHexColorKey() - val isSelected = hexKey == selectedToken - val swatchColor = Color(argb) - val onSwatchColor = if (swatchColor.luminance() > 0.35f) Color(0xFF1C1B1F) else Color.White - - Box( - modifier = Modifier - .size(38.dp) - .clip(CircleShape) - .background(swatchColor) - .then( - if (isSelected) - Modifier.border(3.dp, MaterialTheme.colorScheme.primary, CircleShape) - else Modifier - ) - .clickable { onSelect(hexKey) } - .semantics { - role = Role.RadioButton - selected = isSelected - contentDescription = "#$hexKey" - }, - contentAlignment = Alignment.Center - ) { - if (isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = onSwatchColor, - modifier = Modifier.size(18.dp) - ) - } - } - } - - // Custom colour slot - val primaryColor = MaterialTheme.colorScheme.primary - val outlineColor = MaterialTheme.colorScheme.outline - if (hasCustomColor) { - val customArgb = runCatching { selectedToken.toLong(16).toInt() }.getOrDefault(0) - val customColor = Color(customArgb) - val onCustomColor = if (customColor.luminance() > 0.35f) Color(0xFF1C1B1F) else Color.White - Box( - modifier = Modifier - .size(38.dp) - .clip(CircleShape) - .background(customColor) - .border(3.dp, primaryColor, CircleShape) - .clickable { showFullPicker = true } - .semantics { - role = Role.Button - contentDescription = "Custom colour (selected). Tap to change" - }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = onCustomColor, - modifier = Modifier.size(18.dp) - ) - } - } else { - Box( - modifier = Modifier - .size(38.dp) - .clickable { showFullPicker = true } - .semantics { - role = Role.Button - contentDescription = "Choose custom colour" - }, - contentAlignment = Alignment.Center - ) { - Canvas(modifier = Modifier.size(38.dp)) { - val strokePx = 2.dp.toPx() - val radius = (size.minDimension / 2f) - strokePx / 2f - drawCircle( - color = outlineColor, - radius = radius, - style = Stroke( - width = strokePx, - pathEffect = androidx.compose.ui.graphics.PathEffect.dashPathEffect( - floatArrayOf(6f, 4f), 0f - ) - ) - ) - } - Icon( - imageVector = Icons.Default.Add, - contentDescription = "Pick custom colour", - tint = outlineColor, - modifier = Modifier.size(18.dp) - ) - } - } - } - } -} - -// ── Full HSV colour picker dialog ───────────────────────────────────────────── -// Internal: also opened from CategoryEditScreen's custom fixed-colour slot. - -@Composable -internal fun FullColorPickerDialog( - initialColor: Int, - onDismiss: () -> Unit, - onColorSelected: (String) -> Unit -) { - val initHsv = FloatArray(3) - android.graphics.Color.colorToHSV(initialColor, initHsv) - - var hue by remember { mutableStateOf(initHsv[0]) } - var saturation by remember { mutableStateOf(initHsv[1]) } - var value by remember { mutableStateOf(initHsv[2]) } - - val currentArgb by remember(hue, saturation, value) { - derivedStateOf { - android.graphics.Color.HSVToColor(floatArrayOf(hue, saturation, value)) - } - } - var hexInput by remember(currentArgb) { - mutableStateOf("%06X".format(currentArgb and 0xFFFFFF)) - } - var hexError by remember { mutableStateOf(false) } - - fun applyHexInput(input: String) { - hexInput = input.uppercase().filter { it.isLetterOrDigit() }.take(6) - if (hexInput.length == 6) { - runCatching { - val parsed = android.graphics.Color.parseColor("#$hexInput") - val hsv = FloatArray(3) - android.graphics.Color.colorToHSV(parsed, hsv) - hue = hsv[0] - saturation = hsv[1] - value = hsv[2] - hexError = false - }.onFailure { hexError = true } - } else { - hexError = hexInput.isNotEmpty() - } - } - - val previewColor = Color(currentArgb or (0xFF shl 24)) - - Dialog(onDismissRequest = onDismiss) { - Card( - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - ) { - Column( - modifier = Modifier - .padding(24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text("Custom Colour", style = MaterialTheme.typography.headlineSmall) - - SaturationValuePanel( - hue = hue, - saturation = saturation, - value = value, - onChanged = { s, v -> saturation = s; value = v } - ) - - HueSlider(hue = hue, onChanged = { hue = it }) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(previewColor) - ) - OutlinedTextField( - value = hexInput, - onValueChange = { applyHexInput(it) }, - label = { Text("Hex") }, - prefix = { Text("#") }, - singleLine = true, - isError = hexError, - modifier = Modifier.weight(1f) - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - TextButton(onClick = onDismiss) { Text("Cancel") } - Spacer(Modifier.width(8.dp)) - Button(onClick = { - val argb = currentArgb - val hexKey = "FF%06X".format(argb and 0xFFFFFF) - onColorSelected(hexKey) - }) { Text("Done") } - } - } - } - } -} - -// ── Saturation/Value panel ──────────────────────────────────────────────────── - -@Composable -private fun SaturationValuePanel( - hue: Float, - saturation: Float, - value: Float, - onChanged: (saturation: Float, value: Float) -> Unit -) { - val hueColor = Color(android.graphics.Color.HSVToColor(floatArrayOf(hue, 1f, 1f))) - - Box( - modifier = Modifier - .fillMaxWidth() - .height(200.dp) - .clip(RoundedCornerShape(8.dp)) - .pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - val w = size.width.toFloat() - val h = size.height.toFloat() - fun updateFromOffset(offset: Offset) { - val s = (offset.x / w).coerceIn(0f, 1f) - val v = (1f - offset.y / h).coerceIn(0f, 1f) - onChanged(s, v) - } - updateFromOffset(down.position) - drag(down.id) { change -> updateFromOffset(change.position) } - } - } - ) { - Box( - modifier = Modifier - .matchParentSize() - .background(Brush.horizontalGradient(listOf(Color.White, hueColor))) - ) - Box( - modifier = Modifier - .matchParentSize() - .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black))) - ) - val thumbX = saturation - val thumbY = 1f - value - Canvas(modifier = Modifier.matchParentSize()) { - val cx = thumbX * size.width - val cy = thumbY * size.height - drawCircle(color = Color.White, radius = 10.dp.toPx(), center = Offset(cx, cy), - style = Stroke(width = 2.dp.toPx())) - drawCircle(color = Color.Black, radius = 12.dp.toPx(), center = Offset(cx, cy), - style = Stroke(width = 1.dp.toPx())) - } - } -} - -// ── Hue slider ──────────────────────────────────────────────────────────────── - -@Composable -private fun HueSlider(hue: Float, onChanged: (Float) -> Unit) { - val hueColors = remember { - listOf( - Color(0xFFFF0000), Color(0xFFFF8000), Color(0xFFFFFF00), Color(0xFF80FF00), - Color(0xFF00FF00), Color(0xFF00FF80), Color(0xFF00FFFF), Color(0xFF0080FF), - Color(0xFF0000FF), Color(0xFF8000FF), Color(0xFFFF00FF), Color(0xFFFF0080), - Color(0xFFFF0000), - ) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .height(24.dp) - .clip(CircleShape) - .background(Brush.horizontalGradient(hueColors)) - .pointerInput(Unit) { - awaitEachGesture { - val down = awaitFirstDown(requireUnconsumed = false) - fun updateFromOffset(offset: Offset) { - val h = (offset.x / size.width.toFloat()).coerceIn(0f, 1f) * 360f - onChanged(h) - } - updateFromOffset(down.position) - drag(down.id) { change -> updateFromOffset(change.position) } - } - } - ) { - val thumbX = hue / 360f - Canvas(modifier = Modifier.matchParentSize()) { - val cx = thumbX * size.width - val cy = size.height / 2f - drawCircle(color = Color.White, radius = 10.dp.toPx(), center = Offset(cx, cy), - style = Stroke(width = 2.dp.toPx())) - drawCircle(color = Color.Black, radius = 12.dp.toPx(), center = Offset(cx, cy), - style = Stroke(width = 1.dp.toPx())) - } - } -} diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesViewModel.kt index 8b13448..7d77dac 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoriesViewModel.kt @@ -43,43 +43,6 @@ class ManageCategoriesViewModel( initialValue = ManageCategoriesUiState() ) - fun addCategory( - name: String, - iconName: String, - colorToken: String, - categoryType: String = "default", - numericMin: Float = 0f, - numericMax: Float = 10f, - allowDecimals: Boolean = false, - numericUnit: String = "", - allowMultiple: Boolean = false, - showInLogPeriod: Boolean = false, - groupId: Long? = null, - onCreated: (Long) -> Unit = {}, - ) { - if (name.isBlank()) return - viewModelScope.launch { - val id = repository.addCategory( - name = name, - iconName = iconName, - colorToken = colorToken, - categoryType = categoryType, - numericMin = numericMin, - numericMax = numericMax, - allowDecimals = allowDecimals, - numericUnit = numericUnit, - allowMultiple = allowMultiple, - showInLogPeriod = showInLogPeriod, - ) - if (groupId != null) repository.assignCategoryToGroup(id, groupId) - onCreated(id) - } - } - - fun updateCategoryAppearance(id: Long, iconName: String, colorToken: String) { - viewModelScope.launch { repository.updateCategoryAppearance(id, iconName, colorToken) } - } - fun archiveCategory(category: TrackingCategory) { viewModelScope.launch { repository.archiveCategory(category.id) } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoryValuesScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoryValuesScreen.kt index 3fda02f..257f6f9 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoryValuesScreen.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoryValuesScreen.kt @@ -12,11 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.activity.compose.BackHandler import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Alarm @@ -52,11 +49,8 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -66,13 +60,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.mapgie.goflo.data.database.entities.CustomAlarm import com.mapgie.goflo.data.database.entities.TrackingCategory import com.mapgie.goflo.data.database.entities.TrackingValue -import com.mapgie.goflo.ui.util.decodeScaleLabels -import com.mapgie.goflo.ui.util.encodeScaleLabels @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -91,16 +82,6 @@ fun ManageCategoryValuesScreen( var pendingDeleteValue by rememberSaveable { mutableStateOf(null) } var pendingArchiveCategory by rememberSaveable { mutableStateOf(false) } var pendingDeleteCategory by rememberSaveable { mutableStateOf(false) } - var hasUnsavedChanges by remember { mutableStateOf(false) } - var currentSaveAction by remember { mutableStateOf<(() -> Unit)?>(null) } - var showUnsavedChangesDialog by rememberSaveable { mutableStateOf(false) } - - val handleBack: () -> Unit = { - if (hasUnsavedChanges) showUnsavedChangesDialog = true - else onNavigateBack() - } - - BackHandler(enabled = hasUnsavedChanges) { showUnsavedChangesDialog = true } LaunchedEffect(state.isLoading, state.category) { if (!state.isLoading && state.category == null) onNavigateBack() @@ -233,35 +214,12 @@ fun ManageCategoryValuesScreen( ) } - if (showUnsavedChangesDialog) { - AlertDialog( - onDismissRequest = { showUnsavedChangesDialog = false }, - title = { Text("Unsaved changes") }, - text = { Text("Do you want to save your changes before going back?") }, - confirmButton = { - Button( - onClick = { - showUnsavedChangesDialog = false - currentSaveAction?.invoke() - }, - enabled = currentSaveAction != null - ) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = { - showUnsavedChangesDialog = false - onNavigateBack() - }) { Text("Discard", color = MaterialTheme.colorScheme.error) } - } - ) - } - Scaffold( topBar = { TopAppBar( title = { Text(state.category?.name ?: "Category") }, navigationIcon = { - IconButton(onClick = handleBack) { + IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, @@ -369,53 +327,22 @@ fun ManageCategoryValuesScreen( val category = state.category Column(modifier = Modifier.padding(padding).fillMaxSize()) { Box(modifier = Modifier.weight(1f)) { + // Per-type numeric settings, the rename/appearance dialogs, and + // the per-category switches were consolidated into the Edit + // flow (CategoryEditScreen) in Phase 8. This screen keeps what + // only it does: the value catalog and the Flow selector mode. when (category?.categoryType) { - "numeric_slider" -> NumericSliderSettings( - category = category, - modifier = Modifier, - onToggleLogWithPeriod = { viewModel.setShowInLogPeriod(it) }, - onToggleAllowMultiple = { viewModel.setAllowMultiple(it) }, - onToggleTrackAgainstTime = { viewModel.setTrackAgainstTime(it) }, - onToggleFlowSlider = { viewModel.setFlowSliderMode(it) }, - onUnsavedState = { hasChanges, saveAction -> - hasUnsavedChanges = hasChanges - currentSaveAction = saveAction - }, - onSave = { min, max, decimals, unit, scaleLabels -> - viewModel.updateNumericSettings(min, max, decimals, unit, scaleLabels) - onNavigateBack() - } - ) - "numeric_free" -> NumericFreeSettings( - category = category, - modifier = Modifier, - onToggleLogWithPeriod = { viewModel.setShowInLogPeriod(it) }, - onToggleAllowMultiple = { viewModel.setAllowMultiple(it) }, - onToggleTrackAgainstTime = { viewModel.setTrackAgainstTime(it) }, - onUnsavedState = { hasChanges, saveAction -> - hasUnsavedChanges = hasChanges - currentSaveAction = saveAction - }, - onSave = { unit -> - viewModel.updateUnit(unit) - onNavigateBack() - } - ) - "increment" -> IncrementCategoryInfo( - category = category, - modifier = Modifier, - onToggleLogWithPeriod = { viewModel.setShowInLogPeriod(it) }, - onToggleTrackAgainstTime = { viewModel.setTrackAgainstTime(it) } + "numeric_slider", "numeric_free", "increment" -> NumericCategoryInfo( + category = category, + modifier = Modifier, + onToggleFlowSlider = { viewModel.setFlowSliderMode(it) }, ) else -> DefaultCategoryValues( - state = state, - modifier = Modifier, - onAddValue = { showAddValue = true }, - onRenameValue = { renamingValue = it.id }, - onToggleLogWithPeriod = { viewModel.setShowInLogPeriod(it) }, - onToggleAllowMultiple = { viewModel.setAllowMultiple(it) }, - onToggleTrackAgainstTime = { viewModel.setTrackAgainstTime(it) }, - onToggleFlowSlider = { viewModel.setFlowSliderMode(it) } + state = state, + modifier = Modifier, + onAddValue = { showAddValue = true }, + onRenameValue = { renamingValue = it.id }, + onToggleFlowSlider = { viewModel.setFlowSliderMode(it) }, ) } } @@ -511,9 +438,6 @@ private fun DefaultCategoryValues( modifier: Modifier, onAddValue: () -> Unit, onRenameValue: (TrackingValue) -> Unit, - onToggleLogWithPeriod: (Boolean) -> Unit, - onToggleAllowMultiple: (Boolean) -> Unit, - onToggleTrackAgainstTime: (Boolean) -> Unit, onToggleFlowSlider: (Boolean) -> Unit, ) { Column( @@ -524,24 +448,10 @@ private fun DefaultCategoryValues( verticalArrangement = Arrangement.spacedBy(12.dp) ) { val category = state.category - if (category?.isSystem == false) { - LogWithPeriodRow( - checked = category.showInLogPeriod, - onChecked = onToggleLogWithPeriod - ) - AllowMultipleRow( - checked = category.allowMultiple, - onChecked = onToggleAllowMultiple - ) - } - TrackAgainstTimeRow( - checked = category?.trackAgainstTime ?: false, - onChecked = onToggleTrackAgainstTime - ) if (category?.systemKey == "flow") { FlowSliderRow(isSlider = false, onToggle = onToggleFlowSlider) + HorizontalDivider() } - HorizontalDivider() Text( "Values in this category", @@ -583,398 +493,92 @@ private fun DefaultCategoryValues( ) ) } - } -} - -// ── Plus One (increment) category info ──────────────────────────────────────── -@Composable -private fun IncrementCategoryInfo( - category: TrackingCategory, - modifier: Modifier, - onToggleLogWithPeriod: (Boolean) -> Unit, - onToggleTrackAgainstTime: (Boolean) -> Unit, -) { - Column( - modifier = modifier - .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - if (!category.isSystem) { - LogWithPeriodRow( - checked = category.showInLogPeriod, - onChecked = onToggleLogWithPeriod - ) - } - TrackAgainstTimeRow( - checked = category.trackAgainstTime, - onChecked = onToggleTrackAgainstTime - ) HorizontalDivider() - - Text( - "Plus One category", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - "Plus One categories don't use predefined values — each log records a running count " + - "for the day. Use the + button on the home screen or the log screen to add to today's total.", - style = MaterialTheme.typography.bodyMedium - ) + EditFlowHint() } } -// ── Numeric slider settings content ────────────────────────────────────────── +// ── Numeric family (slider / input / Plus One) info ────────────────────────── +// The range, step labels, unit, and per-category switches these sections used +// to edit in place moved to the Edit flow (CategoryEditScreen) in Phase 8. @Composable -private fun NumericSliderSettings( +private fun NumericCategoryInfo( category: TrackingCategory, modifier: Modifier, - onToggleLogWithPeriod: (Boolean) -> Unit, - onToggleAllowMultiple: (Boolean) -> Unit, - onToggleTrackAgainstTime: (Boolean) -> Unit, onToggleFlowSlider: (Boolean) -> Unit, - onUnsavedState: (hasChanges: Boolean, saveAction: (() -> Unit)?) -> Unit, - onSave: (min: Float, max: Float, allowDecimals: Boolean, unit: String, scaleLabels: String) -> Unit, ) { - val originalMin = remember { - if (category.allowDecimals) "%.1f".format(category.numericMin) - else category.numericMin.toInt().toString() - } - val originalMax = remember { - if (category.allowDecimals) "%.1f".format(category.numericMax) - else category.numericMax.toInt().toString() - } - val originalAllowDecimals = remember { category.allowDecimals } - val originalUnit = remember { category.numericUnit } - val originalLabels = remember { category.scaleLabels.decodeScaleLabels() } - - var minText by rememberSaveable { mutableStateOf(originalMin) } - var maxText by rememberSaveable { mutableStateOf(originalMax) } - var allowDecimals by rememberSaveable { mutableStateOf(originalAllowDecimals) } - var unit by rememberSaveable { mutableStateOf(originalUnit) } - var labelValuesExpanded by rememberSaveable { mutableStateOf(originalLabels.isNotEmpty()) } - - // Optional per-step labels (e.g. 1→"Good", 5→"Bad"). Editable only for - // whole-number ranges with a manageable number of steps. - val labels = remember { mutableStateMapOf().apply { putAll(originalLabels) } } - - val minInt = minText.toIntOrNull() - val maxInt = maxText.toIntOrNull() - val canLabel = !allowDecimals && minInt != null && maxInt != null && - maxInt > minInt && (maxInt - minInt) <= 20 - - val canSave by remember { - derivedStateOf { - minText.toFloatOrNull() != null && maxText.toFloatOrNull() != null && - (minText.toFloatOrNull() ?: 0f) < (maxText.toFloatOrNull() ?: 10f) - } - } - - val hasChanges by remember { - derivedStateOf { - minText != originalMin || maxText != originalMax || - allowDecimals != originalAllowDecimals || unit != originalUnit || - (labelValuesExpanded && labels.toMap() != originalLabels) || - (!labelValuesExpanded && originalLabels.isNotEmpty()) - } - } - - SideEffect { - onUnsavedState( - hasChanges, - if (hasChanges && canSave) { - { - onSave( - minText.toFloatOrNull() ?: 0f, - maxText.toFloatOrNull() ?: 10f, - allowDecimals, - unit.trim(), - when { - labelValuesExpanded && canLabel -> labels.filterKeys { it in minInt!!..maxInt!! }.encodeScaleLabels() - !labelValuesExpanded -> "" - else -> category.scaleLabels - } - ) - } - } else null - ) - } - Column( modifier = modifier .fillMaxSize() .verticalScroll(rememberScrollState()) .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { - if (!category.isSystem) { - LogWithPeriodRow( - checked = category.showInLogPeriod, - onChecked = onToggleLogWithPeriod - ) - AllowMultipleRow( - checked = category.allowMultiple, - onChecked = onToggleAllowMultiple - ) - } - TrackAgainstTimeRow( - checked = category.trackAgainstTime, - onChecked = onToggleTrackAgainstTime - ) if (category.systemKey == "flow") { - FlowSliderRow(isSlider = true, onToggle = onToggleFlowSlider) - } - HorizontalDivider() - - Text( - "Slider scale settings", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - OutlinedTextField( - value = unit, - onValueChange = { unit = it }, - label = { Text("Unit / Key (optional)") }, - placeholder = { Text("e.g. °C, bpm, kg…") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxWidth() - ) { - OutlinedTextField( - value = minText, - onValueChange = { minText = it }, - label = { Text("Min") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.weight(1f) - ) - OutlinedTextField( - value = maxText, - onValueChange = { maxText = it }, - label = { Text("Max") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.weight(1f) - ) + FlowSliderRow(isSlider = category.categoryType == "numeric_slider", onToggle = onToggleFlowSlider) + HorizontalDivider() } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text("Allow decimals", style = MaterialTheme.typography.titleSmall) + when (category.categoryType) { + "increment" -> { Text( - "Slider snaps to 0.1 steps instead of whole numbers", - style = MaterialTheme.typography.bodySmall, + "Plus One category", + style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Text( + "Plus One categories don't use predefined values. Each log records a " + + "running count for the day: use the + button on the home screen or the " + + "day screen to add to today's total.", + style = MaterialTheme.typography.bodyMedium + ) } - Switch(checked = allowDecimals, onCheckedChange = { enabled -> - allowDecimals = enabled - // Reformat the Min/Max fields to match the new step mode. Turning - // decimals off must strip the trailing ".0" so the values parse as - // whole numbers again, otherwise the label editor (which requires an - // integer range) can never re-enable after decimals are switched off. - fun reformat(text: String): String = - text.toFloatOrNull()?.let { - if (enabled) "%.1f".format(it) else it.toInt().toString() - } ?: text - minText = reformat(minText) - maxText = reformat(maxText) - }) - } - - // ── Optional per-step labels ───────────────────────────────────────── - HorizontalDivider() - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Label values", style = MaterialTheme.typography.titleSmall) - Switch( - checked = labelValuesExpanded, - onCheckedChange = { labelValuesExpanded = it } - ) - } - if (labelValuesExpanded) { - Text( - "Name points on your scale (e.g. 1 = Good, 3 = Neutral, 5 = Bad). " + - "Labels appear in the distribution chart in Stats.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (canLabel) { - (minInt!!..maxInt!!).forEach { step -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - step.toString(), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.width(36.dp) - ) - OutlinedTextField( - value = labels[step] ?: "", - onValueChange = { v -> if (v.isBlank()) labels.remove(step) else labels[step] = v }, - label = { Text("Label") }, - singleLine = true, - modifier = Modifier.weight(1f) - ) - } - } - } else { + "numeric_slider" -> { Text( - "Tip: use whole numbers with a range of 20 steps or fewer (decimals off) to label individual values.", - style = MaterialTheme.typography.bodySmall, + "Slider scale category", + style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Text( + "This category logs a position on a stepped scale, so it has no value " + + "list to manage. The range, step labels, and unit live in the " + + "category's settings.", + style = MaterialTheme.typography.bodyMedium + ) } - } - - Spacer(Modifier.height(4.dp)) - - Button( - onClick = { - if (canSave) onSave( - minText.toFloatOrNull() ?: 0f, - maxText.toFloatOrNull() ?: 10f, - allowDecimals, - unit.trim(), - when { - labelValuesExpanded && canLabel -> labels.filterKeys { it in minInt!!..maxInt!! }.encodeScaleLabels() - !labelValuesExpanded -> "" - else -> category.scaleLabels - } + else -> { + Text( + "Numeric input category", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) - }, - enabled = canSave, - modifier = Modifier.fillMaxWidth() - ) { Text("Save") } - } -} - -// ── Numeric free-input settings content ────────────────────────────────────── - -@Composable -private fun NumericFreeSettings( - category: TrackingCategory, - modifier: Modifier, - onToggleLogWithPeriod: (Boolean) -> Unit, - onToggleAllowMultiple: (Boolean) -> Unit, - onToggleTrackAgainstTime: (Boolean) -> Unit, - onUnsavedState: (hasChanges: Boolean, saveAction: (() -> Unit)?) -> Unit, - onSave: (unit: String) -> Unit, -) { - val originalUnit = remember { category.numericUnit } - var unit by rememberSaveable { mutableStateOf(originalUnit) } - - val hasChanges by remember { derivedStateOf { unit != originalUnit } } - - SideEffect { - onUnsavedState( - hasChanges, - if (hasChanges) ({ onSave(unit.trim()) }) else null - ) - } - - Column( - modifier = modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - if (!category.isSystem) { - LogWithPeriodRow( - checked = category.showInLogPeriod, - onChecked = onToggleLogWithPeriod - ) - AllowMultipleRow( - checked = category.allowMultiple, - onChecked = onToggleAllowMultiple - ) + Text( + "This category logs a typed number, so it has no value list to manage. " + + "The unit lives in the category's settings.", + style = MaterialTheme.typography.bodyMedium + ) + } } - TrackAgainstTimeRow( - checked = category.trackAgainstTime, - onChecked = onToggleTrackAgainstTime - ) - HorizontalDivider() - Text( - "Numeric Input settings", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - OutlinedTextField( - value = unit, - onValueChange = { unit = it }, - label = { Text("Unit / Key (optional)") }, - placeholder = { Text("e.g. °C, bpm, kg…") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - - Button( - onClick = { onSave(unit.trim()) }, - modifier = Modifier.fillMaxWidth() - ) { Text("Save") } + EditFlowHint() } } -// ── Log with period toggle row ──────────────────────────────────────────────── - +/** Points at the Edit action, where everything this screen no longer edits lives. */ @Composable -private fun LogWithPeriodRow(checked: Boolean, onChecked: (Boolean) -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text("Log with period", style = MaterialTheme.typography.titleSmall) - Text( - "Show this category on the Log Period screen", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch(checked = checked, onCheckedChange = onChecked) - } +private fun EditFlowHint() { + Text( + "Use the Edit action in the top bar to change this category's name, icon, " + + "colour, input settings, reminders, and options.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } -@Composable -private fun AllowMultipleRow(checked: Boolean, onChecked: (Boolean) -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text("Allow multiple per day", style = MaterialTheme.typography.titleSmall) - Text( - "Log this category more than once on the same day", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch(checked = checked, onCheckedChange = onChecked) - } -} +// ── Flow selector mode toggle ───────────────────────────────────────────────── +// Only this screen offers the built-in Flow category's chips/slider switch. @Composable private fun FlowSliderRow(isSlider: Boolean, onToggle: (Boolean) -> Unit) { @@ -995,25 +599,6 @@ private fun FlowSliderRow(isSlider: Boolean, onToggle: (Boolean) -> Unit) { } } -@Composable -private fun TrackAgainstTimeRow(checked: Boolean, onChecked: (Boolean) -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text("Track against time", style = MaterialTheme.typography.titleSmall) - Text( - "Record the time of each log entry so you can view them by time of day", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch(checked = checked, onCheckedChange = onChecked) - } -} - // ── Dialogs ─────────────────────────────────────────────────────────────────── @Composable @@ -1054,39 +639,6 @@ private fun AddValueDialog( ) } -// Superseded by CategoryEditScreen (logging redesign Phase 7), which covers -// renaming. Kept unreferenced until Phase 8 removes it against the parity list. -@Suppress("unused") -@Composable -private fun RenameCategoryDialog( - currentName: String, - onRename: (String) -> Unit, - onDismiss: () -> Unit -) { - var name by rememberSaveable { mutableStateOf(currentName) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Rename category") }, - text = { - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Category name") }, - singleLine = true, - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - TextButton( - onClick = { if (name.isNotBlank()) onRename(name) }, - enabled = name.isNotBlank() && name != currentName - ) { Text("Save") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } - ) -} - @Composable private fun RenameValueDialog( value: TrackingValue, diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoryValuesViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoryValuesViewModel.kt index a398fe4..84c7cd3 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoryValuesViewModel.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/ManageCategoryValuesViewModel.kt @@ -44,47 +44,10 @@ class ManageCategoryValuesViewModel( initialValue = ManageCategoryValuesUiState() ) - fun renameCategory(newName: String) { - if (newName.isBlank()) return - viewModelScope.launch { repository.renameCategory(categoryId, newName) } - } - - fun updateAppearance(iconName: String, colorToken: String) { - viewModelScope.launch { repository.updateCategoryAppearance(categoryId, iconName, colorToken) } - } - - fun updateNumericSettings( - min: Float, - max: Float, - allowDecimals: Boolean, - unit: String, - scaleLabels: String = "", - ) { - viewModelScope.launch { - repository.updateNumericSettings(categoryId, min, max, allowDecimals, unit, scaleLabels) - } - } - - fun updateUnit(unit: String) { - viewModelScope.launch { repository.updateNumericUnit(categoryId, unit) } - } - - fun setShowInLogPeriod(show: Boolean) { - viewModelScope.launch { repository.updateShowInLogPeriod(categoryId, show) } - } - - fun setAllowMultiple(allowMultiple: Boolean) { - viewModelScope.launch { repository.updateAllowMultiple(categoryId, allowMultiple) } - } - fun setFlowSliderMode(useSlider: Boolean) { viewModelScope.launch { repository.updateFlowCategoryMode(categoryId, useSlider) } } - fun setTrackAgainstTime(track: Boolean) { - viewModelScope.launch { repository.updateTrackAgainstTime(categoryId, track) } - } - fun addValue(label: String) { if (label.isBlank()) return viewModelScope.launch { repository.addValueToCategory(categoryId, label) } 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 index 5d3bd5b..6674b8e 100644 --- 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 @@ -64,7 +64,8 @@ private val monthDayYear = DateTimeFormatter.ofPattern("MMM d, yyyy") * 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. + * date; the top bar's edit action opens the same screen on the first day, + * where the episode's dates, notes, and deletion live. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -96,9 +97,9 @@ fun PeriodDetailScreen( } }, actions = { - state.period?.let { period -> - IconButton(onClick = { onNavigate(Screen.LogPeriod.withId(period.id)) }) { - Icon(Icons.Outlined.Edit, contentDescription = "Edit period") + state.startDate?.let { firstDay -> + IconButton(onClick = { onNavigate(Screen.LogDay.forDate(firstDay)) }) { + Icon(Icons.Outlined.Edit, contentDescription = "Open first day") } } }, 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 e02c25d..2c16f6e 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 @@ -63,7 +63,6 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import com.mapgie.goflo.BuildConfig -import com.mapgie.goflo.data.repository.PeriodRepository import com.mapgie.goflo.ui.components.CalendarGrid import com.mapgie.goflo.ui.components.DayLogSheet import com.mapgie.goflo.ui.navigation.Screen @@ -111,19 +110,6 @@ fun HomeScreen( // ── Quick Log helper ────────────────────────────────────────────────────── - // If [date] falls within (or within gap tolerance of) an existing period, - // open that period's editor for this specific day so its values can be - // logged independently; the save itself extends, bridges, or continues - // the period as needed. Otherwise start a new period entry for the day. - fun navigateToLogPeriod(date: LocalDate) { - val existing = PeriodRepository.periodForDate(state.periods, date, state.periodGapToleranceDays) - if (existing != null) { - onNavigate(Screen.LogPeriod.withId(existing.id, date)) - } else { - onNavigate(Screen.LogPeriod.newEntryForDate(date)) - } - } - // Open the full log menu (speed dial) targeted at [date], so whatever is // picked from it gets logged for that day rather than today. fun openLogMenuFor(date: LocalDate) { @@ -139,12 +125,14 @@ fun HomeScreen( openLogMenuFor(date) } id == -1L -> - navigateToLogPeriod(date) + // Period quick log: the unified day screen resolves for itself + // whether the day starts, continues, or edits a period. + onNavigate(Screen.LogDay.forDate(date)) cat?.categoryType == "increment" -> // Instantly add one for the tapped day; no screen navigation. viewModel.incrementCategory(id, date) else -> - onNavigate(Screen.LogCategory.newEntry(id, date)) + onNavigate(Screen.LogDay.forCategory(date, id)) } } @@ -156,15 +144,17 @@ fun HomeScreen( period = data.period, trackingLogs = data.trackingLogs, onDismiss = { viewModel.clearSelectedDay() }, - onEditPeriod = { periodId -> + onEditPeriod = { viewModel.clearSelectedDay() - // Open the editor for this specific day so its own flow and - // symptoms are what gets edited, not the period's first day. - onNavigate(Screen.LogPeriod.withId(periodId, data.date)) + // The unified day screen edits this specific day's own flow + // and symptoms, not the period's first day. + onNavigate(Screen.LogDay.forDate(data.date)) }, - onEditTrackingLog = { categoryId, logId -> + onEditTrackingLog = { _, logId -> viewModel.clearSelectedDay() - onNavigate(Screen.LogCategory.editEntry(categoryId, logId)) + // Targets that one log, so a single entry of an allow-multiple + // category is edited in place rather than starting a new one. + onNavigate(Screen.LogDay.forLog(data.date, logId)) }, onLogMore = { viewModel.clearSelectedDay() @@ -231,14 +221,14 @@ fun HomeScreen( onLogPeriod = { showLogMenu = false logMenuTargetDate = null - navigateToLogPeriod(targetDate) + onNavigate(Screen.LogDay.forDate(targetDate)) }, periodTrackingEnabled = state.periodTrackingEnabled, categories = state.trackingCategories, onLogCategory = { categoryId -> showLogMenu = false logMenuTargetDate = null - onNavigate(Screen.LogCategory.newEntry(categoryId, targetDate)) + onNavigate(Screen.LogDay.forCategory(targetDate, categoryId)) } ) } 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 deleted file mode 100644 index 9429939..0000000 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryScreen.kt +++ /dev/null @@ -1,491 +0,0 @@ -package com.mapgie.goflo.ui.screens.log - -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.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -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.filled.DateRange -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberDatePickerState -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.semantics.Role -import androidx.compose.ui.semantics.role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp -import com.mapgie.goflo.data.database.entities.TrackingCategory -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.SelectableChip -import com.mapgie.goflo.ui.components.Timeline -import com.mapgie.goflo.ui.components.TimelineEntryData -import com.mapgie.goflo.ui.components.usesStepScale -import com.mapgie.goflo.ui.util.CategoryType -import com.mapgie.goflo.ui.util.decodeScaleLabels -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") - -/** - * Tappable card showing the date this entry will be logged against. Opens a date - * picker so any category can be logged for a past day, not only today. - */ -@Composable -private fun DateSelectorCard( - date: LocalDate, - onClick: () -> Unit, -) { - ElevatedCard( - modifier = Modifier - .fillMaxWidth() - .semantics { role = Role.Button } - .clickable(onClick = onClick) - ) { - Row( - modifier = Modifier.padding(horizontal = 20.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text( - "Date", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - date.format(displayFormat), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary - ) - } - Icon( - imageVector = Icons.Default.DateRange, - contentDescription = "Change date", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DatePickerDialogWrapper( - initial: LocalDate, - 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 - onConfirm(Instant.ofEpochMilli(millis).atZone(ZoneId.of("UTC")).toLocalDate()) - }) { Text("OK") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } - ) { - DatePicker(state = pickerState) - } -} - -/** - * 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( - name = category.name, - options = availableValues, - min = category.numericMin.toInt(), - max = category.numericMax.toInt(), - stepLabels = category.scaleLabels.decodeScaleLabels(), - unit = category.numericUnit.takeIf { it.isNotBlank() }, - allowDecimals = category.allowDecimals, -) - -/** - * Maps the screen state onto the [MetricValue] variant [MetricInput] expects - * for [type]. Yes/No and Time reuse [LogCategoryUiState.selectedValues] as a - * single-label set, matching how their readings are stored ("Yes"/"No", - * "HH:mm" value labels). - */ -private fun metricValueFor( - type: CategoryType, - config: MetricConfig, - state: LogCategoryUiState, -): MetricValue = when (type) { - CategoryType.DEFAULT -> MetricValue.Choice(state.selectedValues) - CategoryType.NUMERIC_SLIDER -> - if (config.usesStepScale()) MetricValue.Scale(state.numericValue?.toInt()) - else MetricValue.Continuous(state.numericValue) - CategoryType.NUMERIC_FREE -> MetricValue.FreeNumber(state.numericFreeText) - CategoryType.INCREMENT -> MetricValue.Count(state.numericValue?.toInt() ?: 0) - CategoryType.YES_NO -> MetricValue.YesNo( - when { - "Yes" in state.selectedValues -> true - "No" in state.selectedValues -> false - else -> null - } - ) - CategoryType.TIME -> MetricValue.TimeOfDay(state.selectedValues.firstOrNull()) -} - -/** - * Timed increment ("Plus One" + track against time): each append saves a new - * 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 -internal fun TimedIncrementTimeline( - category: TrackingCategory, - entries: List, - onAddOne: () -> Unit, - onDeleteEntry: (com.mapgie.goflo.data.database.entities.TrackingLog) -> Unit, -) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - category.name, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Text( - entries.size.toString(), - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.primary - ) - if (category.numericUnit.isNotBlank()) { - Text( - category.numericUnit, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 12.dp) - ) - } - } - } - } - Timeline( - entries = entries.map { entry -> - TimelineEntryData( - id = entry.log.id, - time = entry.log.loggedAt.ifEmpty { "No time" }, - value = "+1", - ) - }, - role = MaterialTheme.colorScheme.primary, - onAppend = onAddOne, - appendLabel = "Log +1 now", - onDeleteEntry = { data -> - entries.firstOrNull { it.log.id == data.id }?.let { onDeleteEntry(it.log) } - }, - ) -} - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun LogCategoryScreen( - viewModel: LogCategoryViewModel, - onNavigateBack: () -> Unit -) { - val state by viewModel.uiState.collectAsState() - - // Pop back on save or delete - LaunchedEffect(state.saved, state.deleted) { - if (state.saved || state.deleted) onNavigateBack() - } - - var showDeleteConfirm by rememberSaveable { mutableStateOf(false) } - var showDatePicker by rememberSaveable { mutableStateOf(false) } - - if (showDatePicker) { - DatePickerDialogWrapper( - initial = state.date, - onConfirm = { viewModel.setDate(it); showDatePicker = false }, - onDismiss = { showDatePicker = false } - ) - } - - if (showDeleteConfirm) { - AlertDialog( - onDismissRequest = { showDeleteConfirm = false }, - title = { Text("Delete this entry?") }, - text = { Text("This log entry for ${state.category?.name} on ${state.date.format(displayFormat)} will be permanently removed.") }, - confirmButton = { - TextButton( - onClick = { showDeleteConfirm = false; viewModel.delete() } - ) { Text("Delete", color = MaterialTheme.colorScheme.error) } - }, - dismissButton = { TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } } - ) - } - - Scaffold( - topBar = { - LogEntryTopBar( - title = state.category?.name ?: "Log", - subtitle = state.date.format(displayFormat), - onBack = onNavigateBack, - actions = { - if (state.isEditing) { - IconButton(onClick = { showDeleteConfirm = true }) { - Icon( - Icons.Default.Delete, - contentDescription = "Delete entry", - tint = MaterialTheme.colorScheme.error - ) - } - } - } - ) - } - ) { padding -> - if (state.isLoading) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - return@Scaffold - } - - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - // ── Date ────────────────────────────────────────────────────────── - // Editable for new entries so any category can be logged for a past - // day; fixed when editing one specific existing entry. - - if (state.canEditDate) { - DateSelectorCard( - date = state.date, - onClick = { showDatePicker = true } - ) - } - - // ── Input area — everything renders through the MetricInput facade; - // the timed-increment timeline is the one screen-level flow (it - // saves per tap rather than collect-then-save). ──────────────── - - val cat = state.category - val type = cat?.categoryType?.toCategoryType() ?: CategoryType.DEFAULT - val isTimedIncrement = - cat != null && type == CategoryType.INCREMENT && cat.trackAgainstTime - - if (cat != null) { - if (isTimedIncrement) { - TimedIncrementTimeline( - category = cat, - entries = state.timedEntriesToday, - onAddOne = viewModel::addTimedIncrement, - onDeleteEntry = viewModel::deleteTimedEntry - ) - } else { - val config = metricConfigFor(cat, state.availableValues.map { it.label }) - val metricValue = metricValueFor(type, config, state) - val onMetricChange: (MetricValue) -> Unit = { v -> - when (v) { - is MetricValue.Choice -> viewModel.setSelectedValues(v.selected) - is MetricValue.Scale -> v.step?.let { viewModel.setNumericValue(it.toFloat()) } - is MetricValue.Continuous -> v.value?.let { viewModel.setNumericValue(it) } - is MetricValue.FreeNumber -> viewModel.setNumericFreeText(v.text) - is MetricValue.Count -> viewModel.setNumericValue(v.count.toFloat()) - is MetricValue.YesNo -> v.value?.let { - viewModel.setSelectedValues(setOf(if (it) "Yes" else "No")) - } - is MetricValue.TimeOfDay -> v.time?.let { - viewModel.setSelectedValues(setOf(it)) - } - } - } - - if (type == CategoryType.DEFAULT) { - // Chips render bare, with the catalog empty-state and the - // "previously recorded" chips for labels no longer offered. - if (state.availableValues.isNotEmpty()) { - Text( - "Select all that apply:", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - MetricInput( - type = type, - config = config, - value = metricValue, - role = MaterialTheme.colorScheme.primary, - onRole = MaterialTheme.colorScheme.onPrimary, - onChange = onMetricChange - ) - - // Show removed values (in historical record but no longer in catalog) - val removedValues = state.selectedValues.filter { label -> - state.availableValues.none { it.label == label } - } - 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.toggleValue(label) } - ) - } - } - } - } else { - 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 { - // Every other input renders in the same framed card the - // per-type sections used: category name label + control. - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp), - horizontalAlignment = - if (type == CategoryType.INCREMENT) Alignment.CenterHorizontally - else Alignment.Start, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - cat.name, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - MetricInput( - type = type, - config = config, - value = metricValue, - role = MaterialTheme.colorScheme.primary, - onRole = MaterialTheme.colorScheme.onPrimary, - onChange = onMetricChange - ) - } - } - } - } - } - - // Timed increment entries are saved immediately — no notes/save button needed - if (!isTimedIncrement) { - // ── Track against time checkbox ─────────────────────────────────── - - if (cat?.trackAgainstTime == true) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Checkbox( - checked = state.trackTime, - onCheckedChange = viewModel::setTrackTime - ) - Text( - text = "Track against time", - style = MaterialTheme.typography.bodyMedium - ) - } - } - - // ── Notes ────────────────────────────────────────────────────── - - OutlinedTextField( - value = state.notes, - onValueChange = { if (it.length <= 500) viewModel.setNotes(it) }, - label = { Text("Notes (optional)") }, - modifier = Modifier.fillMaxWidth(), - minLines = 2, - maxLines = 4, - supportingText = { - if (state.notes.isNotEmpty()) { - Text("${state.notes.length}/500") - } - } - ) - - Spacer(Modifier.height(8.dp)) - - Button( - onClick = viewModel::save, - modifier = Modifier.fillMaxWidth() - ) { - Text(if (state.isEditing) "Update" else "Save") - } - } - } - } -} diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryViewModel.kt deleted file mode 100644 index ff047eb..0000000 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogCategoryViewModel.kt +++ /dev/null @@ -1,337 +0,0 @@ -package com.mapgie.goflo.ui.screens.log - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -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.repository.TrackingLogWithValues -import com.mapgie.goflo.data.repository.TrackingRepository -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.time.LocalDate -import java.time.LocalTime -import java.time.format.DateTimeFormatter - -data class LogCategoryUiState( - val isLoading: Boolean = true, - val category: TrackingCategory? = null, - val availableValues: List = emptyList(), - /** Labels of values currently selected (may include labels not in availableValues if removed). */ - val selectedValues: Set = emptySet(), - /** - * Current slider position for numeric_slider categories. - * Null until the user interacts (or an existing value is loaded). - * Ignored for text categories. - */ - val numericValue: Float? = null, - /** Current text entry for numeric_free categories. */ - val numericFreeText: String = "", - val date: LocalDate = LocalDate.now(), - val notes: String = "", - val isEditing: Boolean = false, - val existingLog: TrackingLog? = null, - val saved: Boolean = false, - val deleted: Boolean = false, - val error: String? = null, - /** Whether to record the current time with this log entry. Pre-set from category.trackAgainstTime. */ - val trackTime: Boolean = false, - /** Timed entries already logged today for this category (used for increment+trackTime UI). */ - val timedEntriesToday: List = emptyList(), - /** - * Whether the log date can be changed from this screen. True for new entries - * (so any category can be logged retrospectively for a past day); false when - * editing one specific existing entry by id, where changing the date would be - * ambiguous (it would abandon that entry rather than move it). - */ - val canEditDate: Boolean = false, -) - -class LogCategoryViewModel( - private val categoryId: Long, - private val prefilledDate: LocalDate?, - private val existingLogId: Long?, - private val repository: TrackingRepository -) : ViewModel() { - - private val _uiState = MutableStateFlow( - LogCategoryUiState(date = prefilledDate ?: LocalDate.now()) - ) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - // Load initial state once (suspend: gets first emission of category + values + existing log) - viewModelScope.launch { loadInitialData() } - - // Keep category and value list in sync with DB after initial load - viewModelScope.launch { - combine( - repository.getCategoryById(categoryId), - repository.getValuesForCategory(categoryId) - ) { cat, vals -> cat to vals } - .collect { (cat, vals) -> - if (!_uiState.value.isLoading) { - _uiState.update { it.copy(category = cat, availableValues = vals) } - } - } - } - } - - private suspend fun loadInitialData() { - val date = prefilledDate ?: LocalDate.now() - - // Get first emission of category + values (suspend, cancels collection after first) - val (category, values) = combine( - repository.getCategoryById(categoryId), - repository.getValuesForCategory(categoryId) - ) { cat, vals -> cat to vals } - .first() - - // Load existing log for this date+category (or by logId for edit mode) - val existingEntry = when { - existingLogId != null -> repository.getLogById(existingLogId) - category?.allowMultiple == true -> null // always create new when allowMultiple - else -> repository.getExistingLog(date, categoryId) - } - - // For slider + increment categories, parse the first stored label back to Float. - // (Increment reuses numericValue to hold the running count.) - val existingNumeric: Float? = if (category?.categoryType == "numeric_slider" || - category?.categoryType == "increment") - existingEntry?.values?.firstOrNull()?.toFloatOrNull() - else null - - val existingFreeText: String = if (category?.categoryType == "numeric_free") - existingEntry?.values?.firstOrNull() ?: "" - else "" - - - val trackTime = category?.trackAgainstTime == true - val timedEntries = if (trackTime && category?.categoryType == "increment") { - repository.getLogsForDateAndCategory(date, categoryId) - } else emptyList() - - _uiState.update { - it.copy( - isLoading = false, - category = category, - availableValues = values, - selectedValues = existingEntry?.values?.toSet() ?: emptySet(), - numericValue = existingNumeric, - numericFreeText = existingFreeText, - date = existingEntry?.log?.date?.let { d -> - runCatching { LocalDate.parse(d) }.getOrElse { date } - } ?: date, - notes = existingEntry?.log?.notes ?: "", - isEditing = existingEntry != null, - existingLog = existingEntry?.log, - trackTime = trackTime, - timedEntriesToday = timedEntries, - canEditDate = existingLogId == null, - ) - } - } - - /** - * Changes the date this screen logs against and re-resolves any existing entry - * for the new (date, category) pair, so switching to a day that already has an - * entry loads it for editing and switching to a blank day starts a fresh entry. - * Never moves or duplicates data: saving always writes to the currently shown date. - */ - fun setDate(newDate: LocalDate) { - val state = _uiState.value - val category = state.category ?: return - if (newDate == state.date) return - viewModelScope.launch { - val existingEntry = if (category.allowMultiple) null - else repository.getExistingLog(newDate, categoryId) - - val existingNumeric: Float? = if (category.categoryType == "numeric_slider" || - category.categoryType == "increment") - existingEntry?.values?.firstOrNull()?.toFloatOrNull() - else null - - val existingFreeText: String = if (category.categoryType == "numeric_free") - existingEntry?.values?.firstOrNull() ?: "" - else "" - - val timedEntries = if (state.trackTime && category.categoryType == "increment") { - repository.getLogsForDateAndCategory(newDate, categoryId) - } else emptyList() - - _uiState.update { - it.copy( - date = newDate, - selectedValues = existingEntry?.values?.toSet() ?: emptySet(), - numericValue = existingNumeric, - numericFreeText = existingFreeText, - notes = existingEntry?.log?.notes ?: "", - isEditing = existingEntry != null, - existingLog = existingEntry?.log, - timedEntriesToday = timedEntries, - ) - } - } - } - - fun toggleValue(label: String) { - _uiState.update { state -> - val selected = state.selectedValues.toMutableSet() - if (label in selected) selected.remove(label) else selected.add(label) - state.copy(selectedValues = selected) - } - } - - /** - * Replaces the whole selection set. Used by the MetricInput-driven screen: - * chip multi-select passes the toggled set; the single-label yes_no and - * time types pass a one-element set ("Yes"/"No" or "HH:mm"), which is - * exactly the value-label string persisted for them. - */ - fun setSelectedValues(values: Set) = - _uiState.update { it.copy(selectedValues = values) } - - fun setNumericValue(v: Float) = _uiState.update { it.copy(numericValue = v) } - - fun setNumericFreeText(text: String) = _uiState.update { it.copy(numericFreeText = text) } - - - fun setNotes(notes: String) { - _uiState.update { it.copy(notes = notes) } - } - - fun setTrackTime(track: Boolean) { - _uiState.update { it.copy(trackTime = track) } - } - - /** Adds a new time-stamped increment entry immediately. Used for increment+trackAgainstTime. */ - fun addTimedIncrement() { - val state = _uiState.value - if (state.isLoading) return - val time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) - viewModelScope.launch { - runCatching { - repository.saveLog( - date = state.date, - categoryId = categoryId, - selectedValues = setOf("1"), - notes = "", - allowMultiple = true, - loggedAt = time, - ) - val updated = repository.getLogsForDateAndCategory(state.date, categoryId) - _uiState.update { it.copy(timedEntriesToday = updated) } - }.onFailure { e -> - _uiState.update { it.copy(error = e.message) } - } - } - } - - /** Deletes a specific timed entry (for increment+trackAgainstTime undo). */ - fun deleteTimedEntry(log: TrackingLog) { - viewModelScope.launch { - runCatching { - repository.deleteLog(log) - val state = _uiState.value - val updated = repository.getLogsForDateAndCategory(state.date, categoryId) - _uiState.update { it.copy(timedEntriesToday = updated) } - }.onFailure { e -> - _uiState.update { it.copy(error = e.message) } - } - } - } - - fun save() { - val state = _uiState.value - if (state.isLoading) return - val cat = state.category - - // increment + trackAgainstTime: each tap is handled immediately via addTimedIncrement() - if (cat?.categoryType == "increment" && cat.trackAgainstTime) return - - // Determine the values to persist - val valuesToSave: Set = when (cat?.categoryType) { - "numeric_slider" -> { - // Null means the user hasn't dragged the slider; fall back to its - // displayed default (numericMin) rather than blocking the save. - val v = state.numericValue ?: cat.numericMin - setOf(formatNumericValue(v, cat.allowDecimals)) - } - "numeric_free" -> { - val text = state.numericFreeText.trim() - if (text.isEmpty()) return - setOf(text) - } - "increment" -> { - val count = state.numericValue?.toInt() ?: 0 - if (count <= 0) return // nothing to record; use delete to clear - setOf(count.toString()) - } - // default chips, yes_no ("Yes"/"No") and time ("HH:mm") all persist - // their labels straight from the selection set. - else -> state.selectedValues - } - - val loggedAt = if (state.trackTime) { - LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm")) - } else "" - - viewModelScope.launch { - runCatching { - val existingLog = state.existingLog - if (existingLog != null) { - repository.updateLogInPlace(existingLog, valuesToSave, state.notes, loggedAt) - } else { - repository.saveLog( - date = state.date, - categoryId = categoryId, - selectedValues = valuesToSave, - notes = state.notes, - allowMultiple = state.category?.allowMultiple ?: false, - loggedAt = loggedAt, - ) - } - _uiState.update { it.copy(saved = true) } - }.onFailure { e -> - _uiState.update { it.copy(error = e.message) } - } - } - } - - private fun formatNumericValue(v: Float, allowDecimals: Boolean): String = - if (allowDecimals) "%.1f".format(v) else v.toInt().toString() - - fun delete() { - val log = _uiState.value.existingLog ?: return - viewModelScope.launch { - runCatching { - repository.deleteLog(log) - _uiState.update { it.copy(deleted = true) } - }.onFailure { e -> - _uiState.update { it.copy(error = e.message) } - } - } - } - - fun clearError() = _uiState.update { it.copy(error = null) } - - class Factory( - private val categoryId: Long, - private val prefilledDate: LocalDate?, - private val existingLogId: Long?, - private val repository: TrackingRepository - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return LogCategoryViewModel(categoryId, prefilledDate, existingLogId, repository) as T - } - } -} diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogEntryTopBar.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogEntryTopBar.kt deleted file mode 100644 index fa706c5..0000000 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogEntryTopBar.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.mapgie.goflo.ui.screens.log - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.RowScope -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable - -/** - * Shared header for every log-entry screen (period and tracking categories), so - * they read as one family: coloured container, entry title, and the date the - * entry applies to as a subtitle. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun LogEntryTopBar( - title: String, - subtitle: String?, - onBack: () -> Unit, - actions: @Composable RowScope.() -> Unit = {}, -) { - TopAppBar( - title = { - Column { - Text(title) - if (subtitle != null) { - Text( - subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) - ) - } - } - }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - actions = actions, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ) - ) -} diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodScreen.kt deleted file mode 100644 index 5fb539c..0000000 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodScreen.kt +++ /dev/null @@ -1,691 +0,0 @@ -package com.mapgie.goflo.ui.screens.log - -import androidx.activity.compose.BackHandler -import androidx.compose.foundation.BorderStroke -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.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.ui.Alignment -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Remove -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberDatePickerState -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.Modifier -import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.liveRegion -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.mapgie.goflo.data.database.entities.TrackingCategory -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.util.CategoryType -import com.mapgie.goflo.ui.util.decodeScaleLabels -import com.mapgie.goflo.ui.components.SelectableChip -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") - -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) -@Composable -fun LogPeriodScreen( - viewModel: LogPeriodViewModel, - 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) } - - val handleBack: () -> Unit = { - if (state.hasChanges) showUnsavedChangesDialog = true else onBack() - } - - BackHandler(enabled = state.hasChanges) { showUnsavedChangesDialog = true } - - if (showDayPicker && !state.isLoading) { - DatePickerDialogWrapper( - initial = state.date, - onConfirm = { viewModel.setDate(it); showDayPicker = false }, - onDismiss = { showDayPicker = false } - ) - } - - if (showStartPicker && !state.isLoading) { - DatePickerDialogWrapper( - initial = state.startDate, - onConfirm = { viewModel.setStartDate(it); showStartPicker = false }, - onDismiss = { showStartPicker = false } - ) - } - - if (showEndPicker && !state.isLoading) { - DatePickerDialogWrapper( - initial = state.endDate ?: state.date, - minDate = if (state.isEditing) state.startDate else state.date, - onConfirm = { viewModel.setEndDate(it); showEndPicker = false }, - onDismiss = { showEndPicker = false } - ) - } - - 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.delete() }, - 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 } - ) - } - - Scaffold( - topBar = { - LogEntryTopBar( - title = if (state.isEditing) "Edit Period" else "Log Period", - subtitle = if (state.isLoading) null else state.date.format(displayFormat), - onBack = handleBack, - actions = { - IconButton(onClick = { showOverflowMenu = true }) { - Icon(Icons.Default.MoreVert, contentDescription = "More options") - } - DropdownMenu( - expanded = showOverflowMenu, - onDismissRequest = { showOverflowMenu = false }, - ) { - DropdownMenuItem( - text = { Text("Disable period logging") }, - onClick = { - showOverflowMenu = false - viewModel.disablePeriodTracking() - onBack() - } - ) - } - } - ) - } - ) { padding -> - if (state.isLoading) { - Box( - modifier = Modifier.fillMaxSize().padding(padding), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - val softBorder = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.4f)) - if (state.isEditing) { - // Day being edited — its flow, symptoms, and pinned values below - // apply to this day only. - SectionLabel("Day") - Text( - text = state.episodeDayNumber?.let { - "Editing ${state.date.format(displayFormat)} (day $it of this period)" - } ?: "Editing ${state.date.format(displayFormat)}", - style = MaterialTheme.typography.bodyMedium, - ) - Text( - "Pick another day on the calendar to log or edit that day's values.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - SectionLabel("Period dates") - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedButton(onClick = { showStartPicker = true }, modifier = Modifier.weight(1f), border = softBorder) { - Text("Start: ${state.startDate.format(displayFormat)}") - } - OutlinedButton(onClick = { showEndPicker = true }, modifier = Modifier.weight(1f), border = softBorder) { - Text("End: ${state.endDate?.format(displayFormat) ?: "Open"}") - } - } - if (state.endDate != null) { - TextButton(onClick = { viewModel.setEndDate(null) }) { - Text("Clear end date (leave open)") - } - } - } else { - // Day section — the single day being logged. - SectionLabel("Day") - OutlinedButton(onClick = { showDayPicker = true }, modifier = Modifier.fillMaxWidth(), border = softBorder) { - Text(state.date.format(displayFormat)) - } - // Continuation context changes as the user picks dates, so - // announce it politely to screen readers. - Text( - text = state.continuesEpisodeStart?.let { start -> - val dayNo = state.episodeDayNumber - if (dayNo != null && dayNo > 1) { - "Day $dayNo of the period started ${start.format(displayFormat)}" - } else { - "Continues the period started ${start.format(displayFormat)}" - } - } ?: "Starts a new period", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite } - ) - - SectionLabel("End date (optional)") - OutlinedButton(onClick = { showEndPicker = true }, modifier = Modifier.fillMaxWidth(), border = softBorder) { - Text(state.endDate?.let { "Until: ${it.format(displayFormat)}" } ?: "No end date") - } - if (state.endDate != null) { - TextButton(onClick = { viewModel.setEndDate(null) }) { - 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 section — shown whenever the category exists and is not archived - val flowCat = state.flowCategory - if (flowCat != null && !flowCat.isArchived) { - SectionLabel(state.flowCategoryName) - if (flowCat.categoryType == "numeric_slider") { - val sliderValue = state.flowSliderValue ?: flowCat.numericMin - val scaleMap = flowCat.scaleLabels.decodeScaleLabels() - val scaleLabel = scaleMap[sliderValue.toInt()] ?: sliderValue.toInt().toString() - val steps = (flowCat.numericMax - flowCat.numericMin).toInt() - 1 - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.End - ) { - Text( - text = scaleLabel, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary - ) - } - Slider( - value = sliderValue, - onValueChange = { viewModel.setFlowSliderValue(it) }, - valueRange = flowCat.numericMin..flowCat.numericMax, - steps = steps, - modifier = Modifier.fillMaxWidth() - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - scaleMap[flowCat.numericMin.toInt()] ?: flowCat.numericMin.toInt().toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - scaleMap[flowCat.numericMax.toInt()] ?: flowCat.numericMax.toInt().toString(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } else { - if (state.flowOptions.isNotEmpty()) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - state.flowOptions.forEach { option -> - SelectableChip( - label = option.label, - selected = state.selectedFlowLabel == option.label, - onClick = { viewModel.setFlowLevel(option.label) } - ) - } - } - } else { - Text( - "No flow levels configured. Add levels in Settings.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } // end flow showInLogPeriod guard - - // Symptoms section — shown whenever the category exists and is not archived - val symptomsCat = state.symptomsCategory - if (symptomsCat != null && !symptomsCat.isArchived) { - SectionLabel(state.symptomsCategoryName) - 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) } - ) - } - - // "+" chip — opens the Add Symptom dialog to create a new option - AssistChip( - onClick = { showAddSymptomDialog = true }, - 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) - ) - ) - } - } // end symptoms showInLogPeriod guard - - // Pinned tracking categories - state.pinnedCategories.forEach { category -> - SectionLabel(category.name) - PinnedCategoryInput( - category = category, - availableValues = state.pinnedCategoryValues[category.id] ?: emptyList(), - selectedValues = state.pinnedCategorySelections[category.id] ?: emptySet(), - numericValue = state.pinnedNumericValues[category.id], - freeText = state.pinnedFreeTextValues[category.id] ?: "", - onToggleValue = { viewModel.togglePinnedValue(category.id, it) }, - onNumericChange = { viewModel.setPinnedNumericValue(category.id, it) }, - onFreeTextChange = { viewModel.setPinnedFreeText(category.id, it) }, - onSingleValueChange = { viewModel.setPinnedSingleValue(category.id, it) }, - ) - } - - // Notes - SectionLabel("Notes") - OutlinedTextField( - value = state.notes, - onValueChange = { if (it.length <= 500) viewModel.setNotes(it) }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text("How are you feeling? Any other details…") }, - minLines = 3, - maxLines = 6, - supportingText = { Text("${state.notes.length}/500") }, - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) - ) - ) - - Spacer(Modifier.height(8.dp)) - - Button( - onClick = { viewModel.save() }, - modifier = Modifier.fillMaxWidth() - ) { - Text("Save") - } - - // Day removal and full deletion (only when editing) - if (state.isEditing) { - OutlinedButton( - onClick = { showRemoveDayConfirm = true }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Remove this day from period") - } - OutlinedButton( - onClick = { showDeleteConfirm = true }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error) - ) { - Text("Delete Entire Period") - } - } - - state.error?.let { - Text("Error: $it", color = MaterialTheme.colorScheme.error) - } - } - } - } -} - -// ── Pinned category input ───────────────────────────────────────────────────── - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun PinnedCategoryInput( - category: TrackingCategory, - availableValues: List, - selectedValues: Set, - numericValue: Float?, - freeText: String, - onToggleValue: (String) -> Unit, - onNumericChange: (Float) -> Unit, - onFreeTextChange: (String) -> Unit, - onSingleValueChange: (String) -> Unit = {}, -) { - when (category.categoryType) { - // The two Phase 4 types delegate to the MetricInput facade; their - // readings live in the selection set as a single value label - // ("Yes"/"No", "HH:mm"), which the existing pinned-category save - // path already persists. The pre-existing branches below are - // intentionally untouched (they are fully replaced in Phase 5). - "yes_no" -> MetricInput( - type = CategoryType.YES_NO, - config = MetricConfig(name = category.name), - value = MetricValue.YesNo( - when { - "Yes" in selectedValues -> true - "No" in selectedValues -> false - else -> null - } - ), - role = MaterialTheme.colorScheme.primary, - onRole = MaterialTheme.colorScheme.onPrimary, - onChange = { v -> - (v as? MetricValue.YesNo)?.value?.let { - onSingleValueChange(if (it) "Yes" else "No") - } - }, - ) - - "time" -> MetricInput( - type = CategoryType.TIME, - config = MetricConfig(name = category.name), - value = MetricValue.TimeOfDay(selectedValues.firstOrNull()), - role = MaterialTheme.colorScheme.primary, - onRole = MaterialTheme.colorScheme.onPrimary, - onChange = { v -> - (v as? MetricValue.TimeOfDay)?.time?.let(onSingleValueChange) - }, - ) - - "numeric_slider" -> { - val min = category.numericMin - val max = category.numericMax - val sliderValue = numericValue ?: min - val steps = if (category.allowDecimals) 0 else { - val range = (max - min).toInt() - if (range > 1) range - 1 else 0 - } - val displayValue = if (category.allowDecimals) "%.1f".format(sliderValue) - else sliderValue.toInt().toString() - val minLabel = if (category.allowDecimals) "%.1f".format(min) else min.toInt().toString() - val maxLabel = if (category.allowDecimals) "%.1f".format(max) else max.toInt().toString() - val scaleLabel = if (!category.allowDecimals) - category.scaleLabels.decodeScaleLabels()[sliderValue.toInt()] - else null - - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.End - ) { - Text( - text = if (category.numericUnit.isNotBlank()) "$displayValue ${category.numericUnit}" else displayValue, - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.primary - ) - if (scaleLabel != null) { - Text( - scaleLabel, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Slider( - value = sliderValue, - onValueChange = onNumericChange, - valueRange = min..max, - steps = steps, - modifier = Modifier.fillMaxWidth() - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(minLabel, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - if (numericValue == null) { - Text("Drag to set a value", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - Text(maxLabel, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - } - - "numeric_free" -> { - OutlinedTextField( - value = freeText, - onValueChange = onFreeTextChange, - label = { Text(if (category.numericUnit.isNotBlank()) category.numericUnit else "Value") }, - placeholder = { Text("Enter a number") }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors( - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f) - ) - ) - } - - "increment" -> { - val count = numericValue?.toInt() ?: 0 - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - count.toString(), - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.primary - ) - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - IconButton( - onClick = { onNumericChange((count - 1).toFloat()) }, - enabled = count > 0 - ) { - Icon(Icons.Default.Remove, contentDescription = "Decrease") - } - Button(onClick = { onNumericChange((count + 1).toFloat()) }) { - Text("+1") - } - } - } - } - } - - else -> { - if (availableValues.isNotEmpty()) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - availableValues.forEach { label -> - SelectableChip( - label = label, - selected = label in selectedValues, - onClick = { onToggleValue(label) } - ) - } - } - } else { - Text( - "No values configured. Add values in Settings → Tracking Categories.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } -} - -@Composable -private fun SectionLabel(text: String) { - Text(text, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) -} - -@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/LogPeriodViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt deleted file mode 100644 index 6d43922..0000000 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/log/LogPeriodViewModel.kt +++ /dev/null @@ -1,507 +0,0 @@ -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.PeriodEntry -import com.mapgie.goflo.data.database.entities.TrackingCategory -import com.mapgie.goflo.data.database.entities.TrackingValue -import com.mapgie.goflo.data.preferences.AppPreferencesStore -import com.mapgie.goflo.notifications.ReminderScheduler -import com.mapgie.goflo.widget.GoFloWidget -import com.mapgie.goflo.data.repository.PeriodRepository -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 - -/** - * UI state for per-day period logging. - * - * The unit of logging is a single day: [date] is the day being logged or - * edited, and every day-specific value (flow, symptoms, pinned categories) - * applies to that day only. Episode-level context (which period the day - * belongs to, its start, its explicit end, its notes) is shown and editable - * alongside, but the period itself is derived from the logged days. - */ -data class LogPeriodUiState( - val isLoading: Boolean = true, - val isEditing: Boolean = false, - val existingId: Long? = null, - /** The day being logged or edited. All per-day values below apply to it. */ - val date: LocalDate = LocalDate.now(), - /** Episode start (editing only — moving it re-keys the episode's days). */ - val startDate: LocalDate = LocalDate.now(), - /** Explicit episode end ("until"), or null to leave the period open. */ - val endDate: LocalDate? = null, - /** - * When creating: the start date of the existing period that [date] would - * continue (within gap tolerance), or null if it starts a new period. - */ - val continuesEpisodeStart: LocalDate? = null, - /** 1-based day number of [date] within its episode, when known. */ - val episodeDayNumber: Int? = null, - /** Gap tolerance (days) loaded from preferences. */ - val toleranceDays: Int = PeriodRepository.DEFAULT_GAP_TOLERANCE_DAYS, - /** Currently selected flow level label for [date] (e.g. "Medium"). */ - val selectedFlowLabel: String = "Medium", - /** All symptom labels selected for [date]. */ - val symptoms: Set = emptySet(), - /** Episode-level notes. */ - val notes: String = "", - val saved: Boolean = false, - val deleted: Boolean = false, - val error: String? = null, - /** Non-system categories the user has marked "Log with period". */ - val pinnedCategories: List = emptyList(), - /** Available value labels for each pinned default category. */ - val pinnedCategoryValues: Map> = emptyMap(), - /** User-selected value labels for each pinned default category. */ - val pinnedCategorySelections: Map> = emptyMap(), - /** Current slider position for each pinned numeric_slider category. */ - val pinnedNumericValues: Map = emptyMap(), - /** Current text entry for each pinned numeric_free category. */ - val pinnedFreeTextValues: Map = emptyMap(), - /** User-chosen display name for the Flow system category. */ - val flowCategoryName: String = "Flow", - /** User-chosen display name for the Symptoms system category. */ - val symptomsCategoryName: String = "Symptoms", - /** Full Flow system category entity — used to know its current categoryType and showInLogPeriod. */ - val flowCategory: TrackingCategory? = null, - /** Full Symptoms system category entity — used to check showInLogPeriod. */ - val symptomsCategory: TrackingCategory? = null, - /** Current slider position when the Flow category is in slider mode (1-4). */ - val flowSliderValue: Float? = null, - /** Ordered list of selectable flow level options (from TrackingValues). */ - val flowOptions: List = emptyList(), - /** Ordered list of all symptom options (from TrackingValues). */ - val symptomOptions: List = emptyList(), - /** True once the user has made at least one edit — enables the save-on-back prompt. */ - val hasChanges: Boolean = false, -) - -class LogPeriodViewModel( - private val repository: PeriodRepository, - private val periodId: Long, - private val prefilledDate: LocalDate? = null, - private val trackingRepository: com.mapgie.goflo.data.repository.TrackingRepository? = null, - private val application: Application? = null, - private val preferencesStore: AppPreferencesStore? = null, -) : ViewModel() { - - private val _uiState = MutableStateFlow( - LogPeriodUiState( - date = prefilledDate ?: LocalDate.now(), - startDate = prefilledDate ?: LocalDate.now(), - ) - ) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - viewModelScope.launch { - val tolerance = preferencesStore?.preferences?.first()?.periodGapToleranceDays - ?: PeriodRepository.DEFAULT_GAP_TOLERANCE_DAYS - _uiState.update { it.copy(toleranceDays = tolerance) } - - if (periodId > 0) { - val period = repository.getPeriodById(periodId).first() - if (period != null) { - val start = LocalDate.parse(period.startDate) - val storedEnd = period.endDate?.let { d -> LocalDate.parse(d) } - val day = prefilledDate ?: start - // If this screen was opened for a day that continues the - // period (a day past its stored end, within tolerance), - // extend the end to that day so saving naturally continues - // the period instead of trimming the new day away. - val effectiveEnd = if (storedEnd != null && day.isAfter(storedEnd)) day else storedEnd - _uiState.update { - it.copy( - isLoading = false, - isEditing = true, - existingId = period.id, - date = day, - startDate = start, - endDate = effectiveEnd, - episodeDayNumber = dayNumber(start, day), - notes = period.notes, - ) - } - } else { - _uiState.update { it.copy(isLoading = false) } - } - } else { - resolveContinuationContext(_uiState.value.date, tolerance) - _uiState.update { it.copy(isLoading = false) } - } - loadSystemCategoryNames() - loadPinnedCategories() - } - } - - /** - * Looks up whether logging [date] would continue an existing period, and - * prefills the episode context (day number, notes) from it if so. - */ - private suspend fun resolveContinuationContext(date: LocalDate, tolerance: Int) { - val periods = repository.getAllPeriodsOnce() - val episode = PeriodRepository.periodForDate(periods, date, tolerance) - _uiState.update { state -> - if (episode != null) { - val start = LocalDate.parse(episode.startDate) - state.copy( - continuesEpisodeStart = start, - episodeDayNumber = dayNumber(minOf(start, date), date), - notes = if (state.notes.isBlank()) episode.notes else state.notes, - ) - } else { - state.copy(continuesEpisodeStart = null, episodeDayNumber = null) - } - } - } - - private suspend fun loadSystemCategoryNames() { - val tr = trackingRepository ?: return - val flowCat = tr.getSystemCategoryByKey("flow") - val symptomsCat = tr.getSystemCategoryByKey("symptoms") - - // Load current flow and symptoms from TrackingLog for the day being logged. - val date = _uiState.value.date - var editFlowLabel: String? = null - var editFlowSlider: Float? = null - var editSymptoms: Set? = null - if (flowCat != null) { - val raw = tr.getExistingLog(date, flowCat.id)?.values?.firstOrNull() - if (raw != null) { - if (flowCat.categoryType == "numeric_slider") { - editFlowSlider = raw.toFloatOrNull() - editFlowLabel = when (editFlowSlider?.toInt()) { - 1 -> "Spotting"; 2 -> "Light"; 4 -> "Heavy"; else -> "Medium" - } - } else { - editFlowLabel = raw - } - } - } - if (symptomsCat != null) { - editSymptoms = tr.getExistingLog(date, symptomsCat.id)?.values?.toSet() - } - - _uiState.update { state -> - val selectedFlow = editFlowLabel ?: state.selectedFlowLabel - val sliderValue = editFlowSlider ?: if (flowCat?.categoryType == "numeric_slider" && state.flowSliderValue == null) { - flowLabelToSliderValue(selectedFlow) - } else { - state.flowSliderValue - } - state.copy( - flowCategoryName = flowCat?.name ?: state.flowCategoryName, - symptomsCategoryName = symptomsCat?.name ?: state.symptomsCategoryName, - flowCategory = flowCat, - symptomsCategory = symptomsCat, - flowSliderValue = sliderValue, - selectedFlowLabel = selectedFlow, - symptoms = editSymptoms ?: state.symptoms, - ) - } - - // Subscribe to value lists in separate coroutines so chips update live after edits. - if (flowCat != null) { - viewModelScope.launch { - tr.getValuesForCategory(flowCat.id).collect { values -> - _uiState.update { it.copy(flowOptions = values) } - } - } - } - if (symptomsCat != null) { - viewModelScope.launch { - tr.getValuesForCategory(symptomsCat.id).collect { values -> - _uiState.update { it.copy(symptomOptions = values) } - } - } - } - } - - private suspend fun loadPinnedCategories() { - val tr = trackingRepository ?: return - // Exclude system categories (flow, symptoms) — they have dedicated UI sections above. - val categories = tr.getShowInLogPeriodCategories().filter { !it.isSystem } - if (categories.isEmpty()) return - - val valuesMap = mutableMapOf>() - val selectionsMap = mutableMapOf>() - val numericMap = mutableMapOf() - val freeTextMap = mutableMapOf() - - val date = _uiState.value.date - for (cat in categories) { - valuesMap[cat.id] = tr.getValuesForCategory(cat.id).first().map { it.label } - val existing = tr.getExistingLog(date, cat.id) - when (cat.categoryType) { - "numeric_slider", - "increment" -> numericMap[cat.id] = existing?.values?.firstOrNull()?.toFloatOrNull() - "numeric_free" -> freeTextMap[cat.id] = existing?.values?.firstOrNull() ?: "" - else -> selectionsMap[cat.id] = existing?.values?.toSet() ?: emptySet() - } - } - - _uiState.update { - it.copy( - pinnedCategories = categories, - pinnedCategoryValues = valuesMap, - pinnedCategorySelections = selectionsMap, - pinnedNumericValues = numericMap, - pinnedFreeTextValues = freeTextMap, - ) - } - } - - /** - * Changes the day being logged (new entries only). Re-resolves the - * continuation context and reloads that day's existing values so the - * form always reflects the selected day. - */ - fun setDate(date: LocalDate) { - _uiState.update { state -> - val end = if (state.endDate != null && date.isAfter(state.endDate)) null else state.endDate - state.copy(date = date, startDate = date, endDate = end, hasChanges = true) - } - viewModelScope.launch { - resolveContinuationContext(date, _uiState.value.toleranceDays) - loadSystemCategoryNames() - loadPinnedCategories() - } - } - - /** Moves the episode start (editing only). */ - fun setStartDate(date: LocalDate) = _uiState.update { state -> - val end = if (state.endDate != null && date.isAfter(state.endDate)) null else state.endDate - state.copy( - startDate = date, - endDate = end, - episodeDayNumber = dayNumber(date, state.date), - hasChanges = true, - ) - } - - fun setEndDate(date: LocalDate?) = _uiState.update { - it.copy(endDate = date, hasChanges = true) - } - - fun setFlowLevel(label: String) = _uiState.update { it.copy(selectedFlowLabel = label, hasChanges = true) } - - fun setFlowSliderValue(value: Float) = _uiState.update { state -> - // Map slider position to the nearest built-in label for storage. - val label = PeriodDaySync.flowLabelForSliderValue(value.toInt()) - state.copy(flowSliderValue = value, selectedFlowLabel = label, hasChanges = true) - } - - /** Toggles [label] in/out of the selected symptoms set. */ - 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, 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. - */ - fun addNewSymptomToLibrary(name: String) { - val trimmed = name.trim() - if (trimmed.isBlank()) return - viewModelScope.launch { - val tr = trackingRepository ?: return@launch - val sympCat = tr.getSystemCategoryByKey("symptoms") ?: return@launch - tr.addValueToCategory(sympCat.id, trimmed) - } - _uiState.update { state -> state.copy(symptoms = state.symptoms + trimmed, hasChanges = true) } - } - - fun setNotes(notes: String) = _uiState.update { it.copy(notes = notes, hasChanges = true) } - - fun togglePinnedValue(categoryId: Long, label: String) = _uiState.update { state -> - val current = state.pinnedCategorySelections[categoryId] ?: emptySet() - val updated = if (label in current) current - label else current + label - state.copy(pinnedCategorySelections = state.pinnedCategorySelections + (categoryId to updated), hasChanges = true) - } - - fun setPinnedNumericValue(categoryId: Long, value: Float) = _uiState.update { state -> - state.copy(pinnedNumericValues = state.pinnedNumericValues + (categoryId to value), hasChanges = true) - } - - fun setPinnedFreeText(categoryId: Long, text: String) = _uiState.update { state -> - state.copy(pinnedFreeTextValues = state.pinnedFreeTextValues + (categoryId to text), hasChanges = true) - } - - /** - * Replaces a pinned category's selection with one label. Used by the - * single-value yes_no and time input types, whose reading is stored as a - * lone value-label string ("Yes"/"No" or "HH:mm"); the existing - * selection-set save path persists it unchanged. - */ - fun setPinnedSingleValue(categoryId: Long, label: String) = _uiState.update { state -> - state.copy( - pinnedCategorySelections = state.pinnedCategorySelections + (categoryId to setOf(label)), - hasChanges = true, - ) - } - - /** - * Saves the day: marks [LogPeriodUiState.date] as a period day (which - * starts, continues, or bridges an episode as needed), applies any - * episode-boundary edits, and writes this day's flow/symptoms/pinned - * values to the per-day tracking logs. - */ - fun save() { - val state = _uiState.value - viewModelScope.launch { - try { - val tolerance = state.toleranceDays - val episode: PeriodEntry? = if (state.isEditing && state.existingId != null) { - repository.logPeriodDay(state.date, tolerance) - repository.updateEpisode( - id = state.existingId, - start = state.startDate, - end = state.endDate, - notes = state.notes, - 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.notes, - flowLevel = state.selectedFlowLabel, - ) - } - - syncFlowToTrackingLog(state) - syncSymptomsToTrackingLog(state) - syncPinnedCategoryLogs(state) - application?.let { GoFloWidget.updateAllWidgets(it) } - // Saving a period day changes the cycle predictions, so the pre-period, - // ovulation, and daily reminders must be re-armed against the new dates. - // 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.") } - } - } - } - - /** - * Removes the edited 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.") } - } - } - } - - /** - * 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) = - PeriodDaySync.syncFlowToTrackingLog( - trackingRepository, state.date, state.selectedFlowLabel, state.flowSliderValue, - ) - - 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) { - val tr = trackingRepository ?: return - val date = state.date - for (cat in state.pinnedCategories) { - val valuesToSave = computePinnedValues(cat, state) ?: continue - tr.saveLog( - date = date, - categoryId = cat.id, - selectedValues = valuesToSave, - notes = "", - allowMultiple = false, - ) - } - } - - private fun computePinnedValues(cat: TrackingCategory, state: LogPeriodUiState): Set? = - 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) } - } - - /** Deletes the entire episode: its days, its row, and its per-day logs. */ - fun delete() { - val state = _uiState.value - val id = state.existingId ?: 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.") } - } - } - } - - class Factory( - private val repository: PeriodRepository, - private val periodId: Long, - private val prefilledDate: LocalDate? = null, - private val trackingRepository: com.mapgie.goflo.data.repository.TrackingRepository? = null, - private val application: Application? = null, - private val preferencesStore: AppPreferencesStore? = null, - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - @Suppress("UNCHECKED_CAST") - return LogPeriodViewModel(repository, periodId, prefilledDate, trackingRepository, application, preferencesStore) as T - } - } - - companion object { - 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? = - 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 index 4d00b17..1a209ab 100644 --- 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 @@ -31,8 +31,6 @@ 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 @@ -50,7 +48,6 @@ 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 @@ -75,6 +72,7 @@ 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.DatePickerDialogWrapper import com.mapgie.goflo.ui.components.HairlineDivider import com.mapgie.goflo.ui.components.ListCard import com.mapgie.goflo.ui.components.ListRow @@ -92,9 +90,7 @@ 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") @@ -1379,32 +1375,3 @@ private fun entrySummary( } } -// ── 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 index 1f0438e..7672e2d 100644 --- 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 @@ -28,8 +28,8 @@ 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 + * Mirrors the fields the retired single-category screen kept for its one + * 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 @@ -127,9 +127,15 @@ data class LogUiState( * * 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. + * to [PeriodDaySync] and [PeriodRepository], the code paths the retired + * standalone period screen used, so period semantics could not drift during + * the migration. Generic category behaviour keeps the retired category + * screen's save rules per entry (see [entryValuesToSave]). + * + * Deep-link targeting: [focusCategoryId] expands that category's input on + * first load (quick-log widget, speed dial); [editLogId] loads that one + * specific log into its category's entry for in-place editing, which is how a + * single log of an allow-multiple category is edited from the day sheet. */ class LogViewModel( private val repository: PeriodRepository, @@ -137,6 +143,8 @@ class LogViewModel( private val initialDate: LocalDate, private val application: Application? = null, private val preferencesStore: AppPreferencesStore? = null, + focusCategoryId: Long? = null, + editLogId: Long? = null, ) : ViewModel() { private val _uiState = MutableStateFlow(LogUiState(date = initialDate)) @@ -144,6 +152,10 @@ class LogViewModel( private var optionSubscriptionsStarted = false + /** One-shot: consumed by the first [loadDay]; day switches load normally. */ + private var pendingFocusCategoryId: Long? = focusCategoryId + private var pendingEditLogId: Long? = editLogId + init { viewModelScope.launch { val prefs = preferencesStore?.preferences?.first() @@ -181,7 +193,7 @@ class LogViewModel( (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. + // the new day away — the period screen's long-standing rule. val effectiveEnd = if (epEndStored != null && date.isAfter(epEndStored)) date else epEndStored @@ -219,6 +231,29 @@ class LogViewModel( entriesMap[cat.id] = loadEntry(cat, date) } + // One-shot deep-link targeting (consumed on the first load only): + // focus a category, or load one specific log for in-place editing. + // The latter is how a single entry of an allow-multiple category is + // edited: the loaded log carries existingLog, so save() updates it in + // place and "Delete entry" removes exactly it. + var focusId = pendingFocusCategoryId + pendingFocusCategoryId = null + val editTarget = pendingEditLogId?.let { id -> + pendingEditLogId = null + trackingRepository.getLogById(id) + } + if (editTarget != null && editTarget.log.date == date.toString()) { + val cat = categories.firstOrNull { it.id == editTarget.log.categoryId } + if (cat != null) { + focusId = cat.id + // Timed increments render the whole day's timeline already; + // only collect-then-save types load the one targeted log. + if (!(cat.categoryType == "increment" && cat.trackAgainstTime)) { + entriesMap[cat.id] = entryFromLog(cat, editTarget) + } + } + } + _uiState.update { state -> state.copy( date = date, @@ -245,7 +280,7 @@ class LogViewModel( categories = categories, categoryValues = valuesMap, entries = entriesMap, - activeCategoryId = null, + activeCategoryId = focusId, hasChanges = false, ) } @@ -276,8 +311,9 @@ class LogViewModel( 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. + // allowMultiple categories always start a fresh entry (the retired + // category screen's new-entry behaviour); a specific existing log is + // loaded only through the editLogId deep link. val existing = if (timed || cat.allowMultiple) null else trackingRepository.getExistingLog(date, cat.id) val numeric = @@ -297,6 +333,31 @@ class LogViewModel( ) } + /** + * Builds a [DayMetricEntry] from one specific stored log (the [editLogId] + * deep link), mirroring [loadEntry]'s per-type field mapping but pinned to + * exactly that log instead of the day's first. + */ + private fun entryFromLog( + cat: TrackingCategory, + existing: TrackingLogWithValues, + ): DayMetricEntry { + 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(), + numericValue = numeric, + freeText = freeText, + notes = existing.log.notes, + trackTime = cat.trackAgainstTime, + existingLog = existing.log, + ) + } + private suspend fun reloadEntry(categoryId: Long) { val state = _uiState.value val cat = state.categories.firstOrNull { it.id == categoryId } ?: return @@ -308,9 +369,8 @@ class LogViewModel( /** * 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. + * for it, so the form always reflects the selected day. Unsaved edits are + * guarded by the screen before this is called. */ fun setDate(newDate: LocalDate) { if (newDate == _uiState.value.date) return @@ -556,7 +616,7 @@ class LogViewModel( /** * Saves the day. When the day is on-period (or being started), the period - * path mirrors LogPeriodViewModel.save(): mark the day, apply episode + * path keeps the period screen's save order: 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. @@ -663,7 +723,7 @@ class LogViewModel( } /** - * LogCategoryViewModel.save()'s per-type rules, applied per entry: an + * The retired category screen's per-type save 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 @@ -743,10 +803,15 @@ class LogViewModel( private val date: LocalDate, private val application: Application? = null, private val preferencesStore: AppPreferencesStore? = null, + private val focusCategoryId: Long? = null, + private val editLogId: Long? = null, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @Suppress("UNCHECKED_CAST") - return LogViewModel(repository, trackingRepository, date, application, preferencesStore) as T + return LogViewModel( + repository, trackingRepository, date, application, preferencesStore, + focusCategoryId, editLogId, + ) as T } } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/log/MetricSupport.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/log/MetricSupport.kt new file mode 100644 index 0000000..f3b80ae --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/log/MetricSupport.kt @@ -0,0 +1,101 @@ +package com.mapgie.goflo.ui.screens.log + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.data.database.entities.TrackingLog +import com.mapgie.goflo.data.repository.TrackingLogWithValues +import com.mapgie.goflo.ui.components.MetricConfig +import com.mapgie.goflo.ui.components.Timeline +import com.mapgie.goflo.ui.components.TimelineEntryData +import com.mapgie.goflo.ui.util.decodeScaleLabels + +/** + * Builds the [MetricConfig] the MetricInput facade renders from a category + * row. One shared mapping so every surface renders a category identically. + */ +internal fun metricConfigFor( + category: TrackingCategory, + availableValues: List, +): MetricConfig = MetricConfig( + name = category.name, + options = availableValues, + min = category.numericMin.toInt(), + max = category.numericMax.toInt(), + stepLabels = category.scaleLabels.decodeScaleLabels(), + unit = category.numericUnit.takeIf { it.isNotBlank() }, + allowDecimals = category.allowDecimals, +) + +/** + * Timed increment ("Plus One" + track against time): each append saves a new + * timestamped log immediately, so the day renders as a running total plus a + * [Timeline] of the day's entries with per-entry delete. There is deliberately + * no notes field or Save button on this path. + */ +@Composable +internal fun TimedIncrementTimeline( + category: TrackingCategory, + entries: List, + onAddOne: () -> Unit, + onDeleteEntry: (TrackingLog) -> Unit, +) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + category.name, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + entries.size.toString(), + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.primary + ) + if (category.numericUnit.isNotBlank()) { + Text( + category.numericUnit, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp) + ) + } + } + } + } + Timeline( + entries = entries.map { entry -> + TimelineEntryData( + id = entry.log.id, + time = entry.log.loggedAt.ifEmpty { "No time" }, + value = "+1", + ) + }, + role = MaterialTheme.colorScheme.primary, + onAppend = onAddOne, + appendLabel = "Log +1 now", + onDeleteEntry = { data -> + entries.firstOrNull { it.log.id == data.id }?.let { onDeleteEntry(it.log) } + }, + ) +} 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 index f016f66..512e771 100644 --- 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 @@ -8,13 +8,14 @@ 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). + * Period-day logic used by [LogViewModel] (the unified day screen) and the + * period-detail surface. * - * Extracted rather than duplicated so the flow slider mapping and the save + * Originally extracted from the standalone period screen's ViewModel (retired + * in Phase 8 of the logging redesign) 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 + * Flow/Symptoms/pinned categories in Stats) could not drift while both + * surfaces coexisted. Behaviour is byte-for-byte the original period-screen * logic. */ internal object PeriodDaySync { diff --git a/changelog/unreleased/logging-redesign-cleanup.json b/changelog/unreleased/logging-redesign-cleanup.json new file mode 100644 index 0000000..a65498b --- /dev/null +++ b/changelog/unreleased/logging-redesign-cleanup.json @@ -0,0 +1,10 @@ +{ + "bump": "minor", + "changed": [ + "The unified day log is now the standard logging screen everywhere: calendar taps, the Log button, the day sheet, and the Quick Log widget all open it, with the tapped category focused", + "Editing an entry from the day sheet opens the day log targeting that exact entry, including one specific log of a category that allows multiple logs per day", + "The old separate Log Period and category logging screens have been removed now that the day log covers everything they did", + "The period detail screen's pencil action opens the first day of the period in the day log", + "A category's range, step labels, unit, and options are now edited in one place, the Edit screen; the values screen keeps the value list and the Flow selector style" + ] +} diff --git a/docs/design/logging-redesign/PLAN.md b/docs/design/logging-redesign/PLAN.md index 4f8b40b..c40afee 100644 --- a/docs/design/logging-redesign/PLAN.md +++ b/docs/design/logging-redesign/PLAN.md @@ -1,6 +1,6 @@ # Logging & category-system redesign — phased implementation plan -**Status:** Phase 0 complete (planning + groundwork committed). Phases 1-8 not started. +**Status:** Complete. Phases 0 through 8 are done: the unified day screen (`log_day`) is the only logging surface, the superseded screens are removed, and §7 records every phase's outcome. This document is now the historical record of the redesign. **Owner handoff doc.** This is written so a *fresh* session can pick up any phase without re-deriving the codebase. Read this file top-to-bottom, then the one subsystem map relevant to your phase, then start. --- @@ -210,7 +210,7 @@ Each phase is a shippable PR. Order is deliberate: additive foundations first (r | 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 | Done | `claude/logging-redesign-phase-6` | 24 (unchanged) | `ManageCategoriesScreen` restructured to the row-4 mock: Grouped/Ungrouped `SegmentedToggle`, role-tinted group cards, category-centric add-to-group sheet (with a "use the group's colour" switch that sets the `"inherit"` sentinel; defaults on per the handover, but the user can keep the category's own colour, honouring §8 decision 1), group-centric add-member sheet, and a create/edit group dialog (rename, role via `RolePicker` with a new additive `showFixedSection=false` flag, default input type, member unfiling, move up/down reorder, delete-with-members-kept confirmation). Deviations: (1) reorder mode keeps the pre-redesign flat drag list over all active categories (global `displayOrder` preserved; groups reorder separately via the edit dialog), rather than per-group drag. (2) Unfiling is via the edit-group member list and a "Remove from group" row in the add-to-group sheet, not a dedicated surface in the mock. (3) A category created from inside a group keeps the colour picked in the (unchanged Phase 7-bound) creation dialog rather than auto-inheriting; it is pre-set to the group's default input type and filed on creation. All pre-existing management actions (archive/unarchive, delete-with-history, system protection, reorder, values/settings via `ManageCategoryValues`, tracking modes, quick-log) unchanged and reachable. | | 7 — Create/edit + scale + alarms | Done | `claude/logging-redesign-phase-7` | 24 (unchanged) | §8 decision #2 resolved by the owner: **categoryType is "fixed once logged"** — editable until the category has at least one tracking log (checked live via additive `TrackingLogDao.countLogsForCategory` / `TrackingRepository.hasLogs`), then the edit UI shows the type read-only with a one-line explanation. No value-migration machinery. New `CategoryEditScreen` + `CategoryEditViewModel` (route `category_edit?categoryId={id}&groupId={id}`) is one surface for both create (2-step; step 2 only for `numeric_slider`: range, per-step word labels, decimals) and edit (same form prefilled + Reminders wired to the existing CustomAlarm/EditAlarm system with per-alarm enable switches, a "Scale settings" row into step 2, and a delete-with-history danger zone; alarms on edit only). All create entry points (FAB, Ungrouped CTA, add-member sheet) now navigate there — `AddCategoryDialog` is superseded but kept in place for Phase 8; `EditAppearanceDialog`/`RenameCategoryDialog` likewise superseded by the single Edit action on `ManageCategoryValues` (value catalog, per-type settings, flow slider toggle, archive/delete menu all unchanged there). Deviations: (1) both steps live in one route with in-screen step state rather than two nav destinations, so the half-built form never crosses navigation. (2) Creating inside a group shows a "Use the group's colour" switch (default on, writing the `"inherit"` sentinel), extending Phase 6's adopt-colour sheets to creation; the same switch appears on edit for grouped categories. (3) Track-against-time is additionally settable at creation (previously edit-only). (4) System categories: type always locked ("Built-in categories keep their input type", the Flow chip/slider switch stays on ManageCategoryValues), allow-multiple/log-with-period switches hidden as before. | -| 8 — Cleanup & removal | Not started | | | Gate on parity checklist. | +| 8 — Cleanup & removal | Done | `claude/logging-redesign-phase-8` | 24 (unchanged) | **Parity signed off by the owner 2026-08-28** (the gate for this phase). Entry points flipped: calendar quick log, speed dial, day sheet (its day-log row is now the standard "Open day log" action), Quick Log widget deep link, and PeriodDetail's top-bar action all open `log_day`; category-targeted entries focus that category via a new `categoryId` argument. The Phase 5 deviation (editing one specific same-day log of an allowMultiple category) is resolved by a `logId` deep link on `log_day`: the targeted log loads as that category's entry with `existingLog` set, so Save updates it in place and Delete entry removes exactly it. Removed with references proven gone (grep-clean): `LogPeriodScreen`+`LogPeriodViewModel`, `LogCategoryScreen`+`LogCategoryViewModel` (with `PinnedCategoryInput`, both private `DatePickerDialogWrapper` copies, `DateSelectorCard`), the orphaned `LogEntryTopBar`, routes `Screen.LogPeriod`/`Screen.LogCategory` and their registrations, `AddCategoryDialog`/`EditAppearanceDialog`/`RenameCategoryDialog` plus their orphaned helpers (`CategoryColorPicker`, `CategoryIconGrid`, `NumericSettingsSection`) and dead VM methods (`ManageCategoriesViewModel.addCategory`/`.updateCategoryAppearance`; `ManageCategoryValuesViewModel.renameCategory`/`.updateAppearance`/`.updateNumericSettings`/`.updateUnit`/`.setShowInLogPeriod`/`.setAllowMultiple`/`.setTrackAgainstTime`). Relocated, not removed: `metricConfigFor`+`TimedIncrementTimeline` → `ui/screens/log/MetricSupport.kt`; `FullColorPickerDialog`+`isFixedColorToken`/`isCustomColorToken` → `CategoryColorPickerDialog.kt`; `DatePickerDialogWrapper` consolidated as a shared `ui/components/` composable. `PeriodDaySync` (the period fan-out), `AddSymptomDialog`, and every repository method stay. Deviations: (1) `ManageCategoryValues` consolidation goes further than the two dialogs: its per-type settings sections (slider range/labels/decimals/unit, free-numeric unit, and the three switches) are folded into the edit flow, which provably covers each; the screen keeps the value catalog CRUD, the Flow chips/slider switch, alarms, and archive/delete. (2) A handful of repository setters (`renameCategory`, `updateNumericSettings`, `updateNumericUnit`, `updateShowInLogPeriod`, `updateAllowMultiple`, `updateTrackAgainstTime`) now have no UI callers but are retained as data-layer API per this phase's do-not-remove rule. | --- 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 feb9eaf..922729e 100644 --- a/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md +++ b/docs/design/logging-redesign/subsystem-maps/01-logging-screens.md @@ -13,7 +13,11 @@ > **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 +> **Phase 8 removal (branch `claude/logging-redesign-phase-8`) — the two screens this map describes NO LONGER EXIST.** Deleted: `LogPeriodScreen.kt`, `LogPeriodViewModel.kt`, `LogCategoryScreen.kt`, `LogCategoryViewModel.kt` (taking `PinnedCategoryInput`, `DateSelectorCard`, `SectionLabel`, and both private `DatePickerDialogWrapper` copies with them), `LogEntryTopBar.kt` (orphaned), and the `Screen.LogPeriod` / `Screen.LogCategory` routes plus their `MainActivity` registrations. Sections 1 through 6 below are kept as the historical description of the removed code; the durable content is §4's save-flow semantics, which live on in `LogViewModel` + `PeriodDaySync`. +> +> The one logging destination is now `LogScreen` at `log_day?date={date}&categoryId={categoryId}&logId={logId}`: `categoryId` focuses (expands) that category's input — used by the Quick Log widget deep link and the speed dial; `logId` loads that specific log as its category's entry for in-place editing — how one particular log of an allowMultiple category is edited from the day sheet. Entry points after the flip: calendar day tap (`HomeScreen.handleQuickLog`), the speed dial ("Log Period" and every category item), the day sheet ("Open day log", per-entry edit, period edit), `PeriodDetailScreen` (day rows and the top-bar "Open first day" action), and the widget deep link in `MainActivity`. Relocations: `metricConfigFor` + `TimedIncrementTimeline` → `ui/screens/log/MetricSupport.kt`; `DatePickerDialogWrapper` → shared `ui/components/DatePickerDialogWrapper.kt`. Kept: `PeriodDaySync` (flow mapping + fan-out; also used by `PeriodDetailViewModel`), `AddSymptomDialog`, every repository method. + +## Overview: two truly separate destinations (REMOVED in Phase 8 — historical) "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`. diff --git a/docs/design/logging-redesign/subsystem-maps/02-category-data-model.md b/docs/design/logging-redesign/subsystem-maps/02-category-data-model.md index 6dae46a..906fbba 100644 --- a/docs/design/logging-redesign/subsystem-maps/02-category-data-model.md +++ b/docs/design/logging-redesign/subsystem-maps/02-category-data-model.md @@ -7,6 +7,8 @@ > - **Updated 2026-08-25 for Phase 2** (branch `claude/logging-redesign-phase-2-nuaywv`): DB is now **v24** — `groups` table, `TrackingCategory.groupId`, `GroupDao`, group methods on `TrackingRepository`, `"inherit"` colour sentinel. Sections below annotated in place. > - **Phase 7 drift** (branch `claude/logging-redesign-phase-7`, DB still v24): the "immutable after creation" rule on `categoryType` is now **"fixed once logged"** (owner decision) — editable via the new `CategoryEditScreen` until the category has a tracking log, checked live through additive `TrackingLogDao.countLogsForCategory(categoryId)` / `TrackingRepository.hasLogs(categoryId)`. `CategoryEditViewModel.save` routes edits through the pre-existing `updateCategoryFullSettings` (mode key carried through; type/allow-multiple/show-in-period pinned to stored values for system categories or once logs exist). Create still uses `addCategory` + `assignCategoryToGroup`. No schema change. > +> **Phase 8 drift** (branch `claude/logging-redesign-phase-8`, DB still v24, no schema change): the entity, DAO, and repository layers this map describes are untouched. UI-layer drift only: the superseded `AddCategoryDialog`/`EditAppearanceDialog`/`RenameCategoryDialog` are deleted along with the dead ViewModel methods that backed them (`ManageCategoriesViewModel.addCategory`/`.updateCategoryAppearance`; `ManageCategoryValuesViewModel.renameCategory`/`.updateAppearance`/`.updateNumericSettings`/`.updateUnit`/`.setShowInLogPeriod`/`.setAllowMultiple`/`.setTrackAgainstTime`), and `ManageCategoryValuesScreen`'s per-type settings sections are consolidated into `CategoryEditScreen` (the screen keeps value-catalog CRUD, the Flow chips/slider switch via `updateFlowCategoryMode`, alarms, archive/delete). Repository setters `renameCategory`, `updateNumericSettings`, `updateNumericUnit`, `updateShowInLogPeriod`, `updateAllowMultiple`, and `updateTrackAgainstTime` therefore have no UI callers at the moment but remain part of the repository API. `FullColorPickerDialog` and the `isFixedColorToken`/`isCustomColorToken` helpers moved to `ui/screens/categories/CategoryColorPickerDialog.kt`. +> > **Staleness check for future sessions:** the DB class is `data/database/GoFloDatabase.kt`. Confirm its `version = N` before writing a migration — if it is no longer **24**, someone added migrations after this map; read them and target `N → N+1`. Run `git diff d07d947 -- app/src/main/java/com/mapgie/goflo/data/` to see drift. All paths under `app/src/main/java/com/mapgie/goflo/`. **The DB class is `GoFloDatabase.kt`, not `AppDatabase.kt`.**