diff --git a/LESSONS.md b/LESSONS.md index 54e7f51..a5f7999 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -8,6 +8,9 @@ Entries within each section are ordered by risk to a new project if forgotten: b ### Android / Compose +**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. + **`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/ChipToggle.kt b/app/src/main/java/com/mapgie/goflo/ui/components/ChipToggle.kt new file mode 100644 index 0000000..618f38e --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/ChipToggle.kt @@ -0,0 +1,129 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * A state chip in the redesign's selection language: selected fills with a + * tonal container of [role] and gains a leading check; unselected stays a + * hairline outline. The check means selection is never colour-only. + * + * Built on Material's [FilterChip], which carries checkbox-style toggle + * semantics and the minimum interactive size for the tap target. + */ +@Composable +fun ChipToggle( + text: String, + selected: Boolean, + role: Color, + onToggle: () -> Unit, + modifier: Modifier = Modifier, +) { + val container = roleContainerTint(role, MaterialTheme.colorScheme.surface) + FilterChip( + selected = selected, + onClick = onToggle, + label = { Text(text, fontSize = 13.5.sp) }, + modifier = modifier, + leadingIcon = if (selected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(FilterChipDefaults.IconSize), + ) + } + } else { + null + }, + colors = FilterChipDefaults.filterChipColors( + containerColor = Color.Transparent, + labelColor = MaterialTheme.colorScheme.onSurface, + selectedContainerColor = container, + selectedLabelColor = MaterialTheme.colorScheme.onSurface, + selectedLeadingIconColor = MaterialTheme.colorScheme.onSurface, + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selected, + borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), + selectedBorderColor = Color.Transparent, + ), + ) +} + +/** + * A wrapping row of [ChipToggle]s for multi-select values (symptoms, text + * catalog values). State is hoisted: [selected] holds the chosen labels and + * [onToggle] fires with the tapped label. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ChipRow( + options: List, + selected: Set, + role: Color, + onToggle: (String) -> Unit, + modifier: Modifier = Modifier, +) { + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { option -> + ChipToggle( + text = option, + selected = option in selected, + role = role, + onToggle = { onToggle(option) }, + ) + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun ChipRowPreviewContent() { + SectionHeader(label = "Symptoms", value = "2 today") + ChipRow( + options = listOf("Cramps", "Nausea", "Headache", "Bloating", "Fatigue", "Back pain", "Mood swings"), + selected = setOf("Cramps", "Nausea"), + role = MaterialTheme.colorScheme.primary, + onToggle = {}, + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun ChipRowPreviewLight() { + ComponentPreviewSurface { ChipRowPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun ChipRowPreviewDark() { + ComponentPreviewSurface(dark = true) { ChipRowPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun ChipRowPreviewLarge() { + ComponentPreviewSurface { ChipRowPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/ComponentPreviews.kt b/app/src/main/java/com/mapgie/goflo/ui/components/ComponentPreviews.kt new file mode 100644 index 0000000..b01b308 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/ComponentPreviews.kt @@ -0,0 +1,37 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.mapgie.goflo.ui.theme.AppTheme +import com.mapgie.goflo.ui.theme.GoFloTheme + +/** + * Shared scaffolding for the component-library `@Preview`s: wraps the previewed + * primitive in [GoFloTheme] (Coral light or Coral dark) on the themed + * background, so every preview exercises the real colour-resolution path + * (including [com.mapgie.goflo.ui.theme.LocalExtendedRoles]). + * + * Preview-only support code; not for use by screens. + */ +@Composable +internal fun ComponentPreviewSurface( + dark: Boolean = false, + content: @Composable () -> Unit, +) { + GoFloTheme(appTheme = if (dark) AppTheme.CORAL_DARK else AppTheme.CORAL) { + Surface(color = MaterialTheme.colorScheme.background) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + content() + } + } + } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt b/app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt new file mode 100644 index 0000000..4a4f8eb --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt @@ -0,0 +1,112 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +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.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mapgie.goflo.ui.util.CategoryIcon + +/** + * The icon grid for category create/edit: 48dp rounded tiles, one per + * [CategoryIcon]. The selected tile fills with the category's [role] colour + * and every tile announces its display name with radio-button semantics, so + * selection is neither colour-only nor unlabelled. + * + * State is hoisted: [selectedKey] is the stored [CategoryIcon.key] and + * [onPick] fires with the tapped icon. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun IconPicker( + selectedKey: String?, + role: Color, + onRole: Color, + onPick: (CategoryIcon) -> Unit, + modifier: Modifier = Modifier, + icons: List = CategoryIcon.entries, +) { + FlowRow( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + icons.forEach { icon -> + val isSelected = icon.key == selectedKey + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(12.dp)) + .then( + if (isSelected) Modifier.background(role) + else Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(12.dp)) + ) + .semantics { + this.role = Role.RadioButton + this.selected = isSelected + this.contentDescription = icon.displayName + } + .clickable { onPick(icon) }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon.vector, + contentDescription = null, + tint = if (isSelected) onRole else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun IconPickerPreviewContent() { + SectionHeader(label = "Icon") + IconPicker( + selectedKey = CategoryIcon.HEART.key, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onPick = {}, + icons = CategoryIcon.entries.take(10), + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun IconPickerPreviewLight() { + ComponentPreviewSurface { IconPickerPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun IconPickerPreviewDark() { + ComponentPreviewSurface(dark = true) { IconPickerPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun IconPickerPreviewLarge() { + ComponentPreviewSurface { IconPickerPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt b/app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt new file mode 100644 index 0000000..ba7e9a1 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/ListCard.kt @@ -0,0 +1,163 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The white rounded card with hairline-divided rows: "one card per idea, not + * per field". Powers Dates, grouped tracked metrics, alarms, step labels, and + * add-to-group lists. + * + * Flat by design: a 1px hairline outline instead of a drop shadow. Compose + * [ListRow]s (separated by [HairlineDivider]) inside the content lambda. + */ +@Composable +fun ListCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(18.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f)), + ) { + Column(content = content) + } +} + +/** The 1px low-contrast divider between [ListRow]s (about 7% onSurface). */ +@Composable +fun HairlineDivider(modifier: Modifier = Modifier) { + HorizontalDivider( + modifier = modifier, + thickness = 1.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.07f), + ) +} + +/** + * One key/value row inside a [ListCard]: label on the left, tabular value on + * the right, optional trailing slot (defaults to a chevron when clickable). + * + * Rows are at least 52dp tall. A clickable row announces as a button. + */ +@Composable +fun ListRow( + key: String, + modifier: Modifier = Modifier, + value: String? = null, + valueEmphasis: Boolean = false, + valueColor: Color = MaterialTheme.colorScheme.onSurface, + trailing: (@Composable () -> Unit)? = null, + onClick: (() -> Unit)? = null, +) { + val clickModifier = if (onClick != null) { + Modifier + .semantics { role = Role.Button } + .clickable(onClick = onClick) + } else { + Modifier + } + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .then(clickModifier) + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = key, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (value != null) { + Text( + text = value, + fontSize = 14.sp, + fontWeight = if (valueEmphasis) FontWeight.SemiBold else FontWeight.Normal, + color = valueColor, + // Tabular figures so date/number columns align across rows. + style = TextStyle(fontFeatureSettings = "tnum"), + ) + } + when { + trailing != null -> trailing() + onClick != null -> Icon( + imageVector = Icons.Default.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun ListCardPreviewContent() { + ListCard { + ListRow(key = "Started", value = "Aug 6, 2026", valueEmphasis = true, onClick = {}) + HairlineDivider() + ListRow( + key = "Ended", + value = "Still ongoing", + valueEmphasis = true, + valueColor = MaterialTheme.colorScheme.primary, + onClick = {}, + ) + } + ListCard { + ListRow(key = "Weather", value = "Cloudy") + HairlineDivider() + ListRow(key = "Rainfall", value = "12 mm") + } +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun ListCardPreviewLight() { + ComponentPreviewSurface { ListCardPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun ListCardPreviewDark() { + ComponentPreviewSurface(dark = true) { ListCardPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun ListCardPreviewLarge() { + ComponentPreviewSurface { ListCardPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/MetricInput.kt b/app/src/main/java/com/mapgie/goflo/ui/components/MetricInput.kt new file mode 100644 index 0000000..31eed1a --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/MetricInput.kt @@ -0,0 +1,249 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedIconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mapgie.goflo.ui.util.CategoryType + +/** + * The value a [MetricInput] holds, one variant per input model. + * + * The Yes/No and Time variants are defined now so Phase 4 can add the + * matching [CategoryType] entries without reshaping this API; nothing renders + * them yet. + */ +sealed interface MetricValue { + /** Multi-select text labels (the `default` chip input). */ + data class Choice(val selected: Set) : MetricValue + + /** A discrete whole-number step on a rating scale. */ + data class Scale(val step: Int?) : MetricValue + + /** A genuine continuous reading (weight, temperature). */ + data class Continuous(val value: Float?) : MetricValue + + /** Free numeric text, kept as typed until save-time parsing. */ + data class FreeNumber(val text: String) : MetricValue + + /** A per-day tally (the `increment` input). */ + data class Count(val count: Int) : MetricValue + + /** Yes/No state; null until the user answers. Rendered from Phase 4. */ + data class YesNo(val value: Boolean?) : MetricValue + + /** A time of day as "HH:mm"; null until picked. Rendered from Phase 4. */ + data class TimeOfDay(val time: String?) : MetricValue +} + +/** + * Everything a [MetricInput] needs to render a category's input control, + * lifted from the category row by the caller. + */ +data class MetricConfig( + val name: String, + val options: List = emptyList(), + val min: Int = 1, + val max: Int = 5, + val stepLabels: Map = emptyMap(), + val unit: String? = null, + val allowDecimals: Boolean = false, + val endLabels: Pair? = null, +) + +/** + * The one input facade: screens render a metric through this and never branch + * on the category type themselves. + * + * Phase 3 stub: the switch exists and returns a working control per type, but + * full logging behaviour (save blocking, timed timelines, value formatting) + * lands in Phase 4, which is also where the `yes_no` and `time` types join + * [CategoryType]. The "kill the slider" rule is applied here: a discrete + * whole-step scale renders as a [StepScale]; only a genuinely continuous + * measure (decimals allowed, or a wide range) keeps a real slider. + */ +@Composable +fun MetricInput( + type: CategoryType, + config: MetricConfig, + value: MetricValue?, + role: Color, + onRole: Color, + onChange: (MetricValue) -> Unit, + modifier: Modifier = Modifier, +) { + when (type) { + CategoryType.DEFAULT -> ChipRow( + options = config.options, + selected = (value as? MetricValue.Choice)?.selected ?: emptySet(), + role = role, + onToggle = { option -> + val current = (value as? MetricValue.Choice)?.selected ?: emptySet() + val next = if (option in current) current - option else current + option + onChange(MetricValue.Choice(next)) + }, + modifier = modifier, + ) + + CategoryType.NUMERIC_SLIDER -> { + val stepCount = config.max - config.min + 1 + val isContinuous = config.allowDecimals || stepCount > 10 + if (isContinuous) { + val current = (value as? MetricValue.Continuous)?.value ?: config.min.toFloat() + Slider( + value = current.coerceIn(config.min.toFloat(), config.max.toFloat()), + onValueChange = { onChange(MetricValue.Continuous(it)) }, + valueRange = config.min.toFloat()..config.max.toFloat(), + colors = SliderDefaults.colors( + thumbColor = role, + activeTrackColor = role, + ), + modifier = modifier.fillMaxWidth(), + ) + } else { + val range = config.min..config.max + val labels = if (config.stepLabels.isEmpty()) null + else range.map { config.stepLabels[it] ?: it.toString() } + StepScale( + name = config.name, + range = range, + value = (value as? MetricValue.Scale)?.step, + role = role, + onRole = onRole, + onSelect = { onChange(MetricValue.Scale(it)) }, + labels = labels, + endLabels = config.endLabels, + modifier = modifier, + ) + } + } + + CategoryType.NUMERIC_FREE -> OutlinedTextField( + value = (value as? MetricValue.FreeNumber)?.text ?: "", + onValueChange = { onChange(MetricValue.FreeNumber(it)) }, + label = { Text(config.unit ?: "Value") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = modifier.fillMaxWidth(), + ) + + CategoryType.INCREMENT -> { + val count = (value as? MetricValue.Count)?.count ?: 0 + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + OutlinedIconButton( + onClick = { onChange(MetricValue.Count((count - 1).coerceAtLeast(0))) }, + ) { + Icon( + imageVector = Icons.Default.Remove, + contentDescription = "Decrease ${config.name}", + ) + } + Text( + text = count.toString(), + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + FilledIconButton( + onClick = { onChange(MetricValue.Count(count + 1)) }, + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = role, + contentColor = onRole, + ), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Increase ${config.name}", + ) + } + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun MetricInputPreviewContent() { + SectionHeader(label = "Symptoms", value = "1 today") + MetricInput( + type = CategoryType.DEFAULT, + config = MetricConfig(name = "Symptoms", options = listOf("Cramps", "Nausea", "Headache")), + value = MetricValue.Choice(setOf("Nausea")), + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = {}, + ) + SectionHeader(label = "Severity", value = "3 of 5", valueColor = MaterialTheme.colorScheme.primary) + MetricInput( + type = CategoryType.NUMERIC_SLIDER, + config = MetricConfig(name = "Severity", min = 1, max = 5), + value = MetricValue.Scale(3), + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = {}, + ) + SectionHeader(label = "Weight") + MetricInput( + type = CategoryType.NUMERIC_FREE, + config = MetricConfig(name = "Weight", unit = "kg"), + value = MetricValue.FreeNumber("72.4"), + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = {}, + ) + SectionHeader(label = "Count", value = "6 glasses of water") + MetricInput( + type = CategoryType.INCREMENT, + config = MetricConfig(name = "Water"), + value = MetricValue.Count(6), + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onChange = {}, + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun MetricInputPreviewLight() { + ComponentPreviewSurface { MetricInputPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun MetricInputPreviewDark() { + ComponentPreviewSurface(dark = true) { MetricInputPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun MetricInputPreviewLarge() { + ComponentPreviewSurface { MetricInputPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/PrimarySaveBar.kt b/app/src/main/java/com/mapgie/goflo/ui/components/PrimarySaveBar.kt new file mode 100644 index 0000000..efd06a0 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/PrimarySaveBar.kt @@ -0,0 +1,115 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * The sticky bottom action: a full-width 52dp pill in the screen's [role] + * colour, with a gradient fade of the background over the scrolling content + * above it so list items dissolve under the bar instead of clipping. + * + * Place it bottom-aligned over the scroll container (e.g. in a Box). The + * label is always text, so no extra content description is needed. + */ +@Composable +fun PrimarySaveBar( + label: String, + role: Color, + onRole: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val background = MaterialTheme.colorScheme.background + Column(modifier = modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(20.dp) + .background( + Brush.verticalGradient( + listOf(background.copy(alpha = 0f), background) + ) + ), + ) + Box( + modifier = Modifier + .fillMaxWidth() + .background(background) + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp), + ) { + Button( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(26.dp), + colors = ButtonDefaults.buttonColors( + containerColor = role, + contentColor = onRole, + ), + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + Text( + text = label, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + ) + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun PrimarySaveBarPreviewContent() { + PrimarySaveBar( + label = "Save log", + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onClick = {}, + ) + PrimarySaveBar( + label = "Add entry", + role = MaterialTheme.colorScheme.secondary, + onRole = MaterialTheme.colorScheme.onSecondary, + onClick = {}, + enabled = false, + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun PrimarySaveBarPreviewLight() { + ComponentPreviewSurface { PrimarySaveBarPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun PrimarySaveBarPreviewDark() { + ComponentPreviewSurface(dark = true) { PrimarySaveBarPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun PrimarySaveBarPreviewLarge() { + ComponentPreviewSurface { PrimarySaveBarPreviewContent() } +} 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 new file mode 100644 index 0000000..d967fe9 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/RolePicker.kt @@ -0,0 +1,219 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.mapgie.goflo.ui.util.CATEGORY_COLOR_OPTIONS +import com.mapgie.goflo.ui.util.CategoryColor +import com.mapgie.goflo.ui.util.toCategoryColor +import com.mapgie.goflo.ui.util.toCategoryOnColor +import com.mapgie.goflo.ui.util.toHexColorKey + +/** + * The "Pick a colour" control: six in-theme role pills (which re-theme with + * the active palette) above a track of fixed swatches (deliberately exempt + * from theme changes). + * + * Factored out of the Phase 1 inline picker in ManageCategoriesScreen; that + * screen keeps its own copy until a later phase rewires it. [extraFixedSlot] + * lets the create/edit flow append its custom-colour swatch to the fixed + * track. + * + * State is hoisted: [selectedToken] is a [CategoryColor] key or an 8-char hex + * key, and [onPick] fires with the tapped token. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RolePicker( + selectedToken: String, + onPick: (String) -> Unit, + modifier: Modifier = Modifier, + roles: List = CategoryColor.entries, + fixedColors: List = CATEGORY_COLOR_OPTIONS, + extraFixedSlot: (@Composable () -> Unit)? = null, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SectionHeader(label = "In-theme roles", value = "Re-theme automatically") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + roles.forEach { colorOption -> + RolePill( + option = colorOption, + isSelected = colorOption.key == selectedToken, + onPick = onPick, + ) + } + } + 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() + } + } +} + +@Composable +private fun RolePill( + option: CategoryColor, + isSelected: Boolean, + onPick: (String) -> Unit, +) { + val roleColor = option.key.toCategoryColor() + val onRoleColor = option.key.toCategoryOnColor() + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(50)) + .then( + if (isSelected) Modifier.background(roleColor) + else Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(50)) + ) + .semantics { + this.role = Role.RadioButton + this.selected = isSelected + this.contentDescription = option.displayName + } + .clickable { onPick(option.key) } + .padding(horizontal = 14.dp), + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = onRoleColor, + modifier = Modifier.size(16.dp), + ) + } else { + Box( + modifier = Modifier + .size(12.dp) + .clip(CircleShape) + .background(roleColor), + ) + } + Text( + text = option.displayName, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.Bold else null, + color = if (isSelected) onRoleColor else MaterialTheme.colorScheme.onSurface, + ) + } +} + +@Composable +private fun FixedSwatch( + argb: Int, + isSelected: Boolean, + onPick: (String) -> Unit, +) { + val hexKey = argb.toHexColorKey() + val swatchColor = Color(argb) + val onSwatchColor = if (swatchColor.luminance() > 0.35f) Color(0xFF1C1B1F) else Color.White + // 48dp outer tap target around a 38dp visible swatch. + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .semantics { + this.role = Role.RadioButton + this.selected = isSelected + this.contentDescription = "Fixed colour $hexKey" + } + .clickable { onPick(hexKey) }, + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .background(swatchColor) + .then( + if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + else Modifier + ), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = onSwatchColor, + modifier = Modifier.size(18.dp), + ) + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Preview(name = "Light", showBackground = true) +@Composable +private fun RolePickerPreviewLight() { + ComponentPreviewSurface { + RolePicker(selectedToken = "primary", onPick = {}) + } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun RolePickerPreviewDark() { + ComponentPreviewSurface(dark = true) { + RolePicker(selectedToken = CATEGORY_COLOR_OPTIONS.first().toHexColorKey(), onPick = {}) + } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun RolePickerPreviewLarge() { + ComponentPreviewSurface { + RolePicker(selectedToken = "quaternary", onPick = {}) + } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/RoleTint.kt b/app/src/main/java/com/mapgie/goflo/ui/components/RoleTint.kt new file mode 100644 index 0000000..d68d9a1 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/RoleTint.kt @@ -0,0 +1,20 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp + +/** + * Derives the tonal "container" fill for a category role colour by blending the + * accent toward the surface, mirroring the house pattern in + * [com.mapgie.goflo.ui.util.ordinalShade]. + * + * The redesign's selected states are tonal fills (chip fills, hero containers, + * segment fills). Material's ColorScheme only carries containers for its three + * built-in accents, so for an arbitrary role colour (quaternary/quinary/senary + * or a fixed hex) the container is derived here instead. Text placed on the + * result should use onSurface/onSurfaceVariant: at the default fraction the + * fill stays close enough to the surface that surface-level text contrast is + * preserved in both light and dark themes. + */ +fun roleContainerTint(role: Color, surface: Color, fraction: Float = 0.25f): Color = + lerp(surface, role, fraction.coerceIn(0f, 1f)) diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/SectionHeader.kt b/app/src/main/java/com/mapgie/goflo/ui/components/SectionHeader.kt new file mode 100644 index 0000000..efc794a --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/SectionHeader.kt @@ -0,0 +1,88 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp + +/** + * The small uppercase label that sits above every group of controls, with the + * group's current value right-aligned ("FLOW Medium"). + * + * Redesign rule: section titles are small uppercase labels; the current value + * lives in the header, not inside the control. Pass the category's role colour + * as [valueColor] when the value is a live reading; leave the muted default for + * meta values like "Optional" or "Not today". + */ +@Composable +fun SectionHeader( + label: String, + modifier: Modifier = Modifier, + value: String? = null, + valueColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label.uppercase(), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.11.em, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + if (value != null) { + Spacer(Modifier.width(8.dp)) + Text( + text = value, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = valueColor, + ) + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun SectionHeaderPreviewContent() { + SectionHeader( + label = "Flow", + value = "Medium", + valueColor = MaterialTheme.colorScheme.primary, + ) + SectionHeader(label = "Notes", value = "Optional") + SectionHeader(label = "Dates") +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun SectionHeaderPreviewLight() { + ComponentPreviewSurface { SectionHeaderPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun SectionHeaderPreviewDark() { + ComponentPreviewSurface(dark = true) { SectionHeaderPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun SectionHeaderPreviewLarge() { + ComponentPreviewSurface { SectionHeaderPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/SegmentedToggle.kt b/app/src/main/java/com/mapgie/goflo/ui/components/SegmentedToggle.kt new file mode 100644 index 0000000..1799e4e --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/SegmentedToggle.kt @@ -0,0 +1,87 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp + +/** + * Mutually exclusive segments built on Material's single-choice segmented + * buttons (radio-button semantics and an active checkmark come built in, so + * the selected state is never colour-only). + * + * Powers the Grouped/Ungrouped management toggle and the Yes/No input type. + * Pass [role] to tint the active segment with a category's role; leave null + * for the neutral Material default (management surfaces). + */ +@Composable +fun SegmentedToggle( + options: List, + selected: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + role: Color? = null, +) { + SingleChoiceSegmentedButtonRow(modifier = modifier.fillMaxWidth()) { + options.forEachIndexed { index, option -> + SegmentedButton( + selected = index == selected, + onClick = { onSelect(index) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + colors = if (role != null) { + SegmentedButtonDefaults.colors( + activeContainerColor = roleContainerTint(role, MaterialTheme.colorScheme.surface), + activeContentColor = MaterialTheme.colorScheme.onSurface, + ) + } else { + SegmentedButtonDefaults.colors() + }, + ) { + Text(option, fontSize = 13.5.sp) + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun SegmentedTogglePreviewContent() { + SegmentedToggle( + options = listOf("Grouped", "Ungrouped"), + selected = 0, + onSelect = {}, + ) + SectionHeader(label = "Yes / no", value = "Yes", valueColor = MaterialTheme.colorScheme.primary) + SegmentedToggle( + options = listOf("Yes", "No"), + selected = 0, + onSelect = {}, + role = MaterialTheme.colorScheme.primary, + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun SegmentedTogglePreviewLight() { + ComponentPreviewSurface { SegmentedTogglePreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun SegmentedTogglePreviewDark() { + ComponentPreviewSurface(dark = true) { SegmentedTogglePreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun SegmentedTogglePreviewLarge() { + ComponentPreviewSurface { SegmentedTogglePreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt b/app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt new file mode 100644 index 0000000..a47c458 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/StepScale.kt @@ -0,0 +1,203 @@ +package com.mapgie.goflo.ui.components + +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.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * Discrete rising tap-steps: the redesign's replacement for rating sliders. + * One component serves flow, rage, severity, and day-overall; [range], + * [labels], and [role] are the only differences between them. + * + * A single tap selects a step; there is no drag. Step height rises left to + * right so magnitude is never colour-only, and the selected step also gets a + * bold caption. Selection changes are instant (no animation), which honours + * reduce-motion by construction. + * + * Accessibility: the whole scale exposes as ONE control announcing + * ", , of ", with one custom action per step, + * instead of N unlabelled buttons. + */ +@Composable +fun StepScale( + name: String, + range: IntRange, + value: Int?, + role: Color, + onRole: Color, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + labels: List? = null, + endLabels: Pair? = null, +) { + val steps = range.toList() + val total = steps.size + val selectedIndex = value?.let { v -> steps.indexOf(v).takeIf { it >= 0 } } + + fun captionFor(index: Int): String = + labels?.getOrNull(index) ?: steps[index].toString() + + val description = if (selectedIndex == null) { + "$name, not set" + } else { + "$name, ${captionFor(selectedIndex)}, ${selectedIndex + 1} of $total" + } + val stepActions = steps.mapIndexed { index, step -> + CustomAccessibilityAction(label = "Set to ${captionFor(index)}") { + onSelect(step) + true + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .clearAndSetSemantics { + this.role = Role.RadioButton + this.contentDescription = description + this.customActions = stepActions + }, + ) { + // Tap targets fill the full 48dp row height even where the visible bar + // is shorter. + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + steps.forEachIndexed { index, step -> + val isSelected = index == selectedIndex + val rise = if (total <= 1) 1f else index.toFloat() / (total - 1) + val barHeight = (22 + 24 * rise).dp + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .semantics { this.role = Role.RadioButton } + .clickable { onSelect(step) }, + contentAlignment = Alignment.BottomCenter, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(barHeight) + .clip(RoundedCornerShape(6.dp)) + .background( + if (isSelected) role + else MaterialTheme.colorScheme.surfaceVariant + ), + ) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + steps.forEachIndexed { index, _ -> + val isSelected = index == selectedIndex + Text( + text = captionFor(index), + fontSize = 11.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) role else MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f), + ) + } + } + if (endLabels != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + ) { + Text( + text = endLabels.first, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Text( + text = endLabels.second, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun StepScalePreviewContent() { + SectionHeader(label = "Flow", value = "Medium", valueColor = MaterialTheme.colorScheme.primary) + StepScale( + name = "Flow", + range = 1..4, + value = 3, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onSelect = {}, + labels = listOf("Spot", "Light", "Med", "Heavy"), + ) + SectionHeader(label = "Rage", value = "Not today") + StepScale( + name = "Rage", + range = 0..5, + value = null, + role = MaterialTheme.colorScheme.secondary, + onRole = MaterialTheme.colorScheme.onSecondary, + onSelect = {}, + endLabels = "Calm" to "Volcanic", + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun StepScalePreviewLight() { + ComponentPreviewSurface { StepScalePreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun StepScalePreviewDark() { + ComponentPreviewSurface(dark = true) { StepScalePreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun StepScalePreviewLarge() { + ComponentPreviewSurface { StepScalePreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/SwitchRow.kt b/app/src/main/java/com/mapgie/goflo/ui/components/SwitchRow.kt new file mode 100644 index 0000000..4df18a3 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/SwitchRow.kt @@ -0,0 +1,126 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * A full-width toggle row: title (plus optional one-line consequence subtitle) + * on the left, a switch tinted with the category's [role] on the right. + * + * Shared by allow-multiple, log-with-period, allow-decimals, and alarm-enable. + * The whole row is the tap target and carries switch semantics with an + * explicit On/Off state description; the inner Switch has no click handler of + * its own, so TalkBack sees exactly one focusable control. + */ +@Composable +fun SwitchRow( + title: String, + checked: Boolean, + role: Color, + onRole: Color, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .semantics { stateDescription = if (checked) "On" else "Off" } + .toggleable( + value = checked, + role = Role.Switch, + onValueChange = onCheckedChange, + ) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + if (subtitle != null) { + Text( + text = subtitle, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = checked, + onCheckedChange = null, + colors = SwitchDefaults.colors( + checkedTrackColor = role, + checkedThumbColor = onRole, + ), + ) + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun SwitchRowPreviewContent() { + ListCard { + SwitchRow( + title = "Allow multiple per day", + subtitle = "Log it several times, each keeps the time of entry", + checked = true, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = {}, + ) + HairlineDivider() + SwitchRow( + title = "Log with period", + subtitle = "Surface it in the flow context while a period runs", + checked = false, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onCheckedChange = {}, + ) + } +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun SwitchRowPreviewLight() { + ComponentPreviewSurface { SwitchRowPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun SwitchRowPreviewDark() { + ComponentPreviewSurface(dark = true) { SwitchRowPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun SwitchRowPreviewLarge() { + ComponentPreviewSurface { SwitchRowPreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt b/app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt new file mode 100644 index 0000000..7c90577 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/Timeline.kt @@ -0,0 +1,232 @@ +package com.mapgie.goflo.ui.components + +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.Row +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.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * One appended reading in a multiple-per-day [Timeline]: + * "08:10 Level 2 mild". The time is always shown as text, so the entry is + * parseable without colour (colour-blind and greyscale safe). + */ +data class TimelineEntryData( + val id: Long, + val time: String, + val value: String, + val sub: String? = null, +) + +/** + * The multiple-per-day list: when a category allows multiple entries, the day + * becomes a card of timestamped readings plus an append row. Domain state is + * hoisted; the component only renders [entries] and reports taps. + * + * [appendHint] is the small right-aligned helper on the append row, typically + * the current time ("now, 21:30"). Pass [onEditEntry]/[onDeleteEntry] to give + * each row an overflow menu. + */ +@Composable +fun Timeline( + entries: List, + role: Color, + onAppend: () -> Unit, + modifier: Modifier = Modifier, + appendLabel: String = "Log another", + appendHint: String? = null, + onEditEntry: ((TimelineEntryData) -> Unit)? = null, + onDeleteEntry: ((TimelineEntryData) -> Unit)? = null, +) { + ListCard(modifier = modifier) { + entries.forEach { entry -> + TimelineEntry( + time = entry.time, + value = entry.value, + role = role, + sub = entry.sub, + onEdit = onEditEntry?.let { edit -> { edit(entry) } }, + onDelete = onDeleteEntry?.let { delete -> { delete(entry) } }, + ) + HairlineDivider() + } + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .semantics { this.role = Role.Button } + .clickable(onClick = onAppend) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = role, + modifier = Modifier.size(18.dp), + ) + Text( + text = appendLabel, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = role, + modifier = Modifier.weight(1f), + ) + if (appendHint != null) { + Text( + text = appendHint, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * One row of a [Timeline]: leading time in the category's [role] colour, + * value plus optional sub-line, and an overflow menu when [onEdit] or + * [onDelete] is provided. Menu visibility is transient presentation state and + * lives inside the row; domain state stays hoisted. + */ +@Composable +fun TimelineEntry( + time: String, + value: String, + role: Color, + modifier: Modifier = Modifier, + sub: String? = null, + onEdit: (() -> Unit)? = null, + onDelete: (() -> Unit)? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 52.dp) + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = time, + fontSize = 13.5.sp, + fontWeight = FontWeight.SemiBold, + color = role, + style = TextStyle(fontFeatureSettings = "tnum"), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = value, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + if (sub != null) { + Text( + text = sub, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (onEdit != null || onDelete != null) { + Box { + var menuOpen by remember { mutableStateOf(false) } + IconButton(onClick = { menuOpen = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Options for the $time entry", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + if (onEdit != null) { + DropdownMenuItem( + text = { Text("Edit") }, + onClick = { + menuOpen = false + onEdit() + }, + ) + } + if (onDelete != null) { + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + menuOpen = false + onDelete() + }, + ) + } + } + } + } + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun TimelinePreviewContent() { + SectionHeader(label = "Today", value = "3 logged") + Timeline( + entries = listOf( + TimelineEntryData(id = 1, time = "08:10", value = "Level 2", sub = "mild"), + TimelineEntryData(id = 2, time = "13:40", value = "Level 4", sub = "after lunch"), + TimelineEntryData(id = 3, time = "19:05", value = "Level 1", sub = "faded"), + ), + role = MaterialTheme.colorScheme.primary, + onAppend = {}, + appendHint = "now, 21:30", + onEditEntry = {}, + onDeleteEntry = {}, + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun TimelinePreviewLight() { + ComponentPreviewSurface { TimelinePreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun TimelinePreviewDark() { + ComponentPreviewSurface(dark = true) { TimelinePreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun TimelinePreviewLarge() { + ComponentPreviewSurface { TimelinePreviewContent() } +} diff --git a/app/src/main/java/com/mapgie/goflo/ui/components/ToneHero.kt b/app/src/main/java/com/mapgie/goflo/ui/components/ToneHero.kt new file mode 100644 index 0000000..4338723 --- /dev/null +++ b/app/src/main/java/com/mapgie/goflo/ui/components/ToneHero.kt @@ -0,0 +1,113 @@ +package com.mapgie.goflo.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mapgie.goflo.ui.theme.ComfortaaFamily + +/** + * The tonal container that makes one metric the page: a big reading as words + * ("Barely noticeable", "Pretty good") on a container tint of the metric's + * [role] (blue for facts, amber for feelings; the user's group choice). + * + * The hero word uses the Comfortaa brand family, applied explicitly because + * GoFloTypography does not wire it in. The word itself is onSurface so it + * stays readable on the tint in every palette; the role communicates through + * the container. + * + * [content] slots the metric's input control (typically a [StepScale]) under + * the word. + */ +@Composable +fun ToneHero( + word: String, + role: Color, + modifier: Modifier = Modifier, + caption: String? = null, + content: (@Composable ColumnScope.() -> Unit)? = null, +) { + val container = roleContainerTint(role, MaterialTheme.colorScheme.surface) + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(20.dp)) + .background(container) + .padding(horizontal = 18.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = word, + fontFamily = ComfortaaFamily, + fontWeight = FontWeight.Bold, + fontSize = 23.sp, + lineHeight = 30.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + if (caption != null) { + Text( + text = caption, + fontSize = 11.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + content?.invoke(this) + } +} + +// ── Previews ────────────────────────────────────────────────────────────────── + +@Composable +private fun ToneHeroPreviewContent() { + ToneHero( + word = "Barely noticeable", + role = MaterialTheme.colorScheme.primary, + caption = "How bad is it right now?", + ) { + StepScale( + name = "Nose symptoms", + range = 1..5, + value = 1, + role = MaterialTheme.colorScheme.primary, + onRole = MaterialTheme.colorScheme.onPrimary, + onSelect = {}, + endLabels = "Barely there" to "Unbearable", + ) + } + ToneHero( + word = "Pretty good", + role = MaterialTheme.colorScheme.secondary, + caption = "How did today feel overall?", + ) +} + +@Preview(name = "Light", showBackground = true) +@Composable +private fun ToneHeroPreviewLight() { + ComponentPreviewSurface { ToneHeroPreviewContent() } +} + +@Preview(name = "Dark", showBackground = true) +@Composable +private fun ToneHeroPreviewDark() { + ComponentPreviewSurface(dark = true) { ToneHeroPreviewContent() } +} + +@Preview(name = "Light 200%", showBackground = true, fontScale = 2f) +@Composable +private fun ToneHeroPreviewLarge() { + ComponentPreviewSurface { ToneHeroPreviewContent() } +} diff --git a/changelog/unreleased/logging-redesign-phase-3.json b/changelog/unreleased/logging-redesign-phase-3.json new file mode 100644 index 0000000..145d789 --- /dev/null +++ b/changelog/unreleased/logging-redesign-phase-3.json @@ -0,0 +1,4 @@ +{ + "bump": "patch", + "changed": ["Internal: shared UI component library for the logging redesign (no user-facing change yet)"] +} diff --git a/docs/design/logging-redesign/PLAN.md b/docs/design/logging-redesign/PLAN.md index e6596f4..baa754c 100644 --- a/docs/design/logging-redesign/PLAN.md +++ b/docs/design/logging-redesign/PLAN.md @@ -205,7 +205,7 @@ Each phase is a shippable PR. Order is deliberate: additive foundations first (r | 0 — Planning & groundwork | Done | this branch (`claude/design-handover-bdq9he`) | 23 | Subsystem maps stamped at `d07d947` / vc116. Theme-reconciliation split out as separate task. | | 1 — Extended colour roles | Done | `claude/logging-redesign-phase-1-f08e0o` | 23 | Derivation deviates from the sketch in two ways: near-greyscale accents (High Contrast) shift lightness instead of hue (hue rotation of grey is a no-op), and on-colours pick near-black vs white by max contrast rather than a 0.35 luminance threshold (the threshold has a 0.30-0.35 band where white fails 3:1). Spot-check script `wcag_check_roles.py` added (all 12 families + HC + Blue & Orange, worst ratio 4.24:1). Also restored `wcag_check.py`, which was committed as one base64 line in `84624bc`. | | 2 — Group data model | Done | `claude/logging-redesign-phase-2-nuaywv` | 24 | Colour-inheritance deviation (keep existing colours, opt-in `"inherit"`) confirmed with owner. `"inherit"` is a sentinel constant, not a `CategoryColor` entry, so the Phase 1 picker does not offer it. Group methods live in `TrackingRepository` (new nullable `groupDao` ctor param) rather than a separate repository. No FK on `groupId`; `deleteGroup` unfiles members first. Migration test is a JVM test (`Migration23To24Test`) driving the real migration through sqlite-jdbc, since the project has no instrumented tests and `exportSchema = false`. | -| 3 — Component library | Not started | | | | +| 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 | Not started | | | | | 5 — Unified LogScreen | Not started | | | Consider sub-PRs. | | 6 — What You Track home | Not started | | | |