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

Filter by extension

Filter by extension

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

Expand Down
129 changes: 129 additions & 0 deletions app/src/main/java/com/mapgie/goflo/ui/components/ChipToggle.kt
Original file line number Diff line number Diff line change
@@ -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<String>,
selected: Set<String>,
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() }
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
112 changes: 112 additions & 0 deletions app/src/main/java/com/mapgie/goflo/ui/components/IconPicker.kt
Original file line number Diff line number Diff line change
@@ -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> = 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() }
}
Loading
Loading