Compare commits

..

12 Commits

Author SHA1 Message Date
b266653e4e chore: bump floret-kit submodule to latest (component superset)
All checks were successful
CI / ci (push) Successful in 4m0s
Adopt floret-kit main @ 566caf4, picking up the grown GroupedRow / OptionPicker / CollapsingScaffold family superset. App compiles and unit tests pass against it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:13:21 +02:00
2f5012dc34 tasks: consolidate add affordance into a setting (FAB or bottom bar)
All checks were successful
CI / ci (push) Successful in 4m8s
Removes the doubled add idiom (top inline-add bar + FAB). A new "Bottom quick-add bar" setting (default off) picks one affordance per screen: off keeps the floating New task button everywhere; on shows a quick-add field pinned to the bottom of a real list (smart lists keep the button, as they have no single target list).

The bottom field floats on the brightest container tone with a shadow so it stands off the task cards, rides above the keyboard (ime ∪ navigation-bar insets), and drops focus when the IME hides so no stray cursor lingers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:01:32 +02:00
ccc07e86c7 tasks: quiet single-line row meta, lazy subtasks in smart lists, polish
- Collapse a task row's supporting info (priority, due, subtask progress,
  list name) into one low-emphasis meta line instead of stacked filled
  chips: priority is a tinted flag, the list name carries its list colour,
  and dates are compact (formatDateTimeCompact).
- Expand subtasks in smart lists too, by lazily fetching a parent's full
  child set on demand (TasksRepository.subtasks + the view model's live
  lazyChildren), while per-list views stay query-free.
- animateItem on the rows so (un)expanding a group fades its subtasks
  in/out and slides the rows below — clean whether the children were
  already loaded or arrive a frame later.
- Sit the undo chip beside the FAB at its height rather than overlapping
  or floating above it.

Bumps the floret-kit submodule for formatDateTimeCompact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:47:50 +02:00
211450bc46 chore: bump floret-kit submodule to merged main
All checks were successful
CI / ci (push) Successful in 7m24s
Picks up the pushed floret-kit main (the eight shared-kit commits merged with
the upstream docs/changelog/license commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:20:56 +02:00
9d134be621 nav: fix blank back-gesture preview — let navigation drive predictive back
The per-screen Modifier.predictiveBack scaled the leaving screen with a
graphicsLayer, but NavHost only composes one back-stack entry at a time, so
nothing was drawn behind the shrinking screen — the gesture revealed the white
window background instead of the previous screen.

Drive predictive back through navigation-compose instead: it composes the
destination being revealed and seeks popExit with the gesture. popExit is now a
scaleOut + fade peek, so the leaving screen shrinks to reveal the real previous
screen held static. Drops the predictiveBack modifier from the destinations and
Settings (its inner section BackHandler is unchanged). The kit's predictiveBack
stays for overlay-style screens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:15:24 +02:00
37e3c4e659 settings: use the shared AboutCard + LanguagePickerRow
Drop the in-app AboutCard, LanguageRow and languageLabel in favour of the
floret-kit recipes; the app still supplies its logo, strings, links and row
icon. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:24:17 +02:00
78632865f9 edit: use the shared OptionalFormSection in the task form
Drop the in-app copy in favour of floret-kit's. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:17:57 +02:00
e7a62e71df reminders: use the shared DialogAmountField in the custom lead editor
Replace the inline tonal amount field in the reminder picker with floret-kit's
DialogAmountField. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:15:08 +02:00
4c05c86e95 motion: use floret-kit's expandEnter/collapseExit for revealable sections
Replace the inline expandVertically/shrinkVertically + fade in the reminder
picker, task edit form and settings with the shared transitions — which adds
reduced-motion support and the Expressive springs. Bumps the submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:13:06 +02:00
37662e83bb nav: swap the full slide-back for the predictive-back peek
Back was a full-width horizontal slide-off — too heavy. Replace it with
floret-kit's Modifier.predictiveBack on each pop-able destination (task list,
detail, edit) so the leaving screen scales/rounds with the gesture and the held
screen is revealed unmoved; NavHost now does no pop animation. Settings applies
it at the hub only (enabled = section == null) so its inner section back still
returns to the hub. Forward stays a calm fade-in.

Bumps the floret-kit submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:31:20 +02:00
63c6191676 onboarding: draw the onboarding scaffold from floret-kit
Use components' OnboardingScaffold / BenefitRow / OnboardingSpace and drop the
in-app copies; keep Agendula's own SquircleHero (now in OnboardingHero.kt).
Bumps the floret-kit submodule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:16:56 +02:00
4994f5ec2c reminders + locale: draw prefs/reminders and language plumbing from floret-kit
Consume two newly extracted floret-kit modules and drop the in-app copies:

- core-reminders: ReminderUnit / decomposeReminderMinutes, the ReminderOverride
  model and the override codec. SettingsPrefs keeps its own DataStore wiring and
  Agendula's ':' codec dialect; ReminderFormatting/ReminderLeadPicker keep the
  app's R.string labels and presets.
- core-locale: AppLanguage. Deleted the app copy; SettingsScreen now passes
  R.xml.locales_config to supportedTags().

Bumps the floret-kit submodule to include both modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 14:12:50 +02:00
19 changed files with 451 additions and 540 deletions

View File

@@ -121,6 +121,8 @@ dependencies {
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
implementation("de.jeanlucmakiola.floret:core-time") implementation("de.jeanlucmakiola.floret:core-time")
implementation("de.jeanlucmakiola.floret:core-reminders")
implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash") implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:identity") implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components") implementation("de.jeanlucmakiola.floret:components")

View File

@@ -9,6 +9,10 @@ import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@@ -16,16 +20,6 @@ import javax.inject.Singleton
enum class ThemeMode { SYSTEM, LIGHT, DARK } enum class ThemeMode { SYSTEM, LIGHT, DARK }
/** A list's override of the default reminder lead time (mirrors Calendula's per-calendar override). */
sealed interface ListReminderOverride {
/** No override — the list uses the global default. */
data object Inherit : ListReminderOverride
/** Explicit "no reminder" for this list, regardless of the global default. */
data object None : ListReminderOverride
/** A specific lead time in minutes before due. */
data class Minutes(val minutes: Int) : ListReminderOverride
}
data class Settings( data class Settings(
val themeMode: ThemeMode = ThemeMode.SYSTEM, val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true, val dynamicColor: Boolean = true,
@@ -37,6 +31,12 @@ data class Settings(
val remindersEnabled: Boolean = true, val remindersEnabled: Boolean = true,
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */ /** Whether the inline "add a subtask" row shows on expanded task-list groups. */
val showAddSubtaskRow: Boolean = true, val showAddSubtaskRow: Boolean = true,
/**
* Add affordance for a list: `false` = the floating "New task" button (opens
* the editor); `true` = a quick-add bar pinned to the bottom of a real list.
* Smart lists always use the button (they have no single list to add into).
*/
val bottomAddBar: Boolean = false,
/** /**
* Per-list overrides of [reminderLeadMinutes]: a list present in the map * Per-list overrides of [reminderLeadMinutes]: a list present in the map
* overrides the global default (a null value = no reminder); absent = inherit. * overrides the global default (a null value = no reminder); absent = inherit.
@@ -47,11 +47,7 @@ data class Settings(
) { ) {
/** The lead time for a task in [listId]: its override if set, else the global default. */ /** The lead time for a task in [listId]: its override if set, else the global default. */
fun reminderLeadFor(listId: Long): Int? = fun reminderLeadFor(listId: Long): Int? =
if (perListReminderOverride.containsKey(listId)) { perListReminderOverride.reminderLeadFor(listId, reminderLeadMinutes)
perListReminderOverride[listId] // null = explicitly no reminder
} else {
reminderLeadMinutes
}
} }
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */ /** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@@ -68,7 +64,8 @@ class SettingsPrefs @Inject constructor(
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0, reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
remindersEnabled = p[REMINDERS_ENABLED] ?: true, remindersEnabled = p[REMINDERS_ENABLED] ?: true,
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true, showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
perListReminderOverride = parseReminderOverrides(p[LIST_REMINDER_OVERRIDE]), bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
perListReminderOverride = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]),
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty() defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() } .mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
.toSet(), .toSet(),
@@ -92,15 +89,13 @@ class SettingsPrefs @Inject constructor(
suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show } suspend fun setShowAddSubtaskRow(show: Boolean) = dataStore.edit { it[SHOW_ADD_SUBTASK_ROW] = show }
/** Set (or clear, via [ListReminderOverride.Inherit]) a list's reminder override. */ suspend fun setBottomAddBar(enabled: Boolean) = dataStore.edit { it[BOTTOM_ADD_BAR] = enabled }
suspend fun setListReminderOverride(listId: Long, override: ListReminderOverride) = dataStore.edit { p ->
val current = parseReminderOverrides(p[LIST_REMINDER_OVERRIDE]).toMutableMap() /** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
when (override) { suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
ListReminderOverride.Inherit -> current.remove(listId) val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
ListReminderOverride.None -> current[listId] = null current.applyReminderOverride(listId, override)
is ListReminderOverride.Minutes -> current[listId] = override.minutes p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(current)
}
p[LIST_REMINDER_OVERRIDE] = serializeReminderOverrides(current)
} }
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit { suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
@@ -114,26 +109,16 @@ class SettingsPrefs @Inject constructor(
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes") val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled") val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row") val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
val BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done") val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override") val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields") val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
} }
} }
private const val OVERRIDE_ENTRY_SEP = ";" /**
private const val OVERRIDE_KV_SEP = ":" * Agendula's stored dialect for the per-list override map: `id:minutes` entries
private const val OVERRIDE_NONE = "none" * joined by `;` (the family default). Fixed at release — don't change without a
* data migration.
/** Decode "id:minutes;id:none;…" into a map (value null = explicit no-reminder). */ */
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> { private val reminderCodec = ReminderOverrideCodec.DEFAULT
if (stored.isNullOrBlank()) return emptyMap()
return stored.split(OVERRIDE_ENTRY_SEP).mapNotNull { entry ->
val parts = entry.split(OVERRIDE_KV_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
val value = if (parts[1] == OVERRIDE_NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
id to value
}.toMap()
}
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
map.entries.joinToString(OVERRIDE_ENTRY_SEP) { (id, minutes) -> "$id$OVERRIDE_KV_SEP${minutes ?: OVERRIDE_NONE}" }

View File

@@ -19,6 +19,13 @@ enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
interface TasksRepository { interface TasksRepository {
fun taskLists(): Flow<List<TaskList>> fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>> fun tasks(filter: TaskFilter): Flow<List<Task>>
/**
* The direct children of [parentId], re-emitting live like the other flows.
* Lets a smart-list view pull a parent's full subtask set on demand — those
* children are usually filtered out of the list's own (date-based) query.
*/
fun subtasks(parentId: Long): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?> fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long suspend fun createTask(form: TaskForm): Long

View File

@@ -35,6 +35,12 @@ class TasksRepositoryImpl @Inject constructor(
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) } override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
dataSource.subtasks(parentId)
.filter { it.taskId != parentId }
.sortedWith(TaskSorting.DEFAULT)
}
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing { override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
dataSource.task(taskId)?.let { task -> dataSource.task(taskId)?.let { task ->
TaskDetail( TaskDetail(

View File

@@ -45,6 +45,27 @@ fun priorityFill(priority: Priority, dark: Boolean): Color {
return Color(android.graphics.Color.HSVToColor(hsv)) return Color(android.graphics.Color.HSVToColor(hsv))
} }
/**
* Foreground tint (icon/text) for a priority level — the same red/amber/green as
* [priorityFill], but kept saturated enough to read as a small glyph directly on
* the surface rather than as a soft fill. Used by the task list's quiet meta line,
* where priority is a tinted flag instead of a filled pill. [Priority.NONE] is
* unspecified (callers render it neutral / omit it).
*/
fun priorityAccent(priority: Priority, dark: Boolean): Color {
val base = when (priority) {
Priority.HIGH -> 0xFFE53935.toInt() // red
Priority.MEDIUM -> 0xFFFB8C00.toInt() // orange-amber (darker than the fill for contrast)
Priority.LOW -> 0xFF43A047.toInt() // green
Priority.NONE -> return Color.Unspecified
}
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(base, hsv)
hsv[1] = if (dark) 0.55f else 0.85f
hsv[2] = if (dark) 0.85f else 0.62f // light on dark surfaces; deep on light ones
return Color(android.graphics.Color.HSVToColor(hsv))
}
/** /**
* A coloured status pill for a task's priority: a flag plus the level's [label], * A coloured status pill for a task's priority: a flag plus the level's [label],
* filled in that level's pastel hue. Used read-only in the list and detail * filled in that level's pastel hue. Used read-only in the list and detail
@@ -91,14 +112,14 @@ fun PriorityChip(
val body: @Composable () -> Unit = { val body: @Composable () -> Unit = {
Row( Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp), modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (showIcon) { if (showIcon) {
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(15.dp)) Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(14.dp))
} }
Text(label, style = MaterialTheme.typography.labelMedium, color = content) Text(label, style = MaterialTheme.typography.labelSmall, color = content)
} }
} }
val shape = RoundedCornerShape(50) val shape = RoundedCornerShape(50)

View File

@@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.reminders.ReminderUnit
/** Common reminder lead times offered as quick picks in the reminder pickers. */ /** Common reminder lead times offered as quick picks in the reminder pickers. */
val REMINDER_PRESETS = listOf(0, 5, 10, 30, 60, 1_440) val REMINDER_PRESETS = listOf(0, 5, 10, 30, 60, 1_440)
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
enum class ReminderUnit(val minutesFactor: Int) {
Minutes(1),
Hours(60),
Days(1_440),
Weeks(10_080),
}
@StringRes @StringRes
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) { fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
ReminderUnit.Minutes -> R.string.reminder_unit_minutes ReminderUnit.Minutes -> R.string.reminder_unit_minutes

View File

@@ -3,13 +3,14 @@ package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.components.FullScreenPicker import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.DialogAmountField
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.floret.reminders.ReminderUnit
import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import de.jeanlucmakiola.floret.identity.collapseExit
import androidx.compose.animation.fadeIn import de.jeanlucmakiola.floret.identity.expandEnter
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -33,40 +34,38 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ListReminderOverride
/** /**
* Reminder lead-time picker, full-screen: the grouped list of preset lead times * Reminder lead-time picker, full-screen: the grouped list of preset lead times
* plus a "Custom" row that expands an inline amount field + unit selector. * plus a "Custom" row that expands an inline amount field + unit selector.
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds * [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
* a "None" row. Returns the choice as a [ListReminderOverride]. Ported from * a "None" row. Returns the choice as a [ReminderOverride]. Ported from
* Calendula's ReminderDefaultPicker. * Calendula's ReminderDefaultPicker.
*/ */
@Composable @Composable
fun ReminderLeadPicker( fun ReminderLeadPicker(
title: String, title: String,
selected: ListReminderOverride, selected: ReminderOverride,
allowInherit: Boolean, allowInherit: Boolean,
allowNone: Boolean, allowNone: Boolean,
onSelect: (ListReminderOverride) -> Unit, onSelect: (ReminderOverride) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
presets: List<Int> = REMINDER_PRESETS, presets: List<Int> = REMINDER_PRESETS,
) { ) {
val selectedMinutes = (selected as? ListReminderOverride.Minutes)?.minutes val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
val customSelected = selectedMinutes != null && selectedMinutes !in presets val customSelected = selectedMinutes != null && selectedMinutes !in presets
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected }) val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
var customExpanded by rememberSaveable { mutableStateOf(false) } var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.first) } var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
var unit by rememberSaveable { mutableStateOf(seed.second) } var unit by rememberSaveable { mutableStateOf(seed.unit) }
val options = buildList { val options = buildList {
if (allowInherit) add(ListReminderOverride.Inherit) if (allowInherit) add(ReminderOverride.Inherit)
if (allowNone) add(ListReminderOverride.None) if (allowNone) add(ReminderOverride.None)
presets.forEach { add(ListReminderOverride.Minutes(it)) } presets.forEach { add(ReminderOverride.Minutes(it)) }
} }
val rowCount = options.size + 1 // + the custom row val rowCount = options.size + 1 // + the custom row
@@ -97,8 +96,8 @@ fun ReminderLeadPicker(
) )
AnimatedVisibility( AnimatedVisibility(
visible = customExpanded, visible = customExpanded,
enter = expandVertically() + fadeIn(), enter = expandEnter(),
exit = shrinkVertically() + fadeOut(), exit = collapseExit(),
) { ) {
CustomReminderEditor( CustomReminderEditor(
amountText = amountText, amountText = amountText,
@@ -106,7 +105,7 @@ fun ReminderLeadPicker(
unit = unit, unit = unit,
onUnitChange = { unit = it }, onUnitChange = { unit = it },
onConfirm = { minutes -> onConfirm = { minutes ->
onSelect(ListReminderOverride.Minutes(minutes)) onSelect(ReminderOverride.Minutes(minutes))
onDismiss() onDismiss()
}, },
) )
@@ -144,19 +143,7 @@ private fun CustomReminderEditor(
} }
} }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Surface( DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10")
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(12.dp),
) {
InlineTextField(
value = amountText,
onValueChange = { text -> if (text.length <= 3 && text.all(Char::isDigit)) onAmountChange(text) },
placeholder = "10",
textStyle = MaterialTheme.typography.titleMedium,
keyboardType = KeyboardType.Number,
modifier = Modifier.width(72.dp).padding(horizontal = 14.dp, vertical = 12.dp),
)
}
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
Text( Text(
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) } text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
@@ -178,17 +165,8 @@ private fun CustomReminderEditor(
} }
@Composable @Composable
private fun reminderOverrideLabel(override: ListReminderOverride): String = when (override) { private fun reminderOverrideLabel(override: ReminderOverride): String = when (override) {
ListReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
ListReminderOverride.None -> stringResource(R.string.reminder_none) ReminderOverride.None -> stringResource(R.string.reminder_none)
is ListReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes) is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
}
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
minutes == null || minutes <= 0 -> "" to ReminderUnit.Minutes
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
else -> minutes.toString() to ReminderUnit.Minutes
} }

View File

@@ -1,10 +1,5 @@
package de.jeanlucmakiola.agendula.ui.edit package de.jeanlucmakiola.agendula.ui.edit
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -95,6 +90,7 @@ import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionalFormSection
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionCard import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.agendula.ui.common.priorityFill import de.jeanlucmakiola.agendula.ui.common.priorityFill
@@ -545,17 +541,6 @@ private fun EditContent(
* open instead of popping in. (Initially-visible sections render without * open instead of popping in. (Initially-visible sections render without
* animating on the first frame they're added.) * animating on the first frame they're added.)
*/ */
@Composable
private fun OptionalFormSection(visible: Boolean, content: @Composable ColumnScope.() -> Unit) {
AnimatedVisibility(
visible = visible,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column(modifier = Modifier.fillMaxWidth(), content = content)
}
}
/** /**
* One info card mirroring the detail screen's card: tonal container, leading * One info card mirroring the detail screen's card: tonal container, leading
* icon in the gutter, value to the right. Optionally clickable as a whole. * icon in the gutter, value to the right. Optionally clickable as a whole.

View File

@@ -1,11 +1,10 @@
package de.jeanlucmakiola.agendula.ui.navigation package de.jeanlucmakiola.agendula.ui.navigation
import de.jeanlucmakiola.floret.identity.rememberNavSlideSpec
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.scaleOut
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -35,7 +34,6 @@ import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
@Composable @Composable
fun AgendulaNavHost(modifier: Modifier = Modifier) { fun AgendulaNavHost(modifier: Modifier = Modifier) {
val nav = rememberNavController() val nav = rememberNavController()
val slide = rememberNavSlideSpec()
NavHost( NavHost(
navController = nav, navController = nav,
@@ -43,12 +41,13 @@ fun AgendulaNavHost(modifier: Modifier = Modifier) {
modifier = modifier, modifier = modifier,
// Only the screen on top moves; the background is always held static. // Only the screen on top moves; the background is always held static.
// Forward is a plain fade-in over the held screen — calm, not a slide. // Forward is a plain fade-in over the held screen — calm, not a slide.
// Back (and the predictive-back drag, which drives popExit) is the snappy // Back is the predictive-back peek: navigation-compose composes the
// slide-out to the right, revealing the held screen unmoved. // screen being revealed underneath and seeks popExit with the gesture, so
// the leaving screen scales down to reveal the (unmoved) previous screen.
enterTransition = { fadeIn() }, enterTransition = { fadeIn() },
exitTransition = { ExitTransition.None }, exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None }, popEnterTransition = { EnterTransition.None },
popExitTransition = { slideOutHorizontally(slide) { it } + fadeOut() }, popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() },
) { ) {
composable(Dest.LISTS) { composable(Dest.LISTS) {
ListsScreen( ListsScreen(

View File

@@ -0,0 +1,36 @@
package de.jeanlucmakiola.agendula.ui.permission
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
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.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* Agendula's onboarding hero: an icon in the brand squircle. Each app keeps its
* own hero; the surrounding shell comes from floret-kit's OnboardingScaffold.
*/
@Composable
internal fun SquircleHero(icon: ImageVector) {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
}

View File

@@ -1,136 +0,0 @@
package de.jeanlucmakiola.agendula.ui.permission
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
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.vector.ImageVector
import androidx.compose.ui.unit.dp
/** MD3 8dp spacing scale shared by the onboarding screens. */
internal object OnboardingSpace {
val xs = 8.dp
val sm = 16.dp
val md = 24.dp
val lg = 32.dp
val xl = 48.dp
}
/**
* Shared onboarding shell: a scrollable, centred hero + body with the call(s)
* to action pinned to the bottom (clear of the navigation bar). The content
* slot is centred horizontally; benefit rows fill the width so their own
* content left-aligns. Ported from Calendula so the two apps read as one family.
*/
@Composable
internal fun OnboardingScaffold(
hero: @Composable () -> Unit,
actions: @Composable ColumnScope.() -> Unit,
modifier: Modifier = Modifier,
body: @Composable ColumnScope.() -> Unit,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
content = actions,
)
},
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = OnboardingSpace.md),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(OnboardingSpace.xl))
hero()
Spacer(Modifier.height(OnboardingSpace.lg))
body()
Spacer(Modifier.height(OnboardingSpace.md))
}
}
}
/** An icon in the brand squircle — the shared onboarding hero silhouette. */
@Composable
internal fun SquircleHero(icon: ImageVector) {
Box(
modifier = Modifier
.size(128.dp)
.clip(RoundedCornerShape(34.dp))
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
}
}
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
@Composable
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(OnboardingSpace.sm))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(
text = body,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}

View File

@@ -22,6 +22,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.components.BenefitRow
import de.jeanlucmakiola.floret.components.OnboardingScaffold
import de.jeanlucmakiola.floret.components.OnboardingSpace
/** /**
* One-time onboarding step after the tasks-provider grant: explains that Agendula * One-time onboarding step after the tasks-provider grant: explains that Agendula

View File

@@ -1,78 +0,0 @@
package de.jeanlucmakiola.agendula.ui.settings
import android.content.Context
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import de.jeanlucmakiola.agendula.R
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
/**
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
*
* That file is the single source of truth for which languages we ship: dropping
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
* language show up here and in the system per-app-language settings, with no
* other code change. The system-default choice is represented as `null`.
*
* On API 33+ this delegates to the platform per-app-languages API; below that
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
* service), so we don't mirror it in DataStore. Setting a locale recreates the
* activity, which re-reads the current value for the picker.
*/
object AppLanguage {
/**
* The BCP-47 tags the app ships translations for, in declaration order, as
* listed in locales_config.xml. Returns whatever could be parsed; a missing
* or malformed config yields an empty list (the picker then offers only the
* system-default entry rather than crashing).
*/
fun supportedTags(context: Context): List<String> {
val tags = mutableListOf<String>()
val parser = context.resources.getXml(R.xml.locales_config)
try {
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
}
event = parser.next()
}
} catch (_: Exception) {
// Fall back to whatever was parsed before the failure.
} finally {
parser.close()
}
return tags
}
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
fun currentTag(): String? {
val locales = AppCompatDelegate.getApplicationLocales()
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
}
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
fun apply(tag: String?) {
val locales = if (tag == null) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(tag)
}
AppCompatDelegate.setApplicationLocales(locales)
}
/**
* The autonym for a tag — the language's own name in its own script, e.g.
* "Deutsch", "English", "Français" — so users find their language regardless
* of the current UI language. Capitalised per the language's own rules.
*/
fun displayName(tag: String): String {
val locale = Locale.forLanguageTag(tag)
return locale.getDisplayName(locale)
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
}
}

View File

@@ -11,10 +11,8 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -22,7 +20,6 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -52,10 +49,8 @@ import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Percent import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
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.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -84,16 +79,21 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ListReminderOverride
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.AboutLink
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.LanguagePickerRow
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.identity.collapseExit
import de.jeanlucmakiola.floret.identity.expandEnter
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
/** The settings sub-screens reached from the hub's category rows. */ /** The settings sub-screens reached from the hub's category rows. */
@@ -163,7 +163,30 @@ private fun SettingsHub(
onOpenSection: (SettingsSection) -> Unit, onOpenSection: (SettingsSection) -> Unit,
) { ) {
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) { CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() } Box(Modifier.padding(horizontal = 16.dp)) {
AboutCard(
logo = { AppLogo() },
appName = stringResource(R.string.app_name),
author = stringResource(R.string.settings_about_author),
primaryLinks = listOf(
AboutLink(
icon = Icons.Default.Code,
label = stringResource(R.string.settings_about_source),
url = stringResource(R.string.about_source_url),
),
AboutLink(
icon = Icons.Default.Gavel,
label = stringResource(R.string.settings_license),
url = stringResource(R.string.about_license_url),
),
),
highlightLink = AboutLink(
icon = Icons.Default.Favorite,
label = stringResource(R.string.settings_about_support),
url = stringResource(R.string.about_support_url),
),
)
}
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
GroupedRow( GroupedRow(
@@ -187,46 +210,19 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) }, leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Reminders) }, onClick = { onOpenSection(SettingsSection.Reminders) },
) )
LanguageRow(position = Position.Middle) LanguagePickerRow(
position = Position.Middle,
title = stringResource(R.string.settings_language),
autoLabel = stringResource(R.string.settings_language_auto),
localesConfig = R.xml.locales_config,
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
)
ReportProblemRow(position = Position.Bottom) ReportProblemRow(position = Position.Bottom)
AppVersionText() AppVersionText()
} }
} }
/** App-language picker row; setting a locale recreates the activity. */
@Composable
private fun LanguageRow(position: Position) {
val context = LocalContext.current
// Mirror the choice locally so the row updates instantly, before recreation.
var current by remember { mutableStateOf(AppLanguage.currentTag()) }
var showDialog by remember { mutableStateOf(false) }
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
GroupedRow(
title = stringResource(R.string.settings_language),
summary = languageLabel(current),
position = position,
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
onClick = { showDialog = true },
)
if (showDialog) {
OptionPicker(
title = stringResource(R.string.settings_language),
options = options,
selected = current,
label = { languageLabel(it) },
onSelect = {
current = it
AppLanguage.apply(it)
},
onDismiss = { showDialog = false },
)
}
}
/** Opens the project's issue tracker; no data leaves the device until submitted. */ /** Opens the project's issue tracker; no data leaves the device until submitted. */
@Composable @Composable
private fun ReportProblemRow(position: Position) { private fun ReportProblemRow(position: Position) {
@@ -241,10 +237,6 @@ private fun ReportProblemRow(position: Position) {
) )
} }
@Composable
private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sub-screens // Sub-screens
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -346,7 +338,7 @@ private fun TaskFormScreen(
GroupedRow( GroupedRow(
title = stringResource(R.string.settings_add_subtask_row), title = stringResource(R.string.settings_add_subtask_row),
summary = stringResource(R.string.settings_add_subtask_row_hint), summary = stringResource(R.string.settings_add_subtask_row_hint),
position = Position.Bottom, position = Position.Middle,
trailing = { trailing = {
Switch( Switch(
checked = state.settings.showAddSubtaskRow, checked = state.settings.showAddSubtaskRow,
@@ -355,6 +347,18 @@ private fun TaskFormScreen(
}, },
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) }, onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
) )
GroupedRow(
title = stringResource(R.string.settings_bottom_add_bar),
summary = stringResource(R.string.settings_bottom_add_bar_hint),
position = Position.Bottom,
trailing = {
Switch(
checked = state.settings.bottomAddBar,
onCheckedChange = viewModel::setBottomAddBar,
)
},
onClick = { viewModel.setBottomAddBar(!state.settings.bottomAddBar) },
)
} }
if (showDefaultList) { if (showDefaultList) {
@@ -452,8 +456,8 @@ private fun RemindersScreen(
) )
AnimatedVisibility( AnimatedVisibility(
visible = perListExpanded, visible = perListExpanded,
enter = expandVertically() + fadeIn(), enter = expandEnter(),
exit = shrinkVertically() + fadeOut(), exit = collapseExit(),
) { ) {
Column { Column {
lists.forEachIndexed { index, list -> lists.forEachIndexed { index, list ->
@@ -479,10 +483,10 @@ private fun RemindersScreen(
if (showOffset) { if (showOffset) {
ReminderLeadPicker( ReminderLeadPicker(
title = stringResource(R.string.settings_default_reminder), title = stringResource(R.string.settings_default_reminder),
selected = ListReminderOverride.Minutes(state.settings.reminderLeadMinutes), selected = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
allowInherit = false, allowInherit = false,
allowNone = false, allowNone = false,
onSelect = { if (it is ListReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) }, onSelect = { if (it is ReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
onDismiss = { showOffset = false }, onDismiss = { showOffset = false },
) )
} }
@@ -500,84 +504,28 @@ private fun RemindersScreen(
} }
/** The stored override for [listId], as a picker choice (absent → inherit). */ /** The stored override for [listId], as a picker choice (absent → inherit). */
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ListReminderOverride { private fun listOverrideChoice(state: SettingsUiState, listId: Long): ReminderOverride {
val map = state.settings.perListReminderOverride val map = state.settings.perListReminderOverride
return when { return when {
!map.containsKey(listId) -> ListReminderOverride.Inherit !map.containsKey(listId) -> ReminderOverride.Inherit
map[listId] == null -> ListReminderOverride.None map[listId] == null -> ReminderOverride.None
else -> ListReminderOverride.Minutes(map.getValue(listId)!!) else -> ReminderOverride.Minutes(map.getValue(listId)!!)
} }
} }
/** Row summary for a list: its override, or the inherited global default. */ /** Row summary for a list: its override, or the inherited global default. */
@Composable @Composable
private fun listOverrideSummary(choice: ListReminderOverride, globalDefault: Int): String = when (choice) { private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): String = when (choice) {
ListReminderOverride.Inherit -> ReminderOverride.Inherit ->
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault)) stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
ListReminderOverride.None -> stringResource(R.string.reminder_none) ReminderOverride.None -> stringResource(R.string.reminder_none)
is ListReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes) is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// About + shared building blocks // About + shared building blocks
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@Composable
private fun AboutCard() {
val context = LocalContext.current
val sourceUrl = stringResource(R.string.about_source_url)
val licenseUrl = stringResource(R.string.about_license_url)
val supportUrl = stringResource(R.string.about_support_url)
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(24.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
AppLogo()
Spacer(Modifier.width(16.dp))
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge)
Text(
text = stringResource(R.string.settings_about_author),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(Modifier.height(12.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { openUrl(context, sourceUrl) },
contentPadding = PaddingValues(horizontal = 12.dp),
modifier = Modifier.weight(1f),
) {
Icon(Icons.Default.Code, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_about_source))
}
OutlinedButton(
onClick = { openUrl(context, licenseUrl) },
contentPadding = PaddingValues(horizontal = 12.dp),
modifier = Modifier.weight(1f),
) {
Icon(Icons.Default.Gavel, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_license))
}
}
Spacer(Modifier.height(8.dp))
FilledTonalButton(onClick = { openUrl(context, supportUrl) }, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Favorite, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.settings_about_support))
}
}
}
}
@Composable @Composable
private fun AppLogo() { private fun AppLogo() {
Box( Box(

View File

@@ -3,13 +3,13 @@ package de.jeanlucmakiola.agendula.ui.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.ListReminderOverride
import de.jeanlucmakiola.agendula.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.TaskFormField import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.reminders.ReminderOverride
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@@ -44,6 +44,7 @@ class SettingsViewModel @Inject constructor(
fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) } fun setReminderLeadMinutes(minutes: Int) = viewModelScope.launch { prefs.setReminderLeadMinutes(minutes) }
fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) } fun setRemindersEnabled(enabled: Boolean) = viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) } fun setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
/** Toggle whether [field] shows by default on a new task's edit form. */ /** Toggle whether [field] shows by default on a new task's edit form. */
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch { fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
@@ -51,7 +52,7 @@ class SettingsViewModel @Inject constructor(
prefs.setDefaultEditFields(if (enabled) current + field else current - field) prefs.setDefaultEditFields(if (enabled) current + field else current - field)
} }
/** Set (or clear, via [ListReminderOverride.Inherit]) a list's reminder override. */ /** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
fun setListReminderOverride(listId: Long, override: ListReminderOverride) = fun setListReminderOverride(listId: Long, override: ReminderOverride) =
viewModelScope.launch { prefs.setListReminderOverride(listId, override) } viewModelScope.launch { prefs.setListReminderOverride(listId, override) }
} }

View File

@@ -21,6 +21,12 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -39,6 +45,7 @@ import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
@@ -67,6 +74,7 @@ import kotlinx.coroutines.delay
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
@@ -88,10 +96,9 @@ import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSection import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.agendula.ui.common.ListNameChip import de.jeanlucmakiola.agendula.ui.common.priorityAccent
import de.jeanlucmakiola.floret.components.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.agendula.ui.common.PriorityChip import de.jeanlucmakiola.floret.time.formatDateTimeCompact
import de.jeanlucmakiola.floret.time.formatDateTime
import de.jeanlucmakiola.floret.components.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.floret.components.positionOf import de.jeanlucmakiola.floret.components.positionOf
import java.time.ZoneId import java.time.ZoneId
@@ -115,7 +122,13 @@ fun TaskListScreen(
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val listName = (state as? TaskListUiState.Content)?.listName val content = state as? TaskListUiState.Content
val listName = content?.listName
val listId = (filter as? TaskFilter.OfList)?.listId
// One add affordance, never two: a real list with the setting on gets a pinned
// bottom quick-add bar; everything else (incl. smart lists, which have no single
// target list) gets the floating "New task" button.
val showBottomAddBar = content?.bottomAddBar == true && listId != null
// Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip // Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip
// gives an undo window: Undo restores it, otherwise the delete commits. A // gives an undo window: Undo restores it, otherwise the delete commits. A
@@ -150,11 +163,18 @@ fun TaskListScreen(
) )
}, },
floatingActionButton = { floatingActionButton = {
if (!showBottomAddBar) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onNewTask, onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) }, icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) }, text = { Text(stringResource(R.string.new_task)) },
) )
}
},
bottomBar = {
if (showBottomAddBar) {
listId?.let { id -> QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, id) }) }
}
}, },
) { inner -> ) { inner ->
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
@@ -163,19 +183,27 @@ fun TaskListScreen(
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner) TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel) is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel)
} }
// A FAB-height band anchored at the bottom-start with the FAB's own
// margin; centring the chip in it lines the chip up beside the bottom-end
// FAB at exactly its height, rather than sitting a touch above it.
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp, bottom = inner.calculateBottomPadding() + 16.dp)
.height(56.dp),
contentAlignment = Alignment.CenterStart,
) {
UndoChip( UndoChip(
visible = undoTarget != null, visible = undoTarget != null,
onUndo = { onUndo = {
undoTarget?.let { viewModel.undoDelete(it.taskId) } undoTarget?.let { viewModel.undoDelete(it.taskId) }
undoTarget = null undoTarget = null
}, },
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = inner.calculateBottomPadding() + 24.dp),
) )
} }
} }
} }
}
/** /**
* A compact floating "snackchip" for an undoable delete — a rounded pill (not a * A compact floating "snackchip" for an undoable delete — a rounded pill (not a
@@ -251,20 +279,34 @@ private fun TaskListBody(
val childrenByParent = remember(state.tasks) { val childrenByParent = remember(state.tasks) {
state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! } state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! }
} }
// In a smart list a parent's subtasks are usually filtered out (they're undated
// or due other days), so we pull them on demand. Report only expanded parents
// whose full child set isn't already in this view; the VM then observes those
// children live. In a real list that set is empty, so no extra queries run.
val tasksById = remember(state.tasks) { state.tasks.associateBy { it.taskId } }
val parentsNeedingChildren = expandedParents.filter { id ->
val parent = tasksById[id]
parent != null && (childrenByParent[id]?.size ?: 0) != parent.subtaskTotal
}.toSet()
LaunchedEffect(parentsNeedingChildren) { viewModel.setLazyChildParents(parentsNeedingChildren) }
val lazyChildren by viewModel.lazyChildren.collectAsStateWithLifecycle()
// A parent's full child set: the rows already in this view when we hold them
// all, otherwise the lazily-loaded set fetched for smart lists.
fun childrenOf(task: Task): List<Task> {
val inList = childrenByParent[task.taskId].orEmpty()
return if (inList.size == task.subtaskTotal) inList else lazyChildren[task.taskId].orEmpty()
}
// When the bottom quick-add bar is shown it already lives in the scaffold's
// bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB.
val bottomBarShown = state.bottomAddBar && listId != null
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = inner.calculateTopPadding() + 8.dp, top = inner.calculateTopPadding() + 8.dp,
bottom = inner.calculateBottomPadding() + 96.dp, bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp,
), ),
) { ) {
if (listId != null) {
item(key = "inline-add") {
InlineAdd(onAdd = { title -> viewModel.quickAdd(title, listId) })
}
}
if (state.tasks.isEmpty()) { if (state.tasks.isEmpty()) {
item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) } item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) }
} }
@@ -295,19 +337,21 @@ private fun TaskListBody(
val rows = buildList { val rows = buildList {
val topLevel = section.tasks val topLevel = section.tasks
topLevel.forEachIndexed { p, task -> topLevel.forEachIndexed { p, task ->
val children = childrenByParent[task.taskId].orEmpty() // The full child set — already in this view for a real list,
// Only expandable when we hold every child: true in a real // or fetched on demand for a smart list (childrenOf). A parent
// list, but a smart list may omit children due on other // is expandable whenever it has children; until a smart list's
// days, and a half-shown set would mislead. // set has loaded the group simply shows nothing extra.
val canExpand = task.subtaskTotal > 0 && children.size == task.subtaskTotal val children = childrenOf(task)
val canExpand = task.subtaskTotal > 0
val isExpanded = canExpand && task.taskId in expandedParents val isExpanded = canExpand && task.taskId in expandedParents
val hasExpansionContent = children.isNotEmpty() || state.showAddSubtaskRow
// Top-level tasks keep their own connected run — full top // Top-level tasks keep their own connected run — full top
// only on the first, full bottom only on the last — so the // only on the first, full bottom only on the last — so the
// task after an expanded one stays mid-run (small top). An // task after an expanded one stays mid-run (small top). An
// expanded parent's bottom opens (small) to meet its subtasks. // expanded parent's bottom opens (small) to meet its subtasks.
val parentPosition = cornerPosition( val parentPosition = cornerPosition(
topFull = p == 0, topFull = p == 0,
bottomFull = if (isExpanded) false else p == topLevel.lastIndex, bottomFull = if (isExpanded && hasExpansionContent) false else p == topLevel.lastIndex,
) )
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition)) add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
if (isExpanded) { if (isExpanded) {
@@ -331,8 +375,13 @@ private fun TaskListBody(
} }
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r -> itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
val lastInRun = index == rows.lastIndex val lastInRun = index == rows.lastIndex
// animateItem so an (un)expanding group fades its subtasks in/out
// and slides the rows below — clean whether the children were
// already loaded (real list) or arrive a frame later (smart list).
val rowModifier = Modifier.animateItem()
when (val row = r.row) { when (val row = r.row) {
is ListRow.Parent -> TaskRow( is ListRow.Parent -> TaskRow(
modifier = rowModifier,
task = row.task, task = row.task,
position = r.position, position = r.position,
lastInRun = lastInRun, lastInRun = lastInRun,
@@ -356,6 +405,7 @@ private fun TaskListBody(
onClick = { onOpenTask(row.task.taskId) }, onClick = { onOpenTask(row.task.taskId) },
) )
is ListRow.Sub -> SubtaskRow( is ListRow.Sub -> SubtaskRow(
modifier = rowModifier,
task = row.task, task = row.task,
position = r.position, position = r.position,
lastInRun = lastInRun, lastInRun = lastInRun,
@@ -363,6 +413,7 @@ private fun TaskListBody(
onClick = { onOpenTask(row.task.taskId) }, onClick = { onOpenTask(row.task.taskId) },
) )
is ListRow.AddSub -> AddSubtaskRow( is ListRow.AddSub -> AddSubtaskRow(
modifier = rowModifier,
position = r.position, position = r.position,
lastInRun = lastInRun, lastInRun = lastInRun,
onAdd = { viewModel.quickAddSubtask(row.parent, it) }, onAdd = { viewModel.quickAddSubtask(row.parent, it) },
@@ -375,9 +426,9 @@ private fun TaskListBody(
} }
/** Inline "add a task" — title only, into the current list. */ /** Inline "add a task" — title only, into the current list. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
private fun InlineAdd(onAdd: (String) -> Unit) { private fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
fun submit() { fun submit() {
if (text.isNotBlank()) { if (text.isNotBlank()) {
@@ -385,10 +436,25 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
text = "" text = ""
} }
} }
// Compose keeps a TextField focused (cursor blinking) even after the keyboard
// is dismissed by a tap outside it — so when the IME hides, drop focus too.
val focusManager = LocalFocusManager.current
val imeVisible = WindowInsets.isImeVisible
LaunchedEffect(imeVisible) {
if (!imeVisible) focusManager.clearFocus()
}
Surface( Surface(
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh, // Floating use (bottom quick-add) steps up to the brightest container tone
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), // — a real step above the surfaceContainerHigh task cards — and lifts off
// the list with a heavier shadow, so it doesn't blend into the rows.
color = if (elevated) {
MaterialTheme.colorScheme.surfaceContainerHighest
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
},
shadowElevation = if (elevated) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
) { ) {
TextField( TextField(
value = text, value = text,
@@ -416,6 +482,23 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
} }
} }
/**
* The bottom quick-add field: [InlineAdd] pinned as the scaffold's bottomBar, but
* with no bar plate of its own — the rounded field floats on the screen background
* (its own shadow lifting it off the list). Rides above the keyboard and clears the
* navigation bar (ime navigation-bar insets).
*/
@Composable
private fun QuickAddBar(onAdd: (String) -> Unit) {
Box(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)),
) {
InlineAdd(onAdd = onAdd, elevated = true)
}
}
/** /**
* One task row: a tonal grouped-card surface (corners from [position], morphing * One task row: a tonal grouped-card surface (corners from [position], morphing
* rounder on press — the app's shape family) wrapped in a swipe box. Swiping * rounder on press — the app's shape family) wrapped in a swipe box. Swiping
@@ -435,6 +518,7 @@ private fun TaskRow(
onToggle: () -> Unit, onToggle: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val dismissState = rememberSwipeToDismissBoxState( val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value -> confirmValueChange = { value ->
@@ -455,7 +539,7 @@ private fun TaskRow(
SwipeToDismissBox( SwipeToDismissBox(
state = dismissState, state = dismissState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
// dismissDirection (not targetValue) tracks the live drag, so the colour + // dismissDirection (not targetValue) tracks the live drag, so the colour +
// icon reveal as you swipe rather than only once the threshold is crossed. // icon reveal as you swipe rather than only once the threshold is crossed.
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) }, backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
@@ -482,10 +566,11 @@ private fun TaskRowContent(
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner") val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner") val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val due = task.due?.formatDateTime(task.isAllDay) val due = task.due?.formatDateTimeCompact(task.isAllDay)
val listName = task.listName?.takeIf { showListName && it.isNotBlank() } val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
val hasSupporting = due != null || listName != null || subtaskTotal > 0 || // Everything secondary collapses into one quiet supporting line below the title.
task.priority != Priority.NONE val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 ||
listName != null
Surface( Surface(
onClick = onClick, onClick = onClick,
@@ -526,30 +611,18 @@ private fun TaskRowContent(
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
}, },
) )
if (hasSupporting) { if (hasMeta) {
Spacer(Modifier.height(3.dp)) Spacer(Modifier.height(3.dp))
// Flow so the chips wrap to a further line when due date + list TaskMetaLine(
// + subtask progress can't share one (e.g. a dated task with priority = task.priority,
// subtasks), rather than squeezing the trailing chip. due = due,
FlowRow( subtaskDone = subtaskDone,
verticalArrangement = Arrangement.spacedBy(3.dp), subtaskTotal = subtaskTotal,
horizontalArrangement = Arrangement.spacedBy(8.dp), listName = listName,
itemVerticalAlignment = Alignment.CenterVertically, listColor = task.effectiveColor,
) { dark = dark,
if (task.priority != Priority.NONE) {
PriorityChip(task.priority, priorityLabel(task.priority))
}
if (due != null) {
Text(
text = due,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
if (listName != null) ListNameChip(listName, task.effectiveColor)
if (subtaskTotal > 0) SubtaskCountChip(subtaskDone, subtaskTotal)
}
}
} }
// Trailing area is only ever the expand control, so the row's right // Trailing area is only ever the expand control, so the row's right
// edge stays put whether or not the task has children (priority now // edge stays put whether or not the task has children (priority now
@@ -561,32 +634,86 @@ private fun TaskRowContent(
} }
} }
/** A small neutral pill showing subtask progress, e.g. "3 / 5". */ /**
* The single low-emphasis supporting line under a task title: priority, due date,
* subtask progress and (in mixed views) the list name, joined by middots. The text
* is quiet `onSurfaceVariant` throughout; the only colour is the priority flag and
* the list name (tinted to its list) — so even a fully-populated row stays calm and
* one line instead of a stack of filled pills.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
private fun SubtaskCountChip(done: Int, total: Int) { private fun TaskMetaLine(
Surface( priority: Priority,
color = MaterialTheme.colorScheme.surfaceContainerHighest, due: String?,
shape = RoundedCornerShape(50), subtaskDone: Int,
subtaskTotal: Int,
listName: String?,
listColor: Int,
dark: Boolean,
) { ) {
val muted = MaterialTheme.colorScheme.onSurfaceVariant
val style = MaterialTheme.typography.bodySmall
val segments = buildList<@Composable () -> Unit> {
if (priority != Priority.NONE) {
add {
Row( Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(3.dp),
) { ) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Rounded.ListAlt, Icons.Rounded.Flag,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = priorityAccent(priority, dark),
modifier = Modifier.size(14.dp), modifier = Modifier.size(13.dp),
) )
Text(priorityLabel(priority), style = style, color = muted)
}
}
}
if (due != null) add { Text(due, style = style, color = muted) }
if (subtaskTotal > 0) {
add {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
Icon(
Icons.AutoMirrored.Rounded.ListAlt,
contentDescription = null,
tint = muted,
modifier = Modifier.size(13.dp),
)
Text("$subtaskDone / $subtaskTotal", style = style, color = muted)
}
}
}
if (listName != null) {
add {
Text( Text(
text = "$done / $total", listName,
style = MaterialTheme.typography.labelSmall, style = style,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = pastelize(listColor, dark),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
} }
} }
FlowRow(
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
segments.forEachIndexed { i, segment ->
// A middot between segments — its own flow item, so the line can still
// wrap gracefully when truly everything is set on a narrow screen.
if (i > 0) {
Text("·", style = style, color = muted, modifier = Modifier.padding(horizontal = 6.dp))
}
segment()
}
}
}
/** /**
* The inline "add a subtask" row that closes an expanded group — a [BasicTextField] * The inline "add a subtask" row that closes an expanded group — a [BasicTextField]
@@ -599,6 +726,7 @@ private fun AddSubtaskRow(
position: Position, position: Position,
lastInRun: Boolean, lastInRun: Boolean,
onAdd: (String) -> Unit, onAdd: (String) -> Unit,
modifier: Modifier = Modifier,
) { ) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
fun submit() { fun submit() {
@@ -612,7 +740,7 @@ private fun AddSubtaskRow(
shape = cardShape(position, 22.dp, 6.dp), shape = cardShape(position, 22.dp, 6.dp),
// Same tone as the subtask rows it closes, so the group reads as one unit. // Same tone as the subtask rows it closes, so the group reads as one unit.
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
@@ -725,6 +853,7 @@ private fun SubtaskRow(
lastInRun: Boolean, lastInRun: Boolean,
onToggle: () -> Unit, onToggle: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val interaction = remember { MutableInteractionSource() } val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState() val pressed by interaction.collectIsPressedAsState()
@@ -738,7 +867,7 @@ private fun SubtaskRow(
// reads as a distinct, recessive tone — the nesting cue alongside shape. // reads as a distinct, recessive tone — the nesting cue alongside shape.
color = MaterialTheme.colorScheme.surfaceContainer, color = MaterialTheme.colorScheme.surfaceContainer,
interactionSource = interaction, interactionSource = interaction,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap), modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier

View File

@@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -36,6 +37,8 @@ sealed interface TaskListUiState {
val listName: String? = null, val listName: String? = null,
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */ /** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
val showAddSubtaskRow: Boolean = true, val showAddSubtaskRow: Boolean = true,
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
val bottomAddBar: Boolean = false,
) : TaskListUiState ) : TaskListUiState
} }
@@ -70,13 +73,14 @@ class TaskListViewModel @Inject constructor(
} }
// Drop rows pending an undoable delete so the row vanishes on swipe // Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit, // while the actual provider delete waits for the snackbar to commit,
// and fold in the live "show add-subtask row" setting. // and fold in the live UI settings (add-subtask row, bottom add bar).
val showAddSub = settingsPrefs.settings.map { it.showAddSubtaskRow } val uiPrefs = settingsPrefs.settings.map { it.showAddSubtaskRow to it.bottomAddBar }
combine(content, pendingDeletes, showAddSub) { st, pending, showRow -> combine(content, pendingDeletes, uiPrefs) { st, pending, (showRow, bottomBar) ->
if (st is TaskListUiState.Content) { if (st is TaskListUiState.Content) {
st.copy( st.copy(
tasks = st.tasks.filter { it.taskId !in pending }, tasks = st.tasks.filter { it.taskId !in pending },
showAddSubtaskRow = showRow, showAddSubtaskRow = showRow,
bottomAddBar = bottomBar,
) )
} else { } else {
st st
@@ -87,6 +91,32 @@ class TaskListViewModel @Inject constructor(
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
/**
* Parents the screen has expanded whose children it can't pull from the list
* itself — i.e. smart lists, where the date filter drops a parent's subtasks.
* For each we observe the full child set live (below), so the group can expand.
*/
private val lazyChildParents = MutableStateFlow<Set<Long>>(emptySet())
/**
* The on-demand child sets, keyed by parent id, kept live. Empty for real
* lists (the screen reports no parents there, since it already holds every
* child) so no redundant queries run.
*/
val lazyChildren: StateFlow<Map<Long, List<Task>>> =
lazyChildParents
.flatMapLatest { ids ->
if (ids.isEmpty()) {
flowOf(emptyMap())
} else {
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
/** The screen reports which expanded parents need their children fetched. */
fun setLazyChildParents(ids: Set<Long>) { lazyChildParents.value = ids }
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter } fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
fun toggleComplete(task: Task) = viewModelScope.launch { fun toggleComplete(task: Task) = viewModelScope.launch {

View File

@@ -198,6 +198,8 @@
<string name="settings_default_list_first">First available list</string> <string name="settings_default_list_first">First available list</string>
<string name="settings_add_subtask_row">Show \"add a subtask\" row</string> <string name="settings_add_subtask_row">Show \"add a subtask\" row</string>
<string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string> <string name="settings_add_subtask_row_hint">An add field at the end of an expanded task\'s subtasks</string>
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
<!-- Reminder lead times (custom amounts) --> <!-- Reminder lead times (custom amounts) -->
<plurals name="reminder_minutes"> <plurals name="reminder_minutes">