Compare commits

..

18 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
e4c8dbf2c1 build: drop the foojay toolchain resolver (reproducible-build safe)
All checks were successful
CI / ci (push) Successful in 8m7s
The Gradle Java-toolchain auto-download resolver can fetch a JDK at build time,
which an offline / reproducible F-Droid build scanner rejects. It was unused —
modules set jvmTarget directly with no toolchain block — so removing it is a
no-op for the build and brings Agendula in line with floret-kit's no-resolver
rule. Clean assembleDebug verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:34:55 +02:00
069ae38b2c components batch-2: draw CollapsingScaffold + OptionPicker from floret-kit
All checks were successful
CI / ci (push) Successful in 8m37s
Agendula now sources the collapsing settings/sub-screen scaffold and the
full-screen single-select picker (FullScreenPicker/OptionPicker) from the kit's
components module. Deletes the app copies; repoints SettingsScreen imports and
adds the FullScreenPicker import in ReminderLeadPicker. Submodule re-pinned.
ReminderFormatting stays app-local (needs a label-callback API — separate pass).

Clean composite build + tests + lintDebug + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:25:53 +02:00
a5ddf16537 components: draw the shared Compose vocabulary from floret-kit
Some checks failed
CI / ci (push) Has been cancelled
Phase 4 (components). Agendula now sources its grouped-row primitive
(GroupedSurface/GroupedRow + Position/positionOf), InlineTextField, OptionCard,
and pastelize() from the kit's components module instead of its own ui/common
copies. These were Agendula's versions, taken as the canonical family reference
(design sign-off); Agendula keeps its own identity chips (ListColorChip/
ListNameChip task glyph, PriorityChip) which now import the shared pastelize.

- Delete app copies of GroupedList/InlineTextField/OptionCard; drop the local
  pastelize from ListChip. Repoint all imports (cross-package + same-package +
  one inline FQN) to de.jeanlucmakiola.floret.components. Submodule re-pinned.

Clean composite build + tests + lintDebug + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:09:37 +02:00
18e03e27b6 identity: draw the M3 Expressive theme factory + nav slide from floret-kit
All checks were successful
CI / ci (push) Successful in 7m0s
Phase 3 (identity layer). AgendulaTheme is now a thin wrapper over the kit's
FloretExpressiveTheme(lightScheme, darkScheme, …): the shared mechanics
(dynamic colour, light/dark, standard motion scheme) live in floret-kit, while
Agendula keeps its own identity — the seed-derived AgendulaLight/DarkFallback
schemes and AgendulaTypography (unchanged in Color.kt/Type.kt). rememberNavSlideSpec
now comes from the kit; the app's AgendulaTransitions.kt is removed and the
NavHost import repointed. Submodule re-pinned to the identity kit commit.

Clean composite build + tests + lintDebug + debug assemble green; substitution
confirmed (de.jeanlucmakiola.floret:identity -> project :floret-kit:identity).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:59:56 +02:00
152226c1b2 Phase 2: core-crash from floret-kit; complete the core-time wiring
All checks were successful
CI / ci (push) Successful in 9m16s
Adds crash reporting to Agendula by drawing the kit's new core-crash module
(first Android-library module in floret-kit), and finishes the core-time wiring
that a Phase 0 staging slip left uncommitted on main.

core-crash (NEW capability — Agendula had none):
- CrashReporter.install() in AgendulaApp captures uncaught exceptions on-device
  (allowlist-only report, chains to the platform handler, uploads nothing).
- MainActivity routes to a standalone CrashReportActivity on a startup crash-loop,
  and surfaces a single captured crash as a dialog on next launch; markHealthy on
  resume. App label + issue-tracker URL flow in via CrashConfig.
- Thin app-side CrashReportActivity uses AgendulaTheme; the reusable machinery
  (reporter, dialog, submit, config) lives in the kit. Submodule re-pinned to the
  core-crash kit commit.

Completes Phase 0 (was only partially committed in ec7b696):
- Repoint DayWindow + Instant formatting imports to de.jeanlucmakiola.floret.time
  (the app copies were deleted in ec7b696 but the imports/includeBuild/dependency
  were never staged, leaving main non-building).
- settings.gradle.kts includeBuild("floret-kit"); app depends on core-time.

Also fixes the CI checkout YAML: the submodules block was mis-indented under
'uses:' (invalid step mapping) — dedented to a proper sibling.

Clean composite build + unit tests + lintDebug + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 12:24:49 +02:00
ec7b696eb9 Draw core-time from floret-kit via submodule + composite build
Some checks failed
CI / ci (push) Failing after 2m12s
Phase 0 of the shared-kit plan: floret-kit is embedded as a git submodule and
wired in with Gradle includeBuild("floret-kit"), so the app depends on
de.jeanlucmakiola.floret:core-time and Gradle substitutes the local source
module (built from source — F-Droid-safe). Proves the submodule + composite-build
pipeline before any design layers move.

- Remove app copies of DayWindow + Instant date/time formatting (now in
  core-time); repoint imports to de.jeanlucmakiola.floret.time.
- DateTimeField gains an explicit import (was same-package).
- CI checkouts fetch submodules recursively.
- Clean composite build + unit tests + debug assemble green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:27:49 +02:00
43 changed files with 646 additions and 1187 deletions

View File

@@ -21,6 +21,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v4

View File

@@ -15,6 +15,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v4
@@ -73,6 +75,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v4
@@ -322,6 +326,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
submodules: recursive
- name: Extract changelog section for this tag - name: Extract changelog section for this tag
run: | run: |

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "floret-kit"]
path = floret-kit
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git

View File

@@ -120,6 +120,12 @@ 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-reminders")
implementation("de.jeanlucmakiola.floret:core-locale")
implementation("de.jeanlucmakiola.floret:core-crash")
implementation("de.jeanlucmakiola.floret:identity")
implementation("de.jeanlucmakiola.floret:components")
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.ui.test.manifest)

View File

@@ -52,6 +52,14 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Standalone crash-report surface; MainActivity routes here on a
startup crash-loop. Not exported, kept out of recents. -->
<activity
android:name=".ui.crash.CrashReportActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask" />
<!-- Reminder alarm fires here (internal PendingIntent → not exported). --> <!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
<receiver <receiver
android:name=".data.reminders.DueReminderReceiver" android:name=".data.reminders.DueReminderReceiver"

View File

@@ -7,6 +7,8 @@ import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -22,6 +24,17 @@ class AgendulaApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Install first thing so startup crashes are captured too (privacy-
// respecting, on-device; the user submits the report by hand).
CrashReporter.install(
this,
CrashConfig(
appLabel = getString(R.string.app_name),
newIssueUrl = getString(R.string.report_issue_url),
chooseIssueUrl = getString(R.string.report_issue_url),
issueTitle = getString(R.string.crash_report_issue_title),
),
)
val scheduler = EntryPointAccessors val scheduler = EntryPointAccessors
.fromApplication(this, ReminderEntryPoint::class.java) .fromApplication(this, ReminderEntryPoint::class.java)
.reminderScheduler() .reminderScheduler()

View File

@@ -9,6 +9,8 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -17,8 +19,12 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.agendula.data.demo.DemoSeeder import de.jeanlucmakiola.agendula.data.demo.DemoSeeder
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.ui.RootScreen import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -32,10 +38,28 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var demoSeeder: DemoSeeder @Inject lateinit var demoSeeder: DemoSeeder
// A captured crash report awaiting the user's decision, surfaced as a dialog
// over the app on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below.
private var pendingCrashReport by mutableStateOf<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// If the app keeps crashing as it starts, the main UI can't be trusted
// to come up. Route to the standalone report screen instead of
// re-entering the crashing graph.
if (CrashReporter.isCrashLoop(this)) {
startActivity(Intent(this, CrashReportActivity::class.java))
finish()
return
}
enableEdgeToEdge() enableEdgeToEdge()
// Surface a single captured crash as a dialog on the next launch.
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a // Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
// local (non-syncing) demo list once; no-op without the extra. // local (non-syncing) demo list once; no-op without the extra.
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) { if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
@@ -51,9 +75,30 @@ class MainActivity : ComponentActivity() {
} }
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) { AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
RootScreen(modifier = Modifier.fillMaxSize()) RootScreen(modifier = Modifier.fillMaxSize())
pendingCrashReport?.let { report ->
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this@MainActivity, report)
CrashReporter.clearReport(this@MainActivity)
pendingCrashReport = null
},
onDismiss = {
CrashReporter.dismissPrompt(this@MainActivity)
pendingCrashReport = null
},
)
} }
} }
} }
}
override fun onResume() {
super.onResume()
// A successful start breaks any loop; reset the timing trail so a later
// ordinary crash isn't mistaken for a loop.
CrashReporter.markHealthy(this)
}
companion object { companion object {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID" const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"

View File

@@ -2,7 +2,7 @@ package de.jeanlucmakiola.agendula.data.demo
import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher

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

@@ -1,7 +1,7 @@
package de.jeanlucmakiola.agendula.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.agendula.domain.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
@@ -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

@@ -1,18 +0,0 @@
package de.jeanlucmakiola.agendula.domain
import java.time.ZoneId
import kotlin.time.Instant
/** Local-day boundaries used by the smart-list predicates. Pure + testable. */
object DayWindow {
/** Returns `[startOfToday, startOfTomorrow)` in [zone] for the instant [now]. */
fun today(now: Instant, zone: ZoneId): Pair<Instant, Instant> {
val date = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds())
.atZone(zone)
.toLocalDate()
val start = date.atStartOfDay(zone).toInstant().toEpochMilli()
val end = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
return Instant.fromEpochMilliseconds(start) to Instant.fromEpochMilliseconds(end)
}
}

View File

@@ -1,84 +0,0 @@
package de.jeanlucmakiola.agendula.ui.common
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
import de.jeanlucmakiola.agendula.R
/**
* Shared collapsing scaffold for the settings hub, its sub-screens and the
* full-screen pickers: a [LargeTopAppBar] that collapses on scroll, a back
* button, and a vertically scrolling content column. Ported from Calendula so
* the two apps' settings read as one family. The content lays out the connected
* grouped rows.
*/
@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,
)
}
}

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.ui.common package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.time.formatDateTime
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

View File

@@ -1,28 +0,0 @@
package de.jeanlucmakiola.agendula.ui.common
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.time.Instant
/**
* Locale-aware display formatting for the [kotlin.time.Instant]s the domain
* uses. Kept tiny and dependency-free; the data layer stores instants, the UI
* renders them in the device's zone and locale.
*/
private val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
private fun Instant.atSystemZone() =
java.time.Instant.ofEpochMilli(toEpochMilliseconds())
.atZone(ZoneId.systemDefault())
/** Medium localized date, e.g. "18 Jun 2026". */
fun Instant.formatDate(): String = atSystemZone().format(dateFormatter)
/** Short localized time, e.g. "14:30". */
fun Instant.formatTime(): String = atSystemZone().format(timeFormatter)
/** Date alone for all-day items, otherwise date + time. */
fun Instant.formatDateTime(allDay: Boolean): String =
if (allDay) formatDate() else "${formatDate()} · ${formatTime()}"

View File

@@ -1,130 +0,0 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Position of a row within a grouped list (the Android-15 settings pattern): a
* run of rows shares one rounded container, full corners at the group's outer
* edges and small corners between, separated by small gaps. Copied from
* Calendula so the two apps read as one family.
*/
enum class Position { Top, Middle, Bottom, Alone }
fun positionOf(index: Int, count: Int): Position = when {
count <= 1 -> Position.Alone
index == 0 -> Position.Top
index == count - 1 -> Position.Bottom
else -> Position.Middle
}
/** Corner radii for a grouped segment: full at the group's outer edges, small between. */
fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) {
Position.Alone -> RoundedCornerShape(full)
Position.Top -> RoundedCornerShape(topStart = full, topEnd = full, bottomStart = small, bottomEnd = small)
Position.Middle -> RoundedCornerShape(small)
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
}
/**
* One segment of a grouped list: a tonal [Surface] whose corner radii come from
* [position] (so a run reads as a single rounded card), with a small gap below
* all but the last. Corners morph rounder on press — the expressive shape play.
* The caller supplies [content] and any horizontal inset via [modifier], so the
* same primitive serves both the 16dp-inset lists and full-width detail cards.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupedSurface(
position: Position,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
content: @Composable () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
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 shape = groupedShape(position, full, small)
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
val base = modifier.fillMaxWidth().then(gap)
if (onClick != null) {
Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() }
} else {
Surface(color = color, shape = shape, modifier = base) { content() }
}
}
/**
* One row in a grouped list: an M3 [ListItem] over a [GroupedSurface] (the
* tonal, position-shaped, press-morphing family container). Insets 16dp like
* the lists overview.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GroupedRow(
title: String,
position: Position,
modifier: Modifier = Modifier,
summary: String? = null,
selected: Boolean = false,
minHeight: Dp = 72.dp,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
) {
val itemColors = if (selected) {
ListItemDefaults.colors(
containerColor = Color.Transparent,
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else {
ListItemDefaults.colors(containerColor = Color.Transparent)
}
val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
GroupedSurface(
position = position,
modifier = modifier.padding(horizontal = 16.dp),
onClick = onClick,
color = containerColor,
) {
ListItem(
headlineContent = { Text(title) },
supportingContent = summary?.let { text -> { Text(text) } },
leadingContent = leading,
trailingContent = trailing,
colors = itemColors,
modifier = Modifier.heightIn(min = minHeight),
)
}
}

View File

@@ -1,83 +0,0 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
/**
* The app's borderless text input: no underline, no outline, just the tonal
* card behind it. Agendula uses this inside a tonal [androidx.compose.material3.Surface]
* for the edit form (title + cards), so anything that takes text reads as one
* family rather than a boxed Material field. Ported from Calendula so the two
* apps share one input style.
*/
@Composable
fun InlineTextField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true,
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
imeAction: ImeAction = ImeAction.Default,
/** Invoked when the IME action key (e.g. Done) is pressed. */
onImeAction: (() -> Unit)? = null,
) {
val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) {
textStyle.color
} else {
MaterialTheme.colorScheme.onSurface
},
)
BasicTextField(
value = value,
onValueChange = onValueChange,
textStyle = resolvedStyle,
singleLine = singleLine,
minLines = minLines,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
capitalization = capitalization,
imeAction = imeAction,
),
keyboardActions = onImeAction?.let { action ->
KeyboardActions(onAny = { action() })
} ?: KeyboardActions.Default,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box {
if (value.isEmpty()) {
// Clearly fainter than typed text, so a hint never reads as
// prefilled content.
Text(
text = placeholder,
style = resolvedStyle,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
innerTextField()
}
},
modifier = modifier,
)
}

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.ui.common package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.components.pastelize
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -22,20 +23,6 @@ import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* Soften a raw provider colour toward a pastel that fits the active theme —
* keeps the hue (lists stay recognisable), caps saturation so harsh sync
* colours stop screaming, pins brightness to read on light and dark. Copied
* from Calendula so the families' colours behave identically.
*/
fun pastelize(rawArgb: Int, dark: Boolean): Color {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(rawArgb, hsv)
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
hsv[2] = if (dark) 0.82f else 0.72f
return Color(android.graphics.Color.HSVToColor(hsv))
}
/** /**
* Leading avatar for a task list: a neutral round chip holding a checklist glyph * Leading avatar for a task list: a neutral round chip holding a checklist glyph
* tinted in the list's (pastelised) colour — the Calendula calendar-chip pattern, * tinted in the list's (pastelised) colour — the Calendula calendar-chip pattern,

View File

@@ -1,95 +0,0 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* The app's standard pick in a selection dialog: a full-width tonal card,
* optionally with a leading icon and a supporting line; the selected option is
* highlighted. Stack with 8dp gaps inside an AlertDialog — the only sanctioned
* selection-modal style (no radio rows, no bare text lists). Ported from
* Calendula so the families' dialogs read identically.
*/
@Composable
fun OptionCard(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
/** Icon tint override, e.g. a list colour; unspecified follows selection. */
iconTint: Color = Color.Unspecified,
supportingText: String? = null,
selected: Boolean = false,
/** Label colour override, e.g. primary for an emphasised entry. */
labelColor: Color = Color.Unspecified,
) {
val contentColor = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Surface(
onClick = onClick,
color = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
},
shape = RoundedCornerShape(12.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = when {
iconTint.isSpecified -> iconTint
selected -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(12.dp))
}
Column {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (labelColor.isSpecified) labelColor else contentColor,
)
if (supportingText != null) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}

View File

@@ -1,79 +0,0 @@
package de.jeanlucmakiola.agendula.ui.common
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
/**
* Shared 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]. Ported from Calendula.
*/
@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 <T> OptionPicker(
title: String,
options: List<T>,
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,
)
}

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.agendula.ui.common package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.components.pastelize
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -44,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
@@ -90,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

@@ -1,10 +1,16 @@
package de.jeanlucmakiola.agendula.ui.common package de.jeanlucmakiola.agendula.ui.common
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.components.DialogAmountField
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
@@ -28,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
@@ -92,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,
@@ -101,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()
}, },
) )
@@ -139,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) }
@@ -173,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

@@ -0,0 +1,61 @@
package de.jeanlucmakiola.agendula.ui.crash
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import de.jeanlucmakiola.floret.crash.CrashReportDialog
import de.jeanlucmakiola.floret.crash.CrashReporter
import de.jeanlucmakiola.floret.crash.submitCrashReport
/**
* Standalone surface for a captured crash report. [MainActivity] routes here
* when it detects a startup crash-loop (see [CrashReporter.isCrashLoop]): the
* main UI can't be trusted to start, so this screen stays clear of the app's
* Hilt graph, DataStore-backed theme and Compose content — it only reads the
* report file and shows the kit's report dialog. Plain [AgendulaTheme] defaults
* (follow-system, dynamic colour) avoid touching anything that might be the
* cause of the crash. The reusable machinery lives in floret-kit's core-crash.
*/
class CrashReportActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val report = CrashReporter.pendingReport(this)
if (report == null) {
finish()
return
}
enableEdgeToEdge()
setContent {
AgendulaTheme {
// Opaque backdrop so the dialog doesn't float over a bare task.
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
CrashReportDialog(
report = report,
onSend = {
submitCrashReport(this, report)
CrashReporter.clearReport(this)
finish()
},
onDismiss = {
CrashReporter.clearReport(this)
finish()
},
)
}
}
}
override fun onResume() {
super.onResume()
// Reaching this screen breaks the loop; reset the timing trail so a
// later ordinary crash isn't mistaken for a loop.
CrashReporter.markHealthy(this)
}
}

View File

@@ -70,12 +70,12 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.ui.common.GroupedSurface import de.jeanlucmakiola.floret.components.GroupedSurface
import de.jeanlucmakiola.agendula.ui.common.PriorityChip import de.jeanlucmakiola.agendula.ui.common.PriorityChip
import de.jeanlucmakiola.agendula.ui.common.formatDate import de.jeanlucmakiola.floret.time.formatDate
import de.jeanlucmakiola.agendula.ui.common.formatTime import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.agendula.ui.common.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.agendula.ui.common.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
/** /**

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
@@ -86,7 +81,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFormError import de.jeanlucmakiola.agendula.domain.TaskFormError
@@ -94,15 +89,16 @@ import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList 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.agendula.ui.common.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.agendula.ui.common.InlineTextField import de.jeanlucmakiola.floret.components.OptionalFormSection
import de.jeanlucmakiola.agendula.ui.common.OptionCard import de.jeanlucmakiola.floret.components.InlineTextField
import de.jeanlucmakiola.floret.components.OptionCard
import de.jeanlucmakiola.agendula.ui.common.priorityFill import de.jeanlucmakiola.agendula.ui.common.priorityFill
import de.jeanlucmakiola.agendula.ui.common.formatDate import de.jeanlucmakiola.floret.time.formatDate
import de.jeanlucmakiola.agendula.ui.common.formatTime import de.jeanlucmakiola.floret.time.formatTime
import de.jeanlucmakiola.agendula.ui.common.localToInstant import de.jeanlucmakiola.agendula.ui.common.localToInstant
import de.jeanlucmakiola.agendula.ui.common.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.agendula.ui.common.positionOf import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.agendula.ui.common.toLocalDate import de.jeanlucmakiola.agendula.ui.common.toLocalDate
import de.jeanlucmakiola.agendula.ui.common.toLocalTime import de.jeanlucmakiola.agendula.ui.common.toLocalTime
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
@@ -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.
@@ -853,7 +838,7 @@ private fun ParentPickerSheet(
item(key = "none") { item(key = "none") {
GroupedRow( GroupedRow(
title = stringResource(R.string.edit_parent_none), title = stringResource(R.string.edit_parent_none),
position = de.jeanlucmakiola.agendula.ui.common.Position.Alone, position = de.jeanlucmakiola.floret.components.Position.Alone,
selected = selectedId == null, selected = selectedId == null,
minHeight = 56.dp, minHeight = 56.dp,
onClick = { choose(null) }, onClick = { choose(null) },

View File

@@ -49,10 +49,10 @@ import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.common.ActionShapes import de.jeanlucmakiola.agendula.ui.common.ActionShapes
import de.jeanlucmakiola.agendula.ui.common.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.agendula.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
import de.jeanlucmakiola.agendula.ui.common.positionOf import de.jeanlucmakiola.floret.components.positionOf
/** /**
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live * Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live

View File

@@ -4,7 +4,7 @@ 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.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter

View File

@@ -4,7 +4,7 @@ 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
@@ -34,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,
@@ -42,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

@@ -1,21 +0,0 @@
package de.jeanlucmakiola.agendula.ui.navigation
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.IntOffset
/**
* The horizontal slide spec for screen-to-screen navigation: the *fast*
* spring-physics spec from the active motion scheme — snappy with a subtle
* springy settle, rather than a fixed easing curve. Same choice as Calendula's
* calendar slide, so the two apps move the same way.
*
* Read in composable scope so the non-composable `NavHost` transition lambdas
* can capture it.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun rememberNavSlideSpec(): FiniteAnimationSpec<IntOffset> =
MaterialTheme.motionScheme.fastSpatialSpec()

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.agendula.ui.common.CollapsingScaffold import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.agendula.ui.common.GroupedRow import de.jeanlucmakiola.floret.components.AboutLink
import de.jeanlucmakiola.agendula.ui.common.OptionPicker import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.agendula.ui.common.Position import de.jeanlucmakiola.floret.components.LanguagePickerRow
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.OptionPicker
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
import de.jeanlucmakiola.agendula.ui.common.pastelize import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.agendula.ui.common.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
@@ -81,19 +89,18 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.DayWindow import de.jeanlucmakiola.floret.time.DayWindow
import de.jeanlucmakiola.agendula.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task 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.agendula.ui.common.Position import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.agendula.ui.common.PriorityChip import de.jeanlucmakiola.floret.time.formatDateTimeCompact
import de.jeanlucmakiola.agendula.ui.common.formatDateTime import de.jeanlucmakiola.floret.components.pastelize
import de.jeanlucmakiola.agendula.ui.common.pastelize import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.agendula.ui.common.positionOf
import java.time.ZoneId import java.time.ZoneId
import kotlin.time.Clock import kotlin.time.Clock
@@ -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,18 +183,26 @@ 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),
) )
} }
} }
}
} }
/** /**
@@ -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,31 +634,85 @@ 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()
}
}
} }
/** /**
@@ -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

@@ -1,48 +1,28 @@
package de.jeanlucmakiola.agendula.ui.theme package de.jeanlucmakiola.agendula.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
/** /**
* App theme. Honors: * Agendula's theme: the family's [FloretExpressiveTheme] machinery (dynamic
* - System light/dark. * colour on API 31+, system light/dark, the standard motion scheme) fed with
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme * Agendula's own identity — its seed-derived fallback schemes
* derived from [AgendulaSeed]. * ([AgendulaLightFallback] / [AgendulaDarkFallback]) and [AgendulaTypography].
* * The mechanics live in floret-kit; the look stays here.
* Mirrors Calendula's theme: MaterialExpressiveTheme routes all component +
* custom motion through MaterialTheme.motionScheme. STANDARD over expressive()
* is the same deliberate choice — spring choreography without the overshoot.
*
* A Settings screen can later override dynamicColor and theme mode; the M0
* foundation just follows the system.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun AgendulaTheme( fun AgendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colorScheme = when { FloretExpressiveTheme(
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { lightScheme = AgendulaLightFallback,
val ctx = LocalContext.current darkScheme = AgendulaDarkFallback,
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx) darkTheme = darkTheme,
} dynamicColor = dynamicColor,
darkTheme -> AgendulaDarkFallback
else -> AgendulaLightFallback
}
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = AgendulaTypography, typography = AgendulaTypography,
motionScheme = MotionScheme.standard(),
content = content, content = content,
) )
} }

View File

@@ -176,6 +176,7 @@
<string name="settings_report_problem">Report a problem</string> <string name="settings_report_problem">Report a problem</string>
<string name="settings_report_problem_hint">Open the issue tracker</string> <string name="settings_report_problem_hint">Open the issue tracker</string>
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula</string> <string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula</string>
<string name="crash_report_issue_title">Crash report</string>
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string> <string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string>
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/src/branch/main/LICENSE</string> <string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/src/branch/main/LICENSE</string>
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string> <string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
@@ -197,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">

View File

@@ -1,35 +0,0 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.ZoneId
import kotlin.time.Instant
class DayWindowTest {
@Test
fun `today spans exactly 24h in UTC and contains now`() {
val zone = ZoneId.of("UTC")
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
val (start, end) = DayWindow.today(now, zone)
assertThat(end.toEpochMilliseconds() - start.toEpochMilliseconds()).isEqualTo(86_400_000L)
assertThat(now >= start).isTrue()
assertThat(now < end).isTrue()
// In UTC, midnight aligns to a multiple of one day since the epoch.
assertThat(start.toEpochMilliseconds() % 86_400_000L).isEqualTo(0L)
}
@Test
fun `start is local midnight in a non-UTC zone`() {
val zone = ZoneId.of("Europe/Berlin")
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
val (start, end) = DayWindow.today(now, zone)
val startLocal = java.time.Instant.ofEpochMilli(start.toEpochMilliseconds()).atZone(zone)
assertThat(startLocal.hour).isEqualTo(0)
assertThat(startLocal.minute).isEqualTo(0)
assertThat(now >= start).isTrue()
assertThat(now < end).isTrue()
}
}

1
floret-kit Submodule

Submodule floret-kit added at 566caf4305

View File

@@ -11,9 +11,10 @@ pluginManagement {
gradlePluginPortal() gradlePluginPortal()
} }
} }
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" // No Gradle Java-toolchain auto-download resolver plugin: it can fetch a JDK at
} // build time, which an offline / reproducible F-Droid build scanner rejects.
// Modules set jvmTarget directly, so no toolchain resolver is needed.
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
@@ -25,3 +26,4 @@ dependencyResolutionManagement {
rootProject.name = "Agendula" rootProject.name = "Agendula"
include(":app") include(":app")
includeBuild("floret-kit")