diff --git a/LESSONS.md b/LESSONS.md index 17ed2f6..27281a4 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -129,6 +129,9 @@ When implementing the merge itself: don't reach for a `deleteLogsForPeriod`-styl **Derive range entities from per-unit facts; keep the derived rows persistent for consumers** When a domain concept is really a series of per-unit facts (a period is days of menstruation, a streak is days of activity), storing it as a single start/end record forces every attribute to be range-global (one flow level for a five-day period) and breeds edge-case bugs at the boundaries (extend, bridge, fragment, reopen). Store the per-unit facts as the source of truth and derive the range records by grouping under a tolerance rule, rebuilding after every mutation. Keep the derived rows persisted in the old table with stable ids (match old rows to new groups by span overlap, earliest survivor wins) so every existing consumer — stats, widgets, export, reminders — keeps reading the same schema. "Ongoing" then stops being a fragile nullable-end guess: a range is open while its last unit is within the tolerance window of today, and a scheduled reconcile closes it at its true last unit otherwise. +**Gate a storage-representation field on dependent data existing, not on a blanket "immutable after creation" rule** +When a field decides how dependent rows are encoded (a category's input type deciding whether logs hold labels, counts, or numbers), "immutable after creation" is stricter than the real invariant and blocks harmless fixes (user picked the wrong type before ever logging). The true constraint is "no dependent data may be reinterpreted": compute it live with a COUNT over the dependent table at edit time, lock the control only when rows exist, and say why in the UI in one plain sentence. Enforce the same guard again in the save/repository path (the UI check can be stale or bypassed), and don't cache the lock in a stored flag — a count is always current, survives deletion of the dependent rows, and needs no migration. + **Insert/upsert flags need a separate edit-by-ID path** A flag like `allowMultiple` controls whether saving a log upserts an existing row (keyed by date + category) or always inserts a new one. Neither branch handles "update this specific existing row by ID." Routing an edit through `allowMultiple = false` works only when the existing row is uniquely keyed by the natural key; using `allowMultiple = true` creates a duplicate instead. The correct pattern is a dedicated `updateInPlace(existingLog, …)` method. Callers check `existingLog != null` and take this path directly, bypassing the insert/upsert decision entirely. diff --git a/app/src/main/java/com/mapgie/goflo/MainActivity.kt b/app/src/main/java/com/mapgie/goflo/MainActivity.kt index e943b50..11ddf73 100644 --- a/app/src/main/java/com/mapgie/goflo/MainActivity.kt +++ b/app/src/main/java/com/mapgie/goflo/MainActivity.kt @@ -51,6 +51,8 @@ import com.mapgie.goflo.ui.screens.auth.LockViewModel import com.mapgie.goflo.ui.screens.auth.PinSetupScreen import com.mapgie.goflo.ui.screens.auth.PinSetupViewModel import com.mapgie.goflo.ui.screens.disclaimer.DisclaimerScreen +import com.mapgie.goflo.ui.screens.categories.CategoryEditScreen +import com.mapgie.goflo.ui.screens.categories.CategoryEditViewModel import com.mapgie.goflo.ui.screens.categories.ManageCategoriesScreen import com.mapgie.goflo.ui.screens.categories.ManageCategoriesViewModel import com.mapgie.goflo.ui.screens.categories.ManageCategoryValuesScreen @@ -535,10 +537,55 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa onNavigateBack = { navController.popBackStack() }, onNavigateToCategory = { categoryId -> navController.navigate(Screen.ManageCategoryValues.forCategory(categoryId)) + }, + onNavigateToCreateCategory = { groupId -> + navController.navigate( + if (groupId != null) Screen.CategoryEdit.newInGroup(groupId) + else Screen.CategoryEdit.newCategory + ) } ) } + // ── Category create/edit flow (logging redesign Phase 7) ────────────── + + composable( + route = Screen.CategoryEdit.route, + arguments = listOf( + navArgument("categoryId") { type = NavType.LongType; defaultValue = -1L }, + navArgument("groupId") { type = NavType.LongType; defaultValue = -1L }, + ) + ) { backStack -> + val categoryId = backStack.arguments?.getLong("categoryId") ?: -1L + val groupId = backStack.arguments?.getLong("groupId") ?: -1L + val vm: CategoryEditViewModel = viewModel( + key = "category_edit_${categoryId}_$groupId", + factory = CategoryEditViewModel.Factory( + categoryId, groupId, + app.trackingRepository, app.customAlarmRepository, app.applicationContext + ) + ) + CategoryEditScreen( + viewModel = vm, + onNavigateBack = { navController.popBackStack() }, + onCreated = { newId, categoryType -> + navController.popBackStack() + // Default (list-of-values) categories continue to the values + // screen so the user can add their options; every other type + // is fully configured by the create flow already. + if (categoryType == "default") { + navController.navigate(Screen.ManageCategoryValues.forCategory(newId)) + } + }, + onNavigateToNewAlarm = { + navController.navigate(Screen.EditAlarm.newForCategory(categoryId)) + }, + onNavigateToEditAlarm = { alarmId -> + navController.navigate(Screen.EditAlarm.forAlarm(alarmId)) + }, + ) + } + composable( route = Screen.ManageCategoryValues.route, arguments = listOf(navArgument("categoryId") { type = NavType.LongType }) @@ -559,6 +606,9 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa onNavigateToEditAlarm = { alarmId -> navController.navigate(Screen.EditAlarm.forAlarm(alarmId)) }, + onNavigateToEditCategory = { + navController.navigate(Screen.CategoryEdit.forCategory(categoryId)) + }, ) } diff --git a/app/src/main/java/com/mapgie/goflo/data/database/dao/TrackingLogDao.kt b/app/src/main/java/com/mapgie/goflo/data/database/dao/TrackingLogDao.kt index 9d3f4ee..b5cf1e4 100644 --- a/app/src/main/java/com/mapgie/goflo/data/database/dao/TrackingLogDao.kt +++ b/app/src/main/java/com/mapgie/goflo/data/database/dao/TrackingLogDao.kt @@ -36,6 +36,10 @@ interface TrackingLogDao { @Query("SELECT * FROM tracking_logs WHERE date = :date AND categoryId = :categoryId ORDER BY loggedAt ASC, id ASC") suspend fun getLogsForDateAndCategory(date: String, categoryId: Long): List + /** Number of tracking log rows recorded for [categoryId], across all dates. */ + @Query("SELECT COUNT(*) FROM tracking_logs WHERE categoryId = :categoryId") + suspend fun countLogsForCategory(categoryId: Long): Int + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertLog(log: TrackingLog): Long diff --git a/app/src/main/java/com/mapgie/goflo/data/repository/TrackingRepository.kt b/app/src/main/java/com/mapgie/goflo/data/repository/TrackingRepository.kt index 4e4a8fd..3f68fa1 100644 --- a/app/src/main/java/com/mapgie/goflo/data/repository/TrackingRepository.kt +++ b/app/src/main/java/com/mapgie/goflo/data/repository/TrackingRepository.kt @@ -390,6 +390,17 @@ class TrackingRepository( return newCount } + /** + * True when at least one tracking log exists for the category. + * + * Used by the category edit flow (logging redesign Phase 7) to decide + * whether the input type is still editable: the owner-decided rule is + * "the type is fixed once logged" — computed here from the presence of + * dependent data rather than stored as a flag. + */ + suspend fun hasLogs(categoryId: Long): Boolean = + logDao.countLogsForCategory(categoryId) > 0 + /** Returns the existing log (with values) for a specific (date, category), or null. */ suspend fun getExistingLog(date: LocalDate, categoryId: Long): TrackingLogWithValues? { val log = logDao.getLogForDateAndCategory(date.toString(), categoryId) ?: return null 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 3b2a84f..600efb4 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 @@ -39,6 +39,18 @@ sealed class Screen(val route: String) { fun forCategory(id: Long) = "manage_category_values/$id" } + /** + * The 2-step category create/edit flow (logging redesign Phase 7). + * - [categoryId] `> 0` edits an existing category; `-1` creates a new one. + * - [groupId] `> 0` (create only) files the new category into that group on + * save and pre-selects the group's default input type. + */ + data object CategoryEdit : Screen("category_edit?categoryId={categoryId}&groupId={groupId}") { + val newCategory = "category_edit?categoryId=-1&groupId=-1" + fun newInGroup(groupId: Long) = "category_edit?categoryId=-1&groupId=$groupId" + fun forCategory(categoryId: Long) = "category_edit?categoryId=$categoryId&groupId=-1" + } + // ── Custom alarms ────────────────────────────────────────────────────────── data object CustomAlarms : Screen("custom_alarms") diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoryEditScreen.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoryEditScreen.kt new file mode 100644 index 0000000..1d072d5 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoryEditScreen.kt @@ -0,0 +1,871 @@ +package com.mapgie.goflo.ui.screens.categories + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +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.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +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.ui.components.HairlineDivider +import com.mapgie.goflo.ui.components.IconPicker +import com.mapgie.goflo.ui.components.ListCard +import com.mapgie.goflo.ui.components.ListRow +import com.mapgie.goflo.ui.components.PrimarySaveBar +import com.mapgie.goflo.ui.components.RolePicker +import com.mapgie.goflo.ui.components.SectionHeader +import com.mapgie.goflo.ui.components.SwitchRow +import com.mapgie.goflo.ui.util.COLOR_TOKEN_INHERIT +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.decodeScaleLabels +import com.mapgie.goflo.ui.util.encodeScaleLabels +import com.mapgie.goflo.ui.util.toCategoryColor +import com.mapgie.goflo.ui.util.toCategoryIcon +import com.mapgie.goflo.ui.util.toCategoryOnColor + +/** + * The 2-step category create/edit flow (logging redesign Phase 7, rows 5/6). + * + * Step 1 stays short: name, icon, colour role (or fixed colour), input type, + * and the per-category switches. Step 2 exists only for the stepped-scale type + * (`numeric_slider`): min/max range, optional per-step word labels, and the + * decimals switch. + * + * Edit mode uses the same surface prefilled, and adds: + * - a Reminders section wired to the existing CustomAlarm system (rows open + * the existing EditAlarm screen; switches toggle scheduling in place), + * - a "Scale settings" row linking into step 2, + * - a danger-zone delete-with-history row (system categories protected). + * + * The input type is editable only until the category has at least one logged + * entry (owner decision, PLAN.md paragraph 8 item 2); after that it locks and + * the screen says why. Alarms appear on edit, never on first creation. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CategoryEditScreen( + viewModel: CategoryEditViewModel, + onNavigateBack: () -> Unit, + onCreated: (newId: Long, categoryType: String) -> Unit = { _, _ -> }, + onNavigateToNewAlarm: () -> Unit = {}, + onNavigateToEditAlarm: (Long) -> Unit = {}, +) { + val state by viewModel.uiState.collectAsState() + val isEditing = viewModel.isEditing + val category = state.category + val group = state.group + + // The category was deleted (danger zone, or elsewhere) — leave the screen. + LaunchedEffect(state.isLoading, category) { + if (isEditing && !state.isLoading && category == null) onNavigateBack() + } + + if (state.isLoading || (isEditing && category == null)) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + // ── Form state (initialised once the category/group context is loaded) ──── + + var step by rememberSaveable(category?.id) { mutableStateOf(1) } + + var name by rememberSaveable(category?.id) { mutableStateOf(category?.name ?: "") } + var selectedIconKey by rememberSaveable(category?.id) { + mutableStateOf(category?.iconName ?: CategoryIcon.CATEGORY.key) + } + // The role picker never shows the "inherit" sentinel; it is represented by + // the use-group-colour switch instead, with the group's own role preselected + // underneath so switching it off lands somewhere sensible. + var selectedToken by rememberSaveable(category?.id) { + mutableStateOf( + when { + category == null -> group?.colorRole ?: CategoryColor.SECONDARY.key + category.colorToken == COLOR_TOKEN_INHERIT -> + group?.colorRole ?: CategoryColor.SECONDARY.key + else -> category.colorToken + } + ) + } + var useGroupColor by rememberSaveable(category?.id) { + mutableStateOf( + if (category == null) group != null + else category.colorToken == COLOR_TOKEN_INHERIT && group != null + ) + } + var selectedTypeKey by rememberSaveable(category?.id) { + mutableStateOf(category?.categoryType ?: group?.defaultInputType ?: CategoryType.DEFAULT.key) + } + var numericUnit by rememberSaveable(category?.id) { mutableStateOf(category?.numericUnit ?: "") } + var allowDecimals by rememberSaveable(category?.id) { mutableStateOf(category?.allowDecimals ?: false) } + var minText by rememberSaveable(category?.id) { + mutableStateOf( + category?.let { + if (it.allowDecimals) "%.1f".format(it.numericMin) else it.numericMin.toInt().toString() + } ?: "1" + ) + } + var maxText by rememberSaveable(category?.id) { + mutableStateOf( + category?.let { + if (it.allowDecimals) "%.1f".format(it.numericMax) else it.numericMax.toInt().toString() + } ?: "5" + ) + } + var allowMultiple by rememberSaveable(category?.id) { mutableStateOf(category?.allowMultiple ?: false) } + var showInLogPeriod by rememberSaveable(category?.id) { mutableStateOf(category?.showInLogPeriod ?: false) } + var trackAgainstTime by rememberSaveable(category?.id) { mutableStateOf(category?.trackAgainstTime ?: false) } + val labels = remember(category?.id) { + mutableStateMapOf().apply { + putAll(category?.scaleLabels?.decodeScaleLabels() ?: emptyMap()) + } + } + var showDeleteConfirm by rememberSaveable { mutableStateOf(false) } + + // ── Derived form facts ──────────────────────────────────────────────────── + + val isSliderType = selectedTypeKey == CategoryType.NUMERIC_SLIDER.key + val isNumericFamily = isSliderType || + selectedTypeKey == CategoryType.NUMERIC_FREE.key || + selectedTypeKey == CategoryType.INCREMENT.key + val typeLocked = isEditing && (state.hasLogs || category?.isSystem == true) + + val minValue = minText.toFloatOrNull() + val maxValue = maxText.toFloatOrNull() + val rangeValid = !isSliderType || (minValue != null && maxValue != null && minValue < maxValue) + val minInt = minText.toIntOrNull() + val maxInt = maxText.toIntOrNull() + val canLabelSteps = !allowDecimals && minInt != null && maxInt != null && + maxInt > minInt && (maxInt - minInt) <= 20 + + val effectiveToken = if (useGroupColor && group != null) group.colorRole else selectedToken + val bubbleColor = effectiveToken.toCategoryColor() + val onBubbleColor = effectiveToken.toCategoryOnColor() + + fun doSave() { + viewModel.save( + name = name, + iconName = selectedIconKey, + colorToken = if (useGroupColor && group != null) COLOR_TOKEN_INHERIT else selectedToken, + categoryType = selectedTypeKey, + numericMin = minValue ?: (category?.numericMin ?: 0f), + numericMax = maxValue ?: (category?.numericMax ?: 10f), + allowDecimals = allowDecimals, + numericUnit = numericUnit.trim(), + scaleLabels = if (isSliderType && canLabelSteps) { + labels.filterKeys { it in minInt!!..maxInt!! }.encodeScaleLabels() + } else if (isSliderType) { + category?.scaleLabels ?: "" + } else { + "" + }, + allowMultiple = allowMultiple && selectedTypeKey != CategoryType.INCREMENT.key, + showInLogPeriod = showInLogPeriod, + trackAgainstTime = trackAgainstTime, + onSaved = { id -> + if (isEditing) onNavigateBack() else onCreated(id, selectedTypeKey) + }, + ) + } + + // Back from step 2 returns to step 1 with the form intact. + BackHandler(enabled = step == 2) { step = 1 } + + // ── Delete confirmation ─────────────────────────────────────────────────── + + if (showDeleteConfirm && category != null) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + title = { Text("Delete \"${category.name}\"?") }, + text = { + Text( + "This will permanently remove the ${category.name} category and all " + + "log entries recorded for it. If you want to keep a copy of your data, " + + "export it before continuing. This cannot be undone." + ) + }, + confirmButton = { + TextButton(onClick = { + showDeleteConfirm = false + viewModel.deleteCategory() + }) { Text("Delete Everything", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } + } + ) + } + + // ── Scaffold ────────────────────────────────────────────────────────────── + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + when { + step == 2 -> "Scale settings" + isEditing -> "Edit category" + else -> "New category" + } + ) + }, + navigationIcon = { + IconButton(onClick = { if (step == 2) step = 1 else onNavigateBack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(top = 12.dp, bottom = 104.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + if (step == 1) { + // ── Step 1: basics ──────────────────────────────────────── + + if (!isEditing && isSliderType) { + Text( + text = "Step 1 of 2. Range and step labels come on the next screen.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Live preview: bubble + name, resolving the group colour + // when the inherit switch is on. + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(bubbleColor), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = selectedIconKey.toCategoryIcon().vector, + contentDescription = null, + tint = onBubbleColor, + modifier = Modifier.size(28.dp) + ) + } + Column { + if (group != null) { + Text( + text = "In ${group.name}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + text = name.ifBlank { "New category" }, + style = MaterialTheme.typography.headlineSmall + ) + } + } + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + placeholder = { Text("e.g. Mood, Sleep, Exercise…") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + SectionHeader(label = "Icon") + IconPicker( + selectedKey = selectedIconKey, + role = bubbleColor, + onRole = onBubbleColor, + onPick = { selectedIconKey = it.key }, + ) + + SectionHeader(label = "Colour") + if (group != null) { + ListCard { + SwitchRow( + title = "Use the group's colour", + subtitle = "Follows the ${group.name} colour role from now on", + checked = useGroupColor, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = { useGroupColor = it }, + ) + } + } + if (!useGroupColor || group == null) { + RolePicker( + selectedToken = selectedToken, + onPick = { selectedToken = it }, + extraFixedSlot = { + CustomColorSlot( + selectedToken = selectedToken, + onPick = { selectedToken = it }, + ) + }, + ) + } + + InputTypeSection( + selectedTypeKey = selectedTypeKey, + typeLocked = typeLocked, + lockedBecauseSystem = category?.isSystem == true, + onPick = { selectedTypeKey = it }, + ) + + if (isEditing && isSliderType) { + ListCard { + ListRow( + key = "Scale settings", + value = "$minText to $maxText", + onClick = { step = 2 }, + ) + } + } + + if (isNumericFamily) { + OutlinedTextField( + value = numericUnit, + onValueChange = { numericUnit = it }, + label = { Text("Unit / Key (optional)") }, + placeholder = { Text("e.g. °C, bpm, coffees…") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + + // ── Options ─────────────────────────────────────────────── + + val showAllowMultiple = selectedTypeKey != CategoryType.INCREMENT.key && + category?.isSystem != true + val showLogWithPeriod = category?.isSystem != true + SectionHeader(label = "Options") + ListCard { + var first = true + if (showAllowMultiple) { + first = false + SwitchRow( + title = "Allow multiple per day", + subtitle = "Log it several times a day. Each entry keeps its time.", + checked = allowMultiple, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = { allowMultiple = it }, + ) + } + if (showLogWithPeriod) { + if (!first) HairlineDivider() + first = false + SwitchRow( + title = "Log with period", + subtitle = "Surface it in the flow context while a period runs", + checked = showInLogPeriod, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = { showInLogPeriod = it }, + ) + } + if (!first) HairlineDivider() + SwitchRow( + title = "Track against time", + subtitle = "Record the time of each entry to view them by time of day", + checked = trackAgainstTime, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = { trackAgainstTime = it }, + ) + } + + // ── Reminders (edit only) ───────────────────────────────── + + if (isEditing) { + RemindersSection( + alarms = state.alarms, + onAddAlarm = onNavigateToNewAlarm, + onEditAlarm = onNavigateToEditAlarm, + onToggleAlarm = { id, enabled -> viewModel.setAlarmEnabled(id, enabled) }, + ) + } + + // ── Danger zone (edit only, never system) ───────────────── + + if (isEditing && category != null && !category.isSystem) { + SectionHeader(label = "Danger zone") + Surface( + shape = RoundedCornerShape(14.dp), + color = Color.Transparent, + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.error.copy(alpha = 0.5f) + ), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .semantics { role = Role.Button } + .clickable { showDeleteConfirm = true } + .padding(horizontal = 16.dp), + ) { + Text( + text = "Delete category and its history", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } else { + // ── Step 2: scale settings (numeric_slider only) ────────── + + if (!isEditing) { + Text( + text = "Step 2 of 2. ${name.ifBlank { "New category" }}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + SectionHeader( + label = "Range", + value = if (rangeValid) "$minText to $maxText" else null, + ) + 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) + ) + } + if (!rangeValid) { + Text( + text = "Enter numbers with min below max.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Assertive } + ) + } + + SectionHeader(label = "Step labels", value = "Optional words") + if (canLabelSteps) { + ListCard { + (minInt!!..maxInt!!).forEachIndexed { index, stepValue -> + if (index > 0) HairlineDivider() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + ) { + Text( + text = stepValue.toString(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(28.dp) + ) + OutlinedTextField( + value = labels[stepValue] ?: "", + onValueChange = { v -> + if (v.isBlank()) labels.remove(stepValue) + else labels[stepValue] = v + }, + placeholder = { Text("Add word…") }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + } + } + Text( + text = "Words like Barely there or Unbearable appear behind each " + + "step and in the Stats distribution chart.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "Use whole numbers with a range of 20 steps or fewer " + + "(decimals off) to label individual steps.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + ListCard { + SwitchRow( + title = "Allow decimals", + subtitle = "Log values like 3.5 between steps", + checked = allowDecimals, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = { enabled -> + allowDecimals = enabled + // Reformat Min/Max so parse-based gating (whole-number + // label editor) matches the new mode. See LESSONS.md. + fun reformat(text: String): String = + text.toFloatOrNull()?.let { + if (enabled) "%.1f".format(it) else it.toInt().toString() + } ?: text + minText = reformat(minText) + maxText = reformat(maxText) + }, + ) + } + } + } + + PrimarySaveBar( + label = when { + step == 1 && !isEditing && isSliderType -> "Next" + isEditing -> "Save" + else -> "Add category" + }, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + enabled = name.isNotBlank() && (rangeValid || (step == 1 && !isEditing && isSliderType)), + onClick = { + if (step == 1 && !isEditing && isSliderType) step = 2 else doSave() + }, + modifier = Modifier.align(Alignment.BottomCenter), + ) + } + } +} + +// ── Input type section ──────────────────────────────────────────────────────── + +/** + * The input-type selector: chips over every [CategoryType] while the type is + * still editable, or a read-only row plus a plain sentence explaining the lock + * once the category has logged entries (or is built-in). + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun InputTypeSection( + selectedTypeKey: String, + typeLocked: Boolean, + lockedBecauseSystem: Boolean, + onPick: (String) -> Unit, +) { + val selectedType = CategoryType.entries.firstOrNull { it.key == selectedTypeKey } + SectionHeader(label = "Input type", value = selectedType?.displayName) + if (typeLocked) { + Text( + text = if (lockedBecauseSystem) { + "Built-in categories keep their input type." + } else { + "The input type is locked because this category already has logged entries." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + CategoryType.entries.forEach { type -> + FilterChip( + selected = selectedTypeKey == type.key, + onClick = { onPick(type.key) }, + label = { Text(type.displayName, style = MaterialTheme.typography.labelSmall) }, + leadingIcon = if (selectedTypeKey == type.key) { + { + Icon( + Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + } + } else null, + ) + } + } + Text( + text = typeHelperLine(selectedTypeKey), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private fun typeHelperLine(typeKey: String): String = when (typeKey) { + CategoryType.NUMERIC_SLIDER.key -> "A stepped scale with a set range and optional word labels." + CategoryType.NUMERIC_FREE.key -> "Type an exact number, like weight or temperature." + CategoryType.INCREMENT.key -> "A running count for the day. Tap to add one." + CategoryType.YES_NO.key -> "One yes or no answer for the day." + CategoryType.TIME.key -> "A time of day, like when you woke up." + else -> "Pick from a list of options you define, like Happy or Tired." +} + +// ── Reminders section (edit only) ───────────────────────────────────────────── + +/** + * Lists the custom alarms linked to this category. Rows open the existing + * EditAlarm screen; the trailing switch enables/disables scheduling in place. + * "+ Add alarm" opens EditAlarm pre-linked to this category. This section only + * surfaces the existing CustomAlarm system — it schedules nothing itself. + */ +@Composable +private fun RemindersSection( + alarms: List, + onAddAlarm: () -> Unit, + onEditAlarm: (Long) -> Unit, + onToggleAlarm: (Long, Boolean) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + SectionHeader(label = "Reminders", modifier = Modifier.weight(1f)) + TextButton( + onClick = onAddAlarm, + modifier = Modifier.semantics { + contentDescription = "Add alarm for this category" + } + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Add alarm") + } + } + if (alarms.isEmpty()) { + Text( + text = "No reminders yet. Add one to get a nudge to log this category.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + ListCard { + alarms.forEachIndexed { index, alarm -> + if (index > 0) HairlineDivider() + val time = "%02d:%02d".format(alarm.hour, alarm.minute) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .semantics { + role = Role.Button + contentDescription = "Edit reminder at $time" + } + .clickable { onEditAlarm(alarm.id) } + .padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Column(Modifier.weight(1f)) { + Text( + text = if (alarm.label.isNotBlank()) "$time · ${alarm.label}" else time, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = alarmScheduleLabel(alarm), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = alarm.isEnabled, + onCheckedChange = { onToggleAlarm(alarm.id, it) }, + modifier = Modifier.semantics { + contentDescription = "Reminder at $time enabled" + }, + ) + } + } + } + } +} + +// ── Custom fixed-colour slot ────────────────────────────────────────────────── + +/** + * The custom-hex slot appended to the RolePicker's fixed track: shows the + * current custom colour when one is selected, or a plus tile otherwise, and + * opens the existing full HSV picker dialog either way. + */ +@Composable +private fun CustomColorSlot( + selectedToken: String, + onPick: (String) -> Unit, +) { + var showFullPicker by rememberSaveable { mutableStateOf(false) } + val hasCustomColor = 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 -> + onPick(hexKey) + showFullPicker = false + } + ) + } + + if (hasCustomColor) { + val customColor = runCatching { Color(selectedToken.toLong(16)) } + .getOrDefault(MaterialTheme.colorScheme.secondary) + // WCAG: light custom colours get a near-black check, dark ones white. + val onCustomColor = if (customColor.luminance() > 0.35f) Color(0xFF1C1B1F) else Color.White + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .semantics { + role = Role.Button + contentDescription = "Custom colour (selected). Tap to change" + } + .clickable { showFullPicker = true }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(customColor), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = onCustomColor, + modifier = Modifier.size(18.dp) + ) + } + } + } else { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .semantics { + role = Role.Button + contentDescription = "Choose custom colour" + } + .clickable { showFullPicker = true }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoryEditViewModel.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoryEditViewModel.kt new file mode 100644 index 0000000..8c99829 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoryEditViewModel.kt @@ -0,0 +1,185 @@ +package com.mapgie.goflo.ui.screens.categories + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.mapgie.goflo.data.database.entities.CustomAlarm +import com.mapgie.goflo.data.database.entities.Group +import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.data.repository.CustomAlarmRepository +import com.mapgie.goflo.data.repository.TrackingRepository +import com.mapgie.goflo.notifications.ReminderScheduler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * State for the 2-step category create/edit flow (logging redesign Phase 7). + * + * - Create mode: [category] is null; [group] carries the create-in-group + * context (pre-selected input type, adopt-colour switch). + * - Edit mode: [category] is the category being edited, [group] its group (if + * any), [alarms] the custom alarms linked to it, and [hasLogs] whether any + * tracking log exists — the owner-decided rule is that the input type stays + * editable only until the first log is recorded. + */ +data class CategoryEditUiState( + val category: TrackingCategory? = null, + val group: Group? = null, + val alarms: List = emptyList(), + val hasLogs: Boolean = false, + val isLoading: Boolean = true, +) + +class CategoryEditViewModel( + private val categoryId: Long, + private val groupId: Long, + private val repository: TrackingRepository, + private val alarmRepository: CustomAlarmRepository, + private val context: Context, +) : ViewModel() { + + val isEditing: Boolean get() = categoryId > 0 + + private val hasLogsFlow = MutableStateFlow(false) + + init { + if (categoryId > 0) { + viewModelScope.launch { hasLogsFlow.value = repository.hasLogs(categoryId) } + } + } + + val uiState: StateFlow = combine( + if (categoryId > 0) repository.getCategoryById(categoryId) else flowOf(null), + repository.getAllGroups(), + if (categoryId > 0) alarmRepository.getAlarmsByCategory(categoryId) else flowOf(emptyList()), + hasLogsFlow, + ) { category, groups, alarms, hasLogs -> + val contextGroupId = category?.groupId ?: groupId.takeIf { it > 0 } + CategoryEditUiState( + category = category, + group = groups.firstOrNull { it.id == contextGroupId }, + alarms = alarms, + hasLogs = hasLogs, + isLoading = false, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = CategoryEditUiState(), + ) + + /** + * Creates the category (create mode, filing it into the context group when + * one was given) or saves every edited field through + * [TrackingRepository.updateCategoryFullSettings] (edit mode). + * + * Edit-mode guards: the stored input type is kept whenever the category + * already has logs (type is fixed once logged) or is a system category; + * allow-multiple and log-with-period are likewise kept for system + * categories, whose switches the edit surface does not show. The mode key + * is always carried through unchanged. + */ + fun save( + name: String, + iconName: String, + colorToken: String, + categoryType: String, + numericMin: Float, + numericMax: Float, + allowDecimals: Boolean, + numericUnit: String, + scaleLabels: String, + allowMultiple: Boolean, + showInLogPeriod: Boolean, + trackAgainstTime: Boolean, + onSaved: (Long) -> Unit = {}, + ) { + if (name.isBlank()) return + viewModelScope.launch { + if (categoryId > 0) { + val category = uiState.value.category ?: return@launch + val typeLocked = uiState.value.hasLogs || category.isSystem + repository.updateCategoryFullSettings( + id = categoryId, + name = name, + iconName = iconName, + colorToken = colorToken, + categoryType = if (typeLocked) category.categoryType else categoryType, + numericMin = numericMin, + numericMax = numericMax, + allowDecimals = allowDecimals, + numericUnit = numericUnit, + scaleLabels = scaleLabels, + allowMultiple = if (category.isSystem) category.allowMultiple else allowMultiple, + showInLogPeriod = if (category.isSystem) category.showInLogPeriod else showInLogPeriod, + trackAgainstTime = trackAgainstTime, + modeKey = category.modeKey, + ) + onSaved(categoryId) + } else { + val id = repository.addCategory( + name = name, + iconName = iconName, + colorToken = colorToken, + categoryType = categoryType, + numericMin = numericMin, + numericMax = numericMax, + allowDecimals = allowDecimals, + numericUnit = numericUnit, + scaleLabels = scaleLabels, + allowMultiple = allowMultiple, + showInLogPeriod = showInLogPeriod, + trackAgainstTime = trackAgainstTime, + ) + if (groupId > 0) repository.assignCategoryToGroup(id, groupId) + onSaved(id) + } + } + } + + /** Deletes the category and its whole log history. System categories are protected. */ + fun deleteCategory() { + val category = uiState.value.category ?: return + if (category.isSystem) return + viewModelScope.launch { repository.deleteCategory(category) } + } + + /** + * Enables or disables one linked alarm, mirroring the existing + * CustomAlarmsViewModel behaviour: persist the flag, then (re)schedule or + * cancel through the existing [ReminderScheduler] — no parallel alarm + * machinery. + */ + fun setAlarmEnabled(alarmId: Long, enabled: Boolean) { + viewModelScope.launch { + alarmRepository.setEnabled(alarmId, enabled) + val alarm = alarmRepository.getById(alarmId) ?: return@launch + if (enabled) { + ReminderScheduler.scheduleCustomAlarm(context, alarm) + } else { + ReminderScheduler.cancelCustomAlarm(context, alarmId) + } + } + } + + class Factory( + private val categoryId: Long, + private val groupId: Long, + private val repository: TrackingRepository, + private val alarmRepository: CustomAlarmRepository, + private val context: Context, + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return CategoryEditViewModel( + categoryId, groupId, repository, alarmRepository, context + ) as T + } + } +} 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 7b91666..9c75728 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 @@ -148,23 +148,18 @@ import com.mapgie.goflo.ui.util.toHexColorKey fun ManageCategoriesScreen( viewModel: ManageCategoriesViewModel, onNavigateBack: () -> Unit, - onNavigateToCategory: (Long) -> Unit + onNavigateToCategory: (Long) -> Unit, + onNavigateToCreateCategory: (groupId: Long?) -> Unit = {}, ) { val state by viewModel.uiState.collectAsState() var selectedTab by rememberSaveable { mutableStateOf(0) } - var showAddDialog by rememberSaveable { mutableStateOf(false) } var showHelp by rememberSaveable { mutableStateOf(false) } var pendingDelete by rememberSaveable { mutableStateOf(null) } var pendingArchive by rememberSaveable { mutableStateOf(null) } var reorderMode by rememberSaveable { mutableStateOf(false) } var archivedExpanded by rememberSaveable { mutableStateOf(false) } - // New-category dialog context: file into this group on creation, and - // pre-select the group's default input type. - var addDialogGroupId by rememberSaveable { mutableStateOf(null) } - var addDialogInitialType by rememberSaveable { mutableStateOf(CategoryType.DEFAULT.key) } - // Group management state. var addToGroupCategoryId by rememberSaveable { mutableStateOf(null) } var addMembersGroupId by rememberSaveable { mutableStateOf(null) } @@ -191,46 +186,6 @@ fun ManageCategoriesScreen( } } - // ── Add category dialog ─────────────────────────────────────────────────── - - if (showAddDialog) { - AddCategoryDialog( - initialType = addDialogInitialType, - onAdd = { name, iconName, colorToken, categoryType, numericMin, numericMax, allowDecimals, numericUnit, allowMultiple, showInLogPeriod -> - viewModel.addCategory( - name = name, - iconName = iconName, - colorToken = colorToken, - categoryType = categoryType, - numericMin = numericMin, - numericMax = numericMax, - allowDecimals = allowDecimals, - numericUnit = numericUnit, - allowMultiple = allowMultiple, - showInLogPeriod = showInLogPeriod, - groupId = addDialogGroupId, - onCreated = { newId -> - showAddDialog = false - addDialogGroupId = null - addDialogInitialType = CategoryType.DEFAULT.key - // Numeric categories have all settings configured in the creation - // dialog; navigating to the values screen would only confuse the - // user with a redundant "Save" prompt. Default categories need to - // go there so the user can add their value options. - if (categoryType == CategoryType.DEFAULT.key) { - onNavigateToCategory(newId) - } - } - ) - }, - onDismiss = { - showAddDialog = false - addDialogGroupId = null - addDialogInitialType = CategoryType.DEFAULT.key - } - ) - } - // ── Archive confirmation ────────────────────────────────────────────────── if (categoryToArchive != null) { @@ -407,10 +362,12 @@ fun ManageCategoriesScreen( addMembersGroupId = null }, onNewCategory = { - addDialogGroupId = addMembersGroup.id - addDialogInitialType = addMembersGroup.defaultInputType + // The 2-step create flow (CategoryEditScreen) loads the group + // itself to pre-select its default input type and file the + // category on save. + val groupId = addMembersGroup.id addMembersGroupId = null - showAddDialog = true + onNavigateToCreateCategory(groupId) }, onDismiss = { addMembersGroupId = null } ) @@ -512,7 +469,7 @@ fun ManageCategoriesScreen( }, floatingActionButton = { ExtendedFloatingActionButton( - onClick = { showAddDialog = true }, + onClick = { onNavigateToCreateCategory(null) }, icon = { Icon(Icons.Default.Add, contentDescription = null) }, text = { Text("Add Category") }, modifier = Modifier.semantics { contentDescription = "Add category" } @@ -720,7 +677,7 @@ fun ManageCategoriesScreen( } item(key = "new_category") { OutlinedButton( - onClick = { showAddDialog = true }, + onClick = { onNavigateToCreateCategory(null) }, modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) @@ -1504,7 +1461,11 @@ private fun buildCategorySubtitle(category: TrackingCategory): String = buildStr } // ── 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( @@ -1702,7 +1663,11 @@ private fun AddCategoryDialog( } // ── 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, @@ -1877,7 +1842,7 @@ private fun CategoryIconGrid(selectedKey: String, onSelect: (String) -> Unit) { } } -private fun isCustomColorToken(token: String): Boolean { +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 @@ -2099,9 +2064,10 @@ private fun CategoryColorPicker(selectedToken: String, onSelect: (String) -> Uni } // ── Full HSV colour picker dialog ───────────────────────────────────────────── +// Internal: also opened from CategoryEditScreen's custom fixed-colour slot. @Composable -private fun FullColorPickerDialog( +internal fun FullColorPickerDialog( initialColor: Int, onDismiss: () -> Unit, onColorSelected: (String) -> Unit 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 6a34027..3fda02f 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 @@ -25,7 +25,6 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Palette import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.Unarchive @@ -82,13 +81,12 @@ fun ManageCategoryValuesScreen( onNavigateBack: () -> Unit, onNavigateToNewAlarm: () -> Unit = {}, onNavigateToEditAlarm: (Long) -> Unit = {}, + onNavigateToEditCategory: () -> Unit = {}, ) { val state by viewModel.uiState.collectAsState() var showAddValue by rememberSaveable { mutableStateOf(false) } var showHelp by rememberSaveable { mutableStateOf(false) } - var showRenameCategory by rememberSaveable { mutableStateOf(false) } - var showEditAppearance by rememberSaveable { mutableStateOf(false) } var renamingValue by rememberSaveable { mutableStateOf(null) } var pendingDeleteValue by rememberSaveable { mutableStateOf(null) } var pendingArchiveCategory by rememberSaveable { mutableStateOf(false) } @@ -115,17 +113,6 @@ fun ManageCategoryValuesScreen( CategoriesHelpDialog(onDismiss = { showHelp = false }) } - if (showEditAppearance && state.category != null) { - EditAppearanceDialog( - category = state.category!!, - onSave = { iconName, colorToken -> - viewModel.updateAppearance(iconName, colorToken) - showEditAppearance = false - }, - onDismiss = { showEditAppearance = false } - ) - } - if (showAddValue) { AddValueDialog( categoryName = state.category?.name ?: "", @@ -138,17 +125,6 @@ fun ManageCategoryValuesScreen( ) } - if (showRenameCategory && state.category != null) { - RenameCategoryDialog( - currentName = state.category!!.name, - onRename = { newName -> - viewModel.renameCategory(newName) - showRenameCategory = false - }, - onDismiss = { showRenameCategory = false } - ) - } - if (valueToRename != null) { RenameValueDialog( value = valueToRename, @@ -291,17 +267,13 @@ fun ManageCategoryValuesScreen( }, actions = { var showMenu by remember { mutableStateOf(false) } - IconButton(onClick = { showRenameCategory = true }) { + // One Edit action opens the Phase 7 category edit flow, which + // covers rename and appearance (the two former dialog actions) + // plus type, switches, reminders, and the danger zone. + IconButton(onClick = onNavigateToEditCategory) { Icon( Icons.Default.Edit, - contentDescription = "Rename category", - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - IconButton(onClick = { showEditAppearance = true }) { - Icon( - Icons.Outlined.Palette, - contentDescription = "Edit appearance", + contentDescription = "Edit category", tint = MaterialTheme.colorScheme.onPrimaryContainer ) } @@ -512,7 +484,7 @@ private fun CategoryAlarmsSection( HorizontalDivider() } -private fun alarmScheduleLabel(alarm: CustomAlarm): String { +internal fun alarmScheduleLabel(alarm: CustomAlarm): String { val schedule = when (alarm.scheduleType) { "DAILY" -> "Every day" "DURING_PERIOD" -> "During period" @@ -1082,6 +1054,9 @@ 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, diff --git a/changelog/unreleased/category-create-edit-redesign.json b/changelog/unreleased/category-create-edit-redesign.json new file mode 100644 index 0000000..36ef581 --- /dev/null +++ b/changelog/unreleased/category-create-edit-redesign.json @@ -0,0 +1,10 @@ +{ + "bump": "minor", + "added": [ + "Redesigned 2-step category creation: name, icon, colour role, input type, and per-day switches on step 1, with scale range, step labels, and decimals on step 2", + "Category edit screen with per-category reminders (wired to existing custom alarms), a scale settings step, and a delete-with-history danger zone" + ], + "changed": [ + "The input type of a category stays editable until its first logged entry, then locks with an explanation" + ] +} diff --git a/docs/design/logging-redesign/PLAN.md b/docs/design/logging-redesign/PLAN.md index 0818bec..4f8b40b 100644 --- a/docs/design/logging-redesign/PLAN.md +++ b/docs/design/logging-redesign/PLAN.md @@ -209,7 +209,7 @@ Each phase is a shippable PR. Order is deliberate: additive foundations first (r | 4 — MetricInput + Yes/No + Time | Done | `claude/logging-redesign-phase-4` | 24 (unchanged) | §8 decision #3 resolved by the owner: Yes/No and Time store value-label strings ("Yes"/"No"; 24h "HH:mm") in `tracking_log_values` — no new columns, no migration. `LogCategoryScreen` renders every non-timed type through `MetricInput` (timed increment stays screen-driven, now rendering the `Timeline` primitive); rating scales ≤10 whole steps render as `StepScale` (plan §2 rule 1), wider/decimal ranges keep the parity slider incl. stepped whole-number behaviour. `TrackingCategory.isNumeric` re-defined from "not default" to an explicit numeric-type list so yes_no/time chart as label categories in Stats. `PinnedCategoryInput` gained additive yes_no/time branches delegating to `MetricInput` (existing four branches untouched; full replacement stays Phase 5). New `TimeField` primitive added to `ui/components/`. Editing a yes_no/time category still opens the default value-catalog editor (harmless; redesigned in Phase 7). | | 5 — Unified LogScreen | 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 | Not started | | | Decide categoryType mutability. | +| 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. | --- @@ -217,6 +217,6 @@ Each phase is a shippable PR. Order is deliberate: additive foundations first (r ## 8. Open decisions for a human (surface these, don't guess) 1. **Colour default for ungrouped existing categories:** this plan keeps their current `colorToken` (no grey wipe), diverging from the handover's "neutral surfaceVariant by default". Confirm that is the desired behaviour, or accept a one-time optional "Organise your categories" nudge that offers (not forces) filing. -2. **`categoryType` mutability:** currently immutable after creation. The new edit flow implies changing type. Allowing it needs a value-migration story (e.g. scale↔count) or a documented "type is fixed once logged" constraint. Decide before Phase 7. +2. **`categoryType` mutability:** ~~currently immutable after creation. The new edit flow implies changing type. Allowing it needs a value-migration story (e.g. scale↔count) or a documented "type is fixed once logged" constraint. Decide before Phase 7.~~ **Resolved by the owner (2026-08-25): type is "fixed once logged".** The input type stays editable in the edit flow until the category has at least one tracking log; after that it locks and the UI explains why in one plain sentence. The check is computed live from the data (`TrackingRepository.hasLogs`, additive DAO count query), not stored as a flag, and there is no value-migration machinery. Implemented in Phase 7 (`CategoryEditScreen`); the repository still applies the same guard at save time. 3. **Yes/No and Time storage encoding:** ~~confirm storing as value-label strings ("Yes"/"No", "HH:mm") vs a dedicated column. Value-label keeps zero-migration; a column is cleaner for Stats. Decide before Phase 4.~~ **Resolved by the owner (2026-08-25): value-label strings** — "Yes"/"No" for yes_no, 24-hour "HH:mm" for time, stored in `tracking_log_values` exactly like existing values. No new DB columns, no migration. Implemented in Phase 4; documented at the top of `MetricInput.kt`. 4. **Theme-spec reconciliation** (`Color.kt` vs `GoFlo Theme Redesign.md`): in scope as a separate PR, or leave as-is? Not part of this logging plan. 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 84f4bb8..6dae46a 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 @@ -5,6 +5,7 @@ > - versionCode **116**, versionName **0.53.0-beta.1**, DB schema version **23** > - Date: 2026-08-22 > - **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. > > **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. @@ -24,7 +25,7 @@ The central category entity. **Note how much already exists** — icons, colour | `displayOrder` | `Int` | `0` | | | `iconName` | `String` | `"category"` | → `CategoryIcon.key` (20 curated icons) | | `colorToken` | `String` | `"secondary"` | semantic token OR 8-char AARRGGBB hex | -| `categoryType` | `String` | `"default"` | input-type discriminator; **immutable after creation (current rule)** | +| `categoryType` | `String` | `"default"` | input-type discriminator; **fixed once logged** *(Phase 7 rule: editable until the first tracking log exists, then locked — see `TrackingRepository.hasLogs`)* | | `numericMin` | `Float` | `0f` | | | `numericMax` | `Float` | `10f` | | | `allowDecimals` | `Boolean` | `false` | |