diff --git a/LESSONS.md b/LESSONS.md index c70f074..17ed2f6 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -11,6 +11,9 @@ Entries within each section are ordered by risk to a new project if forgotten: b **A `role: Color` parameter shadows the `role` semantics property — qualify with `this.role` inside `semantics {}`** Components that take the category's colour as a `role: Color` parameter break the idiomatic `semantics { role = Role.Button }` assignment: inside the lambda, the enclosing function's `role` parameter shadows the `SemanticsPropertyReceiver.role` extension property, so the unqualified assignment tries to reassign the `val` parameter and fails to compile. Write `this.role = Role.Button` (and likewise `this.selected` / `this.contentDescription` when locals share those names) inside `semantics {}` and `clearAndSetSemantics {}` blocks. The qualified form still satisfies `a11y_check.py`'s pattern match. Crucially, `this.role` still needs the extension property imported: each semantics property is a top-level extension in `androidx.compose.ui.semantics` (`import androidx.compose.ui.semantics.role`, `.selected`, `.contentDescription`, `.customActions`, ...), and importing the `Role` class does NOT cover the lowercase `role` property — omitting them fails only at compile time ("Unresolved reference"), which a build-less environment won't catch. When writing semantics blocks without a compiler, check every property assigned in the block against the file's import list. +**An async completion callback that reads Compose state vars races with the reset that closes the dialog — snapshot into locals first** +A dialog's confirm handler often does two things: launch work whose completion callback reads UI state (`viewModel.addGroup(...) { id -> file(pendingCategoryId, adoptColor) }`) and immediately reset that same state to close the dialog (`pendingCategoryId = null; adoptColor = true`). Because the callback runs after the coroutine completes, it reads the already-reset values, silently dropping the user's choice with no error. Capture every state var the callback needs into an immutable local at the top of the handler and reference only the locals inside the callback. This applies to any `onCreated`/`onComplete`-style lambda passed into a ViewModel from a composable that also clears its own state. + **`SwipeToDismissBox`: use `confirmValueChange` returning `false` to intercept — not `LaunchedEffect` + `reset()`** When a swipe should show a confirmation dialog before committing, the natural-looking approach is `confirmValueChange = { true }` (allow the state change) then call `state.reset()` in the dialog's Cancel handler. This causes two bugs: (1) if the composition survives navigation, the `LaunchedEffect` key hasn't changed on return so the dialog silently re-appears; (2) `reset()` takes one animation frame, leaving a brief window where swiping is disabled. The correct pattern is `confirmValueChange = { newValue -> if (newValue == EndToStart) { showConfirm = true; false } else true }`. Returning `false` rejects the transition entirely — the box springs back immediately, no `reset()` call is needed, and the dialog fully controls the outcome. Remove the `LaunchedEffect` and the coroutine scope from the composable. diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt b/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt index d967fe9..ee17390 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt @@ -51,6 +51,9 @@ import com.mapgie.goflo.ui.util.toHexColorKey * * State is hoisted: [selectedToken] is a [CategoryColor] key or an 8-char hex * key, and [onPick] fires with the tapped token. + * + * Set [showFixedSection] to false on surfaces that only offer in-theme roles + * (a group's colour role is never a raw hex). */ @OptIn(ExperimentalLayoutApi::class) @Composable @@ -61,6 +64,7 @@ fun RolePicker( roles: List = CategoryColor.entries, fixedColors: List = CATEGORY_COLOR_OPTIONS, extraFixedSlot: (@Composable () -> Unit)? = null, + showFixedSection: Boolean = true, ) { Column( modifier = modifier, @@ -79,20 +83,22 @@ fun RolePicker( ) } } - HairlineDivider() - SectionHeader(label = "Fixed colour", value = "Stays put on theme change") - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - fixedColors.forEach { argb -> - FixedSwatch( - argb = argb, - isSelected = argb.toHexColorKey() == selectedToken, - onPick = onPick, - ) + if (showFixedSection) { + HairlineDivider() + SectionHeader(label = "Fixed colour", value = "Stays put on theme change") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + fixedColors.forEach { argb -> + FixedSwatch( + argb = argb, + isSelected = argb.toHexColorKey() == selectedToken, + onPick = onPick, + ) + } + extraFixedSlot?.invoke() } - extraFixedSlot?.invoke() } } } diff --git a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoriesHelp.kt b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoriesHelp.kt index 7272509..93d8f10 100644 --- a/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoriesHelp.kt +++ b/app/src/main/java/com/mapgie/goflo/ui/screens/categories/CategoriesHelp.kt @@ -22,6 +22,14 @@ internal fun CategoriesHelpDialog(onDismiss: () -> Unit) { modifier = Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp) ) { + HelpSection( + "Grouped and Ungrouped", + "The Grouped view shows one card per group, tinted with the group's colour. The Ungrouped view lists categories that are not in any group. Both views show the same categories; nothing is hidden by switching." + ) + HelpSection( + "Groups", + "A group collects related categories and gives them a shared colour role. Use Add to group to file a category, and Edit on a group card to rename it, change its colour, reorder it, or delete it. Deleting a group keeps its categories: they just become ungrouped." + ) HelpSection( "Category types", "Default: choose from a list of named values you define. Slider and Numeric Input: record a number. Plus One: tap to add to a daily count." @@ -32,7 +40,7 @@ internal fun CategoriesHelpDialog(onDismiss: () -> Unit) { ) HelpSection( "Reorder categories", - "Long-press the drag handle on the right side of a row to pick it up, then drag it to a new position." + "Tap the reorder button in the top bar to show every active category in one list, then long-press the drag handle on the right side of a row to pick it up and drag it to a new position." ) HelpSection( "Archive", 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 1432425..7b91666 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 @@ -25,10 +25,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState @@ -40,6 +42,8 @@ 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.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 @@ -57,13 +61,15 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +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 @@ -71,12 +77,14 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState 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 import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -93,8 +101,10 @@ 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 import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics @@ -103,16 +113,36 @@ 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 import com.mapgie.goflo.data.database.entities.TrackingCategory +import com.mapgie.goflo.ui.components.HairlineDivider +import com.mapgie.goflo.ui.components.ListCard +import com.mapgie.goflo.ui.components.ListRow +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). + * + * A Grouped/Ungrouped segmented view over the category list: the Grouped tab + * renders one role-tinted card per [Group] with its member categories, the + * Ungrouped tab renders loose categories neutrally with an "Add to group" + * affordance. Every pre-redesign management action stays reachable: tap a + * category to manage its values and settings, swipe right to archive/restore, + * swipe left to delete (system categories protected), and the toolbar reorder + * mode keeps the global drag-to-reorder list. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ManageCategoriesScreen( @@ -122,15 +152,36 @@ fun ManageCategoriesScreen( ) { 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) } + var editingGroupId by rememberSaveable { mutableStateOf(null) } + var showNewGroupEditor by rememberSaveable { mutableStateOf(false) } + var newGroupFileCategoryId by rememberSaveable { mutableStateOf(null) } + var newGroupAdoptColor by rememberSaveable { mutableStateOf(true) } + var pendingDeleteGroup by rememberSaveable { mutableStateOf(null) } val categoryToDelete = state.categories.firstOrNull { it.id == pendingDelete } val categoryToArchive = state.categories.firstOrNull { it.id == pendingArchive } + val active = state.categories.filter { !it.isArchived } + val archived = state.categories.filter { it.isArchived } + val groupIds = state.groups.map { it.id }.toSet() + val ungroupedActive = active.filter { it.groupId == null || it.groupId !in groupIds } + fun requestArchive(category: TrackingCategory) { // Always show a confirmation for built-in categories regardless of warning preference if (!category.isArchived && !category.isSystem && state.archiveWarningDisabled) { @@ -144,6 +195,7 @@ fun ManageCategoriesScreen( if (showAddDialog) { AddCategoryDialog( + initialType = addDialogInitialType, onAdd = { name, iconName, colorToken, categoryType, numericMin, numericMax, allowDecimals, numericUnit, allowMultiple, showInLogPeriod -> viewModel.addCategory( name = name, @@ -156,8 +208,11 @@ fun ManageCategoriesScreen( 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 @@ -168,7 +223,11 @@ fun ManageCategoriesScreen( } ) }, - onDismiss = { showAddDialog = false } + onDismiss = { + showAddDialog = false + addDialogGroupId = null + addDialogInitialType = CategoryType.DEFAULT.key + } ) } @@ -282,10 +341,141 @@ fun ManageCategoriesScreen( ) } + // ── Delete group confirmation ───────────────────────────────────────────── + + val groupToDelete = state.groups.firstOrNull { it.id == pendingDeleteGroup } + if (groupToDelete != null) { + AlertDialog( + onDismissRequest = { pendingDeleteGroup = null }, + title = { Text("Delete group \"${groupToDelete.name}\"?") }, + text = { + Text( + "Categories filed under this group are kept: they become ungrouped and keep " + + "all their logged entries. Only the group itself is removed." + ) + }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteGroup(groupToDelete.id) + pendingDeleteGroup = null + }) { Text("Delete Group", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { pendingDeleteGroup = null }) { Text("Cancel") } + } + ) + } + + // ── Add-to-group sheet (category-centric) ───────────────────────────────── + + val addToGroupCategory = state.categories.firstOrNull { it.id == addToGroupCategoryId } + if (addToGroupCategory != null) { + AddToGroupSheet( + category = addToGroupCategory, + groups = state.groups, + categories = state.categories, + currentGroup = state.groups.firstOrNull { it.id == addToGroupCategory.groupId }, + onPickGroup = { groupId, adopt -> + viewModel.assignCategoryToGroup(addToGroupCategory, groupId, adopt) + addToGroupCategoryId = null + }, + onRemoveFromGroup = { + viewModel.unassignCategory(addToGroupCategory.id) + addToGroupCategoryId = null + }, + onNewGroup = { adopt -> + newGroupFileCategoryId = addToGroupCategory.id + newGroupAdoptColor = adopt + addToGroupCategoryId = null + showNewGroupEditor = true + }, + onDismiss = { addToGroupCategoryId = null } + ) + } + + // ── Add-member sheet (group-centric) ────────────────────────────────────── + + val addMembersGroup = state.groups.firstOrNull { it.id == addMembersGroupId } + if (addMembersGroup != null) { + AddMemberSheet( + group = addMembersGroup, + candidates = active + .filter { it.groupId != addMembersGroup.id } + .map { cat -> cat to state.groups.firstOrNull { it.id == cat.groupId } }, + onPick = { category, adopt -> + viewModel.assignCategoryToGroup(category, addMembersGroup.id, adopt) + addMembersGroupId = null + }, + onNewCategory = { + addDialogGroupId = addMembersGroup.id + addDialogInitialType = addMembersGroup.defaultInputType + addMembersGroupId = null + showAddDialog = true + }, + onDismiss = { addMembersGroupId = null } + ) + } + + // ── Group editor (create / edit) ────────────────────────────────────────── + + if (showNewGroupEditor) { + val filingCategory = state.categories.firstOrNull { it.id == newGroupFileCategoryId } + GroupEditorDialog( + group = null, + members = emptyList(), + canMoveUp = false, + canMoveDown = false, + filingCategoryName = filingCategory?.name, + onSave = { name, colorRole, defaultInputType -> + // Capture before the state resets below: onCreated fires after the + // repository insert completes, by which point the vars are cleared. + val fileCategory = filingCategory + val adopt = newGroupAdoptColor + viewModel.addGroup(name, colorRole, defaultInputType) { newId -> + fileCategory?.let { viewModel.assignCategoryToGroup(it, newId, adopt) } + } + showNewGroupEditor = false + newGroupFileCategoryId = null + newGroupAdoptColor = true + }, + onRemoveMember = {}, + onMove = {}, + onDeleteGroup = {}, + onDismiss = { + showNewGroupEditor = false + newGroupFileCategoryId = null + newGroupAdoptColor = true + } + ) + } + + val editingGroup = state.groups.firstOrNull { it.id == editingGroupId } + if (editingGroup != null) { + val groupIndex = state.groups.indexOfFirst { it.id == editingGroup.id } + GroupEditorDialog( + group = editingGroup, + members = state.categories.filter { it.groupId == editingGroup.id }, + canMoveUp = groupIndex > 0, + canMoveDown = groupIndex >= 0 && groupIndex < state.groups.size - 1, + filingCategoryName = null, + onSave = { name, colorRole, defaultInputType -> + viewModel.updateGroup(editingGroup.id, name, colorRole, defaultInputType) + editingGroupId = null + }, + onRemoveMember = { category -> viewModel.unassignCategory(category.id) }, + onMove = { delta -> viewModel.moveGroup(editingGroup.id, delta) }, + onDeleteGroup = { + pendingDeleteGroup = editingGroup.id + editingGroupId = null + }, + onDismiss = { editingGroupId = null } + ) + } + Scaffold( topBar = { TopAppBar( - title = { Text("Tracking Categories") }, + title = { Text("What You Track") }, navigationIcon = { IconButton(onClick = onNavigateBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") @@ -351,10 +541,6 @@ fun ManageCategoriesScreen( ) } } else { - val active = state.categories.filter { !it.isArchived } - val archived = state.categories.filter { it.isArchived } - var archivedExpanded by rememberSaveable { mutableStateOf(false) } - val lazyListState = rememberLazyListState() val localActive = remember { mutableStateListOf() } var draggedIndex by remember { mutableStateOf(null) } @@ -375,98 +561,733 @@ fun ManageCategoriesScreen( .padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - items(localActive, key = { it.id }) { category -> - val dragModifier = if (reorderMode) Modifier.pointerInput(category.id) { - detectDragGesturesAfterLongPress( - onDragStart = { - draggedIndex = localActive.indexOfFirst { it.id == category.id } - .takeIf { it >= 0 } - dragOffsetY = 0f - }, - onDrag = { change, dragAmount -> - change.consume() - val idx = draggedIndex ?: return@detectDragGesturesAfterLongPress - dragOffsetY += dragAmount.y - val itemH = lazyListState.layoutInfo.visibleItemsInfo - .firstOrNull { it.key == localActive.getOrNull(idx)?.id } - ?.size?.toFloat() ?: 0f - if (itemH > 0f) { - when { - dragOffsetY > itemH / 2 && idx < localActive.size - 1 -> { - localActive.add(idx + 1, localActive.removeAt(idx)) - draggedIndex = idx + 1 - dragOffsetY -= itemH - } - dragOffsetY < -(itemH / 2) && idx > 0 -> { - localActive.add(idx - 1, localActive.removeAt(idx)) - draggedIndex = idx - 1 - dragOffsetY += itemH + if (reorderMode) { + // Reorder mode keeps the pre-redesign flat drag list: every + // active category in one list, long-press the handle to move. + items(localActive, key = { it.id }) { category -> + val dragModifier = Modifier.pointerInput(category.id) { + detectDragGesturesAfterLongPress( + onDragStart = { + draggedIndex = localActive.indexOfFirst { it.id == category.id } + .takeIf { it >= 0 } + dragOffsetY = 0f + }, + onDrag = { change, dragAmount -> + change.consume() + val idx = draggedIndex ?: return@detectDragGesturesAfterLongPress + dragOffsetY += dragAmount.y + val itemH = lazyListState.layoutInfo.visibleItemsInfo + .firstOrNull { it.key == localActive.getOrNull(idx)?.id } + ?.size?.toFloat() ?: 0f + if (itemH > 0f) { + when { + dragOffsetY > itemH / 2 && idx < localActive.size - 1 -> { + localActive.add(idx + 1, localActive.removeAt(idx)) + draggedIndex = idx + 1 + dragOffsetY -= itemH + } + dragOffsetY < -(itemH / 2) && idx > 0 -> { + localActive.add(idx - 1, localActive.removeAt(idx)) + draggedIndex = idx - 1 + dragOffsetY += itemH + } } } + }, + onDragEnd = { + draggedIndex = null + dragOffsetY = 0f + viewModel.reorderCategories(localActive.map { it.id }) + }, + onDragCancel = { + draggedIndex = null + dragOffsetY = 0f + localActive.clear() + localActive.addAll(active) } - }, - onDragEnd = { - draggedIndex = null - dragOffsetY = 0f - viewModel.reorderCategories(localActive.map { it.id }) - }, - onDragCancel = { - draggedIndex = null - dragOffsetY = 0f - localActive.clear() - localActive.addAll(active) - } + ) + } + SwipeableCategoryRow( + category = category, + onClick = { onNavigateToCategory(category.id) }, + onArchiveToggle = { requestArchive(category) }, + onDelete = { pendingDelete = category.id }, + modifier = Modifier.animateItem(), + dragModifier = dragModifier, + reorderMode = true, ) - } else null + } + } else { + item(key = "view_header") { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = countsLabel(active.size, state.groups.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite } + ) + SegmentedToggle( + options = listOf("Grouped", "Ungrouped"), + selected = selectedTab, + onSelect = { selectedTab = it }, + ) + } + } + + if (selectedTab == 0) { + // ── Grouped view ────────────────────────────────────── + if (state.groups.isEmpty()) { + item(key = "no_groups") { + Text( + text = "No groups yet. A group collects related categories under " + + "one card and gives them a shared colour role.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } + items(state.groups, key = { "group_${it.id}" }) { group -> + GroupCard( + group = group, + members = active.filter { it.groupId == group.id }, + groups = state.groups, + onEdit = { editingGroupId = group.id }, + onAddMember = { addMembersGroupId = group.id }, + onCategoryClick = onNavigateToCategory, + onArchiveToggle = { requestArchive(it) }, + onDelete = { pendingDelete = it.id }, + ) + } + item(key = "new_group") { + OutlinedButton( + onClick = { + newGroupFileCategoryId = null + showNewGroupEditor = true + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("New group") + } + } + } else { + // ── Ungrouped view ──────────────────────────────────── + if (ungroupedActive.isEmpty()) { + item(key = "all_filed") { + Text( + text = "Every category is filed into a group.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } + items(ungroupedActive, key = { "cat_${it.id}" }) { category -> + SwipeableCategoryRow( + category = category, + onClick = { onNavigateToCategory(category.id) }, + onArchiveToggle = { requestArchive(category) }, + onDelete = { pendingDelete = category.id }, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + trailingAction = { + TextButton( + onClick = { addToGroupCategoryId = category.id }, + modifier = Modifier.semantics { + contentDescription = "Add ${category.name} to a group" + } + ) { Text("Add to group") } + }, + ) + } + if (ungroupedActive.isNotEmpty()) { + item(key = "ungrouped_note") { + Text( + text = "Ungrouped categories still log as normal. They sit in " + + "this neutral list until you file them into a group.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp) + ) + } + } + item(key = "new_category") { + OutlinedButton( + onClick = { showAddDialog = true }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("New category") + } + } + } + } + + archivedSection( + archived = archived, + expanded = archivedExpanded, + onToggleExpanded = { archivedExpanded = !archivedExpanded }, + onNavigateToCategory = onNavigateToCategory, + onArchiveToggle = { pendingArchive = it.id }, + onDelete = { pendingDelete = it.id }, + ) + + // Keep the last row reachable above the extended FAB. + item(key = "fab_spacer") { Spacer(Modifier.height(72.dp)) } + } + } + } +} + +private fun countsLabel(categoryCount: Int, groupCount: Int): String { + val cats = if (categoryCount == 1) "1 category" else "$categoryCount categories" + val groups = if (groupCount == 1) "1 group" else "$groupCount groups" + return "$cats · $groups" +} + +// ── Archived section (shared by reorder mode and both tabs) ─────────────────── + +private fun LazyListScope.archivedSection( + archived: List, + expanded: Boolean, + onToggleExpanded: () -> Unit, + onNavigateToCategory: (Long) -> Unit, + onArchiveToggle: (TrackingCategory) -> Unit, + onDelete: (TrackingCategory) -> Unit, +) { + if (archived.isEmpty()) return + + item(key = "archived_header") { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onToggleExpanded() } + .padding(vertical = 8.dp) + .semantics { + role = Role.Button + stateDescription = if (expanded) "Expanded" else "Collapsed" + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Archived (${archived.size})", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess + else Icons.Default.ExpandMore, + contentDescription = if (expanded) "Collapse archived" else "Expand archived", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (expanded) { + items(archived, key = { it.id }) { category -> + SwipeableCategoryRow( + category = category, + onClick = { onNavigateToCategory(category.id) }, + onArchiveToggle = { onArchiveToggle(category) }, + onDelete = { onDelete(category) } + ) + } + } +} + +// ── Group card ──────────────────────────────────────────────────────────────── + +/** + * One role-tinted card per group: colour dot + name + Edit in the header, the + * member categories as swipeable rows, and an inline add-category affordance. + * Member bubbles resolve through [effectiveColorToken], so inherit-categories + * adopt the group role live. + */ +@Composable +private fun GroupCard( + group: Group, + members: List, + groups: List, + onEdit: () -> Unit, + onAddMember: () -> Unit, + onCategoryClick: (Long) -> Unit, + onArchiveToggle: (TrackingCategory) -> Unit, + onDelete: (TrackingCategory) -> Unit, + modifier: Modifier = Modifier, +) { + val roleColor = group.colorRole.toCategoryColor() + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = roleContainerTint(roleColor, MaterialTheme.colorScheme.surface), + ) { + Column(modifier = Modifier.padding(bottom = 4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(start = 16.dp, end = 8.dp) + ) { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(roleColor) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = group.name, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f) + ) + TextButton( + onClick = onEdit, + modifier = Modifier.semantics { + contentDescription = "Edit group ${group.name}" + } + ) { Text("Edit") } + } + + members.forEach { category -> + key(category.id) { SwipeableCategoryRow( category = category, - onClick = { onNavigateToCategory(category.id) }, - onArchiveToggle = { requestArchive(category) }, - onDelete = { pendingDelete = category.id }, - modifier = Modifier.animateItem(), - dragModifier = dragModifier, - reorderMode = reorderMode, + onClick = { onCategoryClick(category.id) }, + onArchiveToggle = { onArchiveToggle(category) }, + onDelete = { onDelete(category) }, + colorToken = category.effectiveColorToken(groups), + containerColor = Color.Transparent, ) } + } + if (members.isEmpty()) { + Text( + text = "No categories in this group yet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } - if (archived.isNotEmpty()) { - item(key = "archived_header") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .semantics { role = Role.Button } + .clickable(onClick = onAddMember) + .padding(horizontal = 16.dp) + ) { + Icon( + Icons.Default.Add, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Add category to this group", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +// ── Add-to-group sheet (category-centric) ───────────────────────────────────── + +/** + * Files one category into a group: pick an existing group, jump to group + * creation pre-filled with this category, or unfile it. The colour switch + * controls whether filing also sets the category's token to the "inherit" + * sentinel so it adopts (and follows) the group's colour role. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddToGroupSheet( + category: TrackingCategory, + groups: List, + categories: List, + currentGroup: Group?, + onPickGroup: (Long, Boolean) -> Unit, + onRemoveFromGroup: () -> Unit, + onNewGroup: (Boolean) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + var adoptColor by rememberSaveable { mutableStateOf(true) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .navigationBarsPadding() + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Add \"${category.name}\" to…", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = currentGroup?.let { "Currently in ${it.name}" } ?: "Currently ungrouped", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ListCard { + SwitchRow( + title = "Use the group's colour", + subtitle = "The category follows its group's colour role from now on", + checked = adoptColor, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = { adoptColor = it }, + ) + } + + ListCard { + groups.forEachIndexed { index, group -> + if (index > 0) HairlineDivider() + GroupPickRow( + group = group, + memberCount = categories.count { it.groupId == group.id && !it.isArchived }, + isCurrent = group.id == currentGroup?.id, + onClick = { onPickGroup(group.id, adoptColor) }, + ) + } + if (groups.isNotEmpty()) HairlineDivider() + ListRow( + key = "New group…", + onClick = { onNewGroup(adoptColor) }, + ) + if (currentGroup != null) { + HairlineDivider() + ListRow( + key = "Remove from group", + onClick = onRemoveFromGroup, + ) + } + } + + Text( + text = "\"${category.name}\" keeps all its past entries. Filing only changes " + + "where it appears and, if the colour switch is on, its colour.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun GroupPickRow( + group: Group, + memberCount: Int, + isCurrent: Boolean, + onClick: () -> Unit, +) { + val dotColor = group.colorRole.toCategoryColor() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .semantics { role = Role.Button } + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + ) { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(dotColor) + ) + Text( + text = group.name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Text( + text = when { + isCurrent -> "Current" + memberCount == 1 -> "1 category" + else -> "$memberCount categories" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +// ── Add-member sheet (group-centric) ────────────────────────────────────────── + +/** + * The "+ Add category to this group" picker: lists every active category not + * already in this group (ungrouped first, then members of other groups, each + * labelled with where it currently lives), plus a "New category" row. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AddMemberSheet( + group: Group, + candidates: List>, + onPick: (TrackingCategory, Boolean) -> Unit, + onNewCategory: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + var adoptColor by rememberSaveable { mutableStateOf(true) } + val sorted = candidates.sortedBy { (_, currentGroup) -> if (currentGroup == null) 0 else 1 } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp) + .navigationBarsPadding() + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Add a category to \"${group.name}\"", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "Filing keeps every past entry. It only changes where the category " + + "appears and, if the colour switch is on, its colour.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + ListCard { + SwitchRow( + title = "Use the group's colour", + subtitle = "Filed categories follow this group's colour role", + checked = adoptColor, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = { adoptColor = it }, + ) + } + + if (sorted.isEmpty()) { + Text( + text = "Every active category is already in this group.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + ListCard { + sorted.forEachIndexed { index, (category, currentGroup) -> + if (index > 0) HairlineDivider() + ListRow( + key = category.name, + value = currentGroup?.let { "In ${it.name}" } ?: "Ungrouped", + valueColor = MaterialTheme.colorScheme.onSurfaceVariant, + onClick = { onPick(category, adoptColor) }, + ) + } + if (sorted.isNotEmpty()) HairlineDivider() + ListRow( + key = "New category…", + onClick = onNewCategory, + ) + } + } + } +} + +// ── Group editor dialog (create / edit) ─────────────────────────────────────── + +/** + * Creates or edits a [Group]: name, in-theme colour role (never a hex: the + * fixed-colour track is hidden), and the default input type pre-selected when + * creating a category inside the group. Edit mode adds the member list with + * unfiling, move up/down reordering, and delete (members are kept). + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun GroupEditorDialog( + group: Group?, + members: List, + canMoveUp: Boolean, + canMoveDown: Boolean, + filingCategoryName: String?, + onSave: (name: String, colorRole: String, defaultInputType: String) -> Unit, + onRemoveMember: (TrackingCategory) -> Unit, + onMove: (Int) -> Unit, + onDeleteGroup: () -> Unit, + onDismiss: () -> Unit, +) { + var name by rememberSaveable(group?.id) { mutableStateOf(group?.name ?: "") } + var selectedRole by rememberSaveable(group?.id) { + mutableStateOf(group?.colorRole ?: CategoryColor.PRIMARY.key) + } + var selectedType by rememberSaveable(group?.id) { + mutableStateOf(group?.defaultInputType ?: CategoryType.DEFAULT.key) + } + + 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( + text = if (group == null) "New Group" else "Edit Group", + style = MaterialTheme.typography.headlineSmall + ) + if (filingCategoryName != null) { + Text( + text = "\"$filingCategoryName\" will be filed into this group.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + placeholder = { Text("e.g. Body, Sleep, Environment…") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + + RolePicker( + selectedToken = selectedRole, + onPick = { selectedRole = it }, + showFixedSection = false, + ) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + "Default input type", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Pre-selected when you create a category inside this group.", + style = MaterialTheme.typography.bodySmall, + 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) } + ) + } + } + + if (group != null) { + HorizontalDivider() + Text( + "Categories in this group", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (members.isEmpty()) { + Text( + "None yet.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + members.forEach { member -> Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickable { archivedExpanded = !archivedExpanded } - .padding(vertical = 8.dp) - .semantics { - role = Role.Button - stateDescription = if (archivedExpanded) "Expanded" else "Collapsed" - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + .heightIn(min = 44.dp) ) { Text( - text = "Archived (${archived.size})", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Icon( - imageVector = if (archivedExpanded) Icons.Default.ExpandLess - else Icons.Default.ExpandMore, - contentDescription = if (archivedExpanded) "Collapse archived" else "Expand archived", - tint = MaterialTheme.colorScheme.onSurfaceVariant + text = member.name, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f) ) + TextButton( + onClick = { onRemoveMember(member) }, + modifier = Modifier.semantics { + contentDescription = "Remove ${member.name} from group" + } + ) { Text("Remove") } } } - if (archivedExpanded) { - items(archived, key = { it.id }) { category -> - SwipeableCategoryRow( - category = category, - onClick = { onNavigateToCategory(category.id) }, - onArchiveToggle = { pendingArchive = category.id }, - onDelete = { pendingDelete = category.id } + HorizontalDivider() + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { onMove(-1) }, enabled = canMoveUp) { + Icon( + Icons.Default.ArrowUpward, + contentDescription = null, + modifier = Modifier.size(18.dp) ) + Spacer(Modifier.width(4.dp)) + Text("Move up") + } + TextButton(onClick = { onMove(1) }, enabled = canMoveDown) { + Icon( + Icons.Default.ArrowDownward, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Move down") } } + TextButton(onClick = onDeleteGroup) { + Text("Delete group", color = MaterialTheme.colorScheme.error) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton(onClick = onDismiss) { Text("Cancel") } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { if (name.isNotBlank()) onSave(name, selectedRole, selectedType) }, + enabled = name.isNotBlank() + ) { Text(if (group == null) "Add" else "Save") } } } } @@ -485,6 +1306,9 @@ private fun SwipeableCategoryRow( modifier: Modifier = Modifier, dragModifier: Modifier? = null, reorderMode: Boolean = false, + colorToken: String = category.colorToken, + containerColor: Color? = null, + trailingAction: (@Composable () -> Unit)? = null, ) { val archiveLabel = when { category.isArchived -> if (category.isSystem) "Restore" else "Unarchive" @@ -520,6 +1344,7 @@ private fun SwipeableCategoryRow( Box( modifier = Modifier .fillMaxSize() + .clip(RoundedCornerShape(12.dp)) .background(bgColor) ) { when (direction) { @@ -572,6 +1397,9 @@ private fun SwipeableCategoryRow( onClick = onClick, dragModifier = dragModifier, reorderMode = reorderMode, + colorToken = colorToken, + containerColor = containerColor, + trailingAction = trailingAction, ) } } @@ -583,9 +1411,12 @@ private fun CategoryRow( modifier: Modifier = Modifier, dragModifier: Modifier? = null, reorderMode: Boolean = false, + colorToken: String = category.colorToken, + containerColor: Color? = null, + trailingAction: (@Composable () -> Unit)? = null, ) { - val bubbleColor = category.colorToken.toCategoryColor() - val iconTint = category.colorToken.toCategoryOnColor() + val bubbleColor = colorToken.toCategoryColor() + val iconTint = colorToken.toCategoryOnColor() Card( onClick = onClick, @@ -593,7 +1424,7 @@ private fun CategoryRow( .fillMaxWidth() .alpha(if (category.isArchived) 0.55f else 1f), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow + containerColor = containerColor ?: MaterialTheme.colorScheme.surfaceContainerLow ) ) { Row( @@ -632,8 +1463,8 @@ private fun CategoryRow( ) } - if (reorderMode && dragModifier != null) { - Icon( + when { + reorderMode && dragModifier != null -> Icon( imageVector = Icons.Default.DragHandle, contentDescription = "Drag to reorder", tint = MaterialTheme.colorScheme.onSurfaceVariant, @@ -641,8 +1472,8 @@ private fun CategoryRow( .size(44.dp) .padding(10.dp) ) - } else { - Icon( + trailingAction != null -> trailingAction() + else -> Icon( imageVector = Icons.Default.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant @@ -681,10 +1512,11 @@ private fun AddCategoryDialog( categoryType: String, numericMin: Float, numericMax: Float, allowDecimals: Boolean, numericUnit: String, allowMultiple: Boolean, showInLogPeriod: Boolean) -> Unit, - onDismiss: () -> Unit + onDismiss: () -> Unit, + initialType: String = CategoryType.DEFAULT.key, ) { var name by rememberSaveable { mutableStateOf("") } - var selectedType by rememberSaveable { mutableStateOf(CategoryType.DEFAULT.key) } + 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) } 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 6d163be..8b13448 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 @@ -3,9 +3,11 @@ package com.mapgie.goflo.ui.screens.categories import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.mapgie.goflo.data.database.entities.Group import com.mapgie.goflo.data.database.entities.TrackingCategory import com.mapgie.goflo.data.preferences.AppPreferencesStore import com.mapgie.goflo.data.repository.TrackingRepository +import com.mapgie.goflo.ui.util.COLOR_TOKEN_INHERIT import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -14,6 +16,7 @@ import kotlinx.coroutines.launch data class ManageCategoriesUiState( val categories: List = emptyList(), + val groups: List = emptyList(), val archiveWarningDisabled: Boolean = false, ) @@ -25,10 +28,12 @@ class ManageCategoriesViewModel( val uiState: StateFlow = combine( repository.getAllCategories(), + repository.getAllGroups(), store.preferences, - ) { cats, prefs -> + ) { cats, groups, prefs -> ManageCategoriesUiState( categories = cats, + groups = groups, archiveWarningDisabled = prefs.archiveWarningDisabled, ) } @@ -49,6 +54,7 @@ class ManageCategoriesViewModel( numericUnit: String = "", allowMultiple: Boolean = false, showInLogPeriod: Boolean = false, + groupId: Long? = null, onCreated: (Long) -> Unit = {}, ) { if (name.isBlank()) return @@ -65,6 +71,7 @@ class ManageCategoriesViewModel( allowMultiple = allowMultiple, showInLogPeriod = showInLogPeriod, ) + if (groupId != null) repository.assignCategoryToGroup(id, groupId) onCreated(id) } } @@ -94,6 +101,75 @@ class ManageCategoriesViewModel( viewModelScope.launch { store.setArchiveWarningDisabled(disabled) } } + // ── Groups (logging redesign Phase 6) ───────────────────────────────────── + + /** + * Creates a group and reports the new id so the caller can chain a + * file-this-category step (the "New group" path of the add-to-group sheet). + */ + fun addGroup( + name: String, + colorRole: String, + defaultInputType: String, + onCreated: (Long) -> Unit = {}, + ) { + if (name.isBlank()) return + viewModelScope.launch { + val id = repository.addGroup( + name = name.trim(), + colorRole = colorRole, + defaultInputType = defaultInputType, + ) + onCreated(id) + } + } + + /** Saves the edited name, colour role, and default input type of a group. */ + fun updateGroup(id: Long, name: String, colorRole: String, defaultInputType: String) { + viewModelScope.launch { + repository.renameGroup(id, name) + repository.updateGroupRole(id, colorRole) + repository.updateGroupDefaultInputType(id, defaultInputType) + } + } + + /** Moves a group up (-1) or down (+1) in the display order. */ + fun moveGroup(id: Long, delta: Int) { + val ordered = uiState.value.groups.map { it.id }.toMutableList() + val index = ordered.indexOf(id) + val target = index + delta + if (index == -1 || target !in ordered.indices) return + ordered.add(target, ordered.removeAt(index)) + viewModelScope.launch { repository.reorderGroups(ordered) } + } + + /** + * Deletes a group. The repository unfiles its member categories first; + * no category or log is ever deleted by this action. + */ + fun deleteGroup(id: Long) { + viewModelScope.launch { repository.deleteGroup(id) } + } + + /** + * Files [category] into the group, optionally switching its colour token to + * the "inherit" sentinel so it adopts the group's colour role (and follows + * any later recolour of the group). + */ + fun assignCategoryToGroup(category: TrackingCategory, groupId: Long, adoptGroupColor: Boolean) { + viewModelScope.launch { + repository.assignCategoryToGroup(category.id, groupId) + if (adoptGroupColor && category.colorToken != COLOR_TOKEN_INHERIT) { + repository.updateCategoryAppearance(category.id, category.iconName, COLOR_TOKEN_INHERIT) + } + } + } + + /** Unfiles a category from its group. Past logs and settings are untouched. */ + fun unassignCategory(categoryId: Long) { + viewModelScope.launch { repository.unassignCategory(categoryId) } + } + class Factory( private val repository: TrackingRepository, private val store: AppPreferencesStore, diff --git a/changelog/unreleased/what-you-track-groups.json b/changelog/unreleased/what-you-track-groups.json new file mode 100644 index 0000000..93f7f03 --- /dev/null +++ b/changelog/unreleased/what-you-track-groups.json @@ -0,0 +1,9 @@ +{ + "bump": "minor", + "changed": [ + "Redesigned the What You Track screen with Grouped and Ungrouped views: group cards, an add-to-group sheet, and inline group management (create, rename, recolour, reorder, delete)" + ], + "added": [ + "Categories can be filed into groups and can optionally adopt the group's colour role, following any later recolour of the group" + ] +} diff --git a/docs/design/logging-redesign/PLAN.md b/docs/design/logging-redesign/PLAN.md index 092d527..0818bec 100644 --- a/docs/design/logging-redesign/PLAN.md +++ b/docs/design/logging-redesign/PLAN.md @@ -208,7 +208,7 @@ Each phase is a shippable PR. Order is deliberate: additive foundations first (r | 3 — Component library | Done | `claude/logging-redesign-phase-3` | 24 (unchanged) | 12 primitives + `MetricInput` stub in `ui/components/`, previews only — no screen consumes them yet. Deviations: `RolePicker` built standalone (visuals mirrored from the Phase 1 picker; `ManageCategoriesScreen` left untouched per this phase's "existing screens compile unchanged" rule — rewire in Phase 6/7); tonal container fills for arbitrary role colours derived via `roleContainerTint` (lerp toward surface, house `ordinalShade` pattern) since M3 `ColorScheme` has no containers for derived/fixed roles; `ToneHero` word uses onSurface on the tint (contrast-safe in every palette) with the role carried by the container; `StepScale` gained a `name` param for its single-control announcement plus per-step `customActions` so TalkBack can still operate it. `MetricValue`/`MetricConfig` value types defined now, incl. YesNo/TimeOfDay variants for Phase 4. | | 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 | Not started | | | | +| 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. | | 8 — Cleanup & removal | Not started | | | Gate on parity checklist. | 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 d04a8d4..84f4bb8 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 @@ -111,4 +111,4 @@ Log CRUD: `getLogsForDate(date): Flow>`, `getAllLogD - **Icons** already exist (`iconName` → `CategoryIcon`, 20 icons). - **Allow-multiple-per-day** already exists (`allowMultiple`, honoured in `saveLog`). - **Timestamps / timeline** partially exist (`trackAgainstTime` + `loggedAt` + `TimedIncrementSection`). → The handover's generalised timeline extends this to all input types. -- **Groups / roles**: *(updated v24)* the `Group` entity, `TrackingCategory.groupId`, and the `"inherit"` colour sentinel now exist (Phase 2). No management UI yet — repository-level only until Phase 6. At the original stamp none of this existed; closest prior mechanisms were `modeKey`, `systemKey`, `showInLogPeriod`, `custom_alarm_categories`. +- **Groups / roles**: *(updated v24)* the `Group` entity, `TrackingCategory.groupId`, and the `"inherit"` colour sentinel now exist (Phase 2). *(Phase 6)* The management UI now exists too: `ManageCategoriesScreen` is the redesigned "What You Track" home with a Grouped/Ungrouped segmented view, group cards, add-to-group sheets, and a group editor (create/rename/recolour/reorder/delete; delete unfiles members via the repository). `ManageCategoriesViewModel` exposes the groups Flow plus group CRUD, `moveGroup`, and `assignCategoryToGroup(category, groupId, adoptGroupColor)` which optionally sets `colorToken = "inherit"`. At the original stamp none of this existed; closest prior mechanisms were `modeKey`, `systemKey`, `showInLogPeriod`, `custom_alarm_categories`.