6 Commits

Author SHA1 Message Date
ed1d3ca5e8 feat(components): let AboutCard join a grouped list
AboutCard drew its own Surface, so it could only stand alone. Taking a
Position and rendering through GroupedSurface lets it be the top of a group
with rows continuing underneath — for apps whose call to action reads better
as a row of its own than as a tonal button inside the card.

Defaults to Position.Alone, so existing callers are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:12:37 +02:00
38e508b259 feat(components): export the grouped-list inset as GroupedListInset
The 16dp a grouped list keeps from the screen edge was a private literal in
GroupedRow, so everything that sits outside the cards but belongs to them —
section headers, hints, picker descriptions — had to repeat the number and
had quietly drifted to 24dp in places, leaving pages with two ragged left
margins. Exporting it gives those callers the one value to take.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:12:37 +02:00
bb3ff1f316 feat(components): give OptionPicker a per-option summary slot
A picker row often names something it doesn't spell out — a range's dates, a
reminder's firing time, what "follow the system" resolves to today. The
summary slot puts that under the label, so every option shows its effect at
once and stays comparable, without the cost of a live preview.

The KDoc now also states the three rungs of picker richness (plain rows /
per-option summary / live preview) and the rule that a preview picker must
not close on selection, since closing hides the thing it exists to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:12:37 +02:00
b4d3ead8f0 Merge pull request 'fix(components): match full-screen picker bar icons to the runtime theme' (#3) from fix/picker-bar-icon-appearance into main 2026-07-24 15:24:12 +00:00
9bbef911c0 fix(components): match full-screen picker bar icons to the runtime theme
A Compose Dialog owns its own Window whose status/navigation-bar icon
appearance is seeded from the XML theme, not from the host activity's
runtime edge-to-edge state. With decorFitsSystemWindows = false the
FullScreenPicker draws under the bars, exposing icons that don't match
the active theme — dark icons on a dark picker (near-invisible), and the
mirror-image nav-bar bug in light theme.

Expose the resolved light/dark decision from FloretExpressiveTheme as
LocalFloretDarkTheme and drive the dialog window's
isAppearanceLight{Status,Navigation}Bars from it, so the appearance
tracks the in-app light/dark choice even when it diverges from the
system night resource qualifier.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:11:16 +02:00
75b90d1b3b Merge pull request 'feat(components): expose SelectedCheck as a family primitive' (#2) from feat/selected-check-primitive into main 2026-07-20 13:27:22 +00:00
5 changed files with 85 additions and 14 deletions

View File

@@ -39,6 +39,8 @@ dependencies {
// core-locale backs the language-picker recipe (AppLanguage). // core-locale backs the language-picker recipe (AppLanguage).
implementation(project(":core-locale")) implementation(project(":core-locale"))
implementation(libs.androidx.core.ktx)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
implementation(libs.androidx.foundation) implementation(libs.androidx.foundation)

View File

@@ -37,6 +37,16 @@ fun positionOf(index: Int, count: Int): Position = when {
else -> Position.Middle else -> Position.Middle
} }
/**
* The inset a grouped list keeps from the screen edge.
*
* Exported because it is a *shared* line, not a private detail of [GroupedRow]:
* everything that sits outside the cards but belongs to them — section headers,
* hints, picker descriptions — has to start on the same edge, or the page reads
* as two ragged columns. Take this value rather than writing 16.dp again.
*/
val GroupedListInset = 16.dp
/** Corner radii for a grouped segment: full at the group's outer edges, small between. */ /** Corner radii for a grouped segment: full at the group's outer edges, small between. */
fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) { fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) {
Position.Alone -> RoundedCornerShape(full) Position.Alone -> RoundedCornerShape(full)
@@ -140,7 +150,7 @@ fun GroupedRow(
} }
GroupedSurface( GroupedSurface(
position = position, position = position,
modifier = modifier.padding(horizontal = 16.dp), modifier = modifier.padding(horizontal = GroupedListInset),
onClick = onClick, onClick = onClick,
color = containerColor, color = containerColor,
gapBelow = gapBelow, gapBelow = gapBelow,

View File

@@ -13,6 +13,8 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.WindowCompat
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
/** /**
* Full-screen scaffold for selection pickers: a full-bleed [Dialog] that reuses * Full-screen scaffold for selection pickers: a full-bleed [Dialog] that reuses
@@ -24,6 +26,20 @@ import androidx.compose.ui.window.DialogWindowProvider
* [scrollable] forwards to [CollapsingScaffold]: leave it on unless [content] * [scrollable] forwards to [CollapsingScaffold]: leave it on unless [content]
* scrolls itself, which a picker only needs for an option list long enough to * scrolls itself, which a picker only needs for an option list long enough to
* warrant a lazy container. * warrant a lazy container.
*
* **Picker richness.** Three shapes, in ascending order — take the cheapest one
* that makes the choice obvious:
* 1. plain option rows, when the label *is* the meaning ("English", "Dark");
* 2. rows plus a per-option summary of the concrete effect, when the meaning is
* a date, a time or a number the label only names ("Next 7 days" →
* "30 Jul 5 Aug"). [OptionPicker]'s `summary` covers this;
* 3. a live preview above the rows, when the effect is *visual* and no words
* carry it (month grid style, dimmed past events, week start).
*
* A picker of the third kind **must not close when an option is tapped** — it
* applies immediately and stays open, because closing would hide the very thing
* the screen exists to show. The user leaves via back. The first two kinds close
* on tap as usual.
*/ */
@Composable @Composable
fun FullScreenPicker( fun FullScreenPicker(
@@ -41,14 +57,28 @@ fun FullScreenPicker(
decorFitsSystemWindows = false, decorFitsSystemWindows = false,
), ),
) { ) {
// The dialog window pans by default when the keyboard opens, which — // A Compose Dialog owns a separate android.view.Window whose bar-icon
// appearance is seeded from the XML theme, not from the activity window's
// runtime edge-to-edge state — so with decorFitsSystemWindows = false it
// draws edge-to-edge under bars whose icons don't match the active theme
// (dark icons on a dark picker). Drive the appearance from the runtime
// [LocalFloretDarkTheme] so it tracks the in-app light/dark choice even
// when that diverges from the system night qualifier.
//
// Same window also pans by default when the keyboard opens, which —
// combined with the content's own imePadding — leaves a fixed black gap // combined with the content's own imePadding — leaves a fixed black gap
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays // above the keyboard. Switch it to ADJUST_NOTHING so the window stays
// full-screen and imePadding alone lifts the focused field. // full-screen and imePadding alone lifts the focused field.
val view = LocalView.current val view = LocalView.current
val darkTheme = LocalFloretDarkTheme.current
SideEffect { SideEffect {
(view.parent as? DialogWindowProvider)?.window (view.parent as? DialogWindowProvider)?.window?.let { window ->
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING) window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
}
} }
CollapsingScaffold( CollapsingScaffold(
title = title, title = title,
@@ -66,6 +96,15 @@ fun FullScreenPicker(
* General single-select picker, full-screen: each option is a connected grouped * General single-select picker, full-screen: each option is a connected grouped
* row and the current one carries a check. The drop-in for the family's option * row and the current one carries a check. The drop-in for the family's option
* dialogs (theme, default list, reminder offset, …). * dialogs (theme, default list, reminder offset, …).
*
* [summary] adds a second line per option — reserve it for the option's
* *concrete effect*, the thing its label names but doesn't spell out: the dates
* a range resolves to, the time a format renders as, what "follow the system"
* currently means. Returning null leaves that row single-line, so one option
* (typically the automatic one) can carry a note the others don't need. It is
* the cheap middle rung of the richness ladder described on [FullScreenPicker]:
* every option shows its effect at once and stays comparable, without the cost
* of a live preview.
*/ */
@Composable @Composable
fun <T> OptionPicker( fun <T> OptionPicker(
@@ -75,6 +114,7 @@ fun <T> OptionPicker(
label: @Composable (T) -> String, label: @Composable (T) -> String,
onSelect: (T) -> Unit, onSelect: (T) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
summary: (@Composable (T) -> String?)? = null,
leading: (@Composable (T) -> Unit)? = null, leading: (@Composable (T) -> Unit)? = null,
header: (@Composable ColumnScope.() -> Unit)? = null, header: (@Composable ColumnScope.() -> Unit)? = null,
predictiveBack: Boolean = false, predictiveBack: Boolean = false,
@@ -85,6 +125,7 @@ fun <T> OptionPicker(
val isSelected = option == selected val isSelected = option == selected
GroupedRow( GroupedRow(
title = label(option), title = label(option),
summary = summary?.invoke(option),
position = positionOf(index, options.size), position = positionOf(index, options.size),
selected = isSelected, selected = isSelected,
leading = leading?.let { { it(option) } }, leading = leading?.let { { it(option) } },

View File

@@ -13,12 +13,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -41,6 +39,12 @@ data class AboutLink(val icon: ImageVector, val label: String, val url: String)
* outlined buttons (e.g. source, licence) and an optional full-width * outlined buttons (e.g. source, licence) and an optional full-width
* [highlightLink] tonal button (e.g. donate). Each link opens its URL in the * [highlightLink] tonal button (e.g. donate). Each link opens its URL in the
* browser. The app supplies its own logo, strings and links. * browser. The app supplies its own logo, strings and links.
*
* [position] lets the card join a grouped list instead of standing alone: with
* [Position.Top] it takes the group's top corners and the 2dp gap below, so a
* [GroupedRow] underneath continues the same container. Use that when the card's
* call to action reads better as a row of its own than as a button inside the
* card — the two then form one block rather than a card with a lid on it.
*/ */
@Composable @Composable
fun AboutCard( fun AboutCard(
@@ -49,15 +53,16 @@ fun AboutCard(
author: String, author: String,
primaryLinks: List<AboutLink>, primaryLinks: List<AboutLink>,
highlightLink: AboutLink? = null, highlightLink: AboutLink? = null,
position: Position = Position.Alone,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val open = { url: String -> val open = { url: String ->
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
Unit Unit
} }
Surface( GroupedSurface(
position = position,
color = MaterialTheme.colorScheme.surfaceContainerHigh, color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Column(Modifier.fillMaxWidth().padding(16.dp)) { Column(Modifier.fillMaxWidth().padding(16.dp)) {

View File

@@ -10,8 +10,19 @@ import androidx.compose.material3.Typography
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
/**
* The resolved light/dark decision the current [FloretExpressiveTheme] is running
* with, carried down the tree so components can align window chrome (e.g. status-
* and navigation-bar icon appearance) with the *runtime* theme rather than the
* system night resource qualifier — the two diverge whenever an app overrides
* light/dark from settings. Defaults to `false` (light) outside a Floret theme.
*/
val LocalFloretDarkTheme = staticCompositionLocalOf { false }
/** /**
* The family's Material 3 Expressive theme factory. Honours system light/dark * The family's Material 3 Expressive theme factory. Honours system light/dark
* and Material You dynamic colour (API 31+), falling back to the app-supplied * and Material You dynamic colour (API 31+), falling back to the app-supplied
@@ -43,10 +54,12 @@ fun FloretExpressiveTheme(
else -> lightScheme else -> lightScheme
} }
CompositionLocalProvider(LocalFloretDarkTheme provides darkTheme) {
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = typography, typography = typography,
motionScheme = MotionScheme.standard(), motionScheme = MotionScheme.standard(),
content = content, content = content,
) )
}
} }