diff --git a/components/build.gradle.kts b/components/build.gradle.kts index e0e71cd..ef4d9af 100644 --- a/components/build.gradle.kts +++ b/components/build.gradle.kts @@ -37,4 +37,5 @@ dependencies { implementation(libs.androidx.ui) implementation(libs.androidx.foundation) implementation(libs.androidx.material3) + implementation(libs.androidx.material.icons.core) } diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt new file mode 100644 index 0000000..72a7526 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/CollapsingScaffold.kt @@ -0,0 +1,83 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp + +/** + * The family's collapsing scaffold for settings hubs, sub-screens and the + * full-screen pickers: a [LargeTopAppBar] that collapses on scroll, a back + * button, and a vertically scrolling content column. [content] lays out the + * connected grouped rows. The back content description uses the kit's `back` + * string, which an app can override with its own (localized) `back`. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CollapsingScaffold( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val scrollBehavior = + TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()) + Scaffold( + modifier = modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + LargeTopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + scrollBehavior = scrollBehavior, + colors = TopAppBarDefaults.largeTopAppBarColors( + scrolledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + // Consume the scaffold's bar insets so imePadding adds only the + // keyboard height beyond them (max, not sum). + .consumeWindowInsets(innerPadding) + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(top = 8.dp, bottom = 24.dp), + content = content, + ) + } +} diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt new file mode 100644 index 0000000..d297437 --- /dev/null +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/OptionPicker.kt @@ -0,0 +1,79 @@ +package de.jeanlucmakiola.floret.components + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +/** + * Full-screen scaffold for selection pickers: a full-bleed [Dialog] that reuses + * [CollapsingScaffold] (collapsing title + back), so a picker is visually + * identical to a settings sub-page and uses the full width. [content] places the + * connected grouped rows; selecting one calls [onDismiss]. + */ +@Composable +fun FullScreenPicker( + title: String, + onDismiss: () -> Unit, + content: @Composable ColumnScope.() -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + CollapsingScaffold(title = title, onBack = onDismiss, content = content) + } +} + +/** + * 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 + * dialogs (theme, default list, reminder offset, …). + */ +@Composable +fun OptionPicker( + title: String, + options: List, + selected: T, + label: @Composable (T) -> String, + onSelect: (T) -> Unit, + onDismiss: () -> Unit, + leading: (@Composable (T) -> Unit)? = null, +) { + FullScreenPicker(title = title, onDismiss = onDismiss) { + options.forEachIndexed { index, option -> + val isSelected = option == selected + GroupedRow( + title = label(option), + position = positionOf(index, options.size), + selected = isSelected, + leading = leading?.let { { it(option) } }, + trailing = if (isSelected) { + { SelectedCheck() } + } else { + null + }, + onClick = { + onSelect(option) + onDismiss() + }, + ) + } + } +} + +@Composable +private fun SelectedCheck() { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) +} diff --git a/components/src/main/res/values/strings.xml b/components/src/main/res/values/strings.xml new file mode 100644 index 0000000..4861636 --- /dev/null +++ b/components/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + + Back + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04e5f08..80f9d72 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +androidx-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } junit-jupiter-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" }