Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b266653e4e | |||
| 2f5012dc34 | |||
| ccc07e86c7 | |||
| 211450bc46 | |||
| 9d134be621 | |||
| 37e3c4e659 | |||
| 78632865f9 | |||
| e7a62e71df | |||
| 4c05c86e95 | |||
| 37662e83bb | |||
| 63c6191676 | |||
| 4994f5ec2c | |||
| e4c8dbf2c1 | |||
| 069ae38b2c | |||
| a5ddf16537 | |||
| 18e03e27b6 | |||
| 152226c1b2 | |||
| ec7b696eb9 | |||
| 76a0139fda | |||
| d84ac60757 |
@@ -21,6 +21,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
@@ -73,6 +75,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
@@ -225,7 +229,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 +252,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"
|
||||||
@@ -322,6 +326,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
- name: Extract changelog section for this tag
|
- name: Extract changelog section for this tag
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "floret-kit"]
|
||||||
|
path = floret-kit
|
||||||
|
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
||||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -6,6 +6,24 @@ 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
|
## [0.2.0] - 2026-06-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -120,6 +120,12 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-time")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||||
|
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||||
|
implementation("de.jeanlucmakiola.floret:identity")
|
||||||
|
implementation("de.jeanlucmakiola.floret:components")
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -51,6 +52,14 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Standalone crash-report surface; MainActivity routes here on a
|
||||||
|
startup crash-loop. Not exported, kept out of recents. -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.crash.CrashReportActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
<!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
|
<!-- Reminder alarm fires here (internal PendingIntent → not exported). -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".data.reminders.DueReminderReceiver"
|
android:name=".data.reminders.DueReminderReceiver"
|
||||||
@@ -76,6 +85,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>
|
||||||
|
|||||||
@@ -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,22 +6,35 @@ 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 de.jeanlucmakiola.floret.crash.CrashConfig
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
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()
|
||||||
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
|
// respecting, on-device; the user submits the report by hand).
|
||||||
|
CrashReporter.install(
|
||||||
|
this,
|
||||||
|
CrashConfig(
|
||||||
|
appLabel = getString(R.string.app_name),
|
||||||
|
newIssueUrl = getString(R.string.report_issue_url),
|
||||||
|
chooseIssueUrl = getString(R.string.report_issue_url),
|
||||||
|
issueTitle = getString(R.string.crash_report_issue_title),
|
||||||
|
),
|
||||||
|
)
|
||||||
val scheduler = EntryPointAccessors
|
val scheduler = EntryPointAccessors
|
||||||
.fromApplication(this, ReminderEntryPoint::class.java)
|
.fromApplication(this, ReminderEntryPoint::class.java)
|
||||||
.reminderScheduler()
|
.reminderScheduler()
|
||||||
114
app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt
Normal file
114
app/src/main/java/de/jeanlucmakiola/agendula/MainActivity.kt
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package de.jeanlucmakiola.agendula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.agendula.data.demo.DemoSeeder
|
||||||
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.agendula.ui.RootScreen
|
||||||
|
import de.jeanlucmakiola.agendula.ui.crash.CrashReportActivity
|
||||||
|
import de.jeanlucmakiola.agendula.ui.settings.SettingsViewModel
|
||||||
|
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
|
||||||
|
* (replaceable) functional scaffold over the real data layer. Task-detail intent
|
||||||
|
* routing for reminder taps lands with the full UI.
|
||||||
|
*/
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
@Inject lateinit var demoSeeder: DemoSeeder
|
||||||
|
|
||||||
|
// A captured crash report awaiting the user's decision, surfaced as a dialog
|
||||||
|
// over the app on the next launch (the single-crash path). A startup
|
||||||
|
// crash-loop is handled out of band, before setContent — see below.
|
||||||
|
private var pendingCrashReport by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// If the app keeps crashing as it starts, the main UI can't be trusted
|
||||||
|
// to come up. Route to the standalone report screen instead of
|
||||||
|
// re-entering the crashing graph.
|
||||||
|
if (CrashReporter.isCrashLoop(this)) {
|
||||||
|
startActivity(Intent(this, CrashReportActivity::class.java))
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// Surface a single captured crash as a dialog on the next launch.
|
||||||
|
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||||
|
|
||||||
|
// Debug-only sample data: `am start ... --ez agendula_seed true`. Seeds a
|
||||||
|
// local (non-syncing) demo list once; no-op without the extra.
|
||||||
|
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
|
||||||
|
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
|
||||||
|
}
|
||||||
|
setContent {
|
||||||
|
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
||||||
|
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
|
||||||
|
val darkTheme = when (ui.settings.themeMode) {
|
||||||
|
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
||||||
|
ThemeMode.LIGHT -> false
|
||||||
|
ThemeMode.DARK -> true
|
||||||
|
}
|
||||||
|
AgendulaTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
|
||||||
|
RootScreen(modifier = Modifier.fillMaxSize())
|
||||||
|
pendingCrashReport?.let { report ->
|
||||||
|
CrashReportDialog(
|
||||||
|
report = report,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(this@MainActivity, report)
|
||||||
|
CrashReporter.clearReport(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
CrashReporter.dismissPrompt(this@MainActivity)
|
||||||
|
pendingCrashReport = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// A successful start breaks any loop; reset the timing trail so a later
|
||||||
|
// ordinary crash isn't mistaken for a loop.
|
||||||
|
CrashReporter.markHealthy(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_TASK_ID = "de.jeanlucmakiola.agendula.extra.TASK_ID"
|
||||||
|
private const val EXTRA_SEED = "agendula_seed"
|
||||||
|
|
||||||
|
/** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
|
||||||
|
fun taskIntent(context: Context, taskId: Long): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
putExtra(EXTRA_TASK_ID, taskId)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.floret.time.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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.data.di
|
package de.jeanlucmakiola.agendula.data.di
|
||||||
|
|
||||||
import javax.inject.Qualifier
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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 de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||||
|
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.reminderLeadFor
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.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,
|
||||||
|
/** 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,
|
||||||
|
/**
|
||||||
|
* Add affordance for a list: `false` = the floating "New task" button (opens
|
||||||
|
* the editor); `true` = a quick-add bar pinned to the bottom of a real list.
|
||||||
|
* Smart lists always use the button (they have no single list to add into).
|
||||||
|
*/
|
||||||
|
val bottomAddBar: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Per-list overrides of [reminderLeadMinutes]: a list present in the map
|
||||||
|
* 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? =
|
||||||
|
perListReminderOverride.reminderLeadFor(listId, 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,
|
||||||
|
bottomAddBar = p[BOTTOM_ADD_BAR] ?: false,
|
||||||
|
perListReminderOverride = reminderCodec.parse(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 }
|
||||||
|
|
||||||
|
suspend fun setBottomAddBar(enabled: Boolean) = dataStore.edit { it[BOTTOM_ADD_BAR] = enabled }
|
||||||
|
|
||||||
|
/** Set (or clear, via [ReminderOverride.Inherit]) a list's reminder override. */
|
||||||
|
suspend fun setListReminderOverride(listId: Long, override: ReminderOverride) = dataStore.edit { p ->
|
||||||
|
val current = reminderCodec.parse(p[LIST_REMINDER_OVERRIDE]).toMutableMap()
|
||||||
|
current.applyReminderOverride(listId, override)
|
||||||
|
p[LIST_REMINDER_OVERRIDE] = reminderCodec.serialize(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 BOTTOM_ADD_BAR = booleanPreferencesKey("bottom_add_bar")
|
||||||
|
val REMINDER_ONBOARDING_DONE = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
|
val LIST_REMINDER_OVERRIDE = stringPreferencesKey("list_reminder_override")
|
||||||
|
val DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agendula's stored dialect for the per-list override map: `id:minutes` entries
|
||||||
|
* joined by `;` (the family default). Fixed at release — don't change without a
|
||||||
|
* data migration.
|
||||||
|
*/
|
||||||
|
private val reminderCodec = ReminderOverrideCodec.DEFAULT
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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).
|
||||||
*/
|
*/
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.data.tasks
|
package de.jeanlucmakiola.agendula.data.tasks
|
||||||
|
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
|
|
||||||
@@ -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 :
|
||||||
@@ -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(
|
||||||
@@ -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. */
|
||||||
@@ -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 {
|
||||||
@@ -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 name→value map. Pure (no ContentValues),
|
* Turns a [TaskForm] / mutation into a name→value map. Pure (no ContentValues),
|
||||||
@@ -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"
|
||||||
@@ -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(
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
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.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
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 kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** Whether Floret can use the tasks provider right now. Drives onboarding. */
|
/** Whether Agendula can use the tasks provider right now. Drives onboarding. */
|
||||||
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +19,13 @@ enum class ProviderStatus { READY, NEEDS_PERMISSION, NO_PROVIDER }
|
|||||||
interface TasksRepository {
|
interface TasksRepository {
|
||||||
fun taskLists(): Flow<List<TaskList>>
|
fun taskLists(): Flow<List<TaskList>>
|
||||||
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
fun tasks(filter: TaskFilter): Flow<List<Task>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The direct children of [parentId], re-emitting live like the other flows.
|
||||||
|
* Lets a smart-list view pull a parent's full subtask set on demand — those
|
||||||
|
* children are usually filtered out of the list's own (date-based) query.
|
||||||
|
*/
|
||||||
|
fun subtasks(parentId: Long): Flow<List<Task>>
|
||||||
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
fun taskDetail(taskId: Long): Flow<TaskDetail?>
|
||||||
|
|
||||||
suspend fun createTask(form: TaskForm): Long
|
suspend fun createTask(form: TaskForm): Long
|
||||||
@@ -1,14 +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.floret.time.DayWindow
|
||||||
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.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.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.floret.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.floret.domain.TaskSorting
|
import de.jeanlucmakiola.agendula.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
|
||||||
@@ -35,6 +35,12 @@ class TasksRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
override fun tasks(filter: TaskFilter): Flow<List<Task>> = observing { loadTasks(filter) }
|
||||||
|
|
||||||
|
override fun subtasks(parentId: Long): Flow<List<Task>> = observing {
|
||||||
|
dataSource.subtasks(parentId)
|
||||||
|
.filter { it.taskId != parentId }
|
||||||
|
.sortedWith(TaskSorting.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
|
override fun taskDetail(taskId: Long): Flow<TaskDetail?> = observing {
|
||||||
dataSource.task(taskId)?.let { task ->
|
dataSource.task(taskId)?.let { task ->
|
||||||
TaskDetail(
|
TaskDetail(
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.domain
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package de.jeanlucmakiola.floret.domain
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A validated create/edit form. Kept free of Android types so ViewModels and
|
* 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
|
* tests can build and validate it on the JVM. The data layer turns it into
|
||||||
* provider ContentValues ([de.jeanlucmakiola.floret.data.tasks.TaskWriteMapper]).
|
* provider ContentValues ([de.jeanlucmakiola.agendula.data.tasks.TaskWriteMapper]).
|
||||||
*/
|
*/
|
||||||
data class TaskForm(
|
data class TaskForm(
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.domain
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -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 {
|
||||||
@@ -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,14 +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.navigation.FloretNavHost
|
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 hands off to
|
* App root: gates on the tasks-provider permission, then hands off to
|
||||||
* [FloretNavHost] (lists → task list → detail / edit).
|
* [AgendulaNavHost] (lists → task list → detail / edit).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun RootScreen(
|
fun RootScreen(
|
||||||
@@ -49,7 +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 -> FloretNavHost(modifier = modifier)
|
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,
|
||||||
|
)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.common
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.time.formatDateTime
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -31,7 +32,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import de.jeanlucmakiola.floret.R
|
import de.jeanlucmakiola.agendula.R
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.common
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -22,24 +23,10 @@ import androidx.compose.ui.graphics.luminance
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
/**
|
|
||||||
* Soften a raw provider colour toward a pastel that fits the active theme —
|
|
||||||
* keeps the hue (lists stay recognisable), caps saturation so harsh sync
|
|
||||||
* colours stop screaming, pins brightness to read on light and dark. Copied
|
|
||||||
* from Calendula so the families' colours behave identically.
|
|
||||||
*/
|
|
||||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
|
||||||
val hsv = FloatArray(3)
|
|
||||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
|
||||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
|
||||||
hsv[2] = if (dark) 0.82f else 0.72f
|
|
||||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leading avatar for a task list: a neutral round chip holding a checklist glyph
|
* Leading avatar for a task list: a neutral round chip holding a checklist glyph
|
||||||
* tinted in the list's (pastelised) colour — the Calendula calendar-chip pattern,
|
* tinted in the list's (pastelised) colour — the Calendula calendar-chip pattern,
|
||||||
* 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) {
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.common
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
@@ -21,7 +22,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.floret.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Priority has no semantic Material role (M3 only ships `error` for danger), so
|
* Priority has no semantic Material role (M3 only ships `error` for danger), so
|
||||||
@@ -44,6 +45,27 @@ fun priorityFill(priority: Priority, dark: Boolean): Color {
|
|||||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground tint (icon/text) for a priority level — the same red/amber/green as
|
||||||
|
* [priorityFill], but kept saturated enough to read as a small glyph directly on
|
||||||
|
* the surface rather than as a soft fill. Used by the task list's quiet meta line,
|
||||||
|
* where priority is a tinted flag instead of a filled pill. [Priority.NONE] is
|
||||||
|
* unspecified (callers render it neutral / omit it).
|
||||||
|
*/
|
||||||
|
fun priorityAccent(priority: Priority, dark: Boolean): Color {
|
||||||
|
val base = when (priority) {
|
||||||
|
Priority.HIGH -> 0xFFE53935.toInt() // red
|
||||||
|
Priority.MEDIUM -> 0xFFFB8C00.toInt() // orange-amber (darker than the fill for contrast)
|
||||||
|
Priority.LOW -> 0xFF43A047.toInt() // green
|
||||||
|
Priority.NONE -> return Color.Unspecified
|
||||||
|
}
|
||||||
|
val hsv = FloatArray(3)
|
||||||
|
android.graphics.Color.colorToHSV(base, hsv)
|
||||||
|
hsv[1] = if (dark) 0.55f else 0.85f
|
||||||
|
hsv[2] = if (dark) 0.85f else 0.62f // light on dark surfaces; deep on light ones
|
||||||
|
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A coloured status pill for a task's priority: a flag plus the level's [label],
|
* A coloured status pill for a task's priority: a flag plus the level's [label],
|
||||||
* filled in that level's pastel hue. Used read-only in the list and detail
|
* filled in that level's pastel hue. Used read-only in the list and detail
|
||||||
@@ -90,14 +112,14 @@ fun PriorityChip(
|
|||||||
|
|
||||||
val body: @Composable () -> Unit = {
|
val body: @Composable () -> Unit = {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (showIcon) {
|
if (showIcon) {
|
||||||
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(15.dp))
|
Icon(Icons.Rounded.Flag, contentDescription = null, tint = iconTint, modifier = Modifier.size(14.dp))
|
||||||
}
|
}
|
||||||
Text(label, style = MaterialTheme.typography.labelMedium, color = content)
|
Text(label, style = MaterialTheme.typography.labelSmall, color = content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val shape = RoundedCornerShape(50)
|
val shape = RoundedCornerShape(50)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
|
||||||
|
/** Common reminder lead times offered as quick picks in the reminder pickers. */
|
||||||
|
val REMINDER_PRESETS = listOf(0, 5, 10, 30, 60, 1_440)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.common
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||||
|
import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
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.unit.dp
|
||||||
|
import de.jeanlucmakiola.agendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 [ReminderOverride]. Ported from
|
||||||
|
* Calendula's ReminderDefaultPicker.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderLeadPicker(
|
||||||
|
title: String,
|
||||||
|
selected: ReminderOverride,
|
||||||
|
allowInherit: Boolean,
|
||||||
|
allowNone: Boolean,
|
||||||
|
onSelect: (ReminderOverride) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
presets: List<Int> = REMINDER_PRESETS,
|
||||||
|
) {
|
||||||
|
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
|
||||||
|
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
||||||
|
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
|
||||||
|
var unit by rememberSaveable { mutableStateOf(seed.unit) }
|
||||||
|
|
||||||
|
val options = buildList {
|
||||||
|
if (allowInherit) add(ReminderOverride.Inherit)
|
||||||
|
if (allowNone) add(ReminderOverride.None)
|
||||||
|
presets.forEach { add(ReminderOverride.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 = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
CustomReminderEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
unit = unit,
|
||||||
|
onUnitChange = { unit = it },
|
||||||
|
onConfirm = { minutes ->
|
||||||
|
onSelect(ReminderOverride.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) {
|
||||||
|
DialogAmountField(value = amountText, onValueChange = onAmountChange, placeholder = "10")
|
||||||
|
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: ReminderOverride): String = when (override) {
|
||||||
|
ReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.crash
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.agendula.ui.theme.AgendulaTheme
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standalone surface for a captured crash report. [MainActivity] routes here
|
||||||
|
* when it detects a startup crash-loop (see [CrashReporter.isCrashLoop]): the
|
||||||
|
* main UI can't be trusted to start, so this screen stays clear of the app's
|
||||||
|
* Hilt graph, DataStore-backed theme and Compose content — it only reads the
|
||||||
|
* report file and shows the kit's report dialog. Plain [AgendulaTheme] defaults
|
||||||
|
* (follow-system, dynamic colour) avoid touching anything that might be the
|
||||||
|
* cause of the crash. The reusable machinery lives in floret-kit's core-crash.
|
||||||
|
*/
|
||||||
|
class CrashReportActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val report = CrashReporter.pendingReport(this)
|
||||||
|
if (report == null) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
AgendulaTheme {
|
||||||
|
// Opaque backdrop so the dialog doesn't float over a bare task.
|
||||||
|
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
|
||||||
|
CrashReportDialog(
|
||||||
|
report = report,
|
||||||
|
onSend = {
|
||||||
|
submitCrashReport(this, report)
|
||||||
|
CrashReporter.clearReport(this)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
onDismiss = {
|
||||||
|
CrashReporter.clearReport(this)
|
||||||
|
finish()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
// Reaching this screen breaks the loop; reset the timing trail so a
|
||||||
|
// later ordinary crash isn't mistaken for a loop.
|
||||||
|
CrashReporter.markHealthy(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.detail
|
package de.jeanlucmakiola.agendula.ui.detail
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -67,16 +67,16 @@ import androidx.compose.ui.text.style.TextDecoration
|
|||||||
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.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskDetail
|
import de.jeanlucmakiola.agendula.domain.TaskDetail
|
||||||
import de.jeanlucmakiola.floret.ui.common.GroupedSurface
|
import de.jeanlucmakiola.floret.components.GroupedSurface
|
||||||
import de.jeanlucmakiola.floret.ui.common.PriorityChip
|
import de.jeanlucmakiola.agendula.ui.common.PriorityChip
|
||||||
import de.jeanlucmakiola.floret.ui.common.formatDate
|
import de.jeanlucmakiola.floret.time.formatDate
|
||||||
import de.jeanlucmakiola.floret.ui.common.formatTime
|
import de.jeanlucmakiola.floret.time.formatTime
|
||||||
import de.jeanlucmakiola.floret.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One task's detail. Each fact is its own tonal card with a leading icon (the
|
* One task's detail. Each fact is its own tonal card with a leading icon (the
|
||||||
@@ -246,7 +246,7 @@ private fun DetailBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Priority — a coloured status chip in the level's hue.
|
// Priority — a coloured status chip in the level's hue.
|
||||||
if (task.priority != de.jeanlucmakiola.floret.domain.Priority.NONE) {
|
if (task.priority != de.jeanlucmakiola.agendula.domain.Priority.NONE) {
|
||||||
add {
|
add {
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Rounded.Flag,
|
icon = Icons.Rounded.Flag,
|
||||||
@@ -1,12 +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.floret.domain.TaskForm
|
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
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.edit
|
package de.jeanlucmakiola.agendula.ui.edit
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -85,27 +80,28 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
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.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.floret.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
import de.jeanlucmakiola.floret.domain.Task
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFormError
|
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.floret.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.floret.domain.TaskSection
|
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||||
import de.jeanlucmakiola.floret.domain.TaskSections
|
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||||
import de.jeanlucmakiola.floret.ui.common.GroupedRow
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.ui.common.InlineTextField
|
import de.jeanlucmakiola.floret.components.OptionalFormSection
|
||||||
import de.jeanlucmakiola.floret.ui.common.OptionCard
|
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.ui.common.priorityFill
|
import de.jeanlucmakiola.floret.components.OptionCard
|
||||||
import de.jeanlucmakiola.floret.ui.common.formatDate
|
import de.jeanlucmakiola.agendula.ui.common.priorityFill
|
||||||
import de.jeanlucmakiola.floret.ui.common.formatTime
|
import de.jeanlucmakiola.floret.time.formatDate
|
||||||
import de.jeanlucmakiola.floret.ui.common.localToInstant
|
import de.jeanlucmakiola.floret.time.formatTime
|
||||||
import de.jeanlucmakiola.floret.ui.common.pastelize
|
import de.jeanlucmakiola.agendula.ui.common.localToInstant
|
||||||
import de.jeanlucmakiola.floret.ui.common.positionOf
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.ui.common.toLocalDate
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.ui.common.toLocalTime
|
import de.jeanlucmakiola.agendula.ui.common.toLocalDate
|
||||||
import de.jeanlucmakiola.floret.ui.tasklist.priorityLabel
|
import de.jeanlucmakiola.agendula.ui.common.toLocalTime
|
||||||
|
import de.jeanlucmakiola.agendula.ui.tasklist.priorityLabel
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -119,7 +115,7 @@ import kotlin.time.Instant
|
|||||||
* "When" card with the all-day toggle and tappable schedule rows, a tappable
|
* "When" card with the all-day toggle and tappable schedule rows, a tappable
|
||||||
* list card, and the optional Description / Priority / Reminder sections that
|
* list card, and the optional Description / Priority / Reminder sections that
|
||||||
* unfold from "More fields" (which ones start open is a setting). Validated
|
* unfold from "More fields" (which ones start open is a setting). Validated
|
||||||
* through the M1 [TaskEditViewModel] / [de.jeanlucmakiola.floret.domain.TaskForm];
|
* through the M1 [TaskEditViewModel] / [de.jeanlucmakiola.agendula.domain.TaskForm];
|
||||||
* the VM flips `saved` once the write lands, which pops us back.
|
* the VM flips `saved` once the write lands, which pops us back.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -545,17 +541,6 @@ private fun EditContent(
|
|||||||
* open instead of popping in. (Initially-visible sections render without
|
* open instead of popping in. (Initially-visible sections render without
|
||||||
* animating on the first frame they're added.)
|
* animating on the first frame they're added.)
|
||||||
*/
|
*/
|
||||||
@Composable
|
|
||||||
private fun OptionalFormSection(visible: Boolean, content: @Composable ColumnScope.() -> Unit) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = visible,
|
|
||||||
enter = expandVertically() + fadeIn(),
|
|
||||||
exit = shrinkVertically() + fadeOut(),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One info card mirroring the detail screen's card: tonal container, leading
|
* One info card mirroring the detail screen's card: tonal container, leading
|
||||||
* icon in the gutter, value to the right. Optionally clickable as a whole.
|
* icon in the gutter, value to the right. Optionally clickable as a whole.
|
||||||
@@ -853,7 +838,7 @@ private fun ParentPickerSheet(
|
|||||||
item(key = "none") {
|
item(key = "none") {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.edit_parent_none),
|
title = stringResource(R.string.edit_parent_none),
|
||||||
position = de.jeanlucmakiola.floret.ui.common.Position.Alone,
|
position = de.jeanlucmakiola.floret.components.Position.Alone,
|
||||||
selected = selectedId == null,
|
selected = selectedId == null,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
onClick = { choose(null) },
|
onClick = { choose(null) },
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.edit
|
package de.jeanlucmakiola.agendula.ui.edit
|
||||||
|
|
||||||
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.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.floret.data.reminders.ReminderScheduler
|
import de.jeanlucmakiola.agendula.data.reminders.ReminderScheduler
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TaskConflictException
|
import de.jeanlucmakiola.agendula.data.tasks.TaskConflictException
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.floret.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
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.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFormError
|
import de.jeanlucmakiola.agendula.domain.TaskFormError
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFormField
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
import de.jeanlucmakiola.floret.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
import de.jeanlucmakiola.floret.domain.populatedFields
|
import de.jeanlucmakiola.agendula.domain.populatedFields
|
||||||
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
|
||||||
@@ -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.floret.components.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.floret.components.positionOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live
|
* Home: smart lists (Today / Overdue / Upcoming / All) as tonal cards with live
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -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.floret.time.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
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.navigation
|
package de.jeanlucmakiola.agendula.ui.navigation
|
||||||
|
|
||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
import androidx.compose.animation.ExitTransition
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -12,14 +12,15 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
import de.jeanlucmakiola.floret.ui.detail.TaskDetailScreen
|
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailScreen
|
||||||
import de.jeanlucmakiola.floret.ui.detail.TaskDetailViewModel
|
import de.jeanlucmakiola.agendula.ui.detail.TaskDetailViewModel
|
||||||
import de.jeanlucmakiola.floret.ui.edit.TaskEditScreen
|
import de.jeanlucmakiola.agendula.ui.edit.TaskEditScreen
|
||||||
import de.jeanlucmakiola.floret.ui.edit.TaskEditViewModel
|
import de.jeanlucmakiola.agendula.ui.edit.TaskEditViewModel
|
||||||
import de.jeanlucmakiola.floret.ui.lists.ListsScreen
|
import de.jeanlucmakiola.agendula.ui.lists.ListsScreen
|
||||||
import de.jeanlucmakiola.floret.ui.tasklist.TaskListScreen
|
import de.jeanlucmakiola.agendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.floret.ui.tasklist.TaskListViewModel
|
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.
|
* The single in-app back stack: lists → task list → detail / edit.
|
||||||
@@ -31,9 +32,8 @@ import de.jeanlucmakiola.floret.ui.tasklist.TaskListViewModel
|
|||||||
* unaware of the nav library.
|
* unaware of the nav library.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun FloretNavHost(modifier: Modifier = Modifier) {
|
fun AgendulaNavHost(modifier: Modifier = Modifier) {
|
||||||
val nav = rememberNavController()
|
val nav = rememberNavController()
|
||||||
val slide = rememberNavSlideSpec()
|
|
||||||
|
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = nav,
|
navController = nav,
|
||||||
@@ -41,20 +41,26 @@ fun FloretNavHost(modifier: Modifier = Modifier) {
|
|||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
// Only the screen on top moves; the background is always held static.
|
// Only the screen on top moves; the background is always held static.
|
||||||
// Forward is a plain fade-in over the held screen — calm, not a slide.
|
// Forward is a plain fade-in over the held screen — calm, not a slide.
|
||||||
// Back (and the predictive-back drag, which drives popExit) is the snappy
|
// Back is the predictive-back peek: navigation-compose composes the
|
||||||
// slide-out to the right, revealing the held screen unmoved.
|
// screen being revealed underneath and seeks popExit with the gesture, so
|
||||||
|
// the leaving screen scales down to reveal the (unmoved) previous screen.
|
||||||
enterTransition = { fadeIn() },
|
enterTransition = { fadeIn() },
|
||||||
exitTransition = { ExitTransition.None },
|
exitTransition = { ExitTransition.None },
|
||||||
popEnterTransition = { EnterTransition.None },
|
popEnterTransition = { EnterTransition.None },
|
||||||
popExitTransition = { slideOutHorizontally(slide) { it } + fadeOut() },
|
popExitTransition = { scaleOut(targetScale = 0.9f) + fadeOut() },
|
||||||
) {
|
) {
|
||||||
composable(Dest.LISTS) {
|
composable(Dest.LISTS) {
|
||||||
ListsScreen(
|
ListsScreen(
|
||||||
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
onOpenFilter = { filter -> nav.navigate(Dest.TaskList.build(filter)) },
|
||||||
onNewTask = { nav.navigate(Dest.TaskEdit.buildNew()) },
|
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 ->
|
composable(Dest.TaskList.route, arguments = Dest.TaskList.arguments) { entry ->
|
||||||
val args = entry.arguments
|
val args = entry.arguments
|
||||||
val filter = Dest.TaskList.filterOf(
|
val filter = Dest.TaskList.filterOf(
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.navigation
|
package de.jeanlucmakiola.agendula.ui.navigation
|
||||||
|
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import de.jeanlucmakiola.floret.domain.SmartList
|
import de.jeanlucmakiola.agendula.domain.SmartList
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* String-based route table for the app's [androidx.navigation.NavHost].
|
* String-based route table for the app's [androidx.navigation.NavHost].
|
||||||
@@ -23,6 +23,9 @@ object Dest {
|
|||||||
/** Home — smart lists + the user's lists. */
|
/** Home — smart lists + the user's lists. */
|
||||||
const val LISTS = "lists"
|
const val LISTS = "lists"
|
||||||
|
|
||||||
|
/** App preferences. */
|
||||||
|
const val SETTINGS = "settings"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
|
* One [TaskFilter]'s tasks. Carries *either* `listId` (a real list) *or*
|
||||||
* `smart` (a [SmartList] name); whichever is set decides the filter.
|
* `smart` (a [SmartList] name); whichever is set decides the filter.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agendula's onboarding hero: an icon in the brand squircle. Each app keeps its
|
||||||
|
* own hero; the surrounding shell comes from floret-kit's OnboardingScaffold.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SquircleHero(icon: ImageVector) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.size(56.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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
|
||||||
|
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time onboarding step after the tasks-provider grant: explains that Agendula
|
||||||
|
* 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
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.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
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.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.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.ThemeMode
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
|
import de.jeanlucmakiola.floret.components.AboutCard
|
||||||
|
import de.jeanlucmakiola.floret.components.AboutLink
|
||||||
|
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||||
|
import de.jeanlucmakiola.floret.components.LanguagePickerRow
|
||||||
|
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||||
|
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.ReminderLeadPicker
|
||||||
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||||
|
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
|
import de.jeanlucmakiola.agendula.ui.common.reminderLeadTimeLabel
|
||||||
|
|
||||||
|
/** 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(
|
||||||
|
logo = { AppLogo() },
|
||||||
|
appName = stringResource(R.string.app_name),
|
||||||
|
author = stringResource(R.string.settings_about_author),
|
||||||
|
primaryLinks = listOf(
|
||||||
|
AboutLink(
|
||||||
|
icon = Icons.Default.Code,
|
||||||
|
label = stringResource(R.string.settings_about_source),
|
||||||
|
url = stringResource(R.string.about_source_url),
|
||||||
|
),
|
||||||
|
AboutLink(
|
||||||
|
icon = Icons.Default.Gavel,
|
||||||
|
label = stringResource(R.string.settings_license),
|
||||||
|
url = stringResource(R.string.about_license_url),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
highlightLink = AboutLink(
|
||||||
|
icon = Icons.Default.Favorite,
|
||||||
|
label = stringResource(R.string.settings_about_support),
|
||||||
|
url = stringResource(R.string.about_support_url),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
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) },
|
||||||
|
)
|
||||||
|
LanguagePickerRow(
|
||||||
|
position = Position.Middle,
|
||||||
|
title = stringResource(R.string.settings_language),
|
||||||
|
autoLabel = stringResource(R.string.settings_language_auto),
|
||||||
|
localesConfig = R.xml.locales_config,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Language, ChipAccent.Neutral) },
|
||||||
|
)
|
||||||
|
ReportProblemRow(position = Position.Bottom)
|
||||||
|
|
||||||
|
AppVersionText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.settings.showAddSubtaskRow,
|
||||||
|
onCheckedChange = viewModel::setShowAddSubtaskRow,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setShowAddSubtaskRow(!state.settings.showAddSubtaskRow) },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_bottom_add_bar),
|
||||||
|
summary = stringResource(R.string.settings_bottom_add_bar_hint),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.settings.bottomAddBar,
|
||||||
|
onCheckedChange = viewModel::setBottomAddBar,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setBottomAddBar(!state.settings.bottomAddBar) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDefaultList) {
|
||||||
|
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 = expandEnter(),
|
||||||
|
exit = collapseExit(),
|
||||||
|
) {
|
||||||
|
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 = ReminderOverride.Minutes(state.settings.reminderLeadMinutes),
|
||||||
|
allowInherit = false,
|
||||||
|
allowNone = false,
|
||||||
|
onSelect = { if (it is ReminderOverride.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): ReminderOverride {
|
||||||
|
val map = state.settings.perListReminderOverride
|
||||||
|
return when {
|
||||||
|
!map.containsKey(listId) -> ReminderOverride.Inherit
|
||||||
|
map[listId] == null -> ReminderOverride.None
|
||||||
|
else -> ReminderOverride.Minutes(map.getValue(listId)!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row summary for a list: its override, or the inherited global default. */
|
||||||
|
@Composable
|
||||||
|
private fun listOverrideSummary(choice: ReminderOverride, globalDefault: Int): String = when (choice) {
|
||||||
|
ReminderOverride.Inherit ->
|
||||||
|
stringResource(R.string.settings_list_reminder_inherits, reminderLeadTimeLabel(globalDefault))
|
||||||
|
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
|
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// About + shared building blocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@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()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Settings
|
||||||
import de.jeanlucmakiola.floret.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.agendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
|
import de.jeanlucmakiola.agendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.floret.data.tasks.TasksRepository
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.floret.domain.TaskList
|
import de.jeanlucmakiola.agendula.domain.TaskFormField
|
||||||
|
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||||
|
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
@@ -40,4 +42,17 @@ 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) }
|
||||||
|
fun setBottomAddBar(enabled: Boolean) = viewModelScope.launch { prefs.setBottomAddBar(enabled) }
|
||||||
|
|
||||||
|
/** 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 [ReminderOverride.Inherit]) a list's reminder override. */
|
||||||
|
fun setListReminderOverride(listId: Long, override: ReminderOverride) =
|
||||||
|
viewModelScope.launch { prefs.setListReminderOverride(listId, override) }
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.tasklist
|
package de.jeanlucmakiola.agendula.ui.tasklist
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
@@ -21,6 +21,12 @@ import androidx.compose.foundation.layout.FlowRow
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.ime
|
||||||
|
import androidx.compose.foundation.layout.isImeVisible
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.union
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -39,6 +45,7 @@ import androidx.compose.material.icons.rounded.Check
|
|||||||
import androidx.compose.material.icons.rounded.Checklist
|
import androidx.compose.material.icons.rounded.Checklist
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
import androidx.compose.material.icons.rounded.ExpandMore
|
||||||
|
import androidx.compose.material.icons.rounded.Flag
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
@@ -67,6 +74,7 @@ import kotlinx.coroutines.delay
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
@@ -80,20 +88,19 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
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.DayWindow
|
import de.jeanlucmakiola.floret.time.DayWindow
|
||||||
import de.jeanlucmakiola.floret.domain.Priority
|
import de.jeanlucmakiola.agendula.domain.Priority
|
||||||
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.TaskSection
|
import de.jeanlucmakiola.agendula.domain.TaskSection
|
||||||
import de.jeanlucmakiola.floret.domain.TaskSections
|
import de.jeanlucmakiola.agendula.domain.TaskSections
|
||||||
import de.jeanlucmakiola.floret.ui.common.ListNameChip
|
import de.jeanlucmakiola.agendula.ui.common.priorityAccent
|
||||||
import de.jeanlucmakiola.floret.ui.common.Position
|
import de.jeanlucmakiola.floret.components.Position
|
||||||
import de.jeanlucmakiola.floret.ui.common.PriorityChip
|
import de.jeanlucmakiola.floret.time.formatDateTimeCompact
|
||||||
import de.jeanlucmakiola.floret.ui.common.formatDateTime
|
import de.jeanlucmakiola.floret.components.pastelize
|
||||||
import de.jeanlucmakiola.floret.ui.common.pastelize
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
import de.jeanlucmakiola.floret.ui.common.positionOf
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
@@ -115,7 +122,13 @@ fun TaskListScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||||
val listName = (state as? TaskListUiState.Content)?.listName
|
val content = state as? TaskListUiState.Content
|
||||||
|
val listName = content?.listName
|
||||||
|
val listId = (filter as? TaskFilter.OfList)?.listId
|
||||||
|
// One add affordance, never two: a real list with the setting on gets a pinned
|
||||||
|
// bottom quick-add bar; everything else (incl. smart lists, which have no single
|
||||||
|
// target list) gets the floating "New task" button.
|
||||||
|
val showBottomAddBar = content?.bottomAddBar == true && listId != null
|
||||||
|
|
||||||
// Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip
|
// Swipe-delete hides the row at once, then a floating "Deleted · Undo" chip
|
||||||
// gives an undo window: Undo restores it, otherwise the delete commits. A
|
// gives an undo window: Undo restores it, otherwise the delete commits. A
|
||||||
@@ -150,11 +163,18 @@ fun TaskListScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
|
if (!showBottomAddBar) {
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = onNewTask,
|
onClick = onNewTask,
|
||||||
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
icon = { Icon(Icons.Rounded.Add, contentDescription = null) },
|
||||||
text = { Text(stringResource(R.string.new_task)) },
|
text = { Text(stringResource(R.string.new_task)) },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (showBottomAddBar) {
|
||||||
|
listId?.let { id -> QuickAddBar(onAdd = { title -> viewModel.quickAdd(title, id) }) }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -163,19 +183,27 @@ fun TaskListScreen(
|
|||||||
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
|
TaskListUiState.Failure -> CenteredMessage(stringResource(R.string.task_list_failure), inner)
|
||||||
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel)
|
is TaskListUiState.Content -> TaskListBody(s, filter, inner, onOpenTask, onRequestDelete, viewModel)
|
||||||
}
|
}
|
||||||
|
// A FAB-height band anchored at the bottom-start with the FAB's own
|
||||||
|
// margin; centring the chip in it lines the chip up beside the bottom-end
|
||||||
|
// FAB at exactly its height, rather than sitting a touch above it.
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.padding(start = 16.dp, bottom = inner.calculateBottomPadding() + 16.dp)
|
||||||
|
.height(56.dp),
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
) {
|
||||||
UndoChip(
|
UndoChip(
|
||||||
visible = undoTarget != null,
|
visible = undoTarget != null,
|
||||||
onUndo = {
|
onUndo = {
|
||||||
undoTarget?.let { viewModel.undoDelete(it.taskId) }
|
undoTarget?.let { viewModel.undoDelete(it.taskId) }
|
||||||
undoTarget = null
|
undoTarget = null
|
||||||
},
|
},
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = inner.calculateBottomPadding() + 24.dp),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A compact floating "snackchip" for an undoable delete — a rounded pill (not a
|
* A compact floating "snackchip" for an undoable delete — a rounded pill (not a
|
||||||
@@ -251,20 +279,34 @@ private fun TaskListBody(
|
|||||||
val childrenByParent = remember(state.tasks) {
|
val childrenByParent = remember(state.tasks) {
|
||||||
state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! }
|
state.tasks.filter { it.isSubtask }.groupBy { it.parentId!! }
|
||||||
}
|
}
|
||||||
|
// In a smart list a parent's subtasks are usually filtered out (they're undated
|
||||||
|
// or due other days), so we pull them on demand. Report only expanded parents
|
||||||
|
// whose full child set isn't already in this view; the VM then observes those
|
||||||
|
// children live. In a real list that set is empty, so no extra queries run.
|
||||||
|
val tasksById = remember(state.tasks) { state.tasks.associateBy { it.taskId } }
|
||||||
|
val parentsNeedingChildren = expandedParents.filter { id ->
|
||||||
|
val parent = tasksById[id]
|
||||||
|
parent != null && (childrenByParent[id]?.size ?: 0) != parent.subtaskTotal
|
||||||
|
}.toSet()
|
||||||
|
LaunchedEffect(parentsNeedingChildren) { viewModel.setLazyChildParents(parentsNeedingChildren) }
|
||||||
|
val lazyChildren by viewModel.lazyChildren.collectAsStateWithLifecycle()
|
||||||
|
// A parent's full child set: the rows already in this view when we hold them
|
||||||
|
// all, otherwise the lazily-loaded set fetched for smart lists.
|
||||||
|
fun childrenOf(task: Task): List<Task> {
|
||||||
|
val inList = childrenByParent[task.taskId].orEmpty()
|
||||||
|
return if (inList.size == task.subtaskTotal) inList else lazyChildren[task.taskId].orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the bottom quick-add bar is shown it already lives in the scaffold's
|
||||||
|
// bottomBar (so `inner` covers it); otherwise reserve room to clear the FAB.
|
||||||
|
val bottomBarShown = state.bottomAddBar && listId != null
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
top = inner.calculateTopPadding() + 8.dp,
|
top = inner.calculateTopPadding() + 8.dp,
|
||||||
bottom = inner.calculateBottomPadding() + 96.dp,
|
bottom = inner.calculateBottomPadding() + if (bottomBarShown) 16.dp else 96.dp,
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
if (listId != null) {
|
|
||||||
item(key = "inline-add") {
|
|
||||||
InlineAdd(onAdd = { title -> viewModel.quickAdd(title, listId) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.tasks.isEmpty()) {
|
if (state.tasks.isEmpty()) {
|
||||||
item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) }
|
item(key = "empty") { EmptyState(stringResource(R.string.task_list_empty)) }
|
||||||
}
|
}
|
||||||
@@ -295,38 +337,51 @@ private fun TaskListBody(
|
|||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
val topLevel = section.tasks
|
val topLevel = section.tasks
|
||||||
topLevel.forEachIndexed { p, task ->
|
topLevel.forEachIndexed { p, task ->
|
||||||
val children = childrenByParent[task.taskId].orEmpty()
|
// The full child set — already in this view for a real list,
|
||||||
// Only expandable when we hold every child: true in a real
|
// or fetched on demand for a smart list (childrenOf). A parent
|
||||||
// list, but a smart list may omit children due on other
|
// is expandable whenever it has children; until a smart list's
|
||||||
// days, and a half-shown set would mislead.
|
// set has loaded the group simply shows nothing extra.
|
||||||
val canExpand = task.subtaskTotal > 0 && children.size == task.subtaskTotal
|
val children = childrenOf(task)
|
||||||
|
val canExpand = task.subtaskTotal > 0
|
||||||
val isExpanded = canExpand && task.taskId in expandedParents
|
val isExpanded = canExpand && task.taskId in expandedParents
|
||||||
|
val hasExpansionContent = children.isNotEmpty() || state.showAddSubtaskRow
|
||||||
// Top-level tasks keep their own connected run — full top
|
// Top-level tasks keep their own connected run — full top
|
||||||
// only on the first, full bottom only on the last — so the
|
// only on the first, full bottom only on the last — so the
|
||||||
// task after an expanded one stays mid-run (small top). An
|
// task after an expanded one stays mid-run (small top). An
|
||||||
// expanded parent's bottom opens (small) to meet its subtasks.
|
// expanded parent's bottom opens (small) to meet its subtasks.
|
||||||
val parentPosition = cornerPosition(
|
val parentPosition = cornerPosition(
|
||||||
topFull = p == 0,
|
topFull = p == 0,
|
||||||
bottomFull = if (isExpanded) false else p == topLevel.lastIndex,
|
bottomFull = if (isExpanded && hasExpansionContent) false else p == topLevel.lastIndex,
|
||||||
)
|
)
|
||||||
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
|
add(RenderRow(ListRow.Parent(task, canExpand, isExpanded), parentPosition))
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
// Subtasks connect up to the parent (small top) and to
|
// When the add row is hidden (M5 setting), the last subtask
|
||||||
// each other; the inline add row below closes the group,
|
// instead rounds the group off (full bottom).
|
||||||
// so every subtask is mid-run (small top + bottom).
|
val sorted = children.sortedBy { it.isCompleted }
|
||||||
children.sortedBy { it.isCompleted }.forEach { sub ->
|
sorted.forEachIndexed { c, sub ->
|
||||||
add(RenderRow(ListRow.Sub(sub), cornerPosition(topFull = false, bottomFull = false)))
|
// 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),
|
// The add row rounds the group off (full bottom),
|
||||||
// independent of whatever top-level task follows.
|
// independent of whatever top-level task follows.
|
||||||
|
if (state.showAddSubtaskRow) {
|
||||||
add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true)))
|
add(RenderRow(ListRow.AddSub(task), cornerPosition(topFull = false, bottomFull = true)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
|
itemsIndexed(rows, key = { _, r -> r.row.key }) { index, r ->
|
||||||
val lastInRun = index == rows.lastIndex
|
val lastInRun = index == rows.lastIndex
|
||||||
|
// animateItem so an (un)expanding group fades its subtasks in/out
|
||||||
|
// and slides the rows below — clean whether the children were
|
||||||
|
// already loaded (real list) or arrive a frame later (smart list).
|
||||||
|
val rowModifier = Modifier.animateItem()
|
||||||
when (val row = r.row) {
|
when (val row = r.row) {
|
||||||
is ListRow.Parent -> TaskRow(
|
is ListRow.Parent -> TaskRow(
|
||||||
|
modifier = rowModifier,
|
||||||
task = row.task,
|
task = row.task,
|
||||||
position = r.position,
|
position = r.position,
|
||||||
lastInRun = lastInRun,
|
lastInRun = lastInRun,
|
||||||
@@ -350,6 +405,7 @@ private fun TaskListBody(
|
|||||||
onClick = { onOpenTask(row.task.taskId) },
|
onClick = { onOpenTask(row.task.taskId) },
|
||||||
)
|
)
|
||||||
is ListRow.Sub -> SubtaskRow(
|
is ListRow.Sub -> SubtaskRow(
|
||||||
|
modifier = rowModifier,
|
||||||
task = row.task,
|
task = row.task,
|
||||||
position = r.position,
|
position = r.position,
|
||||||
lastInRun = lastInRun,
|
lastInRun = lastInRun,
|
||||||
@@ -357,6 +413,7 @@ private fun TaskListBody(
|
|||||||
onClick = { onOpenTask(row.task.taskId) },
|
onClick = { onOpenTask(row.task.taskId) },
|
||||||
)
|
)
|
||||||
is ListRow.AddSub -> AddSubtaskRow(
|
is ListRow.AddSub -> AddSubtaskRow(
|
||||||
|
modifier = rowModifier,
|
||||||
position = r.position,
|
position = r.position,
|
||||||
lastInRun = lastInRun,
|
lastInRun = lastInRun,
|
||||||
onAdd = { viewModel.quickAddSubtask(row.parent, it) },
|
onAdd = { viewModel.quickAddSubtask(row.parent, it) },
|
||||||
@@ -369,9 +426,9 @@ private fun TaskListBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Inline "add a task" — title only, into the current list. */
|
/** Inline "add a task" — title only, into the current list. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun InlineAdd(onAdd: (String) -> Unit) {
|
private fun InlineAdd(onAdd: (String) -> Unit, elevated: Boolean = false) {
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
fun submit() {
|
fun submit() {
|
||||||
if (text.isNotBlank()) {
|
if (text.isNotBlank()) {
|
||||||
@@ -379,10 +436,25 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
|
|||||||
text = ""
|
text = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Compose keeps a TextField focused (cursor blinking) even after the keyboard
|
||||||
|
// is dismissed by a tap outside it — so when the IME hides, drop focus too.
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
val imeVisible = WindowInsets.isImeVisible
|
||||||
|
LaunchedEffect(imeVisible) {
|
||||||
|
if (!imeVisible) focusManager.clearFocus()
|
||||||
|
}
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(22.dp),
|
shape = RoundedCornerShape(22.dp),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
// Floating use (bottom quick-add) steps up to the brightest container tone
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
// — a real step above the surfaceContainerHigh task cards — and lifts off
|
||||||
|
// the list with a heavier shadow, so it doesn't blend into the rows.
|
||||||
|
color = if (elevated) {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
},
|
||||||
|
shadowElevation = if (elevated) 8.dp else 0.dp,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
TextField(
|
TextField(
|
||||||
value = text,
|
value = text,
|
||||||
@@ -410,6 +482,23 @@ private fun InlineAdd(onAdd: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bottom quick-add field: [InlineAdd] pinned as the scaffold's bottomBar, but
|
||||||
|
* with no bar plate of its own — the rounded field floats on the screen background
|
||||||
|
* (its own shadow lifting it off the list). Rides above the keyboard and clears the
|
||||||
|
* navigation bar (ime ∪ navigation-bar insets).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun QuickAddBar(onAdd: (String) -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.windowInsetsPadding(WindowInsets.ime.union(WindowInsets.navigationBars)),
|
||||||
|
) {
|
||||||
|
InlineAdd(onAdd = onAdd, elevated = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One task row: a tonal grouped-card surface (corners from [position], morphing
|
* One task row: a tonal grouped-card surface (corners from [position], morphing
|
||||||
* rounder on press — the app's shape family) wrapped in a swipe box. Swiping
|
* rounder on press — the app's shape family) wrapped in a swipe box. Swiping
|
||||||
@@ -429,6 +518,7 @@ private fun TaskRow(
|
|||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val dismissState = rememberSwipeToDismissBoxState(
|
val dismissState = rememberSwipeToDismissBoxState(
|
||||||
confirmValueChange = { value ->
|
confirmValueChange = { value ->
|
||||||
@@ -449,7 +539,7 @@ private fun TaskRow(
|
|||||||
|
|
||||||
SwipeToDismissBox(
|
SwipeToDismissBox(
|
||||||
state = dismissState,
|
state = dismissState,
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
||||||
// dismissDirection (not targetValue) tracks the live drag, so the colour +
|
// dismissDirection (not targetValue) tracks the live drag, so the colour +
|
||||||
// icon reveal as you swipe rather than only once the threshold is crossed.
|
// icon reveal as you swipe rather than only once the threshold is crossed.
|
||||||
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
|
backgroundContent = { SwipeBackground(dismissState.dismissDirection, task.isCompleted) },
|
||||||
@@ -476,10 +566,11 @@ private fun TaskRowContent(
|
|||||||
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
||||||
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val due = task.due?.formatDateTime(task.isAllDay)
|
val due = task.due?.formatDateTimeCompact(task.isAllDay)
|
||||||
val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
|
val listName = task.listName?.takeIf { showListName && it.isNotBlank() }
|
||||||
val hasSupporting = due != null || listName != null || subtaskTotal > 0 ||
|
// Everything secondary collapses into one quiet supporting line below the title.
|
||||||
task.priority != Priority.NONE
|
val hasMeta = task.priority != Priority.NONE || due != null || subtaskTotal > 0 ||
|
||||||
|
listName != null
|
||||||
|
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
@@ -520,30 +611,18 @@ private fun TaskRowContent(
|
|||||||
MaterialTheme.colorScheme.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (hasSupporting) {
|
if (hasMeta) {
|
||||||
Spacer(Modifier.height(3.dp))
|
Spacer(Modifier.height(3.dp))
|
||||||
// Flow so the chips wrap to a further line when due date + list
|
TaskMetaLine(
|
||||||
// + subtask progress can't share one (e.g. a dated task with
|
priority = task.priority,
|
||||||
// subtasks), rather than squeezing the trailing chip.
|
due = due,
|
||||||
FlowRow(
|
subtaskDone = subtaskDone,
|
||||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
subtaskTotal = subtaskTotal,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
listName = listName,
|
||||||
itemVerticalAlignment = Alignment.CenterVertically,
|
listColor = task.effectiveColor,
|
||||||
) {
|
dark = dark,
|
||||||
if (task.priority != Priority.NONE) {
|
|
||||||
PriorityChip(task.priority, priorityLabel(task.priority))
|
|
||||||
}
|
|
||||||
if (due != null) {
|
|
||||||
Text(
|
|
||||||
text = due,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (listName != null) ListNameChip(listName, task.effectiveColor)
|
|
||||||
if (subtaskTotal > 0) SubtaskCountChip(subtaskDone, subtaskTotal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Trailing area is only ever the expand control, so the row's right
|
// Trailing area is only ever the expand control, so the row's right
|
||||||
// edge stays put whether or not the task has children (priority now
|
// edge stays put whether or not the task has children (priority now
|
||||||
@@ -555,32 +634,86 @@ private fun TaskRowContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small neutral pill showing subtask progress, e.g. "3 / 5". */
|
/**
|
||||||
|
* The single low-emphasis supporting line under a task title: priority, due date,
|
||||||
|
* subtask progress and (in mixed views) the list name, joined by middots. The text
|
||||||
|
* is quiet `onSurfaceVariant` throughout; the only colour is the priority flag and
|
||||||
|
* the list name (tinted to its list) — so even a fully-populated row stays calm and
|
||||||
|
* one line instead of a stack of filled pills.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun SubtaskCountChip(done: Int, total: Int) {
|
private fun TaskMetaLine(
|
||||||
Surface(
|
priority: Priority,
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
due: String?,
|
||||||
shape = RoundedCornerShape(50),
|
subtaskDone: Int,
|
||||||
|
subtaskTotal: Int,
|
||||||
|
listName: String?,
|
||||||
|
listColor: Int,
|
||||||
|
dark: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
val style = MaterialTheme.typography.bodySmall
|
||||||
|
val segments = buildList<@Composable () -> Unit> {
|
||||||
|
if (priority != Priority.NONE) {
|
||||||
|
add {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Rounded.ListAlt,
|
Icons.Rounded.Flag,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = priorityAccent(priority, dark),
|
||||||
modifier = Modifier.size(14.dp),
|
modifier = Modifier.size(13.dp),
|
||||||
)
|
)
|
||||||
|
Text(priorityLabel(priority), style = style, color = muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (due != null) add { Text(due, style = style, color = muted) }
|
||||||
|
if (subtaskTotal > 0) {
|
||||||
|
add {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Rounded.ListAlt,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = muted,
|
||||||
|
modifier = Modifier.size(13.dp),
|
||||||
|
)
|
||||||
|
Text("$subtaskDone / $subtaskTotal", style = style, color = muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (listName != null) {
|
||||||
|
add {
|
||||||
Text(
|
Text(
|
||||||
text = "$done / $total",
|
listName,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = style,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = pastelize(listColor, dark),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
FlowRow(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
segments.forEachIndexed { i, segment ->
|
||||||
|
// A middot between segments — its own flow item, so the line can still
|
||||||
|
// wrap gracefully when truly everything is set on a narrow screen.
|
||||||
|
if (i > 0) {
|
||||||
|
Text("·", style = style, color = muted, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
|
}
|
||||||
|
segment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The inline "add a subtask" row that closes an expanded group — a [BasicTextField]
|
* The inline "add a subtask" row that closes an expanded group — a [BasicTextField]
|
||||||
@@ -593,6 +726,7 @@ private fun AddSubtaskRow(
|
|||||||
position: Position,
|
position: Position,
|
||||||
lastInRun: Boolean,
|
lastInRun: Boolean,
|
||||||
onAdd: (String) -> Unit,
|
onAdd: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var text by remember { mutableStateOf("") }
|
var text by remember { mutableStateOf("") }
|
||||||
fun submit() {
|
fun submit() {
|
||||||
@@ -606,7 +740,7 @@ private fun AddSubtaskRow(
|
|||||||
shape = cardShape(position, 22.dp, 6.dp),
|
shape = cardShape(position, 22.dp, 6.dp),
|
||||||
// Same tone as the subtask rows it closes, so the group reads as one unit.
|
// Same tone as the subtask rows it closes, so the group reads as one unit.
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(start = 12.dp, end = 8.dp),
|
||||||
@@ -719,6 +853,7 @@ private fun SubtaskRow(
|
|||||||
lastInRun: Boolean,
|
lastInRun: Boolean,
|
||||||
onToggle: () -> Unit,
|
onToggle: () -> Unit,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val interaction = remember { MutableInteractionSource() }
|
val interaction = remember { MutableInteractionSource() }
|
||||||
val pressed by interaction.collectIsPressedAsState()
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
@@ -732,7 +867,7 @@ private fun SubtaskRow(
|
|||||||
// reads as a distinct, recessive tone — the nesting cue alongside shape.
|
// reads as a distinct, recessive tone — the nesting cue alongside shape.
|
||||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
interactionSource = interaction,
|
interactionSource = interaction,
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp).then(gap),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.tasklist
|
package de.jeanlucmakiola.agendula.ui.tasklist
|
||||||
|
|
||||||
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.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.floret.domain.Task
|
import de.jeanlucmakiola.agendula.data.tasks.TasksRepository
|
||||||
import de.jeanlucmakiola.floret.domain.TaskFilter
|
import de.jeanlucmakiola.agendula.domain.Task
|
||||||
import de.jeanlucmakiola.floret.domain.TaskForm
|
import de.jeanlucmakiola.agendula.domain.TaskFilter
|
||||||
|
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
|
||||||
@@ -15,6 +16,7 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onStart
|
import kotlinx.coroutines.flow.onStart
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -30,7 +32,14 @@ sealed interface TaskListUiState {
|
|||||||
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
|
* [TaskFilter.OfList] (for the top-bar title), `null` for smart lists —
|
||||||
* the screen falls back to the smart label in that case.
|
* the screen falls back to the smart label in that case.
|
||||||
*/
|
*/
|
||||||
data class Content(val tasks: List<Task>, val listName: String? = null) : TaskListUiState
|
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,
|
||||||
|
/** Whether a real list uses the bottom quick-add bar instead of the FAB. */
|
||||||
|
val bottomAddBar: Boolean = false,
|
||||||
|
) : TaskListUiState
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +51,7 @@ sealed interface TaskListUiState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class TaskListViewModel @Inject constructor(
|
class TaskListViewModel @Inject constructor(
|
||||||
private val repository: TasksRepository,
|
private val repository: TasksRepository,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val filter = MutableStateFlow<TaskFilter?>(null)
|
private val filter = MutableStateFlow<TaskFilter?>(null)
|
||||||
@@ -62,10 +72,16 @@ class TaskListViewModel @Inject constructor(
|
|||||||
tasks.map { TaskListUiState.Content(it) }
|
tasks.map { TaskListUiState.Content(it) }
|
||||||
}
|
}
|
||||||
// Drop rows pending an undoable delete so the row vanishes on swipe
|
// Drop rows pending an undoable delete so the row vanishes on swipe
|
||||||
// while the actual provider delete waits for the snackbar to commit.
|
// while the actual provider delete waits for the snackbar to commit,
|
||||||
combine(content, pendingDeletes) { st, pending ->
|
// and fold in the live UI settings (add-subtask row, bottom add bar).
|
||||||
|
val uiPrefs = settingsPrefs.settings.map { it.showAddSubtaskRow to it.bottomAddBar }
|
||||||
|
combine(content, pendingDeletes, uiPrefs) { st, pending, (showRow, bottomBar) ->
|
||||||
if (st is TaskListUiState.Content) {
|
if (st is TaskListUiState.Content) {
|
||||||
st.copy(tasks = st.tasks.filter { it.taskId !in pending })
|
st.copy(
|
||||||
|
tasks = st.tasks.filter { it.taskId !in pending },
|
||||||
|
showAddSubtaskRow = showRow,
|
||||||
|
bottomAddBar = bottomBar,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
st
|
st
|
||||||
}
|
}
|
||||||
@@ -75,6 +91,32 @@ class TaskListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), TaskListUiState.Loading)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parents the screen has expanded whose children it can't pull from the list
|
||||||
|
* itself — i.e. smart lists, where the date filter drops a parent's subtasks.
|
||||||
|
* For each we observe the full child set live (below), so the group can expand.
|
||||||
|
*/
|
||||||
|
private val lazyChildParents = MutableStateFlow<Set<Long>>(emptySet())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The on-demand child sets, keyed by parent id, kept live. Empty for real
|
||||||
|
* lists (the screen reports no parents there, since it already holds every
|
||||||
|
* child) so no redundant queries run.
|
||||||
|
*/
|
||||||
|
val lazyChildren: StateFlow<Map<Long, List<Task>>> =
|
||||||
|
lazyChildParents
|
||||||
|
.flatMapLatest { ids ->
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
flowOf(emptyMap())
|
||||||
|
} else {
|
||||||
|
combine(ids.map { id -> repository.subtasks(id).map { id to it } }) { it.toMap() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
|
||||||
|
|
||||||
|
/** The screen reports which expanded parents need their children fetched. */
|
||||||
|
fun setLazyChildParents(ids: Set<Long>) { lazyChildParents.value = ids }
|
||||||
|
|
||||||
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
fun bind(taskFilter: TaskFilter) { filter.value = taskFilter }
|
||||||
|
|
||||||
fun toggleComplete(task: Task) = viewModelScope.launch {
|
fun toggleComplete(task: Task) = viewModelScope.launch {
|
||||||
@@ -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),
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package de.jeanlucmakiola.agendula.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agendula's theme: the family's [FloretExpressiveTheme] machinery (dynamic
|
||||||
|
* colour on API 31+, system light/dark, the standard motion scheme) fed with
|
||||||
|
* Agendula's own identity — its seed-derived fallback schemes
|
||||||
|
* ([AgendulaLightFallback] / [AgendulaDarkFallback]) and [AgendulaTypography].
|
||||||
|
* The mechanics live in floret-kit; the look stays here.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AgendulaTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
dynamicColor: Boolean = true,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
FloretExpressiveTheme(
|
||||||
|
lightScheme = AgendulaLightFallback,
|
||||||
|
darkScheme = AgendulaDarkFallback,
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
dynamicColor = dynamicColor,
|
||||||
|
typography = AgendulaTypography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package de.jeanlucmakiola.floret
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import de.jeanlucmakiola.floret.data.demo.DemoSeeder
|
|
||||||
import de.jeanlucmakiola.floret.data.prefs.ThemeMode
|
|
||||||
import de.jeanlucmakiola.floret.ui.RootScreen
|
|
||||||
import de.jeanlucmakiola.floret.ui.settings.SettingsViewModel
|
|
||||||
import de.jeanlucmakiola.floret.ui.theme.FloretTheme
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single activity. The theme follows [SettingsViewModel]; [RootScreen] is the
|
|
||||||
* (replaceable) functional scaffold over the real data layer. Task-detail intent
|
|
||||||
* routing for reminder taps lands with the full UI.
|
|
||||||
*/
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class MainActivity : ComponentActivity() {
|
|
||||||
|
|
||||||
@Inject lateinit var demoSeeder: DemoSeeder
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enableEdgeToEdge()
|
|
||||||
|
|
||||||
// Debug-only sample data: `am start ... --ez floret_seed true`. Seeds a
|
|
||||||
// local (non-syncing) demo list once; no-op without the extra.
|
|
||||||
if (BuildConfig.DEBUG && intent.getBooleanExtra(EXTRA_SEED, false)) {
|
|
||||||
lifecycleScope.launch { runCatching { demoSeeder.seed() } }
|
|
||||||
}
|
|
||||||
setContent {
|
|
||||||
val settingsViewModel: SettingsViewModel = hiltViewModel()
|
|
||||||
val ui by settingsViewModel.state.collectAsStateWithLifecycle()
|
|
||||||
val darkTheme = when (ui.settings.themeMode) {
|
|
||||||
ThemeMode.SYSTEM -> isSystemInDarkTheme()
|
|
||||||
ThemeMode.LIGHT -> false
|
|
||||||
ThemeMode.DARK -> true
|
|
||||||
}
|
|
||||||
FloretTheme(darkTheme = darkTheme, dynamicColor = ui.settings.dynamicColor) {
|
|
||||||
RootScreen(modifier = Modifier.fillMaxSize())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val EXTRA_TASK_ID = "de.jeanlucmakiola.floret.extra.TASK_ID"
|
|
||||||
private const val EXTRA_SEED = "floret_seed"
|
|
||||||
|
|
||||||
/** Opens the app focused on a task (reminder taps). Routing lands with the UI. */
|
|
||||||
fun taskIntent(context: Context, taskId: Long): Intent =
|
|
||||||
Intent(context, MainActivity::class.java).apply {
|
|
||||||
putExtra(EXTRA_TASK_ID, taskId)
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +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 androidx.datastore.preferences.core.stringSetPreferencesKey
|
|
||||||
import de.jeanlucmakiola.floret.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 }
|
|
||||||
|
|
||||||
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,
|
|
||||||
/** Optional edit-form fields shown by default; the rest sit behind "More fields". */
|
|
||||||
val defaultEditFields: Set<TaskFormField> = emptySet(),
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 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,
|
|
||||||
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 }
|
|
||||||
|
|
||||||
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 DEFAULT_EDIT_FIELDS = stringSetPreferencesKey("default_edit_fields")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package de.jeanlucmakiola.floret.domain
|
|
||||||
|
|
||||||
import java.time.ZoneId
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
/** Local-day boundaries used by the smart-list predicates. Pure + testable. */
|
|
||||||
object DayWindow {
|
|
||||||
|
|
||||||
/** Returns `[startOfToday, startOfTomorrow)` in [zone] for the instant [now]. */
|
|
||||||
fun today(now: Instant, zone: ZoneId): Pair<Instant, Instant> {
|
|
||||||
val date = java.time.Instant.ofEpochMilli(now.toEpochMilliseconds())
|
|
||||||
.atZone(zone)
|
|
||||||
.toLocalDate()
|
|
||||||
val start = date.atStartOfDay(zone).toInstant().toEpochMilli()
|
|
||||||
val end = date.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli()
|
|
||||||
return Instant.fromEpochMilliseconds(start) to Instant.fromEpochMilliseconds(end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package de.jeanlucmakiola.floret.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()}"
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.common
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.ListItemDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Position of a row within a grouped list (the Android-15 settings pattern): a
|
|
||||||
* run of rows shares one rounded container, full corners at the group's outer
|
|
||||||
* edges and small corners between, separated by small gaps. Copied from
|
|
||||||
* Calendula so the two apps read as one family.
|
|
||||||
*/
|
|
||||||
enum class Position { Top, Middle, Bottom, Alone }
|
|
||||||
|
|
||||||
fun positionOf(index: Int, count: Int): Position = when {
|
|
||||||
count <= 1 -> Position.Alone
|
|
||||||
index == 0 -> Position.Top
|
|
||||||
index == count - 1 -> Position.Bottom
|
|
||||||
else -> Position.Middle
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Corner radii for a grouped segment: full at the group's outer edges, small between. */
|
|
||||||
fun groupedShape(position: Position, full: Dp, small: Dp): Shape = when (position) {
|
|
||||||
Position.Alone -> RoundedCornerShape(full)
|
|
||||||
Position.Top -> RoundedCornerShape(topStart = full, topEnd = full, bottomStart = small, bottomEnd = small)
|
|
||||||
Position.Middle -> RoundedCornerShape(small)
|
|
||||||
Position.Bottom -> RoundedCornerShape(topStart = small, topEnd = small, bottomStart = full, bottomEnd = full)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One segment of a grouped list: a tonal [Surface] whose corner radii come from
|
|
||||||
* [position] (so a run reads as a single rounded card), with a small gap below
|
|
||||||
* all but the last. Corners morph rounder on press — the expressive shape play.
|
|
||||||
* The caller supplies [content] and any horizontal inset via [modifier], so the
|
|
||||||
* same primitive serves both the 16dp-inset lists and full-width detail cards.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun GroupedSurface(
|
|
||||||
position: Position,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
color: Color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val interaction = remember { MutableInteractionSource() }
|
|
||||||
val pressed by interaction.collectIsPressedAsState()
|
|
||||||
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
|
||||||
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
|
||||||
val shape = groupedShape(position, full, small)
|
|
||||||
val gap = when (position) {
|
|
||||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
|
||||||
Position.Bottom, Position.Alone -> Modifier
|
|
||||||
}
|
|
||||||
val base = modifier.fillMaxWidth().then(gap)
|
|
||||||
if (onClick != null) {
|
|
||||||
Surface(onClick = onClick, color = color, shape = shape, interactionSource = interaction, modifier = base) { content() }
|
|
||||||
} else {
|
|
||||||
Surface(color = color, shape = shape, modifier = base) { content() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row in a grouped list: an M3 [ListItem] over a [GroupedSurface] (the
|
|
||||||
* tonal, position-shaped, press-morphing family container). Insets 16dp like
|
|
||||||
* the lists overview.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun GroupedRow(
|
|
||||||
title: String,
|
|
||||||
position: Position,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
summary: String? = null,
|
|
||||||
selected: Boolean = false,
|
|
||||||
minHeight: Dp = 72.dp,
|
|
||||||
leading: @Composable (() -> Unit)? = null,
|
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val itemColors = if (selected) {
|
|
||||||
ListItemDefaults.colors(
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
|
||||||
}
|
|
||||||
val containerColor = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
}
|
|
||||||
GroupedSurface(
|
|
||||||
position = position,
|
|
||||||
modifier = modifier.padding(horizontal = 16.dp),
|
|
||||||
onClick = onClick,
|
|
||||||
color = containerColor,
|
|
||||||
) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(title) },
|
|
||||||
supportingContent = summary?.let { text -> { Text(text) } },
|
|
||||||
leadingContent = leading,
|
|
||||||
trailingContent = trailing,
|
|
||||||
colors = itemColors,
|
|
||||||
modifier = Modifier.heightIn(min = minHeight),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package de.jeanlucmakiola.floret.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. Floret 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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package de.jeanlucmakiola.floret.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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package de.jeanlucmakiola.floret.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()
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
package de.jeanlucmakiola.floret.ui.theme
|
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.MaterialExpressiveTheme
|
|
||||||
import androidx.compose.material3.MotionScheme
|
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* App theme. Honors:
|
|
||||||
* - System light/dark.
|
|
||||||
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
|
||||||
* derived from [FloretSeed].
|
|
||||||
*
|
|
||||||
* Mirrors Calendula's theme: MaterialExpressiveTheme routes all component +
|
|
||||||
* custom motion through MaterialTheme.motionScheme. STANDARD over expressive()
|
|
||||||
* is the same deliberate choice — spring choreography without the overshoot.
|
|
||||||
*
|
|
||||||
* A Settings screen can later override dynamicColor and theme mode; the M0
|
|
||||||
* foundation just follows the system.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
fun FloretTheme(
|
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
|
||||||
dynamicColor: Boolean = true,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val colorScheme = when {
|
|
||||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
|
||||||
val ctx = LocalContext.current
|
|
||||||
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
|
|
||||||
}
|
|
||||||
darkTheme -> FloretDarkFallback
|
|
||||||
else -> FloretLightFallback
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialExpressiveTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
typography = FloretTypography,
|
|
||||||
motionScheme = MotionScheme.standard(),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<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 -->
|
||||||
@@ -98,6 +98,20 @@
|
|||||||
<string name="reminder_1_hour">1 hour before</string>
|
<string name="reminder_1_hour">1 hour before</string>
|
||||||
<string name="reminder_1_day">1 day 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 -->
|
<!-- Form validation -->
|
||||||
<string name="error_blank_title">Enter a title.</string>
|
<string name="error_blank_title">Enter a title.</string>
|
||||||
<string name="error_no_list">Choose a list.</string>
|
<string name="error_no_list">Choose a list.</string>
|
||||||
@@ -122,10 +136,86 @@
|
|||||||
|
|
||||||
<!-- 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="crash_report_issue_title">Crash report</string>
|
||||||
|
<string name="report_issue_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/agendula/issues/new</string>
|
||||||
|
<string name="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>
|
||||||
|
<string name="settings_bottom_add_bar">Bottom quick-add bar</string>
|
||||||
|
<string name="settings_bottom_add_bar_hint">Add tasks from a bar pinned to the bottom of a list, instead of the floating button</string>
|
||||||
|
|
||||||
|
<!-- Reminder lead times (custom amounts) -->
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
11
app/src/main/res/xml/locales_config.xml
Normal file
11
app/src/main/res/xml/locales_config.xml
Normal 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>
|
||||||
@@ -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 {
|
||||||
@@ -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 {
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package de.jeanlucmakiola.floret.domain
|
package de.jeanlucmakiola.agendula.domain
|
||||||
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package de.jeanlucmakiola.floret.domain
|
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
|
||||||
import org.junit.jupiter.api.Test
|
|
||||||
import java.time.ZoneId
|
|
||||||
import kotlin.time.Instant
|
|
||||||
|
|
||||||
class DayWindowTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `today spans exactly 24h in UTC and contains now`() {
|
|
||||||
val zone = ZoneId.of("UTC")
|
|
||||||
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
|
|
||||||
val (start, end) = DayWindow.today(now, zone)
|
|
||||||
|
|
||||||
assertThat(end.toEpochMilliseconds() - start.toEpochMilliseconds()).isEqualTo(86_400_000L)
|
|
||||||
assertThat(now >= start).isTrue()
|
|
||||||
assertThat(now < end).isTrue()
|
|
||||||
// In UTC, midnight aligns to a multiple of one day since the epoch.
|
|
||||||
assertThat(start.toEpochMilliseconds() % 86_400_000L).isEqualTo(0L)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `start is local midnight in a non-UTC zone`() {
|
|
||||||
val zone = ZoneId.of("Europe/Berlin")
|
|
||||||
val now = Instant.fromEpochMilliseconds(1_750_000_000_000L)
|
|
||||||
val (start, end) = DayWindow.today(now, zone)
|
|
||||||
|
|
||||||
val startLocal = java.time.Instant.ofEpochMilli(start.toEpochMilliseconds()).atZone(zone)
|
|
||||||
assertThat(startLocal.hour).isEqualTo(0)
|
|
||||||
assertThat(startLocal.minute).isEqualTo(0)
|
|
||||||
assertThat(now >= start).isTrue()
|
|
||||||
assertThat(now < end).isTrue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
45
docs/PLAN.md
45
docs/PLAN.md
@@ -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
|
||||||
|
|||||||
@@ -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 (M0–M6 + Posture B), what's done, open decisions, how to build/verify. |
|
| [`ROADMAP.md`](ROADMAP.md) | **Status** and what's next — milestones (M0–M6 + 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. |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -12,11 +12,14 @@ Status legend: ✅ done · 🚧 in progress · ⬜ not started
|
|||||||
|
|
||||||
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**, and the Material 3 Expressive UI is built
|
provider is **done and unit-tested**, and the Material 3 Expressive UI is built
|
||||||
through **M4**: lists → task list (swipe gestures, inline add, smart-list section
|
through **M5**: lists → task list (swipe gestures, inline add, smart-list section
|
||||||
headers) → detail / edit with full CRUD, date-time pickers, priority,
|
headers) → detail / edit with full CRUD, date-time pickers, priority,
|
||||||
percent-complete, conflict-safe saves, per-task reminders, and subtask
|
percent-complete, conflict-safe saves, per-task reminders, and subtask
|
||||||
create + reparent. Remaining work is M5 (notification / exact-alarm onboarding,
|
create + reparent — plus a one-time reminder onboarding step and a Settings
|
||||||
default reminder UI) and M6 (Settings screen, Glance widget, i18n, release).
|
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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -47,7 +50,7 @@ The real Material 3 Expressive UI, built screen by screen against the M1
|
|||||||
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 (`FloretNavHost` + `Dest` route table: lists → task
|
- ✅ Navigation host wiring (`AgendulaNavHost` + `Dest` route table: lists → task
|
||||||
list → detail / edit, each binding its M1 ViewModel from route args).
|
list → detail / edit, each binding its M1 ViewModel from route args).
|
||||||
- ✅ Task list screen — checkbox rows + toggle-complete, swipe-to-complete /
|
- ✅ Task list screen — checkbox rows + toggle-complete, swipe-to-complete /
|
||||||
-delete (`SwipeToDismissBox`), inline add field (`InlineAdd` → `quickAdd`),
|
-delete (`SwipeToDismissBox`), inline add field (`InlineAdd` → `quickAdd`),
|
||||||
@@ -85,7 +88,7 @@ ViewModels.
|
|||||||
last segment while the next top-level task stays mid-run. Children are
|
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'
|
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"
|
`surfaceContainerHigh`) and with no colour bar (checkbox stays aligned). An expanded group ends with an inline "add a subtask"
|
||||||
row (always on for now; an opt-out toggle lands with the M6 settings screen).
|
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
|
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
|
that omit off-day children stay collapsed). `TaskDetail.parent` carries the
|
||||||
parent for the detail card.
|
parent for the detail card.
|
||||||
@@ -95,25 +98,32 @@ ViewModels.
|
|||||||
promote it); switching list clears the now-invalid parent. Candidates stay
|
promote it); switching list clears the now-invalid parent. Candidates stay
|
||||||
active + top-level to keep nesting one level deep, matching the detail screen.
|
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: `ReminderScheduler` + boot / provider-change re-sync,
|
The engine exists (M1: `ReminderScheduler` + boot / provider-change re-sync,
|
||||||
`DueReminderReceiver`, `TaskNotifier`).
|
`DueReminderReceiver`, `TaskNotifier`).
|
||||||
- ✅ Per-task reminder-offset UI (`ReminderPickerDialog` → `TaskForm`
|
- ✅ Per-task reminder-offset UI (`ReminderPickerDialog` → `TaskForm`
|
||||||
`reminderMinutesBeforeDue`).
|
`reminderMinutesBeforeDue`).
|
||||||
- ⬜ `POST_NOTIFICATIONS` + exact-alarm onboarding flow — currently only graceful
|
- ✅ `POST_NOTIFICATIONS` onboarding flow — a one-time `ReminderOnboardingScreen`
|
||||||
runtime checks (`TaskNotifier.canPost`, `canScheduleExactAlarms`), no
|
(Calendula's shell + copy adapted for tasks) gated in `RootScreen` after the
|
||||||
user-facing gate.
|
provider/permission grant; it requests the runtime permission (API 33+) and
|
||||||
- ⬜ Default reminder-offset settings UI — `SettingsPrefs`/`SettingsViewModel`
|
records the choice (`reminderOnboardingDone`). Re-requestable from Settings.
|
||||||
back it, but no Composable exposes it.
|
- ✅ Exact-alarm surface — a status row in Settings → Reminders, shown only on
|
||||||
- ⬜ End-to-end verification on device.
|
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
|
||||||
- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
|
- ✅ F-Droid metadata scaffolded (`fdroid-metadata/`).
|
||||||
- ⬜ Settings screen — `SettingsViewModel` exists but no `SettingsScreen`
|
- ✅ Settings screen — landed early with M5 (`SettingsScreen` in the nav graph,
|
||||||
Composable and it is not in the nav graph (only used by `MainActivity` for
|
reached by the gear on the lists overview). Covers theme, dynamic colour, due
|
||||||
app-level theming). Needs theme / dynamic-color / language, default list,
|
reminders (toggle + default offset + exact-alarm status), default list, and the
|
||||||
default reminder, and the opt-out toggle for the task list's inline
|
add-a-subtask-row opt-out. Still ⬜ a **language** entry (deferred until there
|
||||||
add-a-subtask row (on by default since M4).
|
are translations to switch to).
|
||||||
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
|
- ⬜ Glance task-list widget — deps present in `build.gradle.kts`, zero impl.
|
||||||
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
|
- ⬜ Translations — only `res/values/` (English); no `values-XX`.
|
||||||
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
|
- ⬜ Finalize F-Droid metadata, confirm CI release flow.
|
||||||
@@ -130,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);
|
||||||
|
|||||||
10
fdroid-metadata/de.jeanlucmakiola.agendula.yml
Normal file
10
fdroid-metadata/de.jeanlucmakiola.agendula.yml
Normal 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
|
||||||
@@ -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.
|
||||||
@@ -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
|
|
||||||
1
floret-kit
Submodule
1
floret-kit
Submodule
Submodule floret-kit added at 566caf4305
@@ -11,9 +11,10 @@ pluginManagement {
|
|||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
plugins {
|
|
||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
// No Gradle Java-toolchain auto-download resolver plugin: it can fetch a JDK at
|
||||||
}
|
// build time, which an offline / reproducible F-Droid build scanner rejects.
|
||||||
|
// Modules set jvmTarget directly, so no toolchain resolver is needed.
|
||||||
|
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
@@ -23,5 +24,6 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "Floret"
|
rootProject.name = "Agendula"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
includeBuild("floret-kit")
|
||||||
|
|||||||
Reference in New Issue
Block a user