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
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.mapgie.dash.data.model.Chore
import com.mapgie.dash.data.model.ScanDto
import com.mapgie.dash.ui.components.core.CategoryBadge
import com.mapgie.dash.ui.components.core.OwnerAvatar
import com.mapgie.dash.util.CalendarShareUtils
import com.mapgie.dash.util.calendarEventWithoutTime
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -91,13 +93,17 @@ fun ChoreOverviewSheet(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.weight(1f)) {
if (chore.category != null) {
Text(
chore.category.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// 4dp between eyebrow and title
val hasOwner = chore.owner?.isNotBlank() == true
if (chore.category != null || hasOwner) {
// Same category badge and owner avatar as the list card.
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
chore.category?.let { CategoryBadge(it) }
if (hasOwner) OwnerAvatar(chore.owner!!)
}
// 4dp between identity row and title
Spacer(Modifier.height(4.dp))
}
Text(chore.label, style = MaterialTheme.typography.headlineLarge)
Expand Down
54 changes: 54 additions & 0 deletions app/src/main/java/com/mapgie/dash/ui/components/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# UI components

**Screens compose components; screens do not draw.**

A screen's job is to wire a ViewModel's state to shared composables and route
callbacks back. It should not hand-roll a card, a badge, a header, a swipe wrapper,
or an empty state. When two screens need the same widget, that widget lives here, in
one place, so the screens cannot drift apart.

## Layout

```
ui/components/
core/ the shared, screen-agnostic building blocks
DashListCard.kt the one list-card shell (slots: leading / content / trailing / owner)
SwipeActionRow.kt the one swipe-to-act wrapper
OwnerAvatar.kt the one owner indicator (per-person colour, everywhere)
CategoryBadge.kt the one category pill
SourceChip.kt a Memo's alarm-origin chip
MetaLabel.kt secondary / muted text
ListSectionHeader.kt sticky category header + collapsible section header
DashStates.kt empty / error / loading
DashFilterBar.kt filter-chips-plus-actions bar scaffold
DashScreenHeader.kt the thin type-coloured identity strip
Gallery.kt @Preview gallery of everything above
ChoreCard.kt thin binding: Chore -> DashListCard slots
TaskCard.kt thin binding: TaskDto -> DashListCard slots
ReminderCard.kt thin binding: ReminderDto -> DashListCard slots
(sheets, dialogs, add/edit forms ...)
```

## Rules

- **No `Card(` outside `core/`.** List rows go through `DashListCard`; it owns the
container, the status accent bar, vertical centring, the tap target, the inset, the
owner slot, and the `role` + `combinedClickable` plumbing (so accessibility is
correct in one place).
- **No hardcoded spacing in screen files.** Sizes come from `ui/theme/Dimens.kt`.
- **Card containers are always opaque.** Dim a done/archived row by blending toward
the surface or lowering *content* alpha, never the container's alpha. See
`LESSONS.md` #32.
- **One status vocabulary.** Colour-coded state goes through `StatusTone`
(`ui/theme/StatusTone.kt`), keyed on urgency, so the same colour means the same
thing on every tab. Every colour-coded state must also carry a shape or label
(never colour alone).
- **Every clickable declares a `Role`.** The shell does this for cards; anything new
that is clickable must too (`a11y_check.py` enforces it).

## Adding or changing a component

1. Put it in `core/` if more than one screen could use it.
2. Add or update its `@Preview` in `Gallery.kt`, covering light/dark and any
meaningful state (done, zen, high-contrast).
3. Point the screens at it and delete their private copy.
123 changes: 60 additions & 63 deletions app/src/main/java/com/mapgie/dash/ui/components/ReminderCard.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
package com.mapgie.dash.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.padding
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
Expand All @@ -21,10 +15,27 @@ import androidx.compose.ui.unit.dp
import com.mapgie.dash.data.model.ReminderDto
import com.mapgie.dash.data.model.isPast
import com.mapgie.dash.data.model.remindAtInstant
import com.mapgie.dash.ui.components.core.DashListCard
import com.mapgie.dash.ui.components.core.MetaLabel
import com.mapgie.dash.ui.components.core.SourceChip
import com.mapgie.dash.ui.components.core.SourceKind
import com.mapgie.dash.ui.theme.statusTone
import com.mapgie.dash.ui.theme.textColor
import java.time.ZoneId
import java.time.format.DateTimeFormatter

@OptIn(ExperimentalMaterial3Api::class)
/**
* Thin binding of a [ReminderDto] onto the shared [DashListCard].
*
* Memos is a collation surface, so this fills only the slots that make sense here:
* a done checkbox, the subject, the fire time, and a [SourceChip] naming where the
* alarm came from. No owner avatar, no zen mode, no trailing column (§5c).
*
* Overdue no longer uses the reserved `error` palette: it maps through [statusTone]
* to the shared overdue vocabulary (a red accent bar and red "Overdue" text), and
* the container stays opaque, so an archived overdue Memo no longer leaks the swipe
* panel behind it.
*/
@Composable
fun ReminderCard(
reminder: ReminderDto,
Expand All @@ -34,77 +45,63 @@ fun ReminderCard(
modifier: Modifier = Modifier
) {
val isDone = reminder.completedAt != null
val isPast = reminder.isPast()
// Overdue: time has passed but user hasn't explicitly marked it done
val isOverdue = isPast && !isDone
val isOverdue = reminder.isPast() && !isDone
val tone = reminder.statusTone()

val formatter = remember(reminder.remindAt) {
DateTimeFormatter.ofPattern("MMM d, yyyy 'at' HH:mm")
}
val formatter = remember { DateTimeFormatter.ofPattern("MMM d, yyyy 'at' HH:mm") }
val whenLabel = reminder.remindAtInstant()
?.atZone(ZoneId.systemDefault())
?.format(formatter)

val containerColor = when {
isDone -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
isOverdue -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.25f)
else -> MaterialTheme.colorScheme.surfaceVariant
}

val timeText = if (isOverdue) whenLabel?.let { "Overdue: $it" } ?: "Overdue" else whenLabel
val timeColor = when {
isDone -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
isOverdue -> MaterialTheme.colorScheme.error
isDone -> MaterialTheme.colorScheme.onSurfaceVariant
isOverdue -> tone.textColor()
else -> MaterialTheme.colorScheme.onSurfaceVariant
}

val timeText = when {
isOverdue -> whenLabel?.let { "Overdue: $it" } ?: "Overdue"
else -> whenLabel
val sourceKind = when {
reminder.choreId != null -> SourceKind.CHORE
reminder.taskId != null -> SourceKind.TASK
else -> null
}

Card(
DashListCard(
tone = tone,
modifier = modifier,
onClick = onClick,
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = containerColor)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
dimmed = isDone,
leading = {
Checkbox(
checked = isDone,
onCheckedChange = { onToggleDone() }
onCheckedChange = { onToggleDone() },
modifier = Modifier.align(Alignment.CenterVertically)
)
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp, top = 10.dp, bottom = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = reminder.subject,
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (isDone) TextDecoration.LineThrough else null,
color = if (isDone)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
}
) {
Text(
text = reminder.subject,
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (isDone) TextDecoration.LineThrough else null,
color = if (isDone)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
else
MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
timeText?.let {
MetaLabel(
text = it,
color = timeColor,
style = MaterialTheme.typography.labelMedium
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
timeText?.let {
Text(
text = it,
style = MaterialTheme.typography.labelMedium,
color = timeColor
)
}
linkedLabel?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
}
}
if (sourceKind != null && linkedLabel != null) {
SourceChip(kind = sourceKind, label = linkedLabel)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.mapgie.dash.data.model.TaskDto
import com.mapgie.dash.ui.components.core.CategoryBadge
import com.mapgie.dash.ui.components.core.OwnerAvatar
import com.mapgie.dash.util.CalendarShareUtils
import com.mapgie.dash.util.calendarEventForDate
import com.mapgie.dash.util.calendarEventForInstant
Expand Down Expand Up @@ -66,14 +68,18 @@ fun TaskOverviewSheet(
// 24dp after drag handle
Spacer(Modifier.height(24.dp))

// Category eyebrow
if (task.category != null) {
Text(
task.category.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// 4dp between eyebrow and title
// Category badge + owner avatar, matching the list card.
val hasOwner = task.owner?.isNotBlank() == true
val category = task.category?.takeIf { it.isNotBlank() }
if (category != null || hasOwner) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
category?.let { CategoryBadge(it) }
if (hasOwner) OwnerAvatar(task.owner!!)
}
// 4dp between identity row and title
Spacer(Modifier.height(4.dp))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.mapgie.dash.ui.components.core
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
Expand All @@ -26,6 +27,7 @@ import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.mapgie.dash.ui.theme.Dimens
import com.mapgie.dash.ui.theme.StatusTone
import com.mapgie.dash.ui.theme.barColor
Expand Down Expand Up @@ -114,6 +116,7 @@ fun DashListCard(
modifier = Modifier
.weight(1f)
.padding(Dimens.cardPadding),
verticalArrangement = Arrangement.spacedBy(4.dp),
content = content
)
// Optional trailing slot (e.g. due / last-scanned dates), end-aligned.
Expand Down
Loading