Compare commits

...

10 Commits

Author SHA1 Message Date
76a0139fda Rename app Floret → Agendula
All checks were successful
CI / ci (push) Successful in 8m33s
Floret is promoted to the family / design-language (shared-kit) name; the
tasks app itself becomes Agendula (de.jeanlucmakiola.agendula) — agenda
('things to be done') + Calendula's -ula, a twin of the Calendula name.

Renames the package, namespace, applicationId, rootProject.name, app_name,
FloretApp/FloretNavHost/FloretTransitions classes, theme, F-Droid metadata
dir, CI artifact name, and docs. The botanical word 'florets' is preserved in
the name-origin prose, which is rewritten to Agendula's etymology. Clean
build + unit tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:10:24 +02:00
d84ac60757 M5: reminders onboarding, Settings screen & per-list overrides
Reminders onboarding & polish, plus the Settings screen (landed early).

- One-time reminder onboarding gate after the provider grant
  (ReminderOnboardingScreen + OnboardingScaffold), requesting
  POST_NOTIFICATIONS on API 33+; choice recorded in prefs.
- Settings screen, structured after Calendula as a category hub with
  sliding sub-screens (CollapsingScaffold + grouped rows + full-screen
  OptionPicker): About card, Appearance (theme, dynamic colour), Task
  form (default edit-form fields, default list, add-a-subtask-row
  opt-out), Reminders, Language, and Report a problem. Reached via an
  expressive shaped action button (MaterialShapes.Cookie4Sided) on the
  lists overview. Back from a sub-screen returns to the hub.
- Reminders: master enable toggle (gates the whole engine; re-requests
  the notification permission), a default lead time with a Custom
  amount/unit editor, an Android-12 exact-alarm status row, and
  per-list overrides of the default (Inherit / None / custom lead),
  honoured by ReminderScheduler per task.
- Per-app language (AppLanguage + locales_config.xml + appcompat
  locales service); add-a-subtask-row opt-out wired into the task list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 23:03:26 +02:00
2c457f3915 release: 0.2.0
All checks were successful
CI / ci (push) Successful in 6m18s
Release — F-Droid repo + Gitea release / ci (push) Successful in 7m21s
Release — F-Droid repo + Gitea release / gitea-release (push) Successful in 5s
Release — F-Droid repo + Gitea release / build-and-deploy (push) Successful in 6m14s
Promote [Unreleased] → [0.2.0] in the changelog (M2 UI + M3/M4 + fixes) and
bump the dev-default version to 0.2.0 / 200. The pushed tag drives the released
version per docs/RELEASING.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:27:06 +02:00
e0c56a73e2 M4: percent-complete, conflict-safe saves & subtask reparent
All checks were successful
CI / ci (push) Successful in 6m49s
M3 detail/edit polish:
- "Progress" slider on the edit form writes Tasks.PERCENT_COMPLETE (clamped
  0-100, 5% detents); status stays owned by the complete toggle.
- Conflict-safe saves: updateTask re-checks the provider's last_modified against
  the value captured when the form loaded and throws TaskConflictException; the
  editor offers overwrite-or-cancel instead of clobbering an external change.

M4 subtasks (UI):
- Reparent: a "Parent task" picker files a task under any top-level task in its
  list (or "None" to promote it); candidates stay top-level to keep nesting one
  level deep. Switching list clears the now-invalid parent.
- Tapping a subtask opens its own detail (a new TaskDetail entry, own VM).

Tests: percent clamping, parent-id write, populatedFields reveal logic.
Docs: ROADMAP M2/M3/M4 reconciled to the codebase; CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 21:15:15 +02:00
3db553da85 detail: always show the subtasks group so add is reachable
All checks were successful
CI / ci (push) Successful in 8m38s
Render the subtasks group on every task, not just ones that already
have children, so the inline add field is available from the start.
Hide the done/total count until there's at least one subtask.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:12 +02:00
11f9649dd7 M3: rebuild task create/edit form in Calendula's card language
Replace the boxed-text-field form with the family's tonal-card layout:
Close + filled SAVE top bar, a borderless headline title with a
list-colour accent bar, a "When" card (all-day switch + tappable
start/due rows), a tappable list card and reminder card that open
OptionCard dialogs, and segmented-button priority.

Optional Description / Priority / Reminder sections unfold from a
"More fields" picker (Calendula's disclosure): TaskFormField enum +
populatedFields() auto-reveal fields that already carry a value, and a
persisted defaultEditFields pref decides which start open (the settings
UI to edit it lands with M6). New shared InlineTextField + OptionCard;
date/time conversion helpers in DateTimeField promoted to internal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 11:03:02 +02:00
3397e57794 UI: fix subtask progress chip on the task list
Count a parent's subtask progress over the full task set in the
repository (attached to Task as subtaskTotal/subtaskDone) so completed
children are counted even on smart lists that hide closed tasks — the
chip showed "0 / 2" instead of "18 / 20". List membership (and the home
overview counts) are unchanged; only the parent's derived progress is.

Also wrap the task-row metadata in a FlowRow so the subtask chip drops
to a further line when a dated task with subtasks can't fit due + chips
on one line, rather than squeezing the trailing chip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 10:25:08 +02:00
24cf8fe331 nav: spring slide/fade screen transitions (Calendula feel)
Drive the NavHost with the motion scheme's fastSpatialSpec: forward fades in
over a held, static background; back (and the predictive-back drag) slides the
top screen out to the right, revealing the screen beneath unmoved — the same
overlay feel as Calendula, instead of the default navigation-compose transition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:03:45 +02:00
a19b1373a4 M2: build out task list, detail & edit screens
Replace the base scaffolds with the real Material 3 Expressive UI on the
existing M1 ViewModels.

Task list: due-date section headers (collapsible Completed), grouped tonal
rows with a list-colour accent bar, swipe-to-complete / -delete, inline add,
list-name chip and subtask "done/total" chip in mixed views (children folded
into their parent). New pure TaskSections grouping + unit test.

Detail: each fact its own tonal card with a leading icon (Calendula pattern) —
when/list/priority/progress/description; progress shows only with subtasks.
Subtasks are a connected grouped run whose first segment is the expand toggle,
with an accent-tinted inline add row; completed subtasks sink to the bottom.

Edit: title, description, list dropdown, start/due date-time + all-day,
priority segmented buttons, reminder offset, validation; IME auto-scroll.

Extract GroupedSurface from GroupedRow so the connected-row look is reusable
at any width; add ListNameChip + date-time format/picker helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 16:03:37 +02:00
b196d9ebe0 M2: wire navigation host + base screen scaffolds
Replace the RootScreen nav stubs with a real NavHost. Each destination
binds its existing M1 ViewModel from route args.

- ui/navigation: Dest route table (string-based; no serialization plugin)
  + FloretNavHost (lists -> task list -> detail / edit).
- Base scaffolds for TaskListScreen, TaskDetailScreen, TaskEditScreen,
  wired to their ViewModels — skeletons for the rich M2/M3 UI.
- RootScreen READY branch now hands off to FloretNavHost.
- Add navigation-compose 2.9.0; supporting strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:44:18 +02:00
99 changed files with 6328 additions and 668 deletions

View File

@@ -225,7 +225,7 @@ jobs:
if [ -z "$SAFE_REF_NAME" ]; then if [ -z "$SAFE_REF_NAME" ]; then
SAFE_REF_NAME="${GITHUB_SHA:-manual}" SAFE_REF_NAME="${GITHUB_SHA:-manual}"
fi fi
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/floret_${SAFE_REF_NAME}.apk" cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/agendula_${SAFE_REF_NAME}.apk"
- name: Copy metadata to F-Droid repo - name: Copy metadata to F-Droid repo
run: | run: |
@@ -248,7 +248,7 @@ jobs:
if [ ! -s /tmp/changelog.txt ]; then if [ ! -s /tmp/changelog.txt ]; then
echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt echo "See CHANGELOG.md for $VERSION." > /tmp/changelog.txt
fi fi
CL_DIR="fdroid/metadata/de.jeanlucmakiola.floret/en-US/changelogs" CL_DIR="fdroid/metadata/de.jeanlucmakiola.agendula/en-US/changelogs"
mkdir -p "$CL_DIR" mkdir -p "$CL_DIR"
cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt" cp /tmp/changelog.txt "$CL_DIR/${VERSION_CODE}.txt"
echo "Wrote $CL_DIR/${VERSION_CODE}.txt" echo "Wrote $CL_DIR/${VERSION_CODE}.txt"

View File

@@ -6,6 +6,56 @@ the source of truth for version codes (see Calendula's `docs/RELEASING.md`).
## [Unreleased] ## [Unreleased]
### Added
- M5 reminders onboarding & polish: a one-time reminder onboarding step after the
provider grant explains that Agendula delivers due reminders itself and requests
`POST_NOTIFICATIONS` (API 33+). A new **Settings** screen (gear on the lists
overview), structured after Calendula as a category hub with sliding sub-screens
(`CollapsingScaffold` + grouped category rows + full-screen `OptionPicker`):
an About card, **Appearance** (theme, dynamic colour), **Task form** (which
optional edit-form fields show by default, default list, the add-a-subtask-row
opt-out), and **Reminders** (a master enable switch that re-requests the
notification permission, a "when to remind" default offset, and — on Android 12
only — an exact-alarm status row that deep-links to system settings).
- The reminders master switch gates the whole engine: turning it off clears every
scheduled alarm and suppresses any that fire.
- Expressive top-bar actions: a reusable `ShapedActionButton` wraps an action icon
in a tonal M3 Expressive `MaterialShapes` container (the settings entry uses the
faceted Gem shape, tertiary tones), with a small press scale/rotate flourish.
Future top-bar actions pick their own shape from the `ActionShapes` registry.
## [0.2.0] - 2026-06-27
### Added
- M2 Material 3 Expressive UI — the app is fully navigable now: the
provider/permission onboarding gate, the lists overview (smart lists + user
lists grouped by account), and the task list (swipe-to-complete / -delete,
inline add, smart-list section headers), detail, and create/edit screens, all
wired to the M1 ViewModels.
- M3 detail/edit polish: a "Progress" slider (percent-complete, 5% detents,
written to `Tasks.PERCENT_COMPLETE`) and conflict-safe saves — `updateTask`
re-checks the provider's `last_modified` against the value captured when the
form loaded and surfaces an overwrite-or-cancel prompt instead of clobbering an
external change (e.g. a DAVx5 sync).
- M4 subtasks (UI): reparent — a full-width, searchable "Parent task" sheet on
the edit form, with candidates grouped by due-date section, files a task under
any active top-level task in its list (or "None" to promote it); switching list
clears the now-invalid parent. Tapping a subtask in the detail screen opens its
own detail, which shows a "Part of …" parent card. On the task list a parent has
a dedicated expand button that reveals its children as a nested grouped run,
ending with an inline "add a subtask" row (an opt-out toggle is planned for the
M6 settings screen).
- Priority is coloured by level (green / amber / red pastels) on the list, detail,
and edit screens.
### Fixed
- Overview open-counts now count top-level tasks only, so subtasks — and
especially open subtasks under a completed parent — no longer inflate a list's
"N open" or the smart-list counts.
- Swipe-to-delete now reveals its red background + icon as you drag (tracking the
live direction, not just the settled target), and a floating "Deleted · Undo"
chip defers the actual delete so it can be restored.
## [0.1.0] - 2026-06-18 ## [0.1.0] - 2026-06-18
### Added ### Added

View File

@@ -1,6 +1,6 @@
# Contributing to Floret # Contributing to Agendula
Thanks for your interest in Floret — a Material 3 Expressive task app that's a Thanks for your interest in Agendula — a Material 3 Expressive task app that's a
pure front-end over the OpenTasks `TaskContract` provider, with no own database pure front-end over the OpenTasks `TaskContract` provider, with no own database
or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) or sync stack. Before diving in, skim [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)
(how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and (how it's built), [`docs/ROADMAP.md`](docs/ROADMAP.md) (what's next), and
@@ -80,7 +80,7 @@ push, so run these locally before opening a PR. Keep CI green.
## Scope ## Scope
Floret stays true to its thesis: a front-end over **open** task backends Agendula stays true to its thesis: a front-end over **open** task backends
(CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends (CalDAV / iCalendar / DecSync via the OpenTasks provider). Proprietary backends
(Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning (Google Tasks, Microsoft To Do) are out of scope by design — they'd mean owning
a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's a sync stack. v1 targets the OpenTasks contract (OpenTasks + tasks.org); jtx's

View File

@@ -1,6 +1,6 @@
<div align="center"> <div align="center">
<h1>Floret</h1> <h1>Agendula</h1>
<p><strong>A modern Material 3 Expressive task app for Android.</strong><br> <p><strong>A modern Material 3 Expressive task app for Android.</strong><br>
Reads, writes, and reminds — on top of an existing tasks provider, with no own Reads, writes, and reminds — on top of an existing tasks provider, with no own
@@ -13,14 +13,16 @@ sync stack.</p>
</div> </div>
Floret is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). Agendula is the task-list sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula).
Where Calendula is a pure front-end over Android's `CalendarContract`, Floret is Where Calendula is a pure front-end over Android's `CalendarContract`, Agendula is
a pure front-end over the **OpenTasks `TaskContract` provider** — the store that a pure front-end over the **OpenTasks `TaskContract` provider** — the store that
DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own DAVx5 (and SmoothSync, DecSync, …) syncs your CalDAV `VTODO` tasks into. No own
database, no reinvented sync. database, no reinvented sync.
A Calendula flower head is botanically made of many small *florets* — the The name rhymes with its sibling on purpose: **Agendula** is *agenda* — Latin for
individual items that make up the bloom. Floret is those items: your tasks. “things to be done” — given Calendula's `-ula` ending. Calendula keeps your days;
Agendula keeps your to-dos. (A Calendula flower head is botanically a cluster of
many small *florets* — so the two apps are florets of one bloom.)
> **Status: data layer done, UI in progress.** The full non-visual stack over > **Status: data layer done, UI in progress.** The full non-visual stack over
> the `TaskContract` provider — provider resolution, live-updating reads, > the `TaskContract` provider — provider resolution, live-updating reads,
@@ -32,7 +34,7 @@ individual items that make up the bloom. Floret is those items: your tasks.
## Sync sources (by design) ## Sync sources (by design)
Floret works with anything that writes to the tasks provider — **DAVx5** Agendula works with anything that writes to the tasks provider — **DAVx5**
(CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync (CalDAV), **SmoothSync**, **CalDAV-Sync**, **DecSync CC**, or any Android sync
adapter — because it builds on the provider, not on any one sync app. Google adapter — because it builds on the provider, not on any one sync app. Google
Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean Tasks / Microsoft To Do are out of scope by design (proprietary; they would mean

View File

@@ -16,11 +16,11 @@ val keystoreProperties = Properties().apply {
} }
android { android {
namespace = "de.jeanlucmakiola.floret" namespace = "de.jeanlucmakiola.agendula"
compileSdk = 37 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "de.jeanlucmakiola.floret" applicationId = "de.jeanlucmakiola.agendula"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
// The git tag is the single source of truth for released builds: at // The git tag is the single source of truth for released builds: at
@@ -28,8 +28,8 @@ android {
// the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH // the tag, with versionCode = MAJOR*10000 + MINOR*100 + PATCH
// (e.g. v2.0.0 -> 20000). These committed values are the dev/local // (e.g. v2.0.0 -> 20000). These committed values are the dev/local
// default; keep them matching the latest released tag. See docs/RELEASING.md. // default; keep them matching the latest released tag. See docs/RELEASING.md.
versionCode = 100 versionCode = 200
versionName = "0.1.0" versionName = "0.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -110,6 +110,7 @@ dependencies {
implementation(libs.hilt.android) implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.navigation.compose)
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)

View File

@@ -30,15 +30,16 @@
</queries> </queries>
<application <application
android:name=".FloretApp" android:name=".AgendulaApp"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Floret" android:theme="@style/Theme.Agendula"
tools:targetApi="35"> tools:targetApi="35">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@@ -76,6 +77,17 @@
<data android:scheme="content" android:host="org.dmfs.tasks" /> <data android:scheme="content" android:host="org.dmfs.tasks" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<!-- Persists the per-app language on API < 33, where the platform
per-app-languages API is unavailable. On 33+ this is a no-op. -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application> </application>
</manifest> </manifest>

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret package de.jeanlucmakiola.agendula
import android.app.Application import android.app.Application
import dagger.hilt.EntryPoint import dagger.hilt.EntryPoint
@@ -6,19 +6,19 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors 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.floret.data.reminders.ReminderScheduler import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
* Application entry point. Registered as android:name=".FloretApp". Besides * Application entry point. Registered as android:name=".AgendulaApp". Besides
* Hilt init, it kicks off a reminder re-sync on launch (independent of any UI), * Hilt init, it kicks off a reminder re-sync on launch (independent of any UI),
* so alarms reflect tasks synced while the app was closed. * so alarms reflect tasks synced while the app was closed.
*/ */
@HiltAndroidApp @HiltAndroidApp
class FloretApp : Application() { class AgendulaApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret package de.jeanlucmakiola.agendula
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -14,11 +14,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.floret.data.demo.DemoSeeder import de.jeanlucmakiola.agendula.data.demo.DemoSeeder
import de.jeanlucmakiola.floret.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.floret.ui.RootScreen import de.jeanlucmakiola.agendula.ui.RootScreen
import de.jeanlucmakiola.floret.ui.settings.SettingsViewModel import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.floret.ui.theme.FloretTheme import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -36,7 +36,7 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// Debug-only sample data: `am start ... --ez floret_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)) {
lifecycleScope.launch { runCatching { demoSeeder.seed() } } lifecycleScope.launch { runCatching { demoSeeder.seed() } }
@@ -49,15 +49,15 @@ class MainActivity : ComponentActivity() {
ThemeMode.LIGHT -> false ThemeMode.LIGHT -> false
ThemeMode.DARK -> true ThemeMode.DARK -> true
} }
FloretTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) { AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
RootScreen(modifier = Modifier.fillMaxSize()) RootScreen(modifier = Modifier.fillMaxSize())
} }
} }
} }
companion object { companion object {
const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.extra.TASK_ID" const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
private const val EXTRA_SEED = "floret_seed" private const val EXTRA_SEED = "agendula_seed"
/** Opens the app focused on a task (reminder taps). Routing lands with the UI. */ /** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
fun taskIntent(context: Context, taskId: Long): Intent = fun taskIntent(context: Context, taskId: Long): Intent =

View File

@@ -1,10 +1,10 @@
package de.jeanlucmakiola.floret.data.demo package de.jeanlucmakiola.agendula.data.demo
import de.jeanlucmakiola.floret.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.DayWindow import de.jeanlucmakiola.agendula.domain.DayWindow
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -16,10 +16,10 @@ import kotlin.time.Instant
/** /**
* Debug-only one-shot sample data. Creates a **local, device-only** list * Debug-only one-shot sample data. Creates a **local, device-only** list
* ("Floret Demo") so nothing syncs to a CalDAV server and fills it with * ("Agendula Demo") so nothing syncs to a CalDAV server and fills it with
* tasks spread across overdue / today / upcoming / no-date / completed so every * tasks spread across overdue / today / upcoming / no-date / completed so every
* smart list shows content. Idempotent: skips if the demo list already exists. * smart list shows content. Idempotent: skips if the demo list already exists.
* Triggered from [de.jeanlucmakiola.floret.MainActivity] only in debug builds. * Triggered from [de.jeanlucmakiola.agendula.MainActivity] only in debug builds.
*/ */
@Singleton @Singleton
class DemoSeeder @Inject constructor( class DemoSeeder @Inject constructor(
@@ -47,14 +47,14 @@ class DemoSeeder @Inject constructor(
repository.createTask(TaskForm(title = "Gather receipts", listId = listId, parentId = invoiceId)) repository.createTask(TaskForm(title = "Gather receipts", listId = listId, parentId = invoiceId))
repository.createTask(TaskForm(title = "Book train tickets", listId = listId, due = at(te + 6 * day), priority = Priority.LOW)) repository.createTask(TaskForm(title = "Book train tickets", listId = listId, due = at(te + 6 * day), priority = Priority.LOW))
repository.createTask(TaskForm(title = "Read Compose 1.5 release notes", listId = listId)) repository.createTask(TaskForm(title = "Read Compose 1.5 release notes", listId = listId))
repository.createTask(TaskForm(title = "Sketch the Floret app icon", listId = listId)) repository.createTask(TaskForm(title = "Sketch the Agendula app icon", listId = listId))
val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day))) val done = repository.createTask(TaskForm(title = "Renew domain name", listId = listId, due = at(ts - 2 * day)))
repository.setCompleted(done, completed = true) repository.setCompleted(done, completed = true)
} }
private companion object { private companion object {
const val DEMO_LIST = "Floret Demo" const val DEMO_LIST = "Agendula Demo"
const val DEMO_COLOR = 0xFF7A5C6B.toInt() const val DEMO_COLOR = 0xFF7A5C6B.toInt()
} }
} }

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.di package de.jeanlucmakiola.agendula.data.di
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
@@ -10,16 +10,16 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.floret.data.tasks.AndroidTasksDataSource import de.jeanlucmakiola.agendula.data.tasks.AndroidTasksDataSource
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.data.tasks.TasksRepositoryImpl import de.jeanlucmakiola.agendula.data.tasks.TasksRepositoryImpl
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton import javax.inject.Singleton
private val Context.floretDataStore: DataStore<Preferences> by preferencesDataStore( private val Context.agendulaDataStore: DataStore<Preferences> by preferencesDataStore(
name = "floret_prefs", name = "agendula_prefs",
) )
@Module @Module
@@ -42,7 +42,7 @@ object DataProvideModule {
@Provides @Provides
@Singleton @Singleton
fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> = fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
context.floretDataStore context.agendulaDataStore
@Provides @Provides
@IoDispatcher @IoDispatcher

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.di package de.jeanlucmakiola.agendula.data.di
import javax.inject.Qualifier import javax.inject.Qualifier

View File

@@ -0,0 +1,139 @@
package de.jeanlucmakiola.agendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import de.jeanlucmakiola.agendula.domain.TaskFormField
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
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(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
/** The list a new task defaults to; `null` = first available. */
val defaultListId: Long? = null,
/** Default minutes before due to remind; 0 = at due time. */
val reminderLeadMinutes: Int = 0,
/** Master switch for due reminders; off clears every scheduled alarm. */
val remindersEnabled: Boolean = true,
/** Whether the inline "add a subtask" row shows on expanded task-list groups. */
val showAddSubtaskRow: Boolean = true,
/**
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
* overrides the global default (a null value = no reminder); absent = inherit.
*/
val perListReminderOverride: Map<Long, Int?> = emptyMap(),
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
val defaultEditFields: Set<TaskFormField> = emptySet(),
) {
/** The lead time for a task in [listId]: its override if set, else the global default. */
fun reminderLeadFor(listId: Long): Int? =
if (perListReminderOverride.containsKey(listId)) {
perListReminderOverride[listId] // null = explicitly no reminder
} else {
reminderLeadMinutes
}
}
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@Singleton
class SettingsPrefs @Inject constructor(
private val dataStore: DataStore<Preferences>,
) {
val settings: Flow<Settings> = dataStore.data.map { p ->
Settings(
themeMode = p[THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
?: ThemeMode.SYSTEM,
dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
remindersEnabled = p[REMINDERS_ENABLED] ?: true,
showAddSubtaskRow = p[SHOW_ADD_SUBTASK_ROW] ?: true,
perListReminderOverride = parseReminderOverrides(p[LIST_REMINDER_OVERRIDE]),
defaultEditFields = p[DEFAULT_EDIT_FIELDS].orEmpty()
.mapNotNull { name -> runCatching { TaskFormField.valueOf(name) }.getOrNull() }
.toSet(),
)
}
suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
}
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
/** One-time reminder onboarding gate; false until the step has been shown. */
val reminderOnboardingDone: Flow<Boolean> = dataStore.data.map { it[REMINDER_ONBOARDING_DONE] ?: false }
suspend fun setReminderOnboardingDone() = dataStore.edit { it[REMINDER_ONBOARDING_DONE] = true }
suspend fun setRemindersEnabled(enabled: Boolean) = dataStore.edit { it[REMINDERS_ENABLED] = enabled }
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 setListReminderOverride(listId: Long, override: ListReminderOverride) = dataStore.edit { p ->
val current = parseReminderOverrides(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
when (override) {
ListReminderOverride.Inherit -> current.remove(listId)
ListReminderOverride.None -> current[listId] = null
is ListReminderOverride.Minutes -> current[listId] = override.minutes
}
p[LIST_REMINDER_OVERRIDE] = serializeReminderOverrides(current)
}
suspend fun setDefaultEditFields(fields: Set<TaskFormField>) = dataStore.edit {
it[DEFAULT_EDIT_FIELDS] = fields.mapTo(mutableSetOf()) { field -> field.name }
}
private companion object {
val THEME_MODE = stringPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
val REMINDERS_ENABLED = booleanPreferencesKey("reminders_enabled")
val SHOW_ADD_SUBTASK_ROW = booleanPreferencesKey("show_add_subtask_row")
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
}
}
private const val OVERRIDE_ENTRY_SEP = ";"
private const val OVERRIDE_KV_SEP = ":"
private const val OVERRIDE_NONE = "none"
/** Decode "id:minutes;id:none;…" into a map (value null = explicit no-reminder). */
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
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

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context

View File

@@ -1,13 +1,15 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -21,6 +23,7 @@ class DueReminderReceiver : BroadcastReceiver() {
@Inject lateinit var dataSource: TasksDataSource @Inject lateinit var dataSource: TasksDataSource
@Inject lateinit var notifier: TaskNotifier @Inject lateinit var notifier: TaskNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -30,6 +33,7 @@ class DueReminderReceiver : BroadcastReceiver() {
val pending = goAsync() val pending = goAsync()
scope.launch { scope.launch {
try { try {
if (!settingsPrefs.settings.first().remindersEnabled) return@launch
val task = runCatching { dataSource.task(taskId) }.getOrNull() val task = runCatching { dataSource.task(taskId) }.getOrNull()
if (task != null && !task.isClosed) notifier.postDue(task) if (task != null && !task.isClosed) notifier.postDue(task)
} finally { } finally {
@@ -39,7 +43,7 @@ class DueReminderReceiver : BroadcastReceiver() {
} }
companion object { companion object {
private const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.extra.TASK_ID" private const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
fun intent(context: Context, taskId: Long): Intent = fun intent(context: Context, taskId: Long): Intent =
Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId) Intent(context, DueReminderReceiver::class.java).putExtra(EXTRA_TASK_ID, taskId)

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
@@ -12,7 +12,7 @@ import javax.inject.Inject
/** /**
* Re-syncs reminders whenever the tasks provider changes covers external sync * Re-syncs reminders whenever the tasks provider changes covers external sync
* (DAVx5 pulling new/edited tasks) while Floret isn't in the foreground. The * (DAVx5 pulling new/edited tasks) while Agendula isn't in the foreground. The
* manifest filter targets both known authorities; best-effort (the in-app * manifest filter targets both known authorities; best-effort (the in-app
* ContentObserver covers the foreground case regardless). * ContentObserver covers the foreground case regardless).
*/ */

View File

@@ -1,15 +1,15 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import android.app.AlarmManager import android.app.AlarmManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.floret.data.tasks.TaskQuery import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
import de.jeanlucmakiola.floret.data.tasks.TasksDataSource import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -18,7 +18,7 @@ import javax.inject.Singleton
/** /**
* The self-scheduled due-reminder engine. Tasks providers don't deliver * The self-scheduled due-reminder engine. Tasks providers don't deliver
* reminders, so Floret reads upcoming due tasks and arms one exact [AlarmManager] * reminders, so Agendula reads upcoming due tasks and arms one exact [AlarmManager]
* alarm each, within a rolling window. Re-run on app start, boot and provider * alarm each, within a rolling window. Re-run on app start, boot and provider
* change; it diffs against [ScheduledReminderStore] so only changed alarms move. * change; it diffs against [ScheduledReminderStore] so only changed alarms move.
*/ */
@@ -33,11 +33,11 @@ class ReminderScheduler @Inject constructor(
) { ) {
suspend fun sync() = withContext(io) { suspend fun sync() = withContext(io) {
val provider = providerResolver.resolve() val provider = providerResolver.resolve()
if (provider == null || !providerResolver.hasPermission(provider)) { val settings = settingsPrefs.settings.first()
if (provider == null || !providerResolver.hasPermission(provider) || !settings.remindersEnabled) {
clearAll() clearAll()
return@withContext return@withContext
} }
val lead = settingsPrefs.settings.first().reminderLeadMinutes.coerceAtLeast(0)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val horizon = now + WINDOW_MS val horizon = now + WINDOW_MS
val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) } val tasks = runCatching { dataSource.tasks(TaskQuery(includeCompleted = false)) }
@@ -45,7 +45,13 @@ class ReminderScheduler @Inject constructor(
val desired = tasks val desired = tasks
.filter { !it.isClosed && it.due != null } .filter { !it.isClosed && it.due != null }
.associate { it.taskId to (it.due!!.toEpochMilliseconds() - lead * 60_000L) } .mapNotNull { task ->
// The task's list may override the global lead, or opt out entirely
// (override = null), in which case it gets no reminder at all.
val lead = settings.reminderLeadFor(task.listId) ?: return@mapNotNull null
task.taskId to (task.due!!.toEpochMilliseconds() - lead.coerceAtLeast(0) * 60_000L)
}
.toMap()
.filterValues { it in now..horizon } .filterValues { it in now..horizon }
val previous = store.all() val previous = store.all()

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.reminders package de.jeanlucmakiola.agendula.data.reminders
import android.Manifest import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
@@ -12,9 +12,9 @@ import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import de.jeanlucmakiola.floret.MainActivity import de.jeanlucmakiola.agendula.MainActivity
import de.jeanlucmakiola.floret.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.Instant as JInstant import java.time.Instant as JInstant
import java.time.ZoneId import java.time.ZoneId

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
@@ -9,12 +9,12 @@ import android.net.Uri
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import java.time.ZoneId import java.time.ZoneId
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import android.database.Cursor import android.database.Cursor

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
/** No tasks provider (OpenTasks / tasks.org) is installed on the device. */ /** No tasks provider (OpenTasks / tasks.org) is installed on the device. */
class ProviderUnavailableException : class ProviderUnavailableException :
@@ -7,3 +7,10 @@ class ProviderUnavailableException :
/** A ContentResolver write returned no URI or affected no rows. */ /** A ContentResolver write returned no URI or affected no rows. */
class TaskWriteFailedException(operation: String) : class TaskWriteFailedException(operation: String) :
RuntimeException("Task write failed: $operation") RuntimeException("Task write failed: $operation")
/**
* The task changed (e.g. a DAVx5 sync or another app) since the edit form loaded
* it, so saving would clobber that change. The UI offers to overwrite or reload.
*/
class TaskConflictException(taskId: Long) :
RuntimeException("Task $taskId changed since it was loaded")

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -8,7 +8,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
/** /**
* A tasks provider Floret can talk to. The same dmfs `TaskProvider` backs every * A tasks provider Agendula can talk to. The same dmfs `TaskProvider` backs every
* candidate, so the [TasksContract] columns apply regardless of which is present. * candidate, so the [TasksContract] columns apply regardless of which is present.
*/ */
data class TaskProvider( data class TaskProvider(

View File

@@ -1,12 +1,12 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.domain.priorityFromICal import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.floret.domain.statusFromInt import de.jeanlucmakiola.agendula.domain.statusFromInt
import kotlin.time.Instant import kotlin.time.Instant
/** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */ /** Maps a provider row (via [ColumnReader]) to a domain model. Pure + testable. */

View File

@@ -1,8 +1,8 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
/** Column lists requested from the provider. Order is irrelevant; we read by name. */ /** Column lists requested from the provider. Order is irrelevant; we read by name. */
object TaskProjections { object TaskProjections {

View File

@@ -1,9 +1,9 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.toICal import de.jeanlucmakiola.agendula.domain.toICal
/** /**
* Turns a [TaskForm] / mutation into a namevalue map. Pure (no ContentValues), * Turns a [TaskForm] / mutation into a namevalue map. Pure (no ContentValues),
@@ -17,6 +17,27 @@ object TaskWriteMapper {
put(Tasks.LIST_ID, form.listId) put(Tasks.LIST_ID, form.listId)
put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null }) put(Tasks.DESCRIPTION, form.description?.trim()?.ifBlank { null })
put(Tasks.PRIORITY, form.priority.toICal()) put(Tasks.PRIORITY, form.priority.toICal())
// Keep completion in sync with progress whenever the form carries a
// percent (the Progress field was used). The provider already auto-
// completes at 100% but won't reopen below it — left to itself it strands
// a task "done at 75%" — so we set status both ways. COMPLETED is only
// cleared on reopen; at 100% we leave it for the provider to fill/keep so
// re-saving a finished task doesn't churn its completion timestamp. When
// no percent is present the standalone complete toggle stays authoritative.
val percent = form.percentComplete?.coerceIn(0, 100)
put(Tasks.PERCENT_COMPLETE, percent)
when {
percent == null -> Unit
percent >= 100 -> put(Tasks.STATUS, TasksContract.STATUS_COMPLETED)
percent > 0 -> {
put(Tasks.STATUS, TasksContract.STATUS_IN_PROCESS)
put(Tasks.COMPLETED, null)
}
else -> {
put(Tasks.STATUS, TasksContract.STATUS_NEEDS_ACTION)
put(Tasks.COMPLETED, null)
}
}
put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0) put(Tasks.IS_ALLDAY, if (form.isAllDay) 1 else 0)
put(Tasks.DTSTART, form.start?.toEpochMilliseconds()) put(Tasks.DTSTART, form.start?.toEpochMilliseconds())
put(Tasks.DUE, form.due?.toEpochMilliseconds()) put(Tasks.DUE, form.due?.toEpochMilliseconds())

View File

@@ -1,9 +1,9 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import android.net.Uri import android.net.Uri
/** /**
* The subset of the OpenTasks `TaskContract` that Floret uses, vendored as * The subset of the OpenTasks `TaskContract` that Agendula uses, vendored as
* Kotlin constants. Derived from `org.dmfs.tasks.contract.TaskContract` * Kotlin constants. Derived from `org.dmfs.tasks.contract.TaskContract`
* (Apache-2.0, dmfs GmbH): * (Apache-2.0, dmfs GmbH):
* https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java * https://github.com/dmfs/opentasks/blob/master/opentasks-contract/src/main/java/org/dmfs/tasks/contract/TaskContract.java
@@ -25,7 +25,7 @@ object TasksContract {
const val PARAM_ACCOUNT_TYPE = "account_type" const val PARAM_ACCOUNT_TYPE = "account_type"
const val LOAD_PROPERTIES = "load_properties" const val LOAD_PROPERTIES = "load_properties"
/** Account used for local, device-only task lists Floret owns. */ /** Account used for local, device-only task lists Agendula owns. */
const val LOCAL_ACCOUNT_NAME = "Local" const val LOCAL_ACCOUNT_NAME = "Local"
const val LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL" const val LOCAL_ACCOUNT_TYPE = "org.dmfs.account.LOCAL"
@@ -85,7 +85,7 @@ object TasksContract {
* The `instances` view one row per task occurrence, with recurrence * The `instances` view one row per task occurrence, with recurrence
* resolved and absolute [INSTANCE_START]/[INSTANCE_DUE] timestamps. Inherits * resolved and absolute [INSTANCE_START]/[INSTANCE_DUE] timestamps. Inherits
* every [Tasks] column (title, status, ) plus the instance-only columns * every [Tasks] column (title, status, ) plus the instance-only columns
* below. This is Floret's read source. * below. This is Agendula's read source.
*/ */
object Instances { object Instances {
const val PATH = "instances" const val PATH = "instances"

View File

@@ -1,8 +1,8 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
/** What to fetch from the provider. Smart-list date logic is applied above this. */ /** What to fetch from the provider. Smart-list date logic is applied above this. */
data class TaskQuery( data class TaskQuery(

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskList
import kotlinx.coroutines.flow.Flow
import kotlin.time.Instant
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
/**
* The single entry point the UI layer uses. Flows re-emit automatically when the
* provider changes (our writes *and* external sync like DAVx5), via the
* data source's ContentObserver.
*/
interface TasksRepository {
fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long
/**
* Overwrite [taskId] with [form]. When [expectedLastModified] is non-null, the
* task's current `last_modified` is re-checked first and a
* [TaskConflictException] is thrown if it differs — i.e. something changed it
* since the form loaded. Pass `null` to force the write (overwrite-anyway).
*/
suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant? = null)
suspend fun setCompleted(taskId: Long, completed: Boolean)
suspend fun deleteTask(taskId: Long)
suspend fun createLocalList(name: String, color: Int): Long
/** Synchronous snapshot for the permission/onboarding gate. */
fun providerStatus(): ProviderStatus
}

View File

@@ -1,15 +1,14 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import de.jeanlucmakiola.floret.data.di.IoDispatcher import de.jeanlucmakiola.agendula.data.di.IoDispatcher
import de.jeanlucmakiola.floret.domain.DayWindow import de.jeanlucmakiola.agendula.domain.DayWindow
import de.jeanlucmakiola.floret.domain.SmartList import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.floret.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.floret.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskSorting
import de.jeanlucmakiola.floret.domain.TaskSorting
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@@ -23,6 +22,7 @@ import java.time.ZoneId
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Instant
@Singleton @Singleton
class TasksRepositoryImpl @Inject constructor( class TasksRepositoryImpl @Inject constructor(
@@ -37,26 +37,56 @@ class TasksRepositoryImpl @Inject constructor(
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(task, dataSource.subtasks(taskId).filter { it.taskId != taskId }) TaskDetail(
task = task,
subtasks = dataSource.subtasks(taskId).filter { it.taskId != taskId },
parent = task.parentId?.takeIf { it > 0 }?.let { dataSource.task(it) },
)
} }
} }
private fun loadTasks(filter: TaskFilter): List<Task> { private fun loadTasks(filter: TaskFilter): List<Task> {
val query = when (filter) { val query = when (filter) {
is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true) is TaskFilter.OfList -> TaskQuery(listId = filter.listId, includeCompleted = true)
is TaskFilter.Smart -> TaskQuery(includeCompleted = filter.list == SmartList.COMPLETED) // Read completed tasks too, then let [TaskFiltering.matches] enforce each
// smart list's own open/closed semantics. We over-read on purpose so a
// parent's subtask-progress reflects ALL its children — completed ones
// included — even on smart lists that hide completed tasks themselves.
is TaskFilter.Smart -> TaskQuery(includeCompleted = true)
} }
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
return dataSource.tasks(query) val all = dataSource.tasks(query)
// (done, total) of direct children per parent, over the unfiltered set.
val progress: Map<Long, Pair<Int, Int>> = all
.filter { (it.parentId ?: 0L) > 0L }
.groupingBy { it.parentId!! }
.fold(0 to 0) { (done, total), t ->
(if (t.isCompleted) done + 1 else done) to (total + 1)
}
return all
.filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) } .filter { TaskFiltering.matches(it, filter, todayStart, todayEnd) }
.map { task ->
progress[task.taskId]?.let { (done, total) ->
task.copy(subtaskDone = done, subtaskTotal = total)
} ?: task
}
.sortedWith(TaskSorting.DEFAULT) .sortedWith(TaskSorting.DEFAULT)
} }
override suspend fun createTask(form: TaskForm): Long = override suspend fun createTask(form: TaskForm): Long =
withContext(io) { dataSource.insertTask(form) } withContext(io) { dataSource.insertTask(form) }
override suspend fun updateTask(taskId: Long, form: TaskForm) = override suspend fun updateTask(taskId: Long, form: TaskForm, expectedLastModified: Instant?) =
withContext(io) { dataSource.updateTask(taskId, form) } withContext(io) {
// Conflict-safe overwrite: re-read just before writing and bail if the
// provider's last_modified moved since the form captured it (external
// sync / another app). A null baseline means "force / overwrite anyway".
if (expectedLastModified != null) {
val current = dataSource.task(taskId)?.lastModified
if (current != null && current != expectedLastModified) throw TaskConflictException(taskId)
}
dataSource.updateTask(taskId, form)
}
override suspend fun setCompleted(taskId: Long, completed: Boolean) = override suspend fun setCompleted(taskId: Long, completed: Boolean) =
withContext(io) { dataSource.setCompleted(taskId, completed) } withContext(io) { dataSource.setCompleted(taskId, completed) }

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import java.time.ZoneId import java.time.ZoneId
import kotlin.time.Instant import kotlin.time.Instant

View File

@@ -1,6 +1,6 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import de.jeanlucmakiola.floret.data.tasks.TasksContract import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import kotlin.time.Instant import kotlin.time.Instant
/** A task list (the `tasklists` table). Lists group under their account. */ /** A task list (the `tasklists` table). Lists group under their account. */
@@ -14,7 +14,7 @@ data class TaskList(
val isVisible: Boolean, val isVisible: Boolean,
val owner: String?, val owner: String?,
) { ) {
/** A device-only list Floret (or another app) created locally, not synced. */ /** A device-only list Agendula (or another app) created locally, not synced. */
val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE val isLocal: Boolean get() = accountType == TasksContract.LOCAL_ACCOUNT_TYPE
} }
@@ -52,6 +52,13 @@ data class Task(
val distanceFromCurrent: Int?, val distanceFromCurrent: Int?,
val created: Instant?, val created: Instant?,
val lastModified: Instant?, val lastModified: Instant?,
/**
* Direct-subtask progress, filled in by the repository over the *full* task
* set so a parent's progress chip counts completed children even on smart
* lists that hide them. Both 0 when the task has no subtasks.
*/
val subtaskTotal: Int = 0,
val subtaskDone: Int = 0,
) { ) {
val isCompleted: Boolean get() = status == TaskStatus.COMPLETED val isCompleted: Boolean get() = status == TaskStatus.COMPLETED
val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED val isClosed: Boolean get() = status == TaskStatus.COMPLETED || status == TaskStatus.CANCELLED
@@ -60,10 +67,11 @@ data class Task(
val effectiveColor: Int get() = taskColor ?: listColor val effectiveColor: Int get() = taskColor ?: listColor
} }
/** Detail bundle: a task plus its direct children. */ /** Detail bundle: a task, its parent (if it's a subtask), and its direct children. */
data class TaskDetail( data class TaskDetail(
val task: Task, val task: Task,
val subtasks: List<Task>, val subtasks: List<Task>,
val parent: Task? = null,
) )
// --- pure provider <-> domain value mapping (unit-testable) ------------------ // --- pure provider <-> domain value mapping (unit-testable) ------------------

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import kotlin.time.Instant import kotlin.time.Instant

View File

@@ -0,0 +1,51 @@
package de.jeanlucmakiola.agendula.domain
import kotlin.time.Instant
/**
* A validated create/edit form. Kept free of Android types so ViewModels and
* tests can build and validate it on the JVM. The data layer turns it into
* provider ContentValues ([de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper]).
*/
data class TaskForm(
val title: String,
val listId: Long,
val description: String? = null,
val start: Instant? = null,
val due: Instant? = null,
val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
/** 0..100 progress; `null` leaves it unset (cleared). */
val percentComplete: Int? = null,
/** Minutes before [due] to fire a reminder; `null` = no reminder. */
val reminderMinutesBeforeDue: Int? = null,
) {
fun validate(): Set<TaskFormError> = buildSet {
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
if (listId <= 0L) add(TaskFormError.NO_LIST)
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
if (reminderMinutesBeforeDue != null && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
}
val isValid: Boolean get() = validate().isEmpty()
}
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE }
/**
* Optional edit-form sections that can be shown or tucked behind "More fields"
* (the core Title / List / When are always visible). Which render by default is
* a setting; the rest unfold on demand. Declaring these as an enum keeps the
* disclosure generic, so adding a field later is one entry plus its card.
*/
enum class TaskFormField { Description, Priority, Progress, Parent, Reminder }
/** The optional fields that already carry a value — auto-revealed when editing. */
fun TaskForm.populatedFields(): Set<TaskFormField> = buildSet {
if (!description.isNullOrBlank()) add(TaskFormField.Description)
if (priority != Priority.NONE) add(TaskFormField.Priority)
if ((percentComplete ?: 0) > 0) add(TaskFormField.Progress)
if (parentId != null && parentId > 0) add(TaskFormField.Parent)
if (reminderMinutesBeforeDue != null) add(TaskFormField.Reminder)
}

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.agendula.domain
import kotlin.time.Instant
/** A due-date bucket a task list groups its rows under. */
enum class TaskSection { OVERDUE, TODAY, UPCOMING, NO_DATE, COMPLETED }
/** A non-empty run of tasks under one [TaskSection], already in display order. */
data class SectionedTasks(val section: TaskSection, val tasks: List<Task>)
/**
* Group already-sorted tasks into due-date sections (the standard task-app
* presentation). Pure + side-effect-free so it unit-tests with a fixed clock,
* mirroring [TaskFiltering] / [DayWindow]. Completed tasks always fall to the
* [TaskSection.COMPLETED] bucket regardless of their due date.
*
* [todayStart] is local midnight today and [todayEnd] local midnight tomorrow
* (compute with [DayWindow]). Only non-empty sections are returned, in the
* fixed order Overdue → Today → Upcoming → No date → Completed.
*/
object TaskSections {
fun of(tasks: List<Task>, todayStart: Instant, todayEnd: Instant): List<SectionedTasks> {
val bySection = tasks.groupBy { sectionOf(it, todayStart, todayEnd) }
return TaskSection.entries.mapNotNull { section ->
bySection[section]?.takeIf { it.isNotEmpty() }?.let { SectionedTasks(section, it) }
}
}
fun sectionOf(task: Task, todayStart: Instant, todayEnd: Instant): TaskSection {
if (task.isCompleted) return TaskSection.COMPLETED
val due = task.due ?: return TaskSection.NO_DATE
return when {
due < todayStart -> TaskSection.OVERDUE
due < todayEnd -> TaskSection.TODAY
else -> TaskSection.UPCOMING
}
}
}

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
/** Default task ordering, extracted so it's unit-testable without the repository. */ /** Default task ordering, extracted so it's unit-testable without the repository. */
object TaskSorting { object TaskSorting {

View File

@@ -1,7 +1,8 @@
package de.jeanlucmakiola.floret.ui package de.jeanlucmakiola.agendula.ui
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.Crossfade
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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -17,15 +18,16 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.floret.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.floret.ui.lists.ListsScreen import de.jeanlucmakiola.agendula.ui.navigation.AgendulaNavHost
import de.jeanlucmakiola.floret.ui.permission.PermissionViewModel import de.jeanlucmakiola.agendula.ui.permission.PermissionViewModel
import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingScreen
import de.jeanlucmakiola.agendula.ui.permission.ReminderOnboardingViewModel
/** /**
* App root: gates on the tasks-provider permission, then shows the home * App root: gates on the tasks-provider permission, then hands off to
* ([ListsScreen]). Navigation to the task-list / edit screens is wired as those * [AgendulaNavHost] (lists task list detail / edit).
* screens land (next milestones); the callbacks are stubs for now.
*/ */
@Composable @Composable
fun RootScreen( fun RootScreen(
@@ -50,11 +52,31 @@ fun RootScreen(
action = stringResource(R.string.onboarding_permission_button), action = stringResource(R.string.onboarding_permission_button),
onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) }, onAction = { launcher.launch(permission.permissionsToRequest.toTypedArray()) },
) )
ProviderStatus.READY -> ListsScreen( ProviderStatus.READY -> ReadyGate(modifier = modifier)
}
}
/**
* Second one-time gate after the provider grant: the reminder onboarding step.
* [ReminderOnboardingViewModel.onboardingDone] is null until DataStore's first
* emission render nothing for that frame rather than flash the wrong screen.
* A cross-fade eases the hand-off to the app instead of snapping.
*/
@Composable
private fun ReadyGate(
modifier: Modifier = Modifier,
onboardingViewModel: ReminderOnboardingViewModel = hiltViewModel(),
) {
val done by onboardingViewModel.onboardingDone.collectAsStateWithLifecycle()
Crossfade(targetState = done, label = "reminderOnboardingGate") { state ->
when (state) {
true -> AgendulaNavHost(modifier = modifier)
false -> ReminderOnboardingScreen(
onFinished = onboardingViewModel::finish,
modifier = modifier, modifier = modifier,
onOpenFilter = { /* TODO: navigate to TaskListScreen */ },
onNewTask = { /* TODO: navigate to TaskEditScreen */ },
) )
null -> {}
}
} }
} }

View File

@@ -0,0 +1,84 @@
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

@@ -0,0 +1,166 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Event
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import de.jeanlucmakiola.agendula.R
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import kotlin.time.Instant
private val zone: ZoneId get() = ZoneId.systemDefault()
internal fun Instant.toLocalDate(): LocalDate =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
internal fun Instant.toLocalTime(): LocalTime =
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalTime()
internal fun localToInstant(date: LocalDate, time: LocalTime): Instant =
Instant.fromEpochMilliseconds(date.atTime(time).atZone(zone).toInstant().toEpochMilli())
/**
* A labelled date(-time) field for the edit form: a tonal row showing the
* current value (or nothing), tappable to pick a date and — unless [allDay] —
* a time. A clear affordance appears once a value is set. Emits `null` when
* cleared. Styled to match the app's rounded tonal family.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateTimeField(
label: String,
value: Instant?,
allDay: Boolean,
onChange: (Instant?) -> Unit,
modifier: Modifier = Modifier,
) {
var showDatePicker by remember { mutableStateOf(false) }
var showTimePicker by remember { mutableStateOf(false) }
var pendingDate by remember { mutableStateOf<LocalDate?>(null) }
Surface(
onClick = { showDatePicker = true },
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Rounded.Event, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value?.formatDateTime(allDay) ?: stringResource(R.string.edit_set),
style = MaterialTheme.typography.bodyLarge,
)
}
if (value != null) {
IconButton(onClick = { onChange(null) }) {
Icon(Icons.Rounded.Clear, contentDescription = stringResource(R.string.edit_clear))
}
}
}
}
if (showDatePicker) {
val initialMillis = (value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis()))
.toLocalDate().atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val dateState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
showDatePicker = false
val millis = dateState.selectedDateMillis ?: return@TextButton
val date = java.time.Instant.ofEpochMilli(millis)
.atZone(ZoneOffset.UTC).toLocalDate()
if (allDay) {
onChange(localToInstant(date, LocalTime.MIDNIGHT))
} else {
pendingDate = date
showTimePicker = true
}
},
) { Text(stringResource(android.R.string.ok)) }
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
},
) { DatePicker(state = dateState) }
}
if (showTimePicker) {
val base = value ?: Instant.fromEpochMilliseconds(System.currentTimeMillis())
val timeState = rememberTimePickerState(
initialHour = base.toLocalTime().hour,
initialMinute = base.toLocalTime().minute,
)
Dialog(onDismissRequest = { showTimePicker = false }) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
TimePicker(state = timeState)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = { showTimePicker = false }) {
Text(stringResource(android.R.string.cancel))
}
TextButton(onClick = {
showTimePicker = false
val date = pendingDate ?: return@TextButton
onChange(localToInstant(date, LocalTime.of(timeState.hour, timeState.minute)))
}) { Text(stringResource(android.R.string.ok)) }
}
}
}
}
}
}

View File

@@ -0,0 +1,28 @@
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,4 +1,4 @@
package de.jeanlucmakiola.floret.ui.common package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -36,10 +37,51 @@ fun positionOf(index: Int, count: Int): Position = when {
else -> Position.Middle 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 row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose * One segment of a grouped list: a tonal [Surface] whose corner radii come from
* corner radii come from its [position] (so a run of rows reads as a single * [position] (so a run reads as a single rounded card), with a small gap below
* rounded card). Corners morph further on press the expressive shape play. * 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -54,24 +96,6 @@ fun GroupedRow(
trailing: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
) { ) {
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 = 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,
)
}
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
}
val itemColors = if (selected) { val itemColors = if (selected) {
ListItemDefaults.colors( ListItemDefaults.colors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -83,7 +107,17 @@ fun GroupedRow(
} else { } else {
ListItemDefaults.colors(containerColor = Color.Transparent) ListItemDefaults.colors(containerColor = Color.Transparent)
} }
val item: @Composable () -> Unit = { 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( ListItem(
headlineContent = { Text(title) }, headlineContent = { Text(title) },
supportingContent = summary?.let { text -> { Text(text) } }, supportingContent = summary?.let { text -> { Text(text) } },
@@ -93,24 +127,4 @@ fun GroupedRow(
modifier = Modifier.heightIn(min = minHeight), modifier = Modifier.heightIn(min = minHeight),
) )
} }
val base = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.then(gap)
val containerColor = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
}
if (onClick != null) {
Surface(
onClick = onClick,
color = containerColor,
shape = shape,
interactionSource = interaction,
modifier = base,
) { item() }
} else {
Surface(color = containerColor, shape = shape, modifier = base) { item() }
}
} }

View File

@@ -0,0 +1,83 @@
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,19 +1,25 @@
package de.jeanlucmakiola.floret.ui.common package de.jeanlucmakiola.agendula.ui.common
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
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/** /**
@@ -33,7 +39,7 @@ fun pastelize(rawArgb: Int, dark: Boolean): Color {
/** /**
* 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,
* with a task icon for Floret. * with a task icon for Agendula.
*/ */
@Composable @Composable
fun ListColorChip(color: Int, modifier: Modifier = Modifier) { fun ListColorChip(color: Int, modifier: Modifier = Modifier) {
@@ -53,3 +59,25 @@ fun ListColorChip(color: Int, modifier: Modifier = Modifier) {
) )
} }
} }
/**
* A small pill naming the task's list, filled in the list's (pastelised) colour.
* The label flips black/white by the fill's luminance so it stays legible on any
* hue in light and dark. Used in mixed views (smart lists) where rows span lists.
*/
@Composable
fun ListNameChip(name: String, color: Int, modifier: Modifier = Modifier) {
val dark = isSystemInDarkTheme()
val fill = pastelize(color, dark)
val onFill = if (fill.luminance() > 0.5f) Color.Black else Color.White
Surface(color = fill, shape = RoundedCornerShape(50), modifier = modifier) {
Text(
text = name,
color = onFill,
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
}

View File

@@ -0,0 +1,95 @@
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

@@ -0,0 +1,79 @@
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

@@ -0,0 +1,109 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material3.ExperimentalMaterial3Api
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.luminance
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.agendula.domain.Priority
/**
* Priority has no semantic Material role (M3 only ships `error` for danger), so
* we give the ranked levels their own hues — green / amber / red. They're softened
* harder than the list-colour [pastelize] (lower saturation, higher value) so the
* chips stay gentle and low-key rather than shouting. [Priority.NONE] has no
* colour (callers render it neutral).
*/
fun priorityFill(priority: Priority, dark: Boolean): Color {
val base = when (priority) {
Priority.HIGH -> 0xFFE53935.toInt() // red
Priority.MEDIUM -> 0xFFFFB300.toInt() // amber
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.36f else 0.22f // desaturate toward pastel
hsv[2] = if (dark) 0.58f else 0.92f // light, soft fill in light; muted in dark
return Color(android.graphics.Color.HSVToColor(hsv))
}
/**
* 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
* screens (default [selected] = true), and as a selectable option in the edit
* form — there [selected] = false shows the hue on the flag with an outlined,
* unfilled container, and tapping commits the choice via [onClick].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PriorityChip(
priority: Priority,
label: String,
modifier: Modifier = Modifier,
selected: Boolean = true,
showIcon: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val dark = isSystemInDarkTheme()
val fill = priorityFill(priority, dark)
val hasColor = fill.isSpecified
val container: Color
val content: Color
val iconTint: Color
var border: BorderStroke? = null
when {
selected && hasColor -> {
container = fill
content = if (fill.luminance() > 0.5f) Color.Black else Color.White
iconTint = content
}
selected -> { // NONE, selected — neutral fill, no hue to show
container = MaterialTheme.colorScheme.surfaceContainerHighest
content = MaterialTheme.colorScheme.onSurfaceVariant
iconTint = content
}
else -> { // an unselected option in a picker — outline, hue kept on the flag
container = Color.Transparent
content = MaterialTheme.colorScheme.onSurfaceVariant
iconTint = if (hasColor) fill else MaterialTheme.colorScheme.onSurfaceVariant
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
}
}
val body: @Composable () -> Unit = {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (showIcon) {
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(15.dp))
}
Text(label, style = MaterialTheme.typography.labelMedium, color = content)
}
}
val shape = RoundedCornerShape(50)
if (onClick != null) {
Surface(onClick = onClick, color = container, shape = shape, border = border, modifier = modifier) { body() }
} else {
Surface(color = container, shape = shape, border = border, modifier = modifier) { body() }
}
}

View File

@@ -0,0 +1,44 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.agendula.R
/** Common reminder lead times offered as quick picks in the reminder pickers. */
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
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
ReminderUnit.Hours -> R.string.reminder_unit_hours
ReminderUnit.Days -> R.string.reminder_unit_days
ReminderUnit.Weeks -> R.string.reminder_unit_weeks
}
/**
* Humanise a reminder lead time (minutes before due) into one line: "At time of
* task" (0), "10 minutes before", "1 hour before", … Shared by the reminder
* pickers and the settings summaries so the wording never drifts. Ported from
* Calendula.
*/
@Composable
fun reminderLeadTimeLabel(minutes: Int): String = when {
minutes <= 0 -> stringResource(R.string.reminder_at_due)
minutes % 10_080 == 0 ->
pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080)
minutes % 1_440 == 0 ->
pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440)
minutes % 60 == 0 ->
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
}

View File

@@ -0,0 +1,189 @@
package de.jeanlucmakiola.agendula.ui.common
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.layout.Arrangement
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.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
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
* plus a "Custom" row that expands an inline amount field + unit selector.
* [allowInherit] adds a "Use default" row (per-list overrides); [allowNone] adds
* a "None" row. Returns the choice as a [ListReminderOverride]. Ported from
* Calendula's ReminderDefaultPicker.
*/
@Composable
fun ReminderLeadPicker(
title: String,
selected: ListReminderOverride,
allowInherit: Boolean,
allowNone: Boolean,
onSelect: (ListReminderOverride) -> Unit,
onDismiss: () -> Unit,
presets: List<Int> = REMINDER_PRESETS,
) {
val selectedMinutes = (selected as? ListReminderOverride.Minutes)?.minutes
val customSelected = selectedMinutes != null && selectedMinutes !in presets
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
var customExpanded by rememberSaveable { mutableStateOf(false) }
var amountText by rememberSaveable { mutableStateOf(seed.first) }
var unit by rememberSaveable { mutableStateOf(seed.second) }
val options = buildList {
if (allowInherit) add(ListReminderOverride.Inherit)
if (allowNone) add(ListReminderOverride.None)
presets.forEach { add(ListReminderOverride.Minutes(it)) }
}
val rowCount = options.size + 1 // + the custom row
FullScreenPicker(title = title, onDismiss = onDismiss) {
options.forEachIndexed { index, option ->
val isSelected = option == selected
GroupedRow(
title = reminderOverrideLabel(option),
position = positionOf(index, rowCount),
selected = isSelected,
onClick = {
onSelect(option)
onDismiss()
},
)
}
// Expanded, the Custom row connects downward into the editor card so the
// two read as one grouped container.
GroupedRow(
title = if (customSelected) {
stringResource(R.string.reminder_custom_with_value, reminderLeadTimeLabel(selectedMinutes!!))
} else {
stringResource(R.string.reminder_custom)
},
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
selected = customSelected,
onClick = { customExpanded = !customExpanded },
)
AnimatedVisibility(
visible = customExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
CustomReminderEditor(
amountText = amountText,
onAmountChange = { amountText = it },
unit = unit,
onUnitChange = { unit = it },
onConfirm = { minutes ->
onSelect(ListReminderOverride.Minutes(minutes))
onDismiss()
},
)
}
}
}
@Composable
private fun CustomReminderEditor(
amountText: String,
onAmountChange: (String) -> Unit,
unit: ReminderUnit,
onUnitChange: (ReminderUnit) -> Unit,
onConfirm: (Int) -> Unit,
) {
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
// A Position.Bottom shape: tight top corners meeting the row, full bottom.
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ReminderUnit.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = unit == entry,
onClick = { onUnitChange(entry) },
shape = SegmentedButtonDefaults.itemShape(index, ReminderUnit.entries.size),
label = { Text(stringResource(reminderUnitLabel(entry))) },
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Surface(
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))
Text(
text = amount?.let { reminderLeadTimeLabel(it * unit.minutesFactor) }
?: stringResource(R.string.reminder_custom_amount),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(16.dp))
FilledTonalButton(
onClick = { amount?.let { onConfirm(it * unit.minutesFactor) } },
enabled = amount != null,
) {
Text(stringResource(R.string.reminder_custom_set))
}
}
}
}
}
@Composable
private fun reminderOverrideLabel(override: ListReminderOverride): String = when (override) {
ListReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
ListReminderOverride.None -> stringResource(R.string.reminder_none)
is ListReminderOverride.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,85 @@
package de.jeanlucmakiola.agendula.ui.common
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.graphics.shapes.RoundedPolygon
/**
* A top-bar action whose icon sits in a tonal container clipped to one of the
* M3 Expressive [MaterialShapes] (cookie, clover, sunny, …) — Agendula's playful
* take on a plain [androidx.compose.material3.IconButton]. Each action passes
* its own [shape] and [containerColor], so a row of them reads as a set of
* distinct little tokens rather than identical grey glyphs.
*
* Pressing springs the icon down a touch and gives it a small turn — a light
* expressive flourish, no library motion APIs needed.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ShapedActionButton(
shape: RoundedPolygon,
icon: ImageVector,
contentDescription: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.tertiaryContainer,
contentColor: Color = MaterialTheme.colorScheme.onTertiaryContainer,
size: Dp = 40.dp,
iconSize: Dp = 22.dp,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.88f else 1f, label = "actionScale")
val rotation by animateFloatAsState(if (pressed) 24f else 0f, label = "actionRotation")
Surface(
onClick = onClick,
modifier = modifier.size(size),
shape = shape.toShape(),
color = containerColor,
contentColor = contentColor,
interactionSource = interaction,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = icon,
contentDescription = contentDescription,
modifier = Modifier
.size(iconSize)
.scale(scale)
.rotate(rotation),
)
}
}
}
/**
* Named shapes for the app's top-bar actions, so each action is recognisably
* "its own" and new actions just pick the next unused shape. Expose the
* expressive [MaterialShapes] through a single opt-in site.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
object ActionShapes {
/** Settings — a 4-sided cookie (rounded, scalloped square). */
val Settings: RoundedPolygon get() = MaterialShapes.Cookie4Sided
}

View File

@@ -0,0 +1,528 @@
package de.jeanlucmakiola.agendula.ui.detail
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.heightIn
import androidx.compose.foundation.layout.imePadding
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.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.ui.common.GroupedSurface
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
import de.jeanlucmakiola.agendula.ui.common.formatDate
import de.jeanlucmakiola.agendula.ui.common.formatTime
import de.jeanlucmakiola.agendula.ui.common.pastelize
import de.jeanlucmakiola.agendula.ui.common.positionOf
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
/**
* One task's detail. Each fact is its own tonal card with a leading icon (the
* Calendula detail pattern); only genuinely-grouped things — the subtasks — use
* the grouped-row run. Edit / delete live in the top bar.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskDetailScreen(
onEdit: () -> Unit,
onDeleted: () -> Unit,
onBack: () -> Unit,
onOpenTask: (Long) -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskDetailViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.task_detail_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
actions = {
val content = state as? TaskDetailUiState.Content
IconButton(onClick = onEdit, enabled = content != null) {
Icon(Icons.Rounded.Edit, contentDescription = stringResource(R.string.edit))
}
IconButton(
onClick = {
content?.let {
viewModel.delete(it.detail.task)
onDeleted()
}
},
enabled = content != null,
) {
Icon(Icons.Rounded.Delete, contentDescription = stringResource(R.string.delete))
}
},
)
},
) { inner ->
when (val s = state) {
TaskDetailUiState.Loading -> Unit
TaskDetailUiState.NotFound ->
CenteredMessage(stringResource(R.string.task_detail_not_found), inner)
is TaskDetailUiState.Content -> DetailBody(
detail = s.detail,
inner = inner,
onToggle = { viewModel.toggleComplete(s.detail.task) },
onToggleSubtask = { viewModel.toggleComplete(it) },
onAddSubtask = { viewModel.addSubtask(it) },
onOpenTask = onOpenTask,
)
}
}
}
@Composable
private fun DetailBody(
detail: TaskDetail,
inner: androidx.compose.foundation.layout.PaddingValues,
onToggle: () -> Unit,
onToggleSubtask: (Task) -> Unit,
onAddSubtask: (String) -> Unit,
onOpenTask: (Long) -> Unit,
) {
val task = detail.task
val dark = isSystemInDarkTheme()
val accent = pastelize(task.effectiveColor, dark)
val gap = 12.dp
var subtasksExpanded by rememberSaveable { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(inner)
// Shrink the scroll viewport by the keyboard so the focused add-subtask
// field scrolls into view above the IME instead of being hidden.
.imePadding()
.verticalScroll(rememberScrollState())
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
) {
// Title + complete toggle, with a short list-coloured accent beneath.
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() })
Text(
text = task.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.SemiBold,
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
modifier = Modifier.weight(1f),
)
}
Spacer(Modifier.height(10.dp))
Box(
modifier = Modifier
.width(48.dp)
.height(3.dp)
.background(accent, RoundedCornerShape(2.dp)),
)
Spacer(Modifier.height(20.dp))
// One card per fact; gaps between, no shared container (they aren't a group).
val cards = buildList<@Composable () -> Unit> {
// Parent — only when this is a subtask. Conveys that it's filed under
// another task (and so doesn't stand alone in the list); taps open it.
detail.parent?.let { parent ->
add {
DetailCard(
icon = Icons.Rounded.AccountTree,
iconContentDescription = stringResource(R.string.detail_parent),
onClick = { onOpenTask(parent.taskId) },
) {
Text(
text = stringResource(R.string.detail_part_of),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = parent.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.titleMedium,
)
}
}
}
// When — the Calendula time card: date primary, time(s) secondary.
taskWhenLines(task)?.let { (primary, secondary) ->
add {
DetailCard(icon = Icons.Rounded.Schedule, iconContentDescription = stringResource(R.string.detail_due)) {
Text(primary, style = MaterialTheme.typography.titleMedium)
if (secondary != null) {
Spacer(Modifier.height(2.dp))
Text(
secondary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// List — icon tinted in the list colour conveys identity (no extra dot).
add {
DetailCard(
icon = Icons.Rounded.Checklist,
iconTint = accent,
iconContentDescription = stringResource(R.string.detail_list),
) {
Text(
text = task.listName ?: stringResource(R.string.detail_list),
style = MaterialTheme.typography.titleMedium,
)
}
}
// Priority — a coloured status chip in the level's hue.
if (task.priority != de.jeanlucmakiola.agendula.domain.Priority.NONE) {
add {
DetailCard(
icon = Icons.Rounded.Flag,
iconContentDescription = stringResource(R.string.detail_priority),
) {
PriorityChip(
priority = task.priority,
label = priorityLabel(task.priority),
showIcon = false,
)
}
}
}
// Progress — shown when the task carries its own percent or has
// subtasks (then the completion ratio fills in if no percent is set).
val hasSubtasks = detail.subtasks.isNotEmpty()
if ((task.percentComplete ?: 0) > 0 || hasSubtasks) {
val pct = task.percentComplete
?: (detail.subtasks.count { it.isCompleted } * 100 / detail.subtasks.size)
add {
DetailCard(icon = Icons.Rounded.Percent, iconContentDescription = stringResource(R.string.detail_progress)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(R.string.detail_progress), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.percent_complete, pct),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(progress = { pct / 100f }, modifier = Modifier.fillMaxWidth())
}
}
}
// Description.
task.description?.takeIf { it.isNotBlank() }?.let { description ->
add {
DetailCard(icon = Icons.AutoMirrored.Rounded.Notes, iconContentDescription = null) {
Text(description, style = MaterialTheme.typography.bodyMedium)
}
}
}
// Subtasks — a genuine group: a connected grouped run whose first
// segment is the expand/collapse toggle. Always shown (even with no
// subtasks yet) so the add field is reachable on any task.
add {
SubtasksGroup(
subtasks = detail.subtasks,
expanded = subtasksExpanded,
onToggle = { subtasksExpanded = !subtasksExpanded },
onToggleSubtask = onToggleSubtask,
onAddSubtask = onAddSubtask,
onOpenSubtask = onOpenTask,
)
}
}
cards.forEachIndexed { index, card ->
if (index > 0) Spacer(Modifier.height(gap))
card()
}
}
}
/**
* Subtasks as a connected grouped run (the GroupedRow family look) at the
* cards' width: the first segment is the expand/collapse toggle (icon,
* "Subtasks", a done/total count, chevron); the rest are the subtask rows.
*/
@Composable
private fun SubtasksGroup(
subtasks: List<Task>,
expanded: Boolean,
onToggle: () -> Unit,
onToggleSubtask: (Task) -> Unit,
onAddSubtask: (String) -> Unit,
onOpenSubtask: (Long) -> Unit,
) {
val done = subtasks.count { it.isCompleted }
// Completed subtasks sink to the bottom; stable, so order is otherwise kept.
val ordered = remember(subtasks) { subtasks.sortedBy { it.isCompleted } }
val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "subtasksChevron")
// Segments when expanded: header + the always-present add row + each subtask.
val count = if (expanded) subtasks.size + 2 else 1
// First segment: the toggle header.
GroupedSurface(position = positionOf(0, count), onClick = onToggle) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ListAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Text(
text = stringResource(R.string.detail_subtasks),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
// No count until there's something to count.
if (subtasks.isNotEmpty()) {
Text(
text = "$done / ${subtasks.size}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.width(8.dp))
}
Icon(
imageVector = Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(rotation),
)
}
}
if (expanded) {
// Always-present add row at the top of the list, in an accent tone.
GroupedSurface(
position = positionOf(1, count),
color = MaterialTheme.colorScheme.primaryContainer,
) {
AddSubtaskField(onAdd = onAddSubtask)
}
ordered.forEachIndexed { index, sub ->
GroupedSurface(
position = positionOf(index + 2, count),
onClick = { onOpenSubtask(sub.taskId) },
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(checked = sub.isCompleted, onCheckedChange = { onToggleSubtask(sub) })
Spacer(Modifier.width(4.dp))
Text(
text = sub.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.bodyLarge,
textDecoration = if (sub.isCompleted) TextDecoration.LineThrough else null,
color = if (sub.isCompleted) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.weight(1f),
)
}
}
}
}
}
/**
* Inline "add a subtask" — title only; submits on the tick or the IME action.
* A [BasicTextField] (not the boxed Material field) so its text lines up exactly
* with the subtask titles, the checkbox column matched by the leading + icon.
*/
@Composable
private fun AddSubtaskField(onAdd: (String) -> Unit) {
var text by remember { mutableStateOf("") }
fun submit() {
if (text.isNotBlank()) {
onAdd(text.trim())
text = ""
}
}
val accentOn = MaterialTheme.colorScheme.onPrimaryContainer
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).padding(start = 8.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(Icons.Rounded.Add, contentDescription = null, tint = accentOn)
}
Spacer(Modifier.width(4.dp))
BasicTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = accentOn),
cursorBrush = SolidColor(accentOn),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { submit() }),
modifier = Modifier.weight(1f),
decorationBox = { inner ->
if (text.isEmpty()) {
Text(
text = stringResource(R.string.add_task_hint),
style = MaterialTheme.typography.bodyLarge,
color = accentOn.copy(alpha = 0.7f),
)
}
inner()
},
)
if (text.isNotBlank()) {
IconButton(onClick = ::submit) {
Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.cd_add_task), tint = accentOn)
}
}
}
}
/** One info card: tonal container, leading icon in the gutter, value to the right. */
@Composable
private fun DetailCard(
icon: ImageVector,
iconContentDescription: String?,
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
onClick: (() -> Unit)? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val shape = RoundedCornerShape(16.dp)
val color = MaterialTheme.colorScheme.surfaceContainerHigh
val body: @Composable () -> Unit = {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = iconContentDescription,
tint = iconTint,
modifier = Modifier.size(24.dp),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f), content = content)
}
}
if (onClick != null) {
Surface(onClick = onClick, color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { body() }
} else {
Surface(color = color, shape = shape, modifier = Modifier.fillMaxWidth()) { body() }
}
}
/**
* The task's "when", formatted like Calendula's event time card: a primary date
* line and an optional secondary time line. A start+due pair reads as a range;
* `null` when the task has no dates at all.
*/
@Composable
private fun taskWhenLines(task: Task): Pair<String, String?>? {
val start = task.start
val due = task.due
return when {
start != null && due != null -> {
val sameDay = start.formatDate() == due.formatDate()
val primary = if (sameDay) due.formatDate() else "${start.formatDate()} ${due.formatDate()}"
val secondary = if (task.isAllDay) null else "${start.formatTime()} ${due.formatTime()}"
primary to secondary
}
due != null -> due.formatDate() to if (task.isAllDay) null else due.formatTime()
start != null -> start.formatDate() to if (task.isAllDay) null else start.formatTime()
else -> null
}
}
@Composable
private fun CenteredMessage(text: String, inner: androidx.compose.foundation.layout.PaddingValues) {
Box(
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}

View File

@@ -1,11 +1,12 @@
package de.jeanlucmakiola.floret.ui.detail package de.jeanlucmakiola.agendula.ui.detail
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.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail import de.jeanlucmakiola.agendula.domain.TaskDetail
import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -54,4 +55,15 @@ class TaskDetailViewModel @Inject constructor(
fun delete(task: Task) = viewModelScope.launch { fun delete(task: Task) = viewModelScope.launch {
runCatching { repository.deleteTask(task.taskId) } runCatching { repository.deleteTask(task.taskId) }
} }
/** Add a subtask (title only) under the currently-shown task. */
fun addSubtask(title: String) = viewModelScope.launch {
val parent = (state.value as? TaskDetailUiState.Content)?.detail?.task ?: return@launch
if (title.isBlank()) return@launch
runCatching {
repository.createTask(
TaskForm(title = title.trim(), listId = parent.listId, parentId = parent.taskId),
)
}
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,237 @@
package de.jeanlucmakiola.agendula.ui.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskFormError
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
import de.jeanlucmakiola.agendula.domain.populatedFields
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Instant
/**
* Editable form state for creating or editing a task. The screen renders these
* fields and calls the `on*` setters; [save] validates, writes, re-syncs
* reminders, and flips [TaskEditUiState.saved] for the screen to navigate back.
*/
data class TaskEditUiState(
val loading: Boolean = true,
val isNew: Boolean = true,
val title: String = "",
val description: String = "",
val listId: Long? = null,
val start: Instant? = null,
val due: Instant? = null,
val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
val percentComplete: Int? = null,
val reminderMinutesBeforeDue: Int? = null,
val lists: List<TaskList> = emptyList(),
/** Top-level tasks in the current list this task can be filed under (reparent). */
val parentCandidates: List<Task> = emptyList(),
/** Optional sections currently shown (core Title / List / When are always on). */
val visibleFields: Set<TaskFormField> = emptySet(),
/** Optional sections still tucked behind "More fields". */
val hiddenFields: List<TaskFormField> = emptyList(),
val errors: Set<TaskFormError> = emptySet(),
val saveFailed: Boolean = false,
/** The task changed underneath us; the screen asks to overwrite or cancel. */
val saveConflict: Boolean = false,
val saved: Boolean = false,
) {
/** The currently-selected parent task, if this task is filed under one. */
val parent: Task? get() = parentCandidates.firstOrNull { it.taskId == parentId }
}
@HiltViewModel
class TaskEditViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
private val reminderScheduler: ReminderScheduler,
) : ViewModel() {
private val _state = MutableStateFlow(TaskEditUiState())
val state: StateFlow<TaskEditUiState> = _state.asStateFlow()
private var editingTaskId: Long? = null
/** `last_modified` captured when the form loaded — the conflict-check baseline. */
private var baselineLastModified: Instant? = null
/** Sections shown by default (a setting); the rest unfold via [revealField]. */
private var defaultFields: Set<TaskFormField> = emptySet()
/** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
editingTaskId = null
baselineLastModified = null
viewModelScope.launch {
val settings = settingsPrefs.settings.first()
defaultFields = settings.defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val defaultList = presetListId
?: settings.defaultListId
?: lists.firstOrNull { !it.isLocal }?.id
?: lists.firstOrNull()?.id
_state.value = withFields(
TaskEditUiState(
loading = false,
isNew = true,
listId = defaultList,
parentId = parentId,
lists = lists,
parentCandidates = loadParents(defaultList, selfId = null),
),
)
}
}
/** Load an existing task for editing. */
fun bindEdit(taskId: Long) {
editingTaskId = taskId
viewModelScope.launch {
defaultFields = settingsPrefs.settings.first().defaultEditFields
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull()
if (task == null) {
_state.value = _state.value.copy(loading = false, lists = lists)
return@launch
}
baselineLastModified = task.lastModified
_state.value = withFields(
TaskEditUiState(
loading = false,
isNew = false,
title = task.title,
description = task.description.orEmpty(),
listId = task.listId,
start = task.start,
due = task.due,
isAllDay = task.isAllDay,
priority = task.priority,
parentId = task.parentId,
percentComplete = task.percentComplete,
lists = lists,
parentCandidates = loadParents(task.listId, selfId = taskId),
),
)
}
}
/**
* Tasks the edited task can be filed under: active, top-level tasks in
* [listId], minus the task itself (a task can't parent itself). Restricting to
* top-level keeps the hierarchy one level deep (matching the detail screen),
* and to active so you don't file work under something already done/cancelled.
*/
private suspend fun loadParents(listId: Long?, selfId: Long?): List<Task> {
if (listId == null) return emptyList()
val tasks = runCatching { repository.tasks(TaskFilter.OfList(listId)).first() }.getOrElse { emptyList() }
return tasks.filter { !it.isClosed && !it.isSubtask && it.taskId != selfId }
}
/** Reveal an optional section (one tap from the "More fields" picker). */
fun revealField(field: TaskFormField) = update {
val visible = it.visibleFields + field
it.copy(visibleFields = visible, hiddenFields = hiddenOf(visible))
}
/**
* Resolve which optional sections are visible: the default set, plus any
* field that already carries a value (so editing never hides real data).
*/
private fun withFields(state: TaskEditUiState): TaskEditUiState {
val visible = defaultFields + state.toForm().populatedFields()
return state.copy(visibleFields = visible, hiddenFields = hiddenOf(visible))
}
private fun hiddenOf(visible: Set<TaskFormField>): List<TaskFormField> =
TaskFormField.entries.filterNot { it in visible }
fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) }
fun onDescriptionChange(value: String) = update { it.copy(description = value) }
/** Switching list invalidates the parent (it lived in the old list); reload candidates. */
fun onListChange(listId: Long) {
if (_state.value.listId == listId) return // re-picking the same list keeps the parent
update { it.copy(listId = listId, parentId = null, errors = emptySet()) }
viewModelScope.launch {
val candidates = loadParents(listId, selfId = editingTaskId)
update { it.copy(parentCandidates = candidates) }
}
}
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onPercentChange(value: Int?) = update { it.copy(percentComplete = value?.coerceIn(0, 100)) }
fun onParentChange(parentId: Long?) = update { it.copy(parentId = parentId) }
fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) }
/** Dismiss the conflict prompt without saving (the user keeps editing). */
fun dismissConflict() = update { it.copy(saveConflict = false) }
/** [force] = overwrite even if the task changed since it loaded (conflict prompt). */
fun save(force: Boolean = false) {
val current = _state.value
val form = current.toForm()
val errors = form.validate()
if (errors.isNotEmpty()) {
_state.value = current.copy(errors = errors)
return
}
viewModelScope.launch {
runCatching {
val id = editingTaskId
if (id == null) {
repository.createTask(form)
} else {
repository.updateTask(id, form, expectedLastModified = if (force) null else baselineLastModified)
}
reminderScheduler.sync()
}.onSuccess {
_state.value = _state.value.copy(saved = true, saveFailed = false, saveConflict = false)
}.onFailure { error ->
_state.value = if (error is TaskConflictException) {
_state.value.copy(saveConflict = true, saveFailed = false)
} else {
_state.value.copy(saveFailed = true)
}
}
}
}
private inline fun update(block: (TaskEditUiState) -> TaskEditUiState) {
_state.value = block(_state.value)
}
}
/** Snapshot the editable state as a validated [TaskForm]. */
private fun TaskEditUiState.toForm(): TaskForm = TaskForm(
title = title,
listId = listId ?: 0L,
description = description.ifBlank { null },
start = start,
due = due,
isAllDay = isAllDay,
priority = priority,
parentId = parentId,
percentComplete = percentComplete,
reminderMinutesBeforeDue = reminderMinutesBeforeDue,
)

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.ui.lists package de.jeanlucmakiola.agendula.ui.lists
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -20,6 +20,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ListAlt import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Today import androidx.compose.material.icons.rounded.Today
import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material.icons.rounded.Upcoming
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -44,12 +45,14 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.floret.R import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.floret.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.floret.ui.common.GroupedRow import de.jeanlucmakiola.agendula.ui.common.ActionShapes
import de.jeanlucmakiola.floret.ui.common.ListColorChip import de.jeanlucmakiola.agendula.ui.common.GroupedRow
import de.jeanlucmakiola.floret.ui.common.positionOf import de.jeanlucmakiola.agendula.ui.common.ListColorChip
import de.jeanlucmakiola.agendula.ui.common.ShapedActionButton
import de.jeanlucmakiola.agendula.ui.common.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
@@ -61,6 +64,7 @@ import de.jeanlucmakiola.floret.ui.common.positionOf
fun ListsScreen( fun ListsScreen(
onOpenFilter: (TaskFilter) -> Unit, onOpenFilter: (TaskFilter) -> Unit,
onNewTask: () -> Unit, onNewTask: () -> Unit,
onOpenSettings: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ListsViewModel = hiltViewModel(), viewModel: ListsViewModel = hiltViewModel(),
) { ) {
@@ -72,6 +76,17 @@ fun ListsScreen(
topBar = { topBar = {
MediumTopAppBar( MediumTopAppBar(
title = { Text(stringResource(R.string.app_name)) }, title = { Text(stringResource(R.string.app_name)) },
actions = {
ShapedActionButton(
shape = ActionShapes.Settings,
icon = Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings_title),
onClick = onOpenSettings,
modifier = Modifier.padding(end = 8.dp),
size = 48.dp,
iconSize = 26.dp,
)
},
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
) )
}, },

View File

@@ -1,15 +1,15 @@
package de.jeanlucmakiola.floret.ui.lists package de.jeanlucmakiola.agendula.ui.lists
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.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.DayWindow import de.jeanlucmakiola.agendula.domain.DayWindow
import de.jeanlucmakiola.floret.domain.SmartList import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.floret.domain.Task import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskFiltering import de.jeanlucmakiola.agendula.domain.TaskFiltering
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.domain.TaskList
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
@@ -49,16 +49,20 @@ class ListsViewModel @Inject constructor(
private fun buildContent(lists: List<TaskList>, openTasks: List<Task>): ListsUiState.Content { private fun buildContent(lists: List<TaskList>, openTasks: List<Task>): ListsUiState.Content {
val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) val (todayStart, todayEnd) = DayWindow.today(Clock.System.now(), ZoneId.systemDefault())
// Count only top-level tasks: a subtask is represented by its parent (and
// its progress chip), and an open subtask under a *completed* parent must
// not read as an open item here. Mirrors the task list collapsing subtasks.
val topLevel = openTasks.filter { !it.isSubtask }
fun count(smart: SmartList) = fun count(smart: SmartList) =
openTasks.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) } topLevel.count { TaskFiltering.matches(it, TaskFilter.Smart(smart), todayStart, todayEnd) }
val smartCounts = listOf( val smartCounts = listOf(
SmartCount(SmartList.TODAY, count(SmartList.TODAY)), SmartCount(SmartList.TODAY, count(SmartList.TODAY)),
SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)), SmartCount(SmartList.OVERDUE, count(SmartList.OVERDUE)),
SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)), SmartCount(SmartList.UPCOMING, count(SmartList.UPCOMING)),
SmartCount(SmartList.ALL, openTasks.size), SmartCount(SmartList.ALL, topLevel.size),
) )
val openByList = openTasks.groupingBy { it.listId }.eachCount() val openByList = topLevel.groupingBy { it.listId }.eachCount()
val groups = lists val groups = lists
.groupBy { it.accountName } .groupBy { it.accountName }
.map { (account, accountLists) -> .map { (account, accountLists) ->

View File

@@ -0,0 +1,125 @@
package de.jeanlucmakiola.agendula.ui.navigation
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel
import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen
import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel
import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListScreen
import de.jeanlucmakiola.agendula.ui.tasklist.TaskListViewModel
/**
* The single in-app back stack: lists → task list → detail / edit.
*
* Each destination resolves its own Hilt [androidx.lifecycle.ViewModel] and
* feeds it the route arguments through that VM's `bind(...)` method (the VMs
* were built render-only in M1 and take their input this way rather than from a
* `SavedStateHandle`). Screens receive plain navigation lambdas so they stay
* unaware of the nav library.
*/
@Composable
fun AgendulaNavHost(modifier: Modifier = Modifier) {
val nav = rememberNavController()
val slide = rememberNavSlideSpec()
NavHost(
navController = nav,
startDestination = Dest.LISTS,
modifier = modifier,
// 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.
// Back (and the predictive-back drag, which drives popExit) is the snappy
// slide-out to the right, revealing the held screen unmoved.
enterTransition = { fadeIn() },
exitTransition = { ExitTransition.None },
popEnterTransition = { EnterTransition.None },
popExitTransition = { slideOutHorizontally(slide) { it } + fadeOut() },
) {
composable(Dest.LISTS) {
ListsScreen(
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
onOpenSettings = { nav.navigate(Dest.SETTINGS) },
)
}
composable(Dest.SETTINGS) {
SettingsScreen(onBack = { nav.popBackStack() })
}
composable(Dest.TaskList.route, arguments = Dest.TaskList.arguments) { entry ->
val args = entry.arguments
val filter = Dest.TaskList.filterOf(
smart = args?.getString(Dest.TaskList.ARG_SMART),
listId = args?.getLong(Dest.TaskList.ARG_LIST_ID) ?: Dest.NO_ID,
)
val vm: TaskListViewModel = hiltViewModel()
LaunchedEffect(filter) { vm.bind(filter) }
TaskListScreen(
filter = filter,
viewModel = vm,
onOpenTask = { taskId -> nav.navigate(Dest.TaskDetail.build(taskId)) },
onNewTask = {
val presetListId = (filter as? TaskFilter.OfList)?.listId
nav.navigate(Dest.TaskEdit.buildNew(presetListId = presetListId))
},
onBack = { nav.popBackStack() },
)
}
composable(Dest.TaskDetail.route, arguments = Dest.TaskDetail.arguments) { entry ->
val taskId = entry.arguments?.getLong(Dest.TaskDetail.ARG_TASK_ID) ?: Dest.NO_ID
val vm: TaskDetailViewModel = hiltViewModel()
LaunchedEffect(taskId) { vm.bind(taskId) }
TaskDetailScreen(
viewModel = vm,
onEdit = { nav.navigate(Dest.TaskEdit.buildEdit(taskId)) },
onDeleted = { nav.popBackStack() },
onBack = { nav.popBackStack() },
// A subtask opens its own detail — another entry on the stack, with
// its own VM bound to that id (it may have children of its own).
onOpenTask = { subtaskId -> nav.navigate(Dest.TaskDetail.build(subtaskId)) },
)
}
composable(Dest.TaskEdit.route, arguments = Dest.TaskEdit.arguments) { entry ->
val args = entry.arguments
val taskId = args?.getLong(Dest.TaskEdit.ARG_TASK_ID) ?: Dest.NO_ID
val presetListId = args?.getLong(Dest.TaskEdit.ARG_PRESET_LIST_ID) ?: Dest.NO_ID
val parentId = args?.getLong(Dest.TaskEdit.ARG_PARENT_ID) ?: Dest.NO_ID
val vm: TaskEditViewModel = hiltViewModel()
LaunchedEffect(taskId, presetListId, parentId) {
if (taskId != Dest.NO_ID) {
vm.bindEdit(taskId)
} else {
vm.bindNew(
presetListId = presetListId.takeIf { it != Dest.NO_ID },
parentId = parentId.takeIf { it != Dest.NO_ID },
)
}
}
TaskEditScreen(
viewModel = vm,
onSaved = { nav.popBackStack() },
onBack = { nav.popBackStack() },
)
}
}
}

View File

@@ -0,0 +1,21 @@
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,91 @@
package de.jeanlucmakiola.agendula.ui.navigation
import androidx.navigation.NavType
import androidx.navigation.navArgument
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
/**
* String-based route table for the app's [androidx.navigation.NavHost].
*
* Routes are kept stringly-typed (rather than the type-safe `@Serializable`
* API) so we don't have to pull in the kotlinx-serialization Gradle plugin for
* four small destinations. Each destination exposes a `route` template (for
* `composable(...)`) and a builder that fills the arguments (for `navigate`).
*
* Argument convention: optional `Long`s default to [NO_ID] (`-1`) rather than
* being nullable, which sidesteps the lack of a nullable-Long [NavType].
*/
object Dest {
const val NO_ID = -1L
/** Home — smart lists + the user's lists. */
const val LISTS = "lists"
/** App preferences. */
const val SETTINGS = "settings"
/**
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
* `smart` (a [SmartList] name); whichever is set decides the filter.
*/
object TaskList {
const val ARG_LIST_ID = "listId"
const val ARG_SMART = "smart"
const val route = "taskList?$ARG_LIST_ID={$ARG_LIST_ID}&$ARG_SMART={$ARG_SMART}"
val arguments = listOf(
navArgument(ARG_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_SMART) { type = NavType.StringType; nullable = true; defaultValue = null },
)
fun build(filter: TaskFilter): String = when (filter) {
is TaskFilter.OfList -> "taskList?$ARG_LIST_ID=${filter.listId}"
is TaskFilter.Smart -> "taskList?$ARG_SMART=${filter.list.name}"
}
/** Reconstruct the filter from a destination's arguments. */
fun filterOf(smart: String?, listId: Long): TaskFilter =
if (smart != null) TaskFilter.Smart(SmartList.valueOf(smart))
else TaskFilter.OfList(listId)
}
/** A single task's detail. */
object TaskDetail {
const val ARG_TASK_ID = "taskId"
const val route = "taskDetail/{$ARG_TASK_ID}"
val arguments = listOf(navArgument(ARG_TASK_ID) { type = NavType.LongType })
fun build(taskId: Long): String = "taskDetail/$taskId"
}
/**
* The create / edit form. `taskId` set ([NO_ID] otherwise) ⇒ edit; for a new
* task, `presetListId` / `parentId` optionally seed the form.
*/
object TaskEdit {
const val ARG_TASK_ID = "taskId"
const val ARG_PRESET_LIST_ID = "presetListId"
const val ARG_PARENT_ID = "parentId"
const val route =
"taskEdit?$ARG_TASK_ID={$ARG_TASK_ID}" +
"&$ARG_PRESET_LIST_ID={$ARG_PRESET_LIST_ID}" +
"&$ARG_PARENT_ID={$ARG_PARENT_ID}"
val arguments = listOf(
navArgument(ARG_TASK_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_PRESET_LIST_ID) { type = NavType.LongType; defaultValue = NO_ID },
navArgument(ARG_PARENT_ID) { type = NavType.LongType; defaultValue = NO_ID },
)
fun buildEdit(taskId: Long): String = "taskEdit?$ARG_TASK_ID=$taskId"
fun buildNew(presetListId: Long? = null, parentId: Long? = null): String {
val params = buildList {
if (presetListId != null) add("$ARG_PRESET_LIST_ID=$presetListId")
if (parentId != null) add("$ARG_PARENT_ID=$parentId")
}
return if (params.isEmpty()) "taskEdit" else "taskEdit?${params.joinToString("&")}"
}
}
}

View File

@@ -0,0 +1,136 @@
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

@@ -1,10 +1,10 @@
package de.jeanlucmakiola.floret.ui.permission package de.jeanlucmakiola.agendula.ui.permission
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.tasks.ProviderResolver import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.floret.data.tasks.ProviderStatus import de.jeanlucmakiola.agendula.data.tasks.ProviderStatus
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow

View File

@@ -0,0 +1,111 @@
package de.jeanlucmakiola.agendula.ui.permission
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Schedule
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.agendula.R
/**
* One-time onboarding step after the tasks-provider grant: explains that Agendula
* delivers due reminders itself (tasks providers don't broadcast reminders) and
* requests `POST_NOTIFICATIONS` (a system dialog on API 33+ only).
*
* Reminders default ON: [onFinished] gets true from the primary action even if
* the system dialog is declined — the OS permission is the real gate, and the
* Settings toggle re-requests it. "Not now" turns the in-app toggle off.
*/
@Composable
fun ReminderOnboardingScreen(
onFinished: (remindersEnabled: Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { onFinished(true) }
OnboardingScaffold(
modifier = modifier,
hero = { SquircleHero(Icons.Rounded.Notifications) },
actions = {
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
} else {
onFinished(true)
}
},
modifier = Modifier.fillMaxWidth().height(56.dp),
) {
Text(
text = stringResource(R.string.reminder_onboarding_enable_button),
style = MaterialTheme.typography.titleMedium,
)
}
TextButton(
onClick = { onFinished(false) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.reminder_onboarding_skip_button))
}
},
) {
Text(
text = stringResource(R.string.app_name).uppercase(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(OnboardingSpace.xs))
Text(
text = stringResource(R.string.reminder_onboarding_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
text = stringResource(R.string.reminder_onboarding_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(OnboardingSpace.xl))
BenefitRow(
icon = Icons.Rounded.Notifications,
title = stringResource(R.string.reminder_benefit_delivery_title),
body = stringResource(R.string.reminder_benefit_delivery_body),
)
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Rounded.Schedule,
title = stringResource(R.string.reminder_benefit_timing_title),
body = stringResource(R.string.reminder_benefit_timing_body),
)
Spacer(Modifier.height(OnboardingSpace.sm))
BenefitRow(
icon = Icons.Rounded.Edit,
title = stringResource(R.string.reminder_benefit_reversible_title),
body = stringResource(R.string.reminder_benefit_reversible_body),
)
}
}

View File

@@ -0,0 +1,39 @@
package de.jeanlucmakiola.agendula.ui.permission
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Gates the one-time reminder onboarding step shown after the tasks-provider
* grant. [onboardingDone] is null until DataStore's first emission so the step
* neither flashes for users who completed it nor gets skipped.
*/
@HiltViewModel
class ReminderOnboardingViewModel @Inject constructor(
private val prefs: SettingsPrefs,
) : ViewModel() {
val onboardingDone: StateFlow<Boolean?> = prefs.reminderOnboardingDone
.map { done -> done as Boolean? }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/** Close the step, recording whether due reminders stay on. */
fun finish(remindersEnabled: Boolean) {
viewModelScope.launch {
prefs.setRemindersEnabled(remindersEnabled)
prefs.setReminderOnboardingDone()
}
}
}

View File

@@ -0,0 +1,78 @@
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

@@ -0,0 +1,699 @@
package de.jeanlucmakiola.agendula.ui.settings
import android.Manifest
import android.app.AlarmManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.provider.Settings
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
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.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Notes
import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.rounded.AccountTree
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Percent
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
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.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.data.prefs.ListReminderOverride
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.agendula.ui.common.GroupedRow
import de.jeanlucmakiola.agendula.ui.common.OptionPicker
import de.jeanlucmakiola.agendula.ui.common.Position
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
import de.jeanlucmakiola.agendula.ui.common.pastelize
import de.jeanlucmakiola.agendula.ui.common.positionOf
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, TaskForm, Reminders }
/**
* Token-based accent for a leading icon chip (container / on-container pair),
* so each accent stays correctly paired across theme, dark mode and dynamic
* colour.
*/
private enum class ChipAccent { Neutral, Primary, Tertiary }
/**
* Settings, structured (after Calendula) as a category hub with sliding
* sub-screens. The hub and the sub-screens share [CollapsingScaffold] and the
* grouped-row card system; option lists use the full-screen [OptionPicker].
* A full-screen destination; [onBack] pops it.
*/
@Composable
fun SettingsScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var section by rememberSaveable { mutableStateOf<SettingsSection?>(null) }
// Inside a sub-screen, system back (button or gesture) returns to the hub
// rather than popping the whole Settings destination to the lists overview.
BackHandler(enabled = section != null) { section = null }
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
) {
SettingsHub(onBack = onBack, onOpenSection = { section = it })
SlideInSection(visible = section == SettingsSection.Appearance) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
SlideInSection(visible = section == SettingsSection.TaskForm) {
TaskFormScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
SlideInSection(visible = section == SettingsSection.Reminders) {
RemindersScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
}
}
@Composable
private fun SlideInSection(visible: Boolean, content: @Composable () -> Unit) {
AnimatedVisibility(
visible = visible,
enter = slideInHorizontally { it } + fadeIn(),
exit = slideOutHorizontally { it } + fadeOut(),
) { content() }
}
// ---------------------------------------------------------------------------
// Hub
// ---------------------------------------------------------------------------
@Composable
private fun SettingsHub(
onBack: () -> Unit,
onOpenSection: (SettingsSection) -> Unit,
) {
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.settings_section_appearance),
summary = stringResource(R.string.settings_appearance_subtitle),
position = Position.Top,
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Appearance) },
)
GroupedRow(
title = stringResource(R.string.settings_section_task_form),
summary = stringResource(R.string.settings_task_form_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Tune, ChipAccent.Tertiary) },
onClick = { onOpenSection(SettingsSection.TaskForm) },
)
GroupedRow(
title = stringResource(R.string.settings_section_reminders),
summary = stringResource(R.string.settings_reminders_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Notifications, ChipAccent.Primary) },
onClick = { onOpenSection(SettingsSection.Reminders) },
)
LanguageRow(position = Position.Middle)
ReportProblemRow(position = Position.Bottom)
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. */
@Composable
private fun ReportProblemRow(position: Position) {
val context = LocalContext.current
val issueUrl = stringResource(R.string.report_issue_url)
GroupedRow(
title = stringResource(R.string.settings_report_problem),
summary = stringResource(R.string.settings_report_problem_hint),
position = position,
leading = { CategoryIcon(Icons.Default.BugReport, ChipAccent.Neutral) },
onClick = { openUrl(context, issueUrl) },
)
}
@Composable
private fun languageLabel(tag: String?): String =
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
// ---------------------------------------------------------------------------
// Sub-screens
// ---------------------------------------------------------------------------
@Composable
private fun AppearanceScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
var showTheme by remember { mutableStateOf(false) }
CollapsingScaffold(title = stringResource(R.string.settings_section_appearance), onBack = onBack) {
GroupedRow(
title = stringResource(R.string.settings_theme),
summary = stringResource(themeLabel(state.settings.themeMode)),
position = Position.Top,
onClick = { showTheme = true },
)
GroupedRow(
title = stringResource(R.string.settings_dynamic_color),
summary = if (dynamicColorAvailable) {
stringResource(R.string.settings_dynamic_color_hint)
} else {
stringResource(R.string.settings_dynamic_color_unavailable)
},
position = Position.Bottom,
trailing = {
Switch(
checked = state.settings.dynamicColor && dynamicColorAvailable,
onCheckedChange = viewModel::setDynamicColor,
enabled = dynamicColorAvailable,
)
},
onClick = if (dynamicColorAvailable) {
{ viewModel.setDynamicColor(!state.settings.dynamicColor) }
} else {
null
},
)
}
if (showTheme) {
OptionPicker(
title = stringResource(R.string.settings_theme),
options = ThemeMode.entries,
selected = state.settings.themeMode,
label = { stringResource(themeLabel(it)) },
onSelect = viewModel::setThemeMode,
onDismiss = { showTheme = false },
)
}
}
@Composable
private fun TaskFormScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
var showDefaultList by remember { mutableStateOf(false) }
CollapsingScaffold(title = stringResource(R.string.settings_section_task_form), onBack = onBack) {
Text(
text = stringResource(R.string.settings_form_fields_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
Spacer(Modifier.height(8.dp))
val fields = TaskFormField.entries
fields.forEachIndexed { index, field ->
val checked = field in state.settings.defaultEditFields
GroupedRow(
title = stringResource(formFieldLabel(field)),
position = positionOf(index, fields.size),
leading = {
Icon(
imageVector = formFieldIcon(field),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Switch(checked = checked, onCheckedChange = { viewModel.setFormFieldDefault(field, it) })
},
onClick = { viewModel.setFormFieldDefault(field, !checked) },
)
}
Spacer(Modifier.height(24.dp))
GroupedRow(
title = stringResource(R.string.settings_default_list),
summary = defaultListLabel(state),
position = Position.Top,
onClick = { showDefaultList = true },
)
GroupedRow(
title = stringResource(R.string.settings_add_subtask_row),
summary = stringResource(R.string.settings_add_subtask_row_hint),
position = Position.Bottom,
trailing = {
Switch(
checked = state.settings.showAddSubtaskRow,
onCheckedChange = viewModel::setShowAddSubtaskRow,
)
},
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
)
}
if (showDefaultList) {
val dark = isSystemInDarkTheme()
// null = first available list; the rest are real list ids.
val options = remember(state.lists) { listOf<Long?>(null) + state.lists.map { it.id } }
OptionPicker(
title = stringResource(R.string.settings_default_list),
options = options,
selected = state.settings.defaultListId,
label = { id -> state.lists.firstOrNull { it.id == id }?.name ?: stringResource(R.string.settings_default_list_first) },
leading = { id ->
state.lists.firstOrNull { it.id == id }?.let { list ->
Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
}
},
onSelect = { viewModel.setDefaultList(it) },
onDismiss = { showDefaultList = false },
)
}
}
@Composable
private fun RemindersScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
val context = LocalContext.current
val notifLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) { /* pref already set; a denial just leaves the OS gate shut */ }
val toggleReminders: (Boolean) -> Unit = { enabled ->
viewModel.setRemindersEnabled(enabled)
if (enabled &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
notifLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
val canExact = rememberExactAlarmAllowed(context)
val exactAlarmRow = showExactAlarmRow()
val dark = isSystemInDarkTheme()
var showOffset by remember { mutableStateOf(false) }
var perListExpanded by remember { mutableStateOf(false) }
var listPickerId by remember { mutableStateOf<Long?>(null) }
val lists = state.lists.filter { it.id > 0L }
CollapsingScaffold(title = stringResource(R.string.settings_section_reminders), onBack = onBack) {
GroupedRow(
title = stringResource(R.string.settings_reminders),
summary = stringResource(R.string.settings_reminders_hint),
position = Position.Top,
trailing = { Switch(checked = state.settings.remindersEnabled, onCheckedChange = toggleReminders) },
onClick = { toggleReminders(!state.settings.remindersEnabled) },
)
GroupedRow(
title = stringResource(R.string.settings_default_reminder),
summary = reminderLeadTimeLabel(state.settings.reminderLeadMinutes),
position = if (exactAlarmRow) Position.Middle else Position.Bottom,
onClick = { showOffset = true },
)
if (exactAlarmRow) {
GroupedRow(
title = stringResource(R.string.settings_exact_alarms),
summary = stringResource(
if (canExact) R.string.settings_exact_alarms_allowed
else R.string.settings_exact_alarms_blocked,
),
position = Position.Bottom,
onClick = if (canExact) null else ({ context.openExactAlarmSettings() }),
)
}
// Per-list overrides: the whole section folds behind one header. Each
// list may keep, drop, or replace the global default for its tasks.
if (lists.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
// The header and the list rows form one connected run: the header
// opens its bottom (Top) when expanded, and the last list rounds it off.
GroupedRow(
title = stringResource(R.string.settings_list_reminders_title),
summary = stringResource(R.string.settings_list_reminders_hint),
position = if (perListExpanded) Position.Top else Position.Alone,
trailing = {
Icon(
imageVector = if (perListExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = { perListExpanded = !perListExpanded },
)
AnimatedVisibility(
visible = perListExpanded,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
Column {
lists.forEachIndexed { index, list ->
GroupedRow(
title = list.name,
summary = listOverrideSummary(
listOverrideChoice(state, list.id),
state.settings.reminderLeadMinutes,
),
// Every list is mid-run under the header; the last rounds off.
position = if (index == lists.lastIndex) Position.Bottom else Position.Middle,
leading = {
Icon(Icons.Rounded.Circle, contentDescription = null, tint = pastelize(list.color, dark))
},
onClick = { listPickerId = list.id },
)
}
}
}
}
}
if (showOffset) {
ReminderLeadPicker(
title = stringResource(R.string.settings_default_reminder),
selected = ListReminderOverride.Minutes(state.settings.reminderLeadMinutes),
allowInherit = false,
allowNone = false,
onSelect = { if (it is ListReminderOverride.Minutes) viewModel.setReminderLeadMinutes(it.minutes) },
onDismiss = { showOffset = false },
)
}
listPickerId?.let { id ->
ReminderLeadPicker(
title = state.lists.firstOrNull { it.id == id }?.name
?: stringResource(R.string.settings_default_reminder),
selected = listOverrideChoice(state, id),
allowInherit = true,
allowNone = true,
onSelect = { viewModel.setListReminderOverride(id, it) },
onDismiss = { listPickerId = null },
)
}
}
/** The stored override for [listId], as a picker choice (absent → inherit). */
private fun listOverrideChoice(state: SettingsUiState, listId: Long): ListReminderOverride {
val map = state.settings.perListReminderOverride
return when {
!map.containsKey(listId) -> ListReminderOverride.Inherit
map[listId] == null -> ListReminderOverride.None
else -> ListReminderOverride.Minutes(map.getValue(listId)!!)
}
}
/** Row summary for a list: its override, or the inherited global default. */
@Composable
private fun listOverrideSummary(choice: ListReminderOverride, globalDefault: Int): String = when (choice) {
ListReminderOverride.Inherit ->
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
ListReminderOverride.None -> stringResource(R.string.reminder_none)
is ListReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
}
// ---------------------------------------------------------------------------
// 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
private fun AppLogo() {
Box(
modifier = Modifier
.size(72.dp)
.clip(RoundedCornerShape(20.dp))
.background(colorResource(R.color.ic_launcher_background)),
contentAlignment = Alignment.Center,
) {
Image(
painter = painterResource(R.drawable.ic_launcher_foreground),
contentDescription = stringResource(R.string.settings_about_logo_desc),
modifier = Modifier.requiredSize(108.dp),
)
}
}
@Composable
private fun AppVersionText() {
val context = LocalContext.current
val versionName = remember {
runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
.getOrNull() ?: ""
}
Text(
text = stringResource(R.string.settings_about_version, versionName),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
)
}
/** Leading circular icon chip; colours come from the M3 scheme via a token pair. */
@Composable
private fun CategoryIcon(icon: ImageVector, accent: ChipAccent) {
val scheme = MaterialTheme.colorScheme
val (background, iconColor) = when (accent) {
ChipAccent.Neutral -> scheme.surfaceContainerHighest to scheme.onSurfaceVariant
ChipAccent.Primary -> scheme.primaryContainer to scheme.onPrimaryContainer
ChipAccent.Tertiary -> scheme.tertiaryContainer to scheme.onTertiaryContainer
}
Box(
modifier = Modifier.size(40.dp).clip(CircleShape).background(background),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = iconColor, modifier = Modifier.size(22.dp))
}
}
@Composable
private fun defaultListLabel(state: SettingsUiState): String =
state.lists.firstOrNull { it.id == state.settings.defaultListId }?.name
?: stringResource(R.string.settings_default_list_first)
private fun openUrl(context: Context, url: String) {
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, url.toUri())) }
}
private fun themeLabel(mode: ThemeMode): Int = when (mode) {
ThemeMode.SYSTEM -> R.string.settings_theme_system
ThemeMode.LIGHT -> R.string.settings_theme_light
ThemeMode.DARK -> R.string.settings_theme_dark
}
private fun formFieldLabel(field: TaskFormField): Int = when (field) {
TaskFormField.Description -> R.string.field_description
TaskFormField.Priority -> R.string.edit_priority_label
TaskFormField.Progress -> R.string.edit_progress_label
TaskFormField.Parent -> R.string.edit_parent_label
TaskFormField.Reminder -> R.string.edit_reminder_label
}
private fun formFieldIcon(field: TaskFormField): ImageVector = when (field) {
TaskFormField.Description -> Icons.AutoMirrored.Rounded.Notes
TaskFormField.Priority -> Icons.Rounded.Flag
TaskFormField.Progress -> Icons.Rounded.Percent
TaskFormField.Parent -> Icons.Rounded.AccountTree
TaskFormField.Reminder -> Icons.Default.Notifications
}
/**
* Exact-alarm control is only meaningful on API 31-32 (SCHEDULE_EXACT_ALARM,
* revocable). On 33+ the app holds USE_EXACT_ALARM (always granted), so the row
* would be a permanent no-op — hide it there.
*/
private fun showExactAlarmRow(): Boolean =
Build.VERSION.SDK_INT in Build.VERSION_CODES.S..Build.VERSION_CODES.S_V2
@Composable
private fun rememberExactAlarmAllowed(context: Context): Boolean {
var allowed by remember {
mutableStateOf(
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms(),
)
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val obs = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
allowed = context.getSystemService(AlarmManager::class.java).canScheduleExactAlarms()
}
}
lifecycle.addObserver(obs)
onDispose { lifecycle.removeObserver(obs) }
}
return allowed
}
private fun Context.openExactAlarmSettings() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
runCatching {
startActivity(
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).setData("package:$packageName".toUri()),
)
}
}
}

View File

@@ -1,13 +1,15 @@
package de.jeanlucmakiola.floret.ui.settings 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.floret.data.prefs.Settings import de.jeanlucmakiola.agendula.data.prefs.ListReminderOverride
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.floret.data.prefs.ThemeMode import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.tasks.TasksRepository import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
import de.jeanlucmakiola.floret.domain.TaskList import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.TaskFormField
import de.jeanlucmakiola.agendula.domain.TaskList
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
@@ -40,4 +42,16 @@ class SettingsViewModel @Inject constructor(
fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) } fun setDynamicColor(enabled: Boolean) = viewModelScope.launch { prefs.setDynamicColor(enabled) }
fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) } fun setDefaultList(id: Long?) = viewModelScope.launch { prefs.setDefaultListId(id) }
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 setShowAddSubtaskRow(show: Boolean) = viewModelScope.launch { prefs.setShowAddSubtaskRow(show) }
/** Toggle whether [field] shows by default on a new task's edit form. */
fun setFormFieldDefault(field: TaskFormField, enabled: Boolean) = viewModelScope.launch {
val current = state.value.settings.defaultEditFields
prefs.setDefaultEditFields(if (enabled) current + field else current - field)
}
/** Set (or clear, via [ListReminderOverride.Inherit]) a list's reminder override. */
fun setListReminderOverride(listId: Long, override: ListReminderOverride) =
viewModelScope.launch { prefs.setListReminderOverride(listId, override) }
} }

View File

@@ -0,0 +1,941 @@
package de.jeanlucmakiola.agendula.ui.tasklist
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.ListAlt
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Checklist
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MediumTopAppBar
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.SwipeToDismissBox
import androidx.compose.material3.SwipeToDismissBoxValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberSwipeToDismissBoxState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.agendula.R
import de.jeanlucmakiola.agendula.domain.DayWindow
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSection
import de.jeanlucmakiola.agendula.domain.TaskSections
import de.jeanlucmakiola.agendula.ui.common.ListNameChip
import de.jeanlucmakiola.agendula.ui.common.Position
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
import de.jeanlucmakiola.agendula.ui.common.formatDateTime
import de.jeanlucmakiola.agendula.ui.common.pastelize
import de.jeanlucmakiola.agendula.ui.common.positionOf
import java.time.ZoneId
import kotlin.time.Clock
/**
* The tasks for one [TaskFilter]: due-date section headers, grouped tonal rows
* with a leading checkbox, swipe-to-complete (start→end) / swipe-to-delete
* (end→start), and — for a real list — an inline add field. Renders against the
* M1 [TaskListViewModel]; actions are fire-and-forget and flow back live.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TaskListScreen(
filter: TaskFilter,
onOpenTask: (Long) -> Unit,
onNewTask: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TaskListViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val listName = (state as? TaskListUiState.Content)?.listName
// 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
// second delete while a chip is up commits the first (its window is over).
var undoTarget by remember { mutableStateOf<Task?>(null) }
val onRequestDelete: (Task) -> Unit = { task ->
undoTarget?.let { viewModel.commitDelete(it.taskId) }
viewModel.markPendingDelete(task)
undoTarget = task
}
LaunchedEffect(undoTarget) {
val target = undoTarget ?: return@LaunchedEffect
delay(4_000)
viewModel.commitDelete(target.taskId)
if (undoTarget?.taskId == target.taskId) undoTarget = null
}
Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
MediumTopAppBar(
title = { Text(titleFor(filter, listName)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
)
}
},
scrollBehavior = scrollBehavior,
)
},
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onNewTask,
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
text = { Text(stringResource(R.string.new_task)) },
)
},
) { inner ->
Box(modifier = Modifier.fillMaxSize()) {
when (val s = state) {
TaskListUiState.Loading -> Unit // brief; avoids a flash before first emission
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel)
}
UndoChip(
visible = undoTarget != null,
onUndo = {
undoTarget?.let { viewModel.undoDelete(it.taskId) }
undoTarget = null
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = inner.calculateBottomPadding() + 24.dp),
)
}
}
}
/**
* A compact floating "snackchip" for an undoable delete — a rounded pill (not a
* full-width snackbar) sized to its content, sliding up from the bottom centre.
*/
@Composable
private fun UndoChip(visible: Boolean, onUndo: () -> Unit, modifier: Modifier = Modifier) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = MaterialTheme.colorScheme.onSurface,
shape = RoundedCornerShape(50),
shadowElevation = 6.dp,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 20.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(R.string.task_deleted),
style = MaterialTheme.typography.bodyMedium,
)
TextButton(
onClick = onUndo,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
) {
Text(
text = stringResource(R.string.undo),
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun TaskListBody(
state: TaskListUiState.Content,
filter: TaskFilter,
inner: PaddingValues,
onOpenTask: (Long) -> Unit,
onRequestDelete: (Task) -> Unit,
viewModel: TaskListViewModel,
) {
val (todayStart, todayEnd) = remember { DayWindow.today(Clock.System.now(), ZoneId.systemDefault()) }
// A parent represents its children via a progress chip (counts come from the
// repository, over the full set), so drop children from the flat list — unless
// their parent isn't in this view (can happen in smart lists), where we keep
// them standalone so nothing silently vanishes.
val displayTasks = remember(state.tasks) {
val present = state.tasks.mapTo(HashSet()) { it.taskId }
state.tasks.filter { (it.parentId ?: 0L) <= 0L || it.parentId !in present }
}
val sections = remember(displayTasks) { TaskSections.of(displayTasks, todayStart, todayEnd) }
val showHeaders = sections.size > 1
val listId = (filter as? TaskFilter.OfList)?.listId
// In a mixed view (a smart list) the list colour bar + name disambiguate
// rows; inside a single real list both are redundant.
val showListName = filter is TaskFilter.Smart
// Completed tasks collapse away by default to keep the active list in focus.
var completedExpanded by rememberSaveable { mutableStateOf(false) }
// Parents the user expanded to reveal their subtasks inline. Children are
// grouped from the same flat task set the list already holds.
var expandedParents by rememberSaveable { mutableStateOf(emptySet<Long>()) }
val childrenByParent = remember(state.tasks) {
state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! }
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = inner.calculateTopPadding() + 8.dp,
bottom = inner.calculateBottomPadding() + 96.dp,
),
) {
if (listId != null) {
item(key = "inline-add") {
InlineAdd(onAdd = { title -> viewModel.quickAdd(title, listId) })
}
}
if (state.tasks.isEmpty()) {
item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) }
}
sections.forEach { section ->
val collapsible = showHeaders && section.section == TaskSection.COMPLETED
if (showHeaders) {
stickyHeader(key = "hdr-${section.section}") {
if (collapsible) {
CollapsibleSectionHeader(
label = sectionLabel(section.section),
count = section.tasks.size,
expanded = completedExpanded,
onToggle = { completedExpanded = !completedExpanded },
)
} else {
SectionHeader(sectionLabel(section.section))
}
}
}
if (!collapsible || completedExpanded) {
// Flatten the section into one grouped run: each parent, followed
// by its subtasks when expanded. Each row's corners then come from
// its place in the whole run (positionOf below) — so the children
// read as a grouped list nested in the list's grouped list:
// full-width, connecting to the parent above (small top corners),
// and the run rounds off (full corners) only at its genuine ends.
val rows = buildList {
val topLevel = section.tasks
topLevel.forEachIndexed { p, task ->
val children = childrenByParent[task.taskId].orEmpty()
// Only expandable when we hold every child: true in a real
// list, but a smart list may omit children due on other
// days, and a half-shown set would mislead.
val canExpand = task.subtaskTotal > 0 && children.size == task.subtaskTotal
val isExpanded = canExpand && task.taskId in expandedParents
// Top-level tasks keep their own connected run — full top
// only on the first, full bottom only on the last — so the
// task after an expanded one stays mid-run (small top). An
// expanded parent's bottom opens (small) to meet its subtasks.
val parentPosition = cornerPosition(
topFull = p == 0,
bottomFull = if (isExpanded) false else p == topLevel.lastIndex,
)
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
if (isExpanded) {
// When the add row is hidden (M5 setting), the last subtask
// instead rounds the group off (full bottom).
val sorted = children.sortedBy { it.isCompleted }
sorted.forEachIndexed { c, sub ->
// Subtasks connect up to the parent (small top); with the
// add row present every subtask is mid-run, otherwise the
// last one closes the group (full bottom).
val closesGroup = !state.showAddSubtaskRow && c == sorted.lastIndex
add(RenderRow(ListRow.Sub(sub), cornerPosition(topFull = false, bottomFull = closesGroup)))
}
// The add row rounds the group off (full bottom),
// independent of whatever top-level task follows.
if (state.showAddSubtaskRow) {
add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true)))
}
}
}
}
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
val lastInRun = index == rows.lastIndex
when (val row = r.row) {
is ListRow.Parent -> TaskRow(
task = row.task,
position = r.position,
lastInRun = lastInRun,
showListName = showListName,
subtaskDone = row.task.subtaskDone,
subtaskTotal = row.task.subtaskTotal,
expanded = row.expanded,
onToggleExpand = if (row.expandable) {
{
expandedParents = if (row.task.taskId in expandedParents) {
expandedParents - row.task.taskId
} else {
expandedParents + row.task.taskId
}
}
} else {
null
},
onToggle = { viewModel.toggleComplete(row.task) },
onDelete = { onRequestDelete(row.task) },
onClick = { onOpenTask(row.task.taskId) },
)
is ListRow.Sub -> SubtaskRow(
task = row.task,
position = r.position,
lastInRun = lastInRun,
onToggle = { viewModel.toggleComplete(row.task) },
onClick = { onOpenTask(row.task.taskId) },
)
is ListRow.AddSub -> AddSubtaskRow(
position = r.position,
lastInRun = lastInRun,
onAdd = { viewModel.quickAddSubtask(row.parent, it) },
)
}
}
}
}
}
}
/** Inline "add a task" — title only, into the current list. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InlineAdd(onAdd: (String) -> Unit) {
var text by remember { mutableStateOf("") }
fun submit() {
if (text.isNotBlank()) {
onAdd(text.trim())
text = ""
}
}
Surface(
shape = RoundedCornerShape(22.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
) {
TextField(
value = text,
onValueChange = { text = it },
placeholder = { Text(stringResource(R.string.add_task_hint)) },
leadingIcon = { Icon(Icons.Rounded.Add, contentDescription = null) },
trailingIcon = {
if (text.isNotBlank()) {
IconButton(onClick = ::submit) {
Icon(Icons.Rounded.Check, contentDescription = stringResource(R.string.cd_add_task))
}
}
},
singleLine = true,
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { submit() }),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
modifier = Modifier.fillMaxWidth(),
)
}
}
/**
* 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
* right toggles complete (snaps back); swiping left deletes.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TaskRow(
task: Task,
position: Position,
lastInRun: Boolean,
showListName: Boolean,
subtaskDone: Int,
subtaskTotal: Int,
expanded: Boolean,
onToggleExpand: (() -> Unit)?,
onToggle: () -> Unit,
onDelete: () -> Unit,
onClick: () -> Unit,
) {
val dismissState = rememberSwipeToDismissBoxState(
confirmValueChange = { value ->
when (value) {
// Both snap back: complete toggles status; delete hands off to the
// undo flow, which removes the row from the data. Never *confirm*
// the dismiss — a terminal dismissed state strands a stale
// background bar and won't re-arm when the row is restored.
SwipeToDismissBoxValue.StartToEnd -> { onToggle(); false }
SwipeToDismissBoxValue.EndToStart -> { onDelete(); false }
SwipeToDismissBoxValue.Settled -> false
}
},
)
// A small gap below every row but the run's last — including a group-ending
// Bottom that's followed by a fresh card, so the two groups read apart.
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
SwipeToDismissBox(
state = dismissState,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
// dismissDirection (not targetValue) tracks the live drag, so the colour +
// icon reveal as you swipe rather than only once the threshold is crossed.
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
) {
TaskRowContent(task, position, showListName, subtaskDone, subtaskTotal, expanded, onToggleExpand, onToggle, onClick)
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
private fun TaskRowContent(
task: Task,
position: Position,
showListName: Boolean,
subtaskDone: Int,
subtaskTotal: Int,
expanded: Boolean,
onToggleExpand: (() -> Unit)?,
onToggle: () -> Unit,
onClick: () -> 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 dark = isSystemInDarkTheme()
val due = task.due?.formatDateTime(task.isAllDay)
val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
val hasSupporting = due != null || listName != null || subtaskTotal > 0 ||
task.priority != Priority.NONE
Surface(
onClick = onClick,
shape = cardShape(position, full, small),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
interactionSource = interaction,
modifier = Modifier.fillMaxWidth(),
) {
// Hand-laid row (not ListItem) so a one- vs two-line body never trips
// ListItem's three-line heuristic and leaves dead space at the bottom.
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 64.dp)
.padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Calendula's signature accent: a slim pastelised colour bar for the
// list, paired here with the task's completion checkbox.
Box(
modifier = Modifier
.size(width = 5.dp, height = 32.dp)
.clip(RoundedCornerShape(3.dp))
.background(pastelize(task.effectiveColor, dark)),
)
Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() })
Spacer(Modifier.width(4.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = task.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
color = if (task.isCompleted) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
)
if (hasSupporting) {
Spacer(Modifier.height(3.dp))
// Flow so the chips wrap to a further line when due date + list
// + subtask progress can't share one (e.g. a dated task with
// subtasks), rather than squeezing the trailing chip.
FlowRow(
verticalArrangement = Arrangement.spacedBy(3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
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
// edge stays put whether or not the task has children (priority now
// rides in the supporting chip row instead of a shifting trailing flag).
if (onToggleExpand != null) {
SubtaskExpandButton(expanded = expanded, onToggle = onToggleExpand)
}
}
}
}
/** A small neutral pill showing subtask progress, e.g. "3 / 5". */
@Composable
private fun SubtaskCountChip(done: Int, total: Int) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(50),
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ListAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp),
)
Text(
text = "$done / $total",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
/**
* The inline "add a subtask" row that closes an expanded group — a [BasicTextField]
* aligned with the subtask titles (leading + where the checkbox sits), submitting
* on the tick or the IME action. Same grouped-card segment as the rows around it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AddSubtaskRow(
position: Position,
lastInRun: Boolean,
onAdd: (String) -> Unit,
) {
var text by remember { mutableStateOf("") }
fun submit() {
if (text.isNotBlank()) {
onAdd(text.trim())
text = ""
}
}
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
Surface(
shape = cardShape(position, 22.dp, 6.dp),
// Same tone as the subtask rows it closes, so the group reads as one unit.
color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) {
Row(
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Spacer(Modifier.width(5.dp)) // the parents' colour-bar slot, kept for alignment
Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.width(4.dp))
BasicTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = androidx.compose.foundation.text.KeyboardActions(onDone = { submit() }),
modifier = Modifier.weight(1f),
decorationBox = { inner ->
if (text.isEmpty()) {
Text(
text = stringResource(R.string.add_subtask_hint),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
inner()
},
)
if (text.isNotBlank()) {
IconButton(onClick = ::submit) {
Icon(
Icons.Rounded.Check,
contentDescription = stringResource(R.string.cd_add_task),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
/** The trailing expand/collapse control for a parent row — a rotating chevron. */
@Composable
private fun SubtaskExpandButton(expanded: Boolean, onToggle: () -> Unit) {
val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "subtaskExpand")
IconButton(onClick = onToggle) {
Icon(
imageVector = Icons.Rounded.ExpandMore,
contentDescription = stringResource(
if (expanded) R.string.cd_collapse_subtasks else R.string.cd_expand_subtasks,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(rotation),
)
}
}
/** A visual row in a flattened section run: a top-level task, or one of its subtasks. */
private sealed interface ListRow {
val key: Long
data class Parent(val task: Task, val expandable: Boolean, val expanded: Boolean) : ListRow {
override val key: Long get() = task.taskId
}
data class Sub(val task: Task) : ListRow {
override val key: Long get() = task.taskId
}
/** The inline "add a subtask" row that closes an expanded group. */
data class AddSub(val parent: Task) : ListRow {
// Negative so it never collides with a real (positive) provider task id.
override val key: Long get() = -parent.taskId
}
}
/** A flattened row paired with the grouped-card corners it should render with. */
private data class RenderRow(val row: ListRow, val position: Position)
/**
* Map independent top/bottom corner choices to a [Position]. `full` = the big
* outer corner radius (a run's start/end); otherwise the small connecting radius.
* Letting each edge be chosen on its own lets a subtask group round off (full
* bottom) while the next top-level task keeps a small top and stays mid-run.
*/
private fun cornerPosition(topFull: Boolean, bottomFull: Boolean): Position = when {
topFull && bottomFull -> Position.Alone
topFull -> Position.Top
bottomFull -> Position.Bottom
else -> Position.Middle
}
/**
* One subtask row inside an expanded parent: a full-width grouped-card segment
* (same tonal surface, corners-from-[position], press-morph as the task rows), so
* it's part of the same grouped run. The only difference is a leading indent
* before the checkbox to convey nesting — the card width is preserved.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SubtaskRow(
task: Task,
position: Position,
lastInRun: Boolean,
onToggle: () -> Unit,
onClick: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "subFullCorner")
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "subSmallCorner")
val gap = if (lastInRun) Modifier else Modifier.padding(bottom = 2.dp)
Surface(
onClick = onClick,
shape = cardShape(position, full, small),
// A step below the parents' surfaceContainerHigh so the subtask group
// reads as a distinct, recessive tone — the nesting cue alongside shape.
color = MaterialTheme.colorScheme.surfaceContainer,
interactionSource = interaction,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 56.dp)
.padding(start = 12.dp, end = 16.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// No colour bar — that absence (with the grouped nesting) marks the row
// as a subtask. Reserve the bar's 5dp so the checkbox still lines up
// with the parent rows' checkboxes rather than indenting.
Spacer(Modifier.width(5.dp))
Checkbox(checked = task.isCompleted, onCheckedChange = { onToggle() })
Spacer(Modifier.width(4.dp))
Text(
text = task.title.ifBlank { stringResource(R.string.task_untitled) },
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textDecoration = if (task.isCompleted) TextDecoration.LineThrough else null,
color = if (task.isCompleted) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.weight(1f),
)
}
}
}
/** The colored reveal behind a swiping row: complete on the left, delete on the right. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SwipeBackground(target: SwipeToDismissBoxValue, isCompleted: Boolean) {
val (color, icon, desc, alignment) = when (target) {
SwipeToDismissBoxValue.StartToEnd -> SwipeStyle(
MaterialTheme.colorScheme.primaryContainer,
Icons.Rounded.Check,
if (isCompleted) R.string.cd_reopen else R.string.cd_complete,
Alignment.CenterStart,
)
SwipeToDismissBoxValue.EndToStart -> SwipeStyle(
MaterialTheme.colorScheme.errorContainer,
Icons.Rounded.Delete,
R.string.delete,
Alignment.CenterEnd,
)
SwipeToDismissBoxValue.Settled -> SwipeStyle(
Color.Transparent, Icons.Rounded.Check, R.string.cd_complete, Alignment.CenterStart,
)
}
Surface(color = color, shape = RoundedCornerShape(22.dp), modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp), contentAlignment = alignment) {
Icon(icon, contentDescription = stringResource(desc))
}
}
}
private data class SwipeStyle(
val color: Color,
val icon: androidx.compose.ui.graphics.vector.ImageVector,
val descRes: Int,
val alignment: Alignment,
)
private fun cardShape(position: Position, full: androidx.compose.ui.unit.Dp, small: androidx.compose.ui.unit.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)
}
@Composable
private fun SectionHeader(text: String) {
// Opaque surface so rows scroll cleanly under the pinned header.
Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) {
Text(
text = text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
)
}
}
/** A tappable section header with a count and a chevron that rotates on expand. */
@Composable
private fun CollapsibleSectionHeader(
label: String,
count: Int,
expanded: Boolean,
onToggle: () -> Unit,
) {
val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "chevron")
Surface(
onClick = onToggle,
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.padding(start = 28.dp, end = 20.dp, top = 16.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.size(8.dp))
Text(
text = count.toString(),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = Icons.Rounded.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.rotate(rotation),
)
}
}
}
/** Icon + message for an empty list — the Calendula agenda-empty pattern. */
@Composable
private fun EmptyState(text: String) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 64.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
imageVector = Icons.Rounded.Checklist,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.height(16.dp))
Text(
text = text,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun CenteredMessage(text: String, inner: PaddingValues) {
Box(
modifier = Modifier.fillMaxSize().padding(inner).padding(32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun titleFor(filter: TaskFilter, listName: String?): String = when (filter) {
is TaskFilter.OfList -> listName ?: stringResource(R.string.tasks_title)
is TaskFilter.Smart -> stringResource(
when (filter.list) {
SmartList.TODAY -> R.string.smart_today
SmartList.UPCOMING -> R.string.smart_upcoming
SmartList.OVERDUE -> R.string.smart_overdue
SmartList.ALL -> R.string.smart_all
SmartList.NO_DATE -> R.string.smart_no_date
SmartList.COMPLETED -> R.string.smart_completed
},
)
}
@Composable
private fun sectionLabel(section: TaskSection): String = stringResource(
when (section) {
TaskSection.OVERDUE -> R.string.section_overdue
TaskSection.TODAY -> R.string.section_today
TaskSection.UPCOMING -> R.string.section_upcoming
TaskSection.NO_DATE -> R.string.section_no_date
TaskSection.COMPLETED -> R.string.section_completed
},
)
@Composable
internal fun priorityLabel(priority: Priority): String = stringResource(
when (priority) {
Priority.NONE -> R.string.priority_none
Priority.LOW -> R.string.priority_low
Priority.MEDIUM -> R.string.priority_medium
Priority.HIGH -> R.string.priority_high
},
)

View File

@@ -0,0 +1,131 @@
package de.jeanlucmakiola.agendula.ui.tasklist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
import de.jeanlucmakiola.agendula.domain.Task
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskForm
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface TaskListUiState {
data object Loading : TaskListUiState
data object Failure : TaskListUiState
/**
* [listName] is the real list's name when the filter is a
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
* the screen falls back to the smart label in that case.
*/
data class Content(
val tasks: List<Task>,
val listName: String? = null,
/** Whether the inline "add a subtask" row shows on expanded groups (M5 setting). */
val showAddSubtaskRow: Boolean = true,
) : TaskListUiState
}
/**
* The tasks for one [TaskFilter]. Call [bind] with the filter the screen shows;
* the list re-emits live as the provider changes. Actions are fire-and-forget —
* the resulting provider change flows back through the list automatically.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
) : ViewModel() {
private val filter = MutableStateFlow<TaskFilter?>(null)
/** Tasks swiped to delete but not yet committed — hidden until the undo lapses. */
private val pendingDeletes = MutableStateFlow<Set<Long>>(emptySet())
val state: StateFlow<TaskListUiState> =
filter.filterNotNull()
.flatMapLatest { f ->
val tasks = repository.tasks(f)
val content: kotlinx.coroutines.flow.Flow<TaskListUiState> = when (f) {
is TaskFilter.OfList ->
combine(tasks, repository.taskLists()) { list, lists ->
TaskListUiState.Content(list, lists.firstOrNull { it.id == f.listId }?.name)
}
is TaskFilter.Smart ->
tasks.map { TaskListUiState.Content(it) }
}
// Drop rows pending an undoable delete so the row vanishes on swipe
// while the actual provider delete waits for the snackbar to commit,
// and fold in the live "show add-subtask row" setting.
val showAddSub = settingsPrefs.settings.map { it.showAddSubtaskRow }
combine(content, pendingDeletes, showAddSub) { st, pending, showRow ->
if (st is TaskListUiState.Content) {
st.copy(
tasks = st.tasks.filter { it.taskId !in pending },
showAddSubtaskRow = showRow,
)
} else {
st
}
}
.onStart { emit(TaskListUiState.Loading) }
.catch { emit(TaskListUiState.Failure) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
fun toggleComplete(task: Task) = viewModelScope.launch {
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
}
/** Swipe-delete: hide the row now; the screen's snackbar commits or restores it. */
fun markPendingDelete(task: Task) {
pendingDeletes.value = pendingDeletes.value + task.taskId
}
/** Undo — stop hiding the task; it returns to the list. */
fun undoDelete(taskId: Long) {
pendingDeletes.value = pendingDeletes.value - taskId
}
/**
* Commit the delete once the undo window passes. The id stays in the pending
* set: the task is gone from the provider so it can't reappear, and removing
* it could briefly un-hide the row before the provider change propagates.
*/
fun commitDelete(taskId: Long) = viewModelScope.launch {
if (taskId !in pendingDeletes.value) return@launch
runCatching { repository.deleteTask(taskId) }
}
/** Inline "add task" — title only, into [listId]. */
fun quickAdd(title: String, listId: Long) = viewModelScope.launch {
if (title.isBlank() || listId <= 0L) return@launch
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
}
/** Inline "add subtask" from an expanded list group — files it under [parent]. */
fun quickAddSubtask(parent: Task, title: String) = viewModelScope.launch {
if (title.isBlank() || parent.listId <= 0L) return@launch
runCatching {
repository.createTask(
TaskForm(title = title.trim(), listId = parent.listId, parentId = parent.taskId),
)
}
}
}

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.ui.theme package de.jeanlucmakiola.agendula.ui.theme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
@@ -12,14 +12,14 @@ import androidx.compose.ui.graphics.Color
* *
* Placeholder until branding lands; refine alongside the launcher icon. * Placeholder until branding lands; refine alongside the launcher icon.
*/ */
val FloretSeed: Color = Color(0xFF7A5C6B) val AgendulaSeed: Color = Color(0xFF7A5C6B)
/** /**
* Fallback light scheme for devices without dynamic color (API < 31) or when * Fallback light scheme for devices without dynamic color (API < 31) or when
* the user disables it. Dynamic color (API 31+) is the real path; these are a * the user disables it. Dynamic color (API 31+) is the real path; these are a
* hand-picked approximation of the tonal palette generated from [FloretSeed]. * hand-picked approximation of the tonal palette generated from [AgendulaSeed].
*/ */
val FloretLightFallback = lightColorScheme( val AgendulaLightFallback = lightColorScheme(
primary = Color(0xFF6A4E5B), primary = Color(0xFF6A4E5B),
onPrimary = Color(0xFFFFFFFF), onPrimary = Color(0xFFFFFFFF),
primaryContainer = Color(0xFFF6D9E5), primaryContainer = Color(0xFFF6D9E5),
@@ -32,7 +32,7 @@ val FloretLightFallback = lightColorScheme(
onSurface = Color(0xFF1F1A1C), onSurface = Color(0xFF1F1A1C),
) )
val FloretDarkFallback = darkColorScheme( val AgendulaDarkFallback = darkColorScheme(
primary = Color(0xFFE2BAC9), primary = Color(0xFFE2BAC9),
onPrimary = Color(0xFF402533), onPrimary = Color(0xFF402533),
primaryContainer = Color(0xFF583B49), primaryContainer = Color(0xFF583B49),

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.ui.theme package de.jeanlucmakiola.agendula.ui.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -14,7 +14,7 @@ import androidx.compose.ui.platform.LocalContext
* App theme. Honors: * App theme. Honors:
* - System light/dark. * - System light/dark.
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme * - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
* derived from [FloretSeed]. * derived from [AgendulaSeed].
* *
* Mirrors Calendula's theme: MaterialExpressiveTheme routes all component + * Mirrors Calendula's theme: MaterialExpressiveTheme routes all component +
* custom motion through MaterialTheme.motionScheme. STANDARD over expressive() * custom motion through MaterialTheme.motionScheme. STANDARD over expressive()
@@ -25,7 +25,7 @@ import androidx.compose.ui.platform.LocalContext
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun FloretTheme( fun AgendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, dynamicColor: Boolean = true,
content: @Composable () -> Unit, content: @Composable () -> Unit,
@@ -35,13 +35,13 @@ fun FloretTheme(
val ctx = LocalContext.current val ctx = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx) if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
} }
darkTheme -> FloretDarkFallback darkTheme -> AgendulaDarkFallback
else -> FloretLightFallback else -> AgendulaLightFallback
} }
MaterialExpressiveTheme( MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = FloretTypography, typography = AgendulaTypography,
motionScheme = MotionScheme.standard(), motionScheme = MotionScheme.standard(),
content = content, content = content,
) )

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.ui.theme package de.jeanlucmakiola.agendula.ui.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
@@ -7,4 +7,4 @@ import androidx.compose.material3.Typography
* land in a later UI-design iteration; the defaults keep the M0 foundation lean * land in a later UI-design iteration; the defaults keep the M0 foundation lean
* (same posture as Calendula's scaffolding). * (same posture as Calendula's scaffolding).
*/ */
val FloretTypography = Typography() val AgendulaTypography = Typography()

View File

@@ -1,55 +0,0 @@
package de.jeanlucmakiola.floret.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
enum class ThemeMode { SYSTEM, LIGHT, DARK }
data class Settings(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val dynamicColor: Boolean = true,
/** The list a new task defaults to; `null` = first available. */
val defaultListId: Long? = null,
/** Default minutes before due to remind; 0 = at due time. */
val reminderLeadMinutes: Int = 0,
)
/** App preferences, backed by DataStore. Mirrors Calendula's prefs shape. */
@Singleton
class SettingsPrefs @Inject constructor(
private val dataStore: DataStore<Preferences>,
) {
val settings: Flow<Settings> = dataStore.data.map { p ->
Settings(
themeMode = p[THEME_MODE]?.let { runCatching { ThemeMode.valueOf(it) }.getOrNull() }
?: ThemeMode.SYSTEM,
dynamicColor = p[DYNAMIC_COLOR] ?: true,
defaultListId = p[DEFAULT_LIST_ID]?.takeIf { it > 0 },
reminderLeadMinutes = p[REMINDER_LEAD] ?: 0,
)
}
suspend fun setThemeMode(mode: ThemeMode) = dataStore.edit { it[THEME_MODE] = mode.name }
suspend fun setDynamicColor(enabled: Boolean) = dataStore.edit { it[DYNAMIC_COLOR] = enabled }
suspend fun setDefaultListId(id: Long?) = dataStore.edit {
if (id == null) it.remove(DEFAULT_LIST_ID) else it[DEFAULT_LIST_ID] = id
}
suspend fun setReminderLeadMinutes(minutes: Int) = dataStore.edit { it[REMINDER_LEAD] = minutes }
private companion object {
val THEME_MODE = stringPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val DEFAULT_LIST_ID = longPreferencesKey("default_list_id")
val REMINDER_LEAD = intPreferencesKey("reminder_lead_minutes")
}
}

View File

@@ -1,31 +0,0 @@
package de.jeanlucmakiola.floret.data.tasks
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskDetail
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskList
import kotlinx.coroutines.flow.Flow
/** Whether Floret can use the tasks provider right now. Drives onboarding. */
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
/**
* The single entry point the UI layer uses. Flows re-emit automatically when the
* provider changes (our writes *and* external sync like DAVx5), via the
* data source's ContentObserver.
*/
interface TasksRepository {
fun taskLists(): Flow<List<TaskList>>
fun tasks(filter: TaskFilter): Flow<List<Task>>
fun taskDetail(taskId: Long): Flow<TaskDetail?>
suspend fun createTask(form: TaskForm): Long
suspend fun updateTask(taskId: Long, form: TaskForm)
suspend fun setCompleted(taskId: Long, completed: Boolean)
suspend fun deleteTask(taskId: Long)
suspend fun createLocalList(name: String, color: Int): Long
/** Synchronous snapshot for the permission/onboarding gate. */
fun providerStatus(): ProviderStatus
}

View File

@@ -1,32 +0,0 @@
package de.jeanlucmakiola.floret.domain
import kotlin.time.Instant
/**
* A validated create/edit form. Kept free of Android types so ViewModels and
* tests can build and validate it on the JVM. The data layer turns it into
* provider ContentValues ([de.jeanlucmakiola.floret.data.tasks.TaskWriteMapper]).
*/
data class TaskForm(
val title: String,
val listId: Long,
val description: String? = null,
val start: Instant? = null,
val due: Instant? = null,
val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
/** Minutes before [due] to fire a reminder; `null` = no reminder. */
val reminderMinutesBeforeDue: Int? = null,
) {
fun validate(): Set<TaskFormError> = buildSet {
if (title.isBlank()) add(TaskFormError.BLANK_TITLE)
if (listId <= 0L) add(TaskFormError.NO_LIST)
if (start != null && due != null && due < start) add(TaskFormError.DUE_BEFORE_START)
if (reminderMinutesBeforeDue != null && due == null) add(TaskFormError.REMINDER_WITHOUT_DUE)
}
val isValid: Boolean get() = validate().isEmpty()
}
enum class TaskFormError { BLANK_TITLE, NO_LIST, DUE_BEFORE_START, REMINDER_WITHOUT_DUE }

View File

@@ -1,144 +0,0 @@
package de.jeanlucmakiola.floret.ui.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm
import de.jeanlucmakiola.floret.domain.TaskFormError
import de.jeanlucmakiola.floret.domain.TaskList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Instant
/**
* Editable form state for creating or editing a task. The screen renders these
* fields and calls the `on*` setters; [save] validates, writes, re-syncs
* reminders, and flips [TaskEditUiState.saved] for the screen to navigate back.
*/
data class TaskEditUiState(
val loading: Boolean = true,
val isNew: Boolean = true,
val title: String = "",
val description: String = "",
val listId: Long? = null,
val start: Instant? = null,
val due: Instant? = null,
val isAllDay: Boolean = false,
val priority: Priority = Priority.NONE,
val parentId: Long? = null,
val reminderMinutesBeforeDue: Int? = null,
val lists: List<TaskList> = emptyList(),
val errors: Set<TaskFormError> = emptySet(),
val saveFailed: Boolean = false,
val saved: Boolean = false,
)
@HiltViewModel
class TaskEditViewModel @Inject constructor(
private val repository: TasksRepository,
private val settingsPrefs: SettingsPrefs,
private val reminderScheduler: ReminderScheduler,
) : ViewModel() {
private val _state = MutableStateFlow(TaskEditUiState())
val state: StateFlow<TaskEditUiState> = _state.asStateFlow()
private var editingTaskId: Long? = null
/** Start a fresh task, optionally pre-selecting a list / parent. */
fun bindNew(presetListId: Long? = null, parentId: Long? = null) {
editingTaskId = null
viewModelScope.launch {
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val defaultList = presetListId
?: settingsPrefs.settings.first().defaultListId
?: lists.firstOrNull { !it.isLocal }?.id
?: lists.firstOrNull()?.id
_state.value = TaskEditUiState(
loading = false,
isNew = true,
listId = defaultList,
parentId = parentId,
lists = lists,
)
}
}
/** Load an existing task for editing. */
fun bindEdit(taskId: Long) {
editingTaskId = taskId
viewModelScope.launch {
val lists = runCatching { repository.taskLists().first() }.getOrElse { emptyList() }
val task = runCatching { repository.taskDetail(taskId).first()?.task }.getOrNull()
if (task == null) {
_state.value = _state.value.copy(loading = false, lists = lists)
return@launch
}
_state.value = TaskEditUiState(
loading = false,
isNew = false,
title = task.title,
description = task.description.orEmpty(),
listId = task.listId,
start = task.start,
due = task.due,
isAllDay = task.isAllDay,
priority = task.priority,
parentId = task.parentId,
lists = lists,
)
}
}
fun onTitleChange(value: String) = update { it.copy(title = value, errors = emptySet()) }
fun onDescriptionChange(value: String) = update { it.copy(description = value) }
fun onListChange(listId: Long) = update { it.copy(listId = listId, errors = emptySet()) }
fun onStartChange(value: Instant?) = update { it.copy(start = value) }
fun onDueChange(value: Instant?) = update { it.copy(due = value) }
fun onAllDayChange(value: Boolean) = update { it.copy(isAllDay = value) }
fun onPriorityChange(value: Priority) = update { it.copy(priority = value) }
fun onReminderChange(minutesBeforeDue: Int?) = update { it.copy(reminderMinutesBeforeDue = minutesBeforeDue) }
fun save() {
val current = _state.value
val form = TaskForm(
title = current.title,
listId = current.listId ?: 0L,
description = current.description.ifBlank { null },
start = current.start,
due = current.due,
isAllDay = current.isAllDay,
priority = current.priority,
parentId = current.parentId,
reminderMinutesBeforeDue = current.reminderMinutesBeforeDue,
)
val errors = form.validate()
if (errors.isNotEmpty()) {
_state.value = current.copy(errors = errors)
return
}
viewModelScope.launch {
runCatching {
val id = editingTaskId
if (id == null) repository.createTask(form) else repository.updateTask(id, form)
reminderScheduler.sync()
}.onSuccess {
_state.value = _state.value.copy(saved = true, saveFailed = false)
}.onFailure {
_state.value = _state.value.copy(saveFailed = true)
}
}
}
private inline fun update(block: (TaskEditUiState) -> TaskEditUiState) {
_state.value = block(_state.value)
}
}

View File

@@ -1,67 +0,0 @@
package de.jeanlucmakiola.floret.ui.tasklist
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
import de.jeanlucmakiola.floret.domain.Task
import de.jeanlucmakiola.floret.domain.TaskFilter
import de.jeanlucmakiola.floret.domain.TaskForm
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface TaskListUiState {
data object Loading : TaskListUiState
data object Failure : TaskListUiState
data class Content(val tasks: List<Task>) : TaskListUiState
}
/**
* The tasks for one [TaskFilter]. Call [bind] with the filter the screen shows;
* the list re-emits live as the provider changes. Actions are fire-and-forget —
* the resulting provider change flows back through the list automatically.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val repository: TasksRepository,
) : ViewModel() {
private val filter = MutableStateFlow<TaskFilter?>(null)
val state: StateFlow<TaskListUiState> =
filter.filterNotNull()
.flatMapLatest { f ->
repository.tasks(f)
.map<List<Task>, TaskListUiState> { TaskListUiState.Content(it) }
.onStart { emit(TaskListUiState.Loading) }
.catch { emit(TaskListUiState.Failure) }
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
fun toggleComplete(task: Task) = viewModelScope.launch {
runCatching { repository.setCompleted(task.taskId, !task.isCompleted) }
}
fun delete(task: Task) = viewModelScope.launch {
runCatching { repository.deleteTask(task.taskId) }
}
/** Inline "add task" — title only, into [listId]. */
fun quickAdd(title: String, listId: Long) = viewModelScope.launch {
if (title.isBlank() || listId <= 0L) return@launch
runCatching { repository.createTask(TaskForm(title = title, listId = listId)) }
}
}

View File

@@ -1,10 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
Floret launcher icon foreground — PLACEHOLDER. Agendula launcher icon foreground — PLACEHOLDER.
A simple rounded check mark inside the 108dp adaptive-icon canvas A simple rounded check mark inside the 108dp adaptive-icon canvas
(72dp safe zone). Deliberately not Calendula's calendar mark, so the two (72dp safe zone). Deliberately not Calendula's calendar mark, so the two
apps never look alike. Replace with real branding (a stylized floret) apps never look alike. Replace with real branding (a stylized agendula)
when the design lands — see docs/PLAN.md §9. when the design lands — see docs/PLAN.md §9.
--> -->
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"

View File

@@ -1,9 +1,122 @@
<resources> <resources>
<string name="app_name">Floret</string> <string name="app_name">Agendula</string>
<string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string> <string name="app_tagline">A modern Material 3 Expressive task app.\nComing into bloom.</string>
<!-- Generic --> <!-- Generic -->
<string name="task_untitled">Untitled task</string> <string name="task_untitled">Untitled task</string>
<string name="back">Back</string>
<string name="save">Save</string>
<string name="delete">Delete</string>
<!-- Task list -->
<string name="tasks_title">Tasks</string>
<string name="task_list_empty">No tasks here yet.</string>
<string name="task_list_failure">Could not load these tasks.</string>
<string name="smart_no_date">No date</string>
<string name="smart_completed">Completed</string>
<!-- Task detail -->
<string name="task_detail_title">Task</string>
<string name="task_detail_not_found">This task no longer exists.</string>
<string name="edit">Edit</string>
<!-- Task edit -->
<string name="edit_task_title">Edit task</string>
<string name="new_task_title">New task</string>
<string name="field_title">Title</string>
<string name="field_description">Description</string>
<string name="edit_save_failed">Could not save. Try again.</string>
<!-- Task list: inline add + swipe -->
<string name="add_task_hint">Add a task</string>
<string name="add_subtask_hint">Add a subtask</string>
<string name="cd_add_task">Add task</string>
<string name="cd_complete">Complete</string>
<string name="cd_reopen">Reopen</string>
<string name="task_deleted">Deleted</string>
<string name="undo">Undo</string>
<string name="cd_expand_subtasks">Show subtasks</string>
<string name="cd_collapse_subtasks">Hide subtasks</string>
<!-- Sections (due-date buckets) -->
<string name="section_overdue">Overdue</string>
<string name="section_today">Today</string>
<string name="section_upcoming">Upcoming</string>
<string name="section_no_date">No date</string>
<string name="section_completed">Completed</string>
<!-- Priority -->
<string name="priority_none">None</string>
<string name="priority_low">Low</string>
<string name="priority_medium">Medium</string>
<string name="priority_high">High</string>
<!-- Task detail -->
<string name="detail_mark_complete">Mark complete</string>
<string name="detail_mark_incomplete">Mark not complete</string>
<string name="detail_list">List</string>
<string name="detail_due">Due</string>
<string name="detail_start">Starts</string>
<string name="detail_priority">Priority</string>
<string name="detail_progress">Progress</string>
<string name="detail_subtasks">Subtasks</string>
<string name="detail_parent">Parent task</string>
<string name="detail_part_of">Part of</string>
<string name="percent_complete">%1$d%%</string>
<!-- Task edit -->
<string name="edit_list_label">List</string>
<string name="edit_start_label">Starts</string>
<string name="edit_due_label">Due</string>
<string name="edit_all_day">All day</string>
<string name="edit_priority_label">Priority</string>
<string name="edit_progress_label">Progress</string>
<string name="edit_parent_label">Parent task</string>
<string name="edit_parent_none">None (top-level)</string>
<string name="edit_parent_search">Search tasks</string>
<string name="edit_parent_empty">No matching tasks in this list.</string>
<string name="edit_reminder_label">Reminder</string>
<string name="edit_set">Set</string>
<string name="edit_clear">Clear</string>
<string name="edit_title_hint">Task title</string>
<string name="edit_when">When</string>
<string name="edit_more_fields">More fields</string>
<string name="close">Close</string>
<string name="dialog_cancel">Cancel</string>
<!-- Save conflict -->
<string name="edit_conflict_title">Task changed elsewhere</string>
<string name="edit_conflict_body">This task was updated somewhere else (a sync or another app) since you opened it. Overwrite those changes with your edits?</string>
<string name="edit_conflict_overwrite">Overwrite</string>
<!-- Reminder offsets -->
<string name="reminder_none">None</string>
<string name="reminder_at_due">At time of task</string>
<string name="reminder_5_min">5 minutes before</string>
<string name="reminder_10_min">10 minutes before</string>
<string name="reminder_30_min">30 minutes before</string>
<string name="reminder_1_hour">1 hour before</string>
<string name="reminder_1_day">1 day before</string>
<!-- Reminder custom lead time + per-list overrides -->
<string name="reminder_custom">Custom</string>
<string name="reminder_custom_with_value">Custom (%1$s)</string>
<string name="reminder_custom_set">Set</string>
<string name="reminder_custom_amount">Enter an amount</string>
<string name="reminder_use_default">Use default</string>
<string name="reminder_unit_minutes">Minutes</string>
<string name="reminder_unit_hours">Hours</string>
<string name="reminder_unit_days">Days</string>
<string name="reminder_unit_weeks">Weeks</string>
<string name="settings_list_reminders_title">Per-list reminders</string>
<string name="settings_list_reminders_hint">Override the default reminder for individual lists</string>
<string name="settings_list_reminder_inherits">Default (%1$s)</string>
<!-- Form validation -->
<string name="error_blank_title">Enter a title.</string>
<string name="error_no_list">Choose a list.</string>
<string name="error_due_before_start">Due can\'t be before the start.</string>
<string name="error_reminder_without_due">Set a due date to use a reminder.</string>
<!-- Lists overview --> <!-- Lists overview -->
<string name="lists_header">Lists</string> <string name="lists_header">Lists</string>
@@ -23,10 +136,83 @@
<!-- Provider / permission onboarding --> <!-- Provider / permission onboarding -->
<string name="onboarding_no_provider_title">A tasks app is needed</string> <string name="onboarding_no_provider_title">A tasks app is needed</string>
<string name="onboarding_no_provider_body">Floret shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.</string> <string name="onboarding_no_provider_body">Agendula shows and edits the tasks stored by OpenTasks or tasks.org, synced by DAVx5. Install one of them to get started.</string>
<string name="onboarding_permission_title">Allow access to your tasks</string> <string name="onboarding_permission_title">Allow access to your tasks</string>
<string name="onboarding_permission_body">Floret needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string> <string name="onboarding_permission_body">Agendula needs permission to read and write your tasks. That\'s the only thing it ever asks for.</string>
<string name="onboarding_permission_button">Grant task access</string> <string name="onboarding_permission_button">Grant task access</string>
<string name="onboarding_install_opentasks">Install OpenTasks</string> <string name="onboarding_install_opentasks">Install OpenTasks</string>
<string name="onboarding_install_tasksorg">Install tasks.org</string> <string name="onboarding_install_tasksorg">Install tasks.org</string>
<!-- Reminder onboarding (one-time, after the provider grant) -->
<string name="reminder_onboarding_title">Never miss what\'s due</string>
<string name="reminder_onboarding_body">Tasks apps don\'t send reminders themselves, so Agendula delivers them for you. Turn them on to get a notification when a task is due.</string>
<string name="reminder_onboarding_enable_button">Enable reminders</string>
<string name="reminder_onboarding_skip_button">Not now</string>
<string name="reminder_benefit_delivery_title">Agendula reminds you</string>
<string name="reminder_benefit_delivery_body">It schedules a notification for each task with a due date.</string>
<string name="reminder_benefit_timing_title">On your schedule</string>
<string name="reminder_benefit_timing_body">Choose how far ahead to be reminded in Settings.</string>
<string name="reminder_benefit_reversible_title">Change it anytime</string>
<string name="reminder_benefit_reversible_body">Turn reminders off whenever you like — it\'s just a switch.</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="settings_section_appearance">Appearance</string>
<string name="settings_appearance_subtitle">Theme and colour</string>
<string name="settings_section_task_form">Task form</string>
<string name="settings_task_form_subtitle">Default fields, list and subtasks</string>
<string name="settings_reminders_subtitle">Notifications for due tasks</string>
<string name="settings_form_fields_hint">Fields shown by default on a new task. The rest sit behind \"More fields\".</string>
<string name="settings_dynamic_color_unavailable">Available on Android 12 and later</string>
<!-- About -->
<string name="settings_about_author">by Jean-Luc Makiola</string>
<string name="settings_about_source">Source</string>
<string name="settings_license">License</string>
<string name="settings_about_support">Support development</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Agendula app icon</string>
<string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string>
<string name="settings_report_problem">Report a problem</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="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_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
<string name="settings_theme">Theme</string>
<string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_hint">Use colours from your wallpaper</string>
<string name="settings_section_reminders">Reminders</string>
<string name="settings_reminders">Due reminders</string>
<string name="settings_reminders_hint">Notify me when a task is due</string>
<string name="settings_default_reminder">When to remind</string>
<string name="settings_exact_alarms">Exact timing</string>
<string name="settings_exact_alarms_allowed">Reminders fire at the exact time</string>
<string name="settings_exact_alarms_blocked">Blocked — tap to allow exact reminders</string>
<string name="settings_section_tasks">Tasks</string>
<string name="settings_default_list">Default 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_hint">An add field at the end of an expanded task\'s subtasks</string>
<!-- Reminder lead times (custom amounts) -->
<plurals name="reminder_minutes">
<item quantity="one">%1$d minute before</item>
<item quantity="other">%1$d minutes before</item>
</plurals>
<plurals name="reminder_hours">
<item quantity="one">%1$d hour before</item>
<item quantity="other">%1$d hours before</item>
</plurals>
<plurals name="reminder_days">
<item quantity="one">%1$d day before</item>
<item quantity="other">%1$d days before</item>
</plurals>
<plurals name="reminder_weeks">
<item quantity="one">%1$d week before</item>
<item quantity="other">%1$d weeks before</item>
</plurals>
</resources> </resources>

View File

@@ -1,5 +1,5 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Floret" parent="android:Theme.Material.Light.NoActionBar"> <style name="Theme.Agendula" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item> <item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item> <item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">true</item> <item name="android:windowLightStatusBar">true</item>

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The languages Agendula ships translations for. Single source of truth: each
entry needs a matching res/values-<tag>/strings.xml, and is surfaced in both
the in-app language picker (parsed at runtime by AppLanguage) and the system
per-app language settings (Android 13+, via android:localeConfig). To add a
translation, drop in the values-<tag> folder and add one <locale> line here.
-->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
</locale-config>

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
/** A [ColumnReader] backed by a Map, so mappers test without a real Cursor. */ /** A [ColumnReader] backed by a Map, so mappers test without a real Cursor. */
class MapColumnReader(private val values: Map<String, Any?>) : ColumnReader { class MapColumnReader(private val values: Map<String, Any?>) : ColumnReader {

View File

@@ -1,11 +1,11 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Instances import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskStatus import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
class TaskMapperTest { class TaskMapperTest {

View File

@@ -1,10 +1,10 @@
package de.jeanlucmakiola.floret.data.tasks package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Lists import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.floret.data.tasks.TasksContract.Tasks import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.floret.domain.Priority import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.floret.domain.TaskForm import de.jeanlucmakiola.agendula.domain.TaskForm
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.time.Instant import kotlin.time.Instant
@@ -28,6 +28,50 @@ class TaskWriteMapperTest {
assertThat(values[Tasks.TZ]).isEqualTo("Europe/Berlin") assertThat(values[Tasks.TZ]).isEqualTo("Europe/Berlin")
} }
@Test
fun `percent complete is written and clamped to 0-100`() {
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 140),
tzId = "UTC",
)
assertThat(values[Tasks.PERCENT_COMPLETE]).isEqualTo(100)
val unset = TaskWriteMapper.taskValues(TaskForm(title = "x", listId = 1L), tzId = "UTC")
assertThat(unset[Tasks.PERCENT_COMPLETE]).isNull()
}
@Test
fun `progress keeps completion in sync both ways`() {
// 100% completes the task (timestamp left to the provider / existing value).
val done = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 100),
tzId = "UTC",
)
assertThat(done[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_COMPLETED)
assertThat(done.containsKey(Tasks.COMPLETED)).isFalse()
// Below 100% reopens it and clears the completion timestamp.
val reopened = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, percentComplete = 75),
tzId = "UTC",
)
assertThat(reopened[Tasks.STATUS]).isEqualTo(TasksContract.STATUS_IN_PROCESS)
assertThat(reopened[Tasks.COMPLETED]).isNull()
// No percent in the form ⇒ status is left to the complete toggle.
val untouched = TaskWriteMapper.taskValues(TaskForm(title = "x", listId = 1L), tzId = "UTC")
assertThat(untouched.containsKey(Tasks.STATUS)).isFalse()
}
@Test
fun `parent id is written so a task can be filed under another`() {
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, parentId = 7L),
tzId = "UTC",
)
assertThat(values[Tasks.PARENT_ID]).isEqualTo(7L)
}
@Test @Test
fun `all-day task clears the timezone`() { fun `all-day task clears the timezone`() {
val form = TaskForm( val form = TaskForm(

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -35,4 +35,13 @@ class TaskFormTest {
val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate() val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate()
assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE) assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE)
} }
@Test
fun `progress and parent auto-reveal only when they carry a value`() {
assertThat(TaskForm(title = "x", listId = 1).populatedFields()).isEmpty()
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 0, parentId = 0).populatedFields())
.isEmpty()
assertThat(TaskForm(title = "x", listId = 1, percentComplete = 30, parentId = 9).populatedFields())
.containsExactly(TaskFormField.Progress, TaskFormField.Parent)
}
} }

View File

@@ -0,0 +1,47 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class TaskSectionsTest {
private val dayMs = 86_400_000L
private val todayStart = Instant.fromEpochMilliseconds(1000 * dayMs)
private val todayEnd = Instant.fromEpochMilliseconds(1001 * dayMs)
private fun at(millis: Long) = Instant.fromEpochMilliseconds(millis)
private fun sectionOf(task: Task) = TaskSections.sectionOf(task, todayStart, todayEnd)
@Test
fun `buckets by due relative to today`() {
assertThat(sectionOf(testTask(due = at(1000 * dayMs - 1)))).isEqualTo(TaskSection.OVERDUE)
assertThat(sectionOf(testTask(due = at(1000 * dayMs + 1)))).isEqualTo(TaskSection.TODAY)
assertThat(sectionOf(testTask(due = at(1001 * dayMs + 1)))).isEqualTo(TaskSection.UPCOMING)
assertThat(sectionOf(testTask(due = null))).isEqualTo(TaskSection.NO_DATE)
}
@Test
fun `completed always lands in completed regardless of due`() {
val overdueButDone = testTask(status = TaskStatus.COMPLETED, due = at(1000 * dayMs - 1))
assertThat(sectionOf(overdueButDone)).isEqualTo(TaskSection.COMPLETED)
}
@Test
fun `of returns only non-empty sections in fixed order`() {
val tasks = listOf(
testTask(id = 1, due = at(1001 * dayMs + 1)), // upcoming
testTask(id = 2, due = at(1000 * dayMs - 1)), // overdue
testTask(id = 3, status = TaskStatus.COMPLETED), // completed
)
val sections = TaskSections.of(tasks, todayStart, todayEnd)
assertThat(sections.map { it.section })
.containsExactly(TaskSection.OVERDUE, TaskSection.UPCOMING, TaskSection.COMPLETED)
.inOrder()
}
@Test
fun `empty input yields no sections`() {
assertThat(TaskSections.of(emptyList(), todayStart, todayEnd)).isEmpty()
}
}

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test

View File

@@ -1,4 +1,4 @@
package de.jeanlucmakiola.floret.domain package de.jeanlucmakiola.agendula.domain
import kotlin.time.Instant import kotlin.time.Instant

View File

@@ -1,6 +1,6 @@
# Floret — architecture # Agendula — architecture
This document describes how Floret is built **as it stands today**. For the This document describes how Agendula is built **as it stands today**. For the
*why* behind the big decisions and the long-term plan, see *why* behind the big decisions and the long-term plan, see
[`PLAN.md`](PLAN.md); for status and what's next, see [`ROADMAP.md`](ROADMAP.md). [`PLAN.md`](PLAN.md); for status and what's next, see [`ROADMAP.md`](ROADMAP.md).
@@ -8,10 +8,10 @@ This document describes how Floret is built **as it stands today**. For the
## 1. The thesis in one sentence ## 1. The thesis in one sentence
Floret is a Material 3 Expressive **front-end** over the OpenTasks Agendula is a Material 3 Expressive **front-end** over the OpenTasks
`TaskContract` provider — it reads, writes, and reminds on top of a tasks store `TaskContract` provider — it reads, writes, and reminds on top of a tasks store
that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over that some other app (DAVx5, SmoothSync, DecSync CC, tasks.org, …) syncs over
CalDAV. **Floret owns no database and no sync stack.** It is the task-list CalDAV. **Agendula owns no database and no sync stack.** It is the task-list
sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula), sibling to [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula),
which does the same thing for `CalendarContract`. which does the same thing for `CalendarContract`.
@@ -69,7 +69,7 @@ Both are bound in Hilt in `data/di/DataModule.kt`.
## 3. Module & package layout ## 3. Module & package layout
Single `:app` module (Posture A). Package root `de.jeanlucmakiola.floret`. Single `:app` module (Posture A). Package root `de.jeanlucmakiola.agendula`.
| Package | Contents | | Package | Contents |
|---|---| |---|---|
@@ -80,7 +80,7 @@ Single `:app` module (Posture A). Package root `de.jeanlucmakiola.floret`.
| `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). | | `data/di/` | `DataModule` (binds + provides), `Qualifiers` (`@IoDispatcher`). |
| `data/demo/` | `DemoSeeder` (debug-only sample data). | | `data/demo/` | `DemoSeeder` (debug-only sample data). |
| `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. | | `ui/` | `theme/`, `common/` (GroupedList, ListChip), `lists/`, `tasklist/`, `detail/`, `edit/`, `settings/`, `permission/` (each a ViewModel + UiState; `lists` also has its screen), `RootScreen`. |
| root | `FloretApp` (Hilt app), `MainActivity`. | | root | `AgendulaApp` (Hilt app), `MainActivity`. |
--- ---
@@ -106,7 +106,7 @@ runtime perms for the active provider.
### 4.2 `TasksContract` ### 4.2 `TasksContract`
A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names, A vendored subset of the Apache-2.0 OpenTasks `TaskContract` — column names,
table paths, status/priority constants, the local-account type. Floret does table paths, status/priority constants, the local-account type. Agendula does
**not** take a runtime dependency on OpenTasks; the authority is injected from **not** take a runtime dependency on OpenTasks; the authority is injected from
`ProviderResolver`, never hardcoded in the contract. `ProviderResolver`, never hardcoded in the contract.
@@ -117,7 +117,7 @@ occurrence is a row with the joined list colour, account, etc.), maps each
cursor row through `ColumnReader``TaskMapper` → domain `Task`, and exposes cursor row through `ColumnReader``TaskMapper` → domain `Task`, and exposes
the result as a `Flow`. A `ContentObserver` on the active authority's the result as a `Flow`. A `ContentObserver` on the active authority's
Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change Tasks/TaskLists URIs bridges into the Flow via `callbackFlow`, so **any change
re-emits** — Floret's own writes *and* external sync (DAVx5 pulling new tasks) re-emits** — Agendula's own writes *and* external sync (DAVx5 pulling new tasks)
update the UI live, and multiple sync sources coexist in one list. update the UI live, and multiple sync sources coexist in one list.
### 4.4 Writes — repository API ### 4.4 Writes — repository API
@@ -170,7 +170,7 @@ priority / etc.). None of this touches Android, which is why it's all in
## 6. Reminders — the one subsystem that does NOT mirror Calendula ## 6. Reminders — the one subsystem that does NOT mirror Calendula
Calendula relies on the calendar provider broadcasting `EVENT_REMINDER`. **Tasks Calendula relies on the calendar provider broadcasting `EVENT_REMINDER`. **Tasks
providers broadcast nothing**, so Floret schedules its own (`data/reminders/`): providers broadcast nothing**, so Agendula schedules its own (`data/reminders/`):
- **`ReminderScheduler.sync()`** reads upcoming, non-closed, due-dated tasks - **`ReminderScheduler.sync()`** reads upcoming, non-closed, due-dated tasks
within a rolling **30-day window**, computes each trigger as `due lead` within a rolling **30-day window**, computes each trigger as `due lead`
@@ -186,7 +186,7 @@ providers broadcast nothing**, so Floret schedules its own (`data/reminders/`):
sync changed the data). The store lets each run diff like Calendula diffs sync changed the data). The store lets each run diff like Calendula diffs
reminder rows. reminder rows.
This is the single largest piece of genuinely-new code in Floret. This is the single largest piece of genuinely-new code in Agendula.
--- ---
@@ -201,7 +201,7 @@ This is the single largest piece of genuinely-new code in Floret.
domain, and `TasksRepository` do not change** — only the resolver's default and domain, and `TasksRepository` do not change** — only the resolver's default and
some manifest perms. some manifest perms.
Bundling the provider bundles **storage, not sync**Floret stays a pure Bundling the provider bundles **storage, not sync**Agendula stays a pure
front-end over open backends either way. front-end over open backends either way.
--- ---
@@ -228,8 +228,8 @@ checkbox/FAB/swipe motion).
Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module Hilt, `SingletonComponent`. `DataModule` has a `@Binds` module
(`TasksDataSource``AndroidTasksDataSource`, `TasksRepository` (`TasksDataSource``AndroidTasksDataSource`, `TasksRepository`
`TasksRepositoryImpl`) and a `@Provides` module (the `floret_prefs` DataStore, `TasksRepositoryImpl`) and a `@Provides` module (the `agendula_prefs` DataStore,
the `@IoDispatcher`). `FloretApp` is the `@HiltAndroidApp` entry point; the `@IoDispatcher`). `AgendulaApp` is the `@HiltAndroidApp` entry point;
`MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected. `MainActivity` is `@AndroidEntryPoint`. ViewModels get the repository injected.
--- ---

View File

@@ -1,23 +1,25 @@
# Floret — implementation plan # Agendula — implementation plan
> A modern Material 3 Expressive **task** app for Android. Reads, writes, and > A modern Material 3 Expressive **task** app for Android. Reads, writes, and
> reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync / > reminds — on top of an existing tasks provider (synced by DAVx5 / SmoothSync /
> DecSync over CalDAV), with no own sync stack. > DecSync over CalDAV), with no own sync stack.
> >
> Sibling to **Calendula**. Calendula is a calendar over `CalendarContract`; > Sibling to **Calendula**. Calendula is a calendar over `CalendarContract`;
> Floret is a to-do list over the **OpenTasks `TaskContract` provider**. A > Agendula is a to-do list over the **OpenTasks `TaskContract` provider**. The
> Calendula flower head is made of many small *florets* — the individual items > name mirrors Calendula's: *agenda* (Latin, “things to be done”) + the `-ula`
> that make up the bloom. > ending — and a Calendula flower head is itself a cluster of small *florets*,
> so the two apps are florets of one bloom.
Working identifiers (placeholder, easily renamed): Identifiers: `applicationId = de.jeanlucmakiola.agendula`, app name **Agendula**
`applicationId = de.jeanlucmakiola.floret`, app name **Floret**. (renamed from the working title *Floret*, which was promoted to the shared
family / design-language name).
--- ---
## 0. The thesis this app embodies ## 0. The thesis this app embodies
A nice M3-Expressive front end over open backends, no reinvented storage or A nice M3-Expressive front end over open backends, no reinvented storage or
sync. Calendula proved the pattern against the OS calendar provider. Floret sync. Calendula proved the pattern against the OS calendar provider. Agendula
applies it to tasks. The crucial difference: **there is no OS tasks provider**, applies it to tasks. The crucial difference: **there is no OS tasks provider**,
so we depend on a tasks *provider app* being present — exactly as Calendula so we depend on a tasks *provider app* being present — exactly as Calendula
depends on a sync app like DAVx5 for CalDAV. depends on a sync app like DAVx5 for CalDAV.
@@ -46,7 +48,7 @@ string leak above the data layer.**
Calendula's layering is the template. Lift these **verbatim or near-verbatim**: Calendula's layering is the template. Lift these **verbatim or near-verbatim**:
| Area | From Calendula | Change for Floret | | Area | From Calendula | Change for Agendula |
|---|---|---| |---|---|---|
| Gradle setup | `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml`, wrapper, `key.properties` flow, versionCode-from-tag CI | namespace/appId only | | Gradle setup | `build.gradle.kts`, `settings.gradle.kts`, `gradle/libs.versions.toml`, wrapper, `key.properties` flow, versionCode-from-tag CI | namespace/appId only |
| Build config | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, compileSdk 37 / minSdk 29 / targetSdk 36, Java 17 | identical | | Build config | AGP 9.2.1, Kotlin 2.3.21, KSP, Hilt 2.59.2, compileSdk 37 / minSdk 29 / targetSdk 36, Java 17 | identical |
@@ -65,7 +67,7 @@ Calendula's layering is the template. Lift these **verbatim or near-verbatim**:
`TaskContract` data layer, the task screens, and a **self-scheduled reminder `TaskContract` data layer, the task screens, and a **self-scheduled reminder
engine** (see §6 — the one place Calendula's pattern does *not* carry over). engine** (see §6 — the one place Calendula's pattern does *not* carry over).
### What does NOT exist here (why Floret is simpler than Calendula) ### What does NOT exist here (why Agendula is simpler than Calendula)
No month/week/day grid rendering. No recurrence-scoped writes ("this & following" No month/week/day grid rendering. No recurrence-scoped writes ("this & following"
vs "whole series"). No timezone/all-day gymnastics. Tasks are a flat-or-lightly- vs "whole series"). No timezone/all-day gymnastics. Tasks are a flat-or-lightly-
@@ -76,7 +78,7 @@ nested list with a due date and a checkbox.
## 2. Module & package layout ## 2. Module & package layout
Single `:app` module for A (mirrors Calendula). Package root Single `:app` module for A (mirrors Calendula). Package root
`de.jeanlucmakiola.floret`. `de.jeanlucmakiola.agendula`.
``` ```
domain/ domain/
@@ -111,10 +113,10 @@ ui/
edit/ TaskEditScreen + VM + UiState edit/ TaskEditScreen + VM + UiState
settings/ (copy + adapt) settings/ (copy + adapt)
permission/ provider-presence + permission + POST_NOTIFICATIONS onboarding permission/ provider-presence + permission + POST_NOTIFICATIONS onboarding
FloretHost.kt nav host (mirrors CalendarHost) AgendulaHost.kt nav host (mirrors CalendarHost)
RootScreen.kt RootScreen.kt
widget/ Glance task widget (later milestone) widget/ Glance task widget (later milestone)
FloretApp.kt, MainActivity.kt AgendulaApp.kt, MainActivity.kt
``` ```
--- ---
@@ -217,9 +219,9 @@ Material 3 Expressive throughout: `MaterialExpressiveTheme`,
Three gates, in order: Three gates, in order:
1. **Provider present?** `ProviderResolver.detect()`. If none → a screen 1. **Provider present?** `ProviderResolver.detect()`. If none → a screen
explaining Floret needs a tasks provider, with one-tap links to install explaining Agendula needs a tasks provider, with one-tap links to install
**OpenTasks** (FOSS) or **tasks.org**, plus "I use DAVx5 — set its Tasks app". **OpenTasks** (FOSS) or **tasks.org**, plus "I use DAVx5 — set its Tasks app".
(This is Floret's "needs DAVx5" moment. Disappears entirely under Posture B.) (This is Agendula's "needs DAVx5" moment. Disappears entirely under Posture B.)
2. **Tasks read/write permission** — request the active provider's runtime perms 2. **Tasks read/write permission** — request the active provider's runtime perms
(`org.dmfs.permission.*` etc.). Declared in the manifest *and* requested at (`org.dmfs.permission.*` etc.). Declared in the manifest *and* requested at
runtime; resolved dynamically from the detected provider. runtime; resolved dynamically from the detected provider.
@@ -235,7 +237,7 @@ exactly like Calendula's launcher query.
Calendula relies on the **calendar provider broadcasting `EVENT_REMINDER`** and Calendula relies on the **calendar provider broadcasting `EVENT_REMINDER`** and
just posts the notification (Etar model). **Tasks providers do not broadcast just posts the notification (Etar model). **Tasks providers do not broadcast
reminders.** So Floret must schedule its own: reminders.** So Agendula must schedule its own:
- `DueReminderScheduler` (AlarmManager, exact alarms via `USE_EXACT_ALARM` / - `DueReminderScheduler` (AlarmManager, exact alarms via `USE_EXACT_ALARM` /
`SCHEDULE_EXACT_ALARM`) sets an alarm per task at `DUE` minus the chosen `SCHEDULE_EXACT_ALARM`) sets an alarm per task at `DUE` minus the chosen
@@ -273,7 +275,7 @@ receiver. **No `EVENT_REMINDER` receiver** (doesn't apply).
## 8. Milestones ## 8. Milestones
- **M0 — Skeleton.** Copy Gradle/version-catalog/theme/DI/app+activity from - **M0 — Skeleton.** Copy Gradle/version-catalog/theme/DI/app+activity from
Calendula, rename to Floret. Builds, shows themed empty scaffold. *(~½ day)* Calendula, rename to Agendula. Builds, shows themed empty scaffold. *(~½ day)*
- **M1 — Read path.** `TasksContract` + `ProviderResolver` + `OpenTasksDataSource` - **M1 — Read path.** `TasksContract` + `ProviderResolver` + `OpenTasksDataSource`
+ repository. Lists overview + task list render real synced tasks (read-only), + repository. Lists overview + task list render real synced tasks (read-only),
live-updating via ContentObserver. *(the meaty milestone)* live-updating via ContentObserver. *(the meaty milestone)*
@@ -297,21 +299,22 @@ M4 (subtasks) and M5 (reminders) are the two spots needing real thought.
## 9. Open decisions / to verify ## 9. Open decisions / to verify
1. **Name**`Floret` is the working title (florets compose a Calendula head). 1. ~~**Name**~~**Agendula** (final): *agenda* + Calendula's `-ula`. Renamed
Confirm or replace before M0 (touches appId, namespace, package). from the working title *Floret*, which was promoted to the family /
design-language name.
2. **tasks.org provider authority** — verify on a real device; OpenTasks 2. **tasks.org provider authority** — verify on a real device; OpenTasks
(`org.dmfs.tasks`) is the certain target for v1. (`org.dmfs.tasks`) is the certain target for v1.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? 3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
4. **B authority choice** — bundling `org.dmfs.tasks` makes Floret a *replacement* 4. **B authority choice** — bundling `org.dmfs.tasks` makes Agendula a *replacement*
for OpenTasks (one authority owner per device). Intended (one app instead of for OpenTasks (one authority owner per device). Intended (one app instead of
two), but a conscious choice. two), but a conscious choice.
5. **Repo home** — sibling Gitea repo next to Calendula, MIT license, same CI. 5. **Repo home** — sibling Gitea repo next to Calendula, MIT license, same CI.
--- ---
## 10. Sync sources Floret inherits for free (README copy) ## 10. Sync sources Agendula inherits for free (README copy)
Because Floret builds on the provider, not on any one sync app, it works with Because Agendula builds on the provider, not on any one sync app, it works with
**anything that writes to the tasks provider**: DAVx5 (CalDAV), SmoothSync, **anything that writes to the tasks provider**: DAVx5 (CalDAV), SmoothSync,
CalDAV-Sync, DecSync CC, and any Android sync adapter — no per-app integration. CalDAV-Sync, DecSync CC, and any Android sync adapter — no per-app integration.
Google Tasks / Microsoft To Do are out of scope by design (proprietary, would Google Tasks / Microsoft To Do are out of scope by design (proprietary, would

View File

@@ -1,6 +1,6 @@
# Floret — documentation # Agendula — documentation
Floret is a Material 3 Expressive **task** app for Android: a pure front-end over Agendula is a Material 3 Expressive **task** app for Android: a pure front-end over
the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync the OpenTasks `TaskContract` provider (synced by DAVx5 / SmoothSync / DecSync
over CalDAV), with no own database or sync stack. Sibling to over CalDAV), with no own database or sync stack. Sibling to
[Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). See the [Calendula](https://gitea.jeanlucmakiola.de/makiolaj/calendula). See the
@@ -10,7 +10,7 @@ top-level [`../README.md`](../README.md) for the project pitch.
| Doc | What it covers | | Doc | What it covers |
|---|---| |---|---|
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Floret is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. | | [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Agendula is built **today** — layers, the data seam, provider resolution, the reminder engine, DI, build/tooling, manifest. Start here to work on the code. |
| [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0M6 + Posture B), what's done, open decisions, how to build/verify. | | [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0M6 + Posture B), what's done, open decisions, how to build/verify. |
| [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". | | [`PLAN.md`](PLAN.md) | The original implementation plan and **design rationale** — the A-now-B-later thesis, what transfers from Calendula, the locked decisions. The "why". |
| [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. | | [`RELEASING.md`](RELEASING.md) | How to cut a release — the git-tag-as-source-of-truth flow, CI jobs, F-Droid repo, required secrets. |

View File

@@ -1,6 +1,6 @@
# Floret — releasing # Agendula — releasing
Floret is distributed through a **self-hosted F-Droid repo** (on Hetzner) with a Agendula is distributed through a **self-hosted F-Droid repo** (on Hetzner) with a
human-readable **Gitea release** per tag. Both are produced automatically by human-readable **Gitea release** per tag. Both are produced automatically by
`.gitea/workflows/release.yaml` when you push a tag. There are no APK assets on `.gitea/workflows/release.yaml` when you push a tag. There are no APK assets on
the Gitea release itself — distribution lives in the F-Droid repo; the release is the Gitea release itself — distribution lives in the F-Droid repo; the release is
@@ -94,7 +94,7 @@ Keep this green before tagging.
## F-Droid metadata ## F-Droid metadata
App store listing lives in `fdroid-metadata/` (`de.jeanlucmakiola.floret.yml` App store listing lives in `fdroid-metadata/` (`de.jeanlucmakiola.agendula.yml`
plus `en-US/` summary/description). Per-version changelogs are generated into the plus `en-US/` summary/description). Per-version changelogs are generated into the
repo's `metadata/.../en-US/changelogs/<versionCode>.txt` from `CHANGELOG.md` at repo's `metadata/.../en-US/changelogs/<versionCode>.txt` from `CHANGELOG.md` at
release time; metadata is uploaded alongside `repo/` so changelog history release time; metadata is uploaded alongside `repo/` so changelog history

View File

@@ -1,4 +1,4 @@
# Floret — roadmap # Agendula — roadmap
Where the project is and where it's going. This is the **status** view; the Where the project is and where it's going. This is the **status** view; the
design rationale behind each milestone lives in [`PLAN.md`](PLAN.md), and how the design rationale behind each milestone lives in [`PLAN.md`](PLAN.md), and how the
@@ -11,9 +11,15 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
## Current state (one line) ## Current state (one line)
The full non-visual stack ("backoffice") over the OpenTasks `TaskContract` The full non-visual stack ("backoffice") over the OpenTasks `TaskContract`
provider is **done and unit-tested**; the Material 3 Expressive UI is being provider is **done and unit-tested**, and the Material 3 Expressive UI is built
built screen by screen on top of it. App builds, gates on provider/permission, through **M5**: lists → task list (swipe gestures, inline add, smart-list section
and renders the lists overview. headers) → detail / edit with full CRUD, date-time pickers, priority,
percent-complete, conflict-safe saves, per-task reminders, and subtask
create + reparent — plus a one-time reminder onboarding step and a Settings
screen (theme, dynamic colour, due-reminder master toggle + default offset +
exact-alarm status, default list, and the add-a-subtask-row opt-out). Remaining
work is M6 (Glance widget, translations, F-Droid release; the Settings screen
landed early with M5 and still needs a language entry).
--- ---
@@ -39,36 +45,88 @@ The complete non-visual stack:
- JVM unit tests across mappers, filtering, sorting, form, value mapping, and - JVM unit tests across mappers, filtering, sorting, form, value mapping, and
day windows. day windows.
### 🚧 M2 — Screens: lists, task list, complete & CRUD ### M2 — Screens: lists, task list, complete & CRUD
Replace the functional scaffold with the real Material 3 Expressive UI, screen The real Material 3 Expressive UI, built screen by screen against the M1
by screen, against the already-built ViewModels. ViewModels.
- ✅ Provider/permission onboarding gate (`RootScreen`). - ✅ Provider/permission onboarding gate (`RootScreen`).
- ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account. - ✅ Lists overview (`ListsScreen`) — smart lists + user lists grouped by account.
- 🚧 Navigation host wiring (the `onOpenFilter` / `onNewTask` callbacks in - Navigation host wiring (`AgendulaNavHost` + `Dest` route table: lists → task
`RootScreen` are currently stubs). list → detail / edit, each binding its M1 ViewModel from route args).
- Task list screen — checkbox rows, swipe-to-complete / swipe-to-delete, - Task list screen — checkbox rows + toggle-complete, swipe-to-complete /
inline add field, section headers for smart lists, FAB. -delete (`SwipeToDismissBox`), inline add field (`InlineAdd``quickAdd`),
- ⬜ Toggle complete (swipe + checkbox), create / edit / delete, local-list smart-list section headers (`TaskSections`).
creation wired to the UI. - ✅ Detail + edit screens — detail shows title / description / subtasks with
edit+delete; edit has the full CRUD form (title, description, save) wired to a
list picker.
### M3 — Detail / edit polish ### M3 — Detail / edit polish
Task detail and edit screens: due/start date-time pickers, priority, - ✅ Due / start date-time pickers (`DateTimePickerFlow` in `TaskEditScreen`).
percent-complete, conflict-safe saves (re-check before overwrite), smart-list - ✅ Priority — coloured by level: green / amber / red pastels (`priorityFill`;
section presentation. M3 has no priority role, only `error`). A shared `ui/common/PriorityChip` on the
list and detail screens, and the edit form's M3 segmented selector tints its
active segment in the chosen level's hue.
- ✅ Smart-list section presentation (`TaskSections` headers on the task list).
- ✅ Percent-complete field — optional "Progress" slider (5% detents) on the edit
form; writes `Tasks.PERCENT_COMPLETE` (clamped 0100, status left to the
complete toggle).
- ✅ Conflict-safe saves — `updateTask` re-checks `last_modified` against the
value captured when the form loaded and throws `TaskConflictException`; the
editor offers overwrite-or-cancel instead of clobbering an external change.
### M4 — Subtasks (UI) ### M4 — Subtasks (UI)
`RELATED-TO` hierarchy in the UI: indentation, create subtask, reparent. The `RELATED-TO` hierarchy in the UI. The data layer already reads `parentId`.
data layer already reads `parentId`; this is the trickiest UI piece. - ✅ Create subtask (`AddSubtaskField``TaskDetailViewModel.addSubtask` sets
`parentId`); subtasks render as a grouped section in the detail screen. Tapping
a subtask opens its own detail (a new `TaskDetail` entry, so it can have
children too), and the subtask's detail shows a tappable "Part of …" parent
card so it never reads as a stray standalone task.
- ✅ Inline expansion on the task list — a parent row has a dedicated expand
button (trailing chevron, separate from the count chip). Each section flattens
into one grouped run (parents + their expanded children) and corners are chosen
per-edge (`cornerPosition`): top-level tasks keep their own run, an expanded
parent opens its bottom to its children, and the child group rounds off on its
last segment while the next top-level task stays mid-run. Children are
full-width, set a step down in tone (`surfaceContainer` vs the parents'
`surfaceContainerHigh`) and with no colour bar (checkbox stays aligned). An expanded group ends with an inline "add a subtask"
row (on by default; opt out in Settings → Tasks since M5).
Offered only where the list holds all the children (a real list; smart lists
that omit off-day children stay collapsed). `TaskDetail.parent` carries the
parent for the detail card.
- ✅ Reparent — a full-width, searchable "Parent task" sheet on the edit form
groups active candidates by due-date section (Overdue / Today / Upcoming / No
date) and files a task under any top-level task in its list (or "None" to
promote it); switching list clears the now-invalid parent. Candidates stay
active + top-level to keep nesting one level deep, matching the detail screen.
### M5 — Reminders onboarding & polish ### M5 — Reminders onboarding & polish
The engine exists (M1). Remaining: the `POST_NOTIFICATIONS` + exact-alarm The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
onboarding flow, per-task / default reminder-offset settings UI, and `DueReminderReceiver`, `TaskNotifier`).
end-to-end verification on device. - ✅ Per-task reminder-offset UI (`ReminderPickerDialog``TaskForm`
`reminderMinutesBeforeDue`).
-`POST_NOTIFICATIONS` onboarding flow — a one-time `ReminderOnboardingScreen`
(Calendula's shell + copy adapted for tasks) gated in `RootScreen` after the
provider/permission grant; it requests the runtime permission (API 33+) and
records the choice (`reminderOnboardingDone`). Re-requestable from Settings.
- ✅ Exact-alarm surface — a status row in Settings → Reminders, shown only on
Android 12 (where `SCHEDULE_EXACT_ALARM` is revocable), deep-linking to the
system grant screen; on 13+ the app holds `USE_EXACT_ALARM` (always granted).
- ✅ Default reminder-offset settings UI — exposed as "When to remind" in
Settings → Reminders (`reminderLeadMinutes`), behind a master `remindersEnabled`
switch that gates the entire engine (`ReminderScheduler` clears all alarms when
off; `DueReminderReceiver` suppresses any in-flight fire).
- ⬜ End-to-end verification on device (build + unit tests green; not yet run on
a device with a live provider).
### M6 — Settings, widget, i18n, release ### 🚧 M6 — Settings, widget, i18n, release
Settings screen (theme / dynamic-color / language, default list, default - ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
reminder), Glance task-list widget, translations, finalize F-Droid metadata, - ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
confirm CI release flow. reached by the gear on the lists overview). Covers theme, dynamic colour, due
reminders (toggle + default offset + exact-alarm status), default list, and the
add-a-subtask-row opt-out. Still ⬜ a **language** entry (deferred until there
are translations to switch to).
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
### ⬜ Posture B (separate track, later) ### ⬜ Posture B (separate track, later)
Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`; Add a `:provider` module bundling the Apache-2.0 `opentasks-provider`;
@@ -82,12 +140,12 @@ permissions; ship self-contained. UI / repository / domain untouched — see
These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through. These carry over from [`PLAN.md`](PLAN.md) §9; resolved ones are struck through.
1. ~~**Name** — `Floret`~~ confirmed (appId `de.jeanlucmakiola.floret`). 1. ~~**Name** — `Agendula`~~ confirmed (appId `de.jeanlucmakiola.agendula`).
2. ~~**tasks.org provider authority**~~ verified on device: 2. ~~**tasks.org provider authority**~~ verified on device:
`org.tasks.opentasks` + `org.tasks.permission.*`. `org.tasks.opentasks` + `org.tasks.permission.*`.
3. **jtx Board** — support its richer contract later, or stay OpenTasks-only? 3. **jtx Board** — support its richer contract later, or stay OpenTasks-only?
(Not in the candidate list today.) (Not in the candidate list today.)
4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Floret a 4. **Posture B authority choice** — bundling `org.dmfs.tasks` makes Agendula a
*replacement* for OpenTasks (one authority owner per device). Intended, but a *replacement* for OpenTasks (one authority owner per device). Intended, but a
conscious choice. conscious choice.
5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists); 5. **Recurring tasks** — read as occurrences today (`isRecurring` flag exists);

View File

@@ -0,0 +1,10 @@
AuthorName: Jean-Luc Makiola
License: MIT
Name: Agendula
Summary: A modern Material 3 Expressive task app for Android.
Categories:
- Time
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/agendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues

View File

@@ -1,4 +1,4 @@
Floret is a modern, open-source task app for Android. It works directly on an Agendula is a modern, open-source task app for Android. It works directly on an
existing tasks provider (OpenTasks / tasks.org), so any CalDAV tasks synced to existing tasks provider (OpenTasks / tasks.org), so any CalDAV tasks synced to
your device via DAVx5, SmoothSync or DecSync show up automatically, and changes your device via DAVx5, SmoothSync or DecSync show up automatically, and changes
you make sync back the same way — no own account, no own sync. you make sync back the same way — no own account, no own sync.

View File

@@ -1,10 +0,0 @@
AuthorName: Jean-Luc Makiola
License: MIT
Name: Floret
Summary: A modern Material 3 Expressive task app for Android.
Categories:
- Time
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/floret
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/floret/issues

View File

@@ -22,6 +22,7 @@ kotlinxDatetime = "0.7.0"
kotlinxCoroutines = "1.10.2" kotlinxCoroutines = "1.10.2"
turbine = "1.2.0" turbine = "1.2.0"
hiltNavigationCompose = "1.3.0" hiltNavigationCompose = "1.3.0"
navigationCompose = "2.9.0"
lifecycleCompose = "2.10.0" lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). # Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
@@ -78,6 +79,9 @@ turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine
# Hilt navigation-compose (for hiltViewModel() in Composables) # Hilt navigation-compose (for hiltViewModel() in Composables)
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
# Navigation-compose (the NavHost / back stack)
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
# Lifecycle compose (for collectAsStateWithLifecycle) # Lifecycle compose (for collectAsStateWithLifecycle)
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" } androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleCompose" }

View File

@@ -23,5 +23,5 @@ dependencyResolutionManagement {
} }
} }
rootProject.name = "Floret" rootProject.name = "Agendula"
include(":app") include(":app")