Compare commits
22 Commits
8f87dc3564
...
feat/per-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 225f4c3491 | |||
| 5d887524b1 | |||
| 048f407ba1 | |||
| a89560953d | |||
| 4e498de051 | |||
| f2fb3d6279 | |||
| c2d88e744e | |||
| 2ae4c818ba | |||
| fb2a40114a | |||
| e1f79df67a | |||
| e258a4a7f2 | |||
| 3f27a47d1e | |||
| feb87a1e73 | |||
| 28e464a616 | |||
| 93df6d6d62 | |||
| 49ddb9437b | |||
| e1cf00999d | |||
| 2d0d707a7d | |||
| de1fe31223 | |||
| 273cfce969 | |||
| b6a45b7264 | |||
| c7ef52e426 |
@@ -29,7 +29,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
# Full history so the base..HEAD diff below has a merge-base.
|
# Full history so the base..HEAD diff below has a merge-base.
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
# Cheap, always-on guard: the release build must stay reproducible for the
|
# Cheap, always-on guard: the release build must stay reproducible for the
|
||||||
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
||||||
|
|||||||
@@ -33,8 +33,6 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
submodules: recursive
|
|
||||||
|
|
||||||
- name: Resolve version and whether it is a new release
|
- name: Resolve version and whether it is a new release
|
||||||
id: v
|
id: v
|
||||||
@@ -84,8 +82,6 @@ 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
|
||||||
|
|||||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
|||||||
[submodule "floret-kit"]
|
|
||||||
path = floret-kit
|
|
||||||
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
|
||||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -5,6 +5,68 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Set more than one default reminder per calendar. A calendar's default
|
||||||
|
reminders — and the global defaults under Settings → Notifications — can now
|
||||||
|
hold several lead times instead of just one, so new events can start with, say,
|
||||||
|
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||||
|
multi-select; per-calendar overrides can still inherit the global default or
|
||||||
|
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||||
|
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||||
|
popping up — matching how a disabled calendar's events already stay hidden
|
||||||
|
everywhere else in the app ([#17]).
|
||||||
|
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||||
|
this event** and saving a change to one event in a repeating series silently
|
||||||
|
did nothing — the change was rejected and the edit form simply reappeared with
|
||||||
|
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||||
|
lands on just that one event and leaves the rest of the series untouched
|
||||||
|
([#16]).
|
||||||
|
|
||||||
|
## [2.12.0] — 2026-06-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- See past events your way. Two new settings change how events that have already
|
||||||
|
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||||
|
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||||
|
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||||
|
(Settings → Appearance) separately fades finished events in the month and week
|
||||||
|
views. Both are off by default, an event only counts as finished once it has
|
||||||
|
actually ended (events still in progress are never dimmed), and the lists
|
||||||
|
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||||
|
([#12]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Readable titles on overlapping events. In the week and day views, events that
|
||||||
|
overlap split a day into slim columns where the title used to be clipped to a
|
||||||
|
character or two. The title now wraps across as many lines as the block can
|
||||||
|
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||||
|
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||||
|
for the suggestion ([#13]).
|
||||||
|
|
||||||
|
## [2.11.2] — 2026-06-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||||
|
the cursor in the title field and opens the keyboard right away, so you can type
|
||||||
|
the name without an extra tap. It's on by default and only affects creating an
|
||||||
|
event — editing never grabs focus — and a new **Focus title on new event**
|
||||||
|
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#10]).
|
||||||
|
|
||||||
|
## [2.11.1] — 2026-06-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Calendula can now be set as your default calendar app. It registers the
|
||||||
|
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||||
|
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||||
|
that day. Android has no way for an app to make itself the default, so you pick
|
||||||
|
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||||
|
|
||||||
## [2.11.0] — 2026-06-27
|
## [2.11.0] — 2026-06-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -685,3 +747,10 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
||||||
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
||||||
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||||
|
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
||||||
|
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
||||||
|
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||||
|
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||||
|
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
|
||||||
|
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||||
|
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21100
|
versionCode = 21200
|
||||||
versionName = "2.11.0"
|
versionName = "2.12.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -90,6 +90,8 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
// BuildConfig.DEBUG gates the in-app debug ribbon (see DebugRibbon).
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
// Don't embed AGP's dependency-metadata block in the APK signing block. It's
|
||||||
@@ -159,12 +161,6 @@ dependencies {
|
|||||||
implementation(libs.androidx.glance.material3)
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation("de.jeanlucmakiola.floret:core-time")
|
|
||||||
implementation("de.jeanlucmakiola.floret:core-locale")
|
|
||||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
|
||||||
implementation("de.jeanlucmakiola.floret:core-reminders")
|
|
||||||
implementation("de.jeanlucmakiola.floret:identity")
|
|
||||||
implementation("de.jeanlucmakiola.floret:components")
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
|||||||
10
app/src/debug/res/values/colors.xml
Normal file
10
app/src/debug/res/values/colors.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Debug-only launcher-icon background. Production is slate (#5C6B7A); the
|
||||||
|
debug build paints the adaptive-icon background burnt orange instead, so the
|
||||||
|
debug icon reads at a glance as "not the real app" on the home screen. The
|
||||||
|
off-white foreground mark contrasts on both. See drawable/ic_launcher_background.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#FFB23B00</color>
|
||||||
|
</resources>
|
||||||
10
app/src/debug/res/values/strings.xml
Normal file
10
app/src/debug/res/values/strings.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Debug-build resource overrides. Merged on top of src/main for the `debug`
|
||||||
|
build type only (release/releaseTest keep the production values), so the
|
||||||
|
debug app is unmistakable on the launcher: its own label, alongside the
|
||||||
|
real app thanks to the `.debug` applicationId suffix.
|
||||||
|
-->
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Calendula Debug</string>
|
||||||
|
</resources>
|
||||||
@@ -59,6 +59,35 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Be selectable as the system calendar app. Android has no API for
|
||||||
|
an app to make itself the default, so registering the filters
|
||||||
|
launchers and the OS use is what lets the user pick Calendula
|
||||||
|
from the system chooser when a date action fires (issue #9).
|
||||||
|
APP_CALENDAR is the "open the calendar app" action; the VIEW
|
||||||
|
filters below catch a tapped date. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.APP_CALENDAR" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
|
||||||
|
time Uri (content://com.android.calendar/time/<epochMillis>);
|
||||||
|
some surfaces use the time/epoch mime type. We open the day view
|
||||||
|
on that date (MainActivity.calendarTimeDateOrNull). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data
|
||||||
|
android:scheme="content"
|
||||||
|
android:host="com.android.calendar"
|
||||||
|
android:pathPrefix="/time" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="time/epoch" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import dagger.hilt.android.EntryPointAccessors
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
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
|
||||||
@@ -23,18 +22,8 @@ class CalendulaApp : Application() {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
// Install first thing so startup crashes are captured too (privacy-
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
// respecting, on-device; the user submits the report by hand). The
|
// respecting, on-device; the user submits the report by hand).
|
||||||
// capture/loop-detection/report machinery lives in floret-kit's
|
CrashReporter.install(this)
|
||||||
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
|
||||||
CrashReporter.install(
|
|
||||||
this,
|
|
||||||
CrashConfig(
|
|
||||||
appLabel = getString(R.string.app_name),
|
|
||||||
newIssueUrl = getString(R.string.report_issue_url),
|
|
||||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
|
||||||
issueTitle = getString(R.string.crash_report_issue_title),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -21,6 +22,7 @@ import androidx.core.net.toUri
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
@@ -28,13 +30,16 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
|
||||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
@@ -98,19 +103,24 @@ class MainActivity : AppCompatActivity() {
|
|||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
LocalUse24HourFormat provides use24Hour,
|
CompositionLocalProvider(
|
||||||
LocalShowHourLines provides settings.showHourLines,
|
LocalUse24HourFormat provides use24Hour,
|
||||||
) {
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
RootScreen(
|
) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
RootScreen(
|
||||||
requestedDetailKey = requestedDetailKey,
|
modifier = Modifier.fillMaxSize(),
|
||||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
requestedDetailKey = requestedDetailKey,
|
||||||
widgetNavRequest = requestedNav,
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
onWidgetNavConsumed = { requestedNav = null },
|
widgetNavRequest = requestedNav,
|
||||||
requestedImportUri = requestedImportUri,
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
onImportConsumed = { requestedImportUri = null },
|
requestedImportUri = requestedImportUri,
|
||||||
)
|
onImportConsumed = { requestedImportUri = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// A persistent corner marker so a debug build is never
|
||||||
|
// mistaken for the production app; compiled out of release.
|
||||||
|
if (BuildConfig.DEBUG) DebugRibbon()
|
||||||
}
|
}
|
||||||
pendingCrashReport?.let { report ->
|
pendingCrashReport?.let { report ->
|
||||||
CrashReportDialog(
|
CrashReportDialog(
|
||||||
@@ -156,10 +166,33 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
else -> null
|
else -> null
|
||||||
} ?: return null
|
} ?: return null
|
||||||
|
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||||
|
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
|
||||||
|
if (uri.host == CALENDAR_PROVIDER_HOST) return null
|
||||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||||
|
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||||
|
* <epochMillis>`. Null for any other intent. The matching manifest filter is
|
||||||
|
* what lets users pick Calendula from the system calendar chooser (issue #9).
|
||||||
|
*/
|
||||||
|
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
|
||||||
|
if (action != Intent.ACTION_VIEW) return null
|
||||||
|
val uri = data ?: return null
|
||||||
|
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||||
|
val segments = uri.pathSegments
|
||||||
|
if (segments.firstOrNull() != "time") return null
|
||||||
|
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||||
|
return Instant.fromEpochMilliseconds(millis)
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
}
|
||||||
|
|
||||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||||
|
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||||
|
// the day view rooted over the default home view (OpenDate source = null).
|
||||||
|
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||||
val source = sourceViewOrNull()
|
val source = sourceViewOrNull()
|
||||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
return when {
|
return when {
|
||||||
@@ -201,6 +234,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
|
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||||
|
|
||||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||||
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
|
||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
|
|||||||
@@ -118,8 +118,14 @@ internal fun buildEventUpdateValues(
|
|||||||
* provider clone the series row and apply these on top. Unlike the series
|
* provider clone the series row and apply these on top. Unlike the series
|
||||||
* update there is no dirty check — the exception is a fresh row, so every
|
* update there is no dirty check — the exception is a fresh row, so every
|
||||||
* form-backed column is written (empty optionals as explicit NULLs, since the
|
* form-backed column is written (empty optionals as explicit NULLs, since the
|
||||||
* clone starts from the parent's values). An exception is a single event:
|
* clone starts from the parent's values).
|
||||||
* DTEND, never RRULE/DURATION.
|
*
|
||||||
|
* The occurrence's length travels as DURATION, never DTEND: the provider
|
||||||
|
* rejects DTEND on an exception outright (`CalendarProvider2`:
|
||||||
|
* "Exceptions can't overwrite dtend") and derives the instance end from
|
||||||
|
* DTSTART + DURATION itself, clearing the inherited RRULE in the process. This
|
||||||
|
* matches how AOSP Calendar/Etar write exceptions; sending DTEND is what made
|
||||||
|
* "only this event" fail on-device (Codeberg #16).
|
||||||
*/
|
*/
|
||||||
internal fun buildOccurrenceExceptionValues(
|
internal fun buildOccurrenceExceptionValues(
|
||||||
form: EventForm,
|
form: EventForm,
|
||||||
@@ -131,7 +137,7 @@ internal fun buildOccurrenceExceptionValues(
|
|||||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.calendar
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
|
||||||
|
|
||||||
|
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.crash
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.pm.PackageInfoCompat
|
||||||
|
import java.io.File
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.io.StringWriter
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
|
||||||
|
* exception it writes a self-contained report to the app's private storage and
|
||||||
|
* then chains to the platform's default handler, so the process still dies
|
||||||
|
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
|
||||||
|
* the app holds no `INTERNET` permission. The user submits the report later,
|
||||||
|
* by hand, as a Gitea issue (see the ui/crash surfaces).
|
||||||
|
*
|
||||||
|
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
|
||||||
|
* version, locale, time, and the stack trace — and **nothing else**: no device
|
||||||
|
* identifiers, no account names, no calendar/event content, no logcat. The user
|
||||||
|
* is always shown the full text before it leaves the device.
|
||||||
|
*/
|
||||||
|
object CrashReporter {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
|
||||||
|
* also catches crashes during startup. The handler swallows nothing — it
|
||||||
|
* persists, then delegates to the previously-registered handler.
|
||||||
|
*/
|
||||||
|
fun install(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
// Capturing must never mask the original crash, so guard every step.
|
||||||
|
runCatching {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
|
||||||
|
recordCrashTime(appContext, now)
|
||||||
|
}
|
||||||
|
previous?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The persisted report from the last crash, or null if there is none. */
|
||||||
|
fun pendingReport(context: Context): String? {
|
||||||
|
val file = reportFile(context)
|
||||||
|
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to surface the report unprompted (on the next launch): a report
|
||||||
|
* exists and the user hasn't already waved this one away. Settings reaches
|
||||||
|
* the report via [pendingReport] regardless, so "Not now" only stops the
|
||||||
|
* auto-prompt — it doesn't discard the report.
|
||||||
|
*/
|
||||||
|
fun shouldPrompt(context: Context): Boolean =
|
||||||
|
reportFile(context).exists() && !dismissedFile(context).exists()
|
||||||
|
|
||||||
|
/** Stop auto-prompting for the current report without discarding it. */
|
||||||
|
fun dismissPrompt(context: Context) {
|
||||||
|
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the persisted report once the user has reported it (or from Settings). */
|
||||||
|
fun clearReport(context: Context) {
|
||||||
|
runCatching { reportFile(context).delete() }
|
||||||
|
runCatching { dismissedFile(context).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the app appears to be in a startup crash-loop: at least
|
||||||
|
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
|
||||||
|
* can't be trusted to start, so the caller routes straight to the standalone
|
||||||
|
* report screen instead of re-entering the crashing graph.
|
||||||
|
*/
|
||||||
|
fun isCrashLoop(context: Context): Boolean {
|
||||||
|
val times = readCrashTimes(context)
|
||||||
|
if (times.size < LOOP_THRESHOLD) return false
|
||||||
|
val recent = times.sortedDescending()
|
||||||
|
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the app as having started successfully, resetting the loop counter so
|
||||||
|
* an ordinary single crash much later never trips loop detection. The
|
||||||
|
* pending report itself is kept — only the timing trail is cleared.
|
||||||
|
*/
|
||||||
|
fun markHealthy(context: Context) {
|
||||||
|
runCatching { timesFile(context).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- persistence -------------------------------------------------------
|
||||||
|
|
||||||
|
private fun writeReport(context: Context, report: String) {
|
||||||
|
val file = reportFile(context).apply { parentFile?.mkdirs() }
|
||||||
|
file.writeText(report.take(MAX_REPORT_CHARS))
|
||||||
|
// A fresh crash should prompt again, even if the previous one was waved away.
|
||||||
|
runCatching { dismissedFile(context).delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordCrashTime(context: Context, nowMillis: Long) {
|
||||||
|
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
|
||||||
|
timesFile(context).apply { parentFile?.mkdirs() }
|
||||||
|
.writeText(kept.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readCrashTimes(context: Context): List<Long> {
|
||||||
|
val file = timesFile(context)
|
||||||
|
if (!file.exists()) return emptyList()
|
||||||
|
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
|
||||||
|
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
|
||||||
|
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
|
||||||
|
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
|
||||||
|
|
||||||
|
private const val CRASH_DIR = "crash"
|
||||||
|
private const val REPORT_FILE = "last_crash.txt"
|
||||||
|
private const val TIMES_FILE = "crash_times.txt"
|
||||||
|
private const val DISMISSED_FILE = "dismissed"
|
||||||
|
private const val MAX_TIMES = 5
|
||||||
|
private const val MAX_REPORT_CHARS = 64 * 1024
|
||||||
|
private const val LOOP_THRESHOLD = 2
|
||||||
|
private const val LOOP_WINDOW_MS = 10_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The allowlist of non-personal facts that go into a crash report. Built from
|
||||||
|
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
|
||||||
|
*/
|
||||||
|
data class CrashContext(
|
||||||
|
val appVersionName: String,
|
||||||
|
val appVersionCode: Long,
|
||||||
|
val sdkInt: Int,
|
||||||
|
val androidRelease: String,
|
||||||
|
val manufacturer: String,
|
||||||
|
val model: String,
|
||||||
|
val locale: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun from(context: Context): CrashContext {
|
||||||
|
val pkg = runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
}.getOrNull()
|
||||||
|
return CrashContext(
|
||||||
|
appVersionName = pkg?.versionName ?: "?",
|
||||||
|
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
|
||||||
|
sdkInt = Build.VERSION.SDK_INT,
|
||||||
|
androidRelease = Build.VERSION.RELEASE ?: "?",
|
||||||
|
manufacturer = Build.MANUFACTURER ?: "?",
|
||||||
|
model = Build.MODEL ?: "?",
|
||||||
|
locale = Locale.getDefault().toLanguageTag(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
|
||||||
|
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
|
||||||
|
* tested. The leading marker doubles as the file's sanity check in
|
||||||
|
* [CrashReporter.pendingReport].
|
||||||
|
*/
|
||||||
|
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
|
||||||
|
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||||
|
val time = runCatching {
|
||||||
|
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
|
||||||
|
}.getOrDefault(nowMillis.toString())
|
||||||
|
return buildString {
|
||||||
|
appendLine("Calendula crash report")
|
||||||
|
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
|
||||||
|
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
|
||||||
|
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
|
||||||
|
appendLine("Locale: ${ctx.locale}")
|
||||||
|
appendLine("Time: $time")
|
||||||
|
appendLine()
|
||||||
|
appendLine("Stack trace:")
|
||||||
|
append(trace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
@@ -12,9 +12,6 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
|
||||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -41,6 +38,19 @@ sealed interface WeekStartPref {
|
|||||||
*/
|
*/
|
||||||
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the Agenda screen treats events that have already finished today: [SHOW]
|
||||||
|
* leaves them as-is (the historical behaviour), [DIM] fades them, [HIDE] drops
|
||||||
|
* them from the list entirely.
|
||||||
|
*/
|
||||||
|
enum class PastEventDisplay { SHOW, DIM, HIDE }
|
||||||
|
|
||||||
|
/** Parse a stored [PastEventDisplay.name]; unknown/null falls back to [default]. */
|
||||||
|
fun parsePastEventDisplay(
|
||||||
|
stored: String?,
|
||||||
|
default: PastEventDisplay = PastEventDisplay.SHOW,
|
||||||
|
): PastEventDisplay = PastEventDisplay.entries.firstOrNull { it.name == stored } ?: default
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
|
* Resolve to a concrete 24-hour flag. AUTO defers to [systemIs24Hour] (the
|
||||||
* device's `DateFormat.is24HourFormat` value).
|
* device's `DateFormat.is24HourFormat` value).
|
||||||
@@ -119,6 +129,31 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the Agenda screen treats events that already ended today. Defaults to
|
||||||
|
* [PastEventDisplay.SHOW] — the historical behaviour; users opt into dimming
|
||||||
|
* or hiding.
|
||||||
|
*/
|
||||||
|
val pastEventDisplay: Flow<PastEventDisplay> = store.data.map { prefs ->
|
||||||
|
prefs[PAST_EVENT_DISPLAY_KEY].toEnum(PastEventDisplay.SHOW)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||||
|
store.edit { it[PAST_EVENT_DISPLAY_KEY] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the month/week grids fade events that have already finished.
|
||||||
|
* Defaults to OFF — independent of the Agenda's [pastEventDisplay].
|
||||||
|
*/
|
||||||
|
val dimCompletedEvents: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[DIM_COMPLETED_EVENTS_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDimCompletedEvents(enabled: Boolean) {
|
||||||
|
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||||
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||||
@@ -185,6 +220,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether opening the new-event form focuses the title field and raises the
|
||||||
|
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||||
|
* gets a title, so this saves a tap; users who set the time/calendar first
|
||||||
|
* can turn it off. Only the create form auto-focuses — editing never does.
|
||||||
|
*/
|
||||||
|
val autofocusEventTitle: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[AUTOFOCUS_EVENT_TITLE_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAutofocusEventTitle(enabled: Boolean) {
|
||||||
|
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||||
* for users whose only calendar app this is, reminders are essential; the
|
* for users whose only calendar app this is, reminders are essential; the
|
||||||
@@ -227,35 +276,36 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default reminder lead time (minutes before start) prefilled on new
|
* The default reminder lead times (minutes before start) prefilled on new
|
||||||
* **timed** events. `null` = no default reminder — the prior behaviour, kept
|
* **timed** events. The empty list = no default reminder — the prior
|
||||||
* as the factory default so existing users aren't surprised by reminders they
|
* behaviour, kept as the factory default so existing users aren't surprised by
|
||||||
* never asked for. Stored as a string so "none" is distinct from a numeric
|
* reminders they never asked for. Stored as a comma-joined list of minutes, or
|
||||||
* value (and from an unset key, which is also "none"). Per-calendar overrides
|
* "none"/empty for no reminder (also the unset state). A legacy single value
|
||||||
* in [perCalendarReminderOverride] take precedence; all-day events instead use
|
* ("30") parses transparently to a one-element list. Per-calendar overrides in
|
||||||
|
* [perCalendarReminderOverride] take precedence; all-day events instead use
|
||||||
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
* [defaultAllDayReminderMinutes]. Resolve with [resolveDefaultReminder].
|
||||||
*/
|
*/
|
||||||
val defaultReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
val defaultReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||||
prefs[DEFAULT_REMINDER_KEY].toReminderMinutes()
|
prefs[DEFAULT_REMINDER_KEY].toReminderList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDefaultReminderMinutes(minutes: Int?) {
|
suspend fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||||
store.edit { it[DEFAULT_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
store.edit { it[DEFAULT_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default reminder lead time prefilled on new **all-day** events, in
|
* The default reminder lead times prefilled on new **all-day** events, in
|
||||||
* minutes before the start of the day. All-day events want day-scale lead
|
* minutes before the start of the day. All-day events want day-scale lead
|
||||||
* times ("1 day before"), so they have their own default rather than reusing
|
* times ("1 day before"), so they have their own default rather than reusing
|
||||||
* the timed one. `null` = no default. Per-calendar overrides do **not** apply
|
* the timed one. Empty list = no default. Per-calendar overrides do **not**
|
||||||
* to all-day events — they always use this global value.
|
* apply to all-day events — they always use this global value.
|
||||||
*/
|
*/
|
||||||
val defaultAllDayReminderMinutes: Flow<Int?> = store.data.map { prefs ->
|
val defaultAllDayReminderMinutes: Flow<List<Int>> = store.data.map { prefs ->
|
||||||
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderMinutes()
|
prefs[DEFAULT_ALLDAY_REMINDER_KEY].toReminderList()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
suspend fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||||
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes?.toString() ?: NONE }
|
store.edit { it[DEFAULT_ALLDAY_REMINDER_KEY] = minutes.toStoredReminders() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -292,21 +342,21 @@ class SettingsPrefs @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
* Per-calendar overrides of [defaultReminderMinutes] for **timed** events,
|
||||||
* keyed by calendar id. A calendar **present** in the map overrides the global
|
* keyed by calendar id. A calendar **present** in the map overrides the global
|
||||||
* timed default for its new events: a `null` value means "no reminder", an int
|
* timed default for its new events: an **empty** list means "no reminder", a
|
||||||
* means that lead time. A calendar **absent** from the map inherits the global
|
* non-empty list means those lead times. A calendar **absent** from the map
|
||||||
* default. Serialised as `id=value;id=value`, with `none` for an explicit
|
* inherits the global default. Serialised as `id=value;id=value`, where a value
|
||||||
* no-reminder override. (All-day events ignore this and use
|
* is a comma-joined minute list or `none` for an explicit no-reminder override.
|
||||||
* [defaultAllDayReminderMinutes].)
|
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||||
*/
|
*/
|
||||||
val perCalendarReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||||
reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
|
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
val current = reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
current.applyReminderOverride(calendarId, override)
|
current.applyOverride(calendarId, override)
|
||||||
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,19 +365,19 @@ class SettingsPrefs @Inject constructor(
|
|||||||
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
* events, with the same semantics as [perCalendarReminderOverride] (absent =
|
||||||
* inherit the global all-day default; present null = no reminder).
|
* inherit the global all-day default; present null = no reminder).
|
||||||
*/
|
*/
|
||||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, Int?>> = store.data.map { prefs ->
|
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||||
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setCalendarAllDayReminderOverride(
|
suspend fun setCalendarAllDayReminderOverride(
|
||||||
calendarId: Long,
|
calendarId: Long,
|
||||||
override: ReminderOverride,
|
override: CalendarReminderOverride,
|
||||||
) {
|
) {
|
||||||
store.edit { prefs ->
|
store.edit { prefs ->
|
||||||
val current =
|
val current =
|
||||||
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||||
current.applyReminderOverride(calendarId, override)
|
current.applyOverride(calendarId, override)
|
||||||
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,8 +443,11 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||||
|
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||||
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
|
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
@@ -437,30 +490,55 @@ data class BackupStatus(
|
|||||||
val consecutiveFailures: Int,
|
val consecutiveFailures: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||||
|
sealed interface CalendarReminderOverride {
|
||||||
|
/** No override — the calendar uses the global default. */
|
||||||
|
data object Inherit : CalendarReminderOverride
|
||||||
|
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||||
|
data object None : CalendarReminderOverride
|
||||||
|
/** Specific lead times in minutes before the event start (non-empty). */
|
||||||
|
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The lead time to prefill on a new event: the matching per-calendar override
|
* The lead times to prefill on a new event: the matching per-calendar override
|
||||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||||
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
* that kind. All-day events consult [allDayOverrides] / [allDayGlobal]; timed
|
||||||
* events consult [timedOverrides] / [timedGlobal]. `null` = no reminder. Pure so
|
* events consult [timedOverrides] / [timedGlobal]. The empty list = no reminder.
|
||||||
* it can be unit-tested.
|
* Pure so it can be unit-tested.
|
||||||
*/
|
*/
|
||||||
fun resolveDefaultReminder(
|
fun resolveDefaultReminder(
|
||||||
timedGlobal: Int?,
|
timedGlobal: List<Int>,
|
||||||
allDayGlobal: Int?,
|
allDayGlobal: List<Int>,
|
||||||
timedOverrides: Map<Long, Int?>,
|
timedOverrides: Map<Long, List<Int>>,
|
||||||
allDayOverrides: Map<Long, Int?>,
|
allDayOverrides: Map<Long, List<Int>>,
|
||||||
calendarId: Long?,
|
calendarId: Long?,
|
||||||
isAllDay: Boolean,
|
isAllDay: Boolean,
|
||||||
): Int? {
|
): List<Int> {
|
||||||
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
val overrides = if (isAllDay) allDayOverrides else timedOverrides
|
||||||
val global = if (isAllDay) allDayGlobal else timedGlobal
|
val global = if (isAllDay) allDayGlobal else timedGlobal
|
||||||
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
return if (calendarId != null && overrides.containsKey(calendarId)) {
|
||||||
overrides[calendarId]
|
overrides.getValue(calendarId)
|
||||||
} else {
|
} else {
|
||||||
global
|
global
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
||||||
|
* key; [None] and an empty [Minutes] both store the empty list).
|
||||||
|
*/
|
||||||
|
private fun MutableMap<Long, List<Int>>.applyOverride(
|
||||||
|
calendarId: Long,
|
||||||
|
override: CalendarReminderOverride,
|
||||||
|
) {
|
||||||
|
when (override) {
|
||||||
|
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||||
|
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
||||||
|
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||||
private const val WEEK_START_AUTO = "AUTO"
|
private const val WEEK_START_AUTO = "AUTO"
|
||||||
|
|
||||||
@@ -481,18 +559,38 @@ private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val NONE = "none"
|
private const val NONE = "none"
|
||||||
|
private const val ENTRY_SEP = ";"
|
||||||
|
private const val KEY_VALUE_SEP = "="
|
||||||
|
private const val LIST_SEP = ","
|
||||||
|
|
||||||
private fun String?.toReminderMinutes(): Int? = when (this) {
|
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||||
null, "", NONE -> null
|
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||||
else -> toIntOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendula's dialect of the shared per-target reminder-override codec: entries
|
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||||
* `id=minutes` joined by `;`, with `none` for an explicit no-reminder. These
|
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
|
||||||
* separators are the on-disk format and must not change without a migration.
|
* one-element list. Non-numeric parts are dropped defensively.
|
||||||
*/
|
*/
|
||||||
private val reminderOverrideCodec = ReminderOverrideCodec(entrySep = ";", keyValueSep = "=", noneToken = NONE)
|
private fun String?.toReminderList(): List<Int> = when {
|
||||||
|
this == null || isEmpty() || this == NONE -> emptyList()
|
||||||
|
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialise lead times for storage: "none" when empty, else comma-joined. */
|
||||||
|
private fun List<Int>.toStoredReminders(): String =
|
||||||
|
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||||
|
|
||||||
|
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
||||||
|
if (stored.isNullOrBlank()) return emptyMap()
|
||||||
|
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||||
|
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
|
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||||
|
id to parts[1].toReminderList()
|
||||||
|
}.toMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
||||||
|
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
||||||
|
|
||||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.content.pm.PackageManager
|
|||||||
import android.provider.CalendarContract
|
import android.provider.CalendarContract
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -16,6 +17,17 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The due alerts that should actually surface as notifications: everything
|
||||||
|
* except alerts whose calendar the user has disabled in-app (mirroring the
|
||||||
|
* event filtering in CalendarRepositoryImpl). The caller still marks the full
|
||||||
|
* due set fired, so suppressed alerts are not re-broadcast by the provider.
|
||||||
|
*/
|
||||||
|
internal fun postableAlerts(
|
||||||
|
due: List<ReminderAlert>,
|
||||||
|
disabledCalendarIds: Set<Long>,
|
||||||
|
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||||
* visible notifications (the Etar model — the provider broadcasts
|
* visible notifications (the Etar model — the provider broadcasts
|
||||||
@@ -32,6 +44,7 @@ class EventReminderReceiver : BroadcastReceiver() {
|
|||||||
@Inject lateinit var alertStore: ReminderAlertStore
|
@Inject lateinit var alertStore: ReminderAlertStore
|
||||||
@Inject lateinit var notifier: ReminderNotifier
|
@Inject lateinit var notifier: ReminderNotifier
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
|
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||||
@@ -46,7 +59,11 @@ class EventReminderReceiver : BroadcastReceiver() {
|
|||||||
if (settingsPrefs.remindersEnabled.first()) {
|
if (settingsPrefs.remindersEnabled.first()) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val due = alertStore.dueAlerts(now)
|
val due = alertStore.dueAlerts(now)
|
||||||
due.forEach { notifier.post(it) }
|
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||||
|
// Suppress reminders for disabled calendars, but still mark
|
||||||
|
// every due alert fired so the provider stops re-broadcasting
|
||||||
|
// the suppressed ones.
|
||||||
|
postableAlerts(due, disabled).forEach { notifier.post(it) }
|
||||||
alertStore.markFired(due.map { it.alertId }, now)
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
private const val EXTRA_ALERT_ID = "alert_id"
|
private const val EXTRA_ALERT_ID = "alert_id"
|
||||||
private const val EXTRA_EVENT_ID = "event_id"
|
private const val EXTRA_EVENT_ID = "event_id"
|
||||||
|
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||||
private const val EXTRA_BEGIN = "begin"
|
private const val EXTRA_BEGIN = "begin"
|
||||||
private const val EXTRA_END = "end"
|
private const val EXTRA_END = "end"
|
||||||
private const val EXTRA_TITLE = "title"
|
private const val EXTRA_TITLE = "title"
|
||||||
@@ -87,6 +88,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
this.action = action
|
this.action = action
|
||||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||||
|
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||||
putExtra(EXTRA_END, alert.endMillis)
|
putExtra(EXTRA_END, alert.endMillis)
|
||||||
putExtra(EXTRA_TITLE, alert.title)
|
putExtra(EXTRA_TITLE, alert.title)
|
||||||
@@ -113,6 +115,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
return ReminderAlert(
|
return ReminderAlert(
|
||||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||||
|
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||||
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
endMillis = intent.getLongExtra(EXTRA_END, 0L),
|
||||||
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import javax.inject.Singleton
|
|||||||
data class ReminderAlert(
|
data class ReminderAlert(
|
||||||
val alertId: Long,
|
val alertId: Long,
|
||||||
val eventId: Long,
|
val eventId: Long,
|
||||||
|
val calendarId: Long,
|
||||||
val beginMillis: Long,
|
val beginMillis: Long,
|
||||||
val endMillis: Long,
|
val endMillis: Long,
|
||||||
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
|
||||||
@@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor(
|
|||||||
ReminderAlert(
|
ReminderAlert(
|
||||||
alertId = c.getLong(0),
|
alertId = c.getLong(0),
|
||||||
eventId = c.getLong(1),
|
eventId = c.getLong(1),
|
||||||
beginMillis = c.getLong(2),
|
calendarId = c.getLong(2),
|
||||||
endMillis = c.getLong(3),
|
beginMillis = c.getLong(3),
|
||||||
title = c.getString(4).orEmpty(),
|
endMillis = c.getLong(4),
|
||||||
location = c.getString(5)?.takeIf { it.isNotBlank() },
|
title = c.getString(5).orEmpty(),
|
||||||
isAllDay = c.getInt(6) == 1,
|
location = c.getString(6)?.takeIf { it.isNotBlank() },
|
||||||
|
isAllDay = c.getInt(7) == 1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor(
|
|||||||
val PROJECTION = arrayOf(
|
val PROJECTION = arrayOf(
|
||||||
CalendarContract.CalendarAlerts._ID,
|
CalendarContract.CalendarAlerts._ID,
|
||||||
CalendarContract.CalendarAlerts.EVENT_ID,
|
CalendarContract.CalendarAlerts.EVENT_ID,
|
||||||
|
CalendarContract.CalendarAlerts.CALENDAR_ID,
|
||||||
CalendarContract.CalendarAlerts.BEGIN,
|
CalendarContract.CalendarAlerts.BEGIN,
|
||||||
CalendarContract.CalendarAlerts.END,
|
CalendarContract.CalendarAlerts.END,
|
||||||
CalendarContract.CalendarAlerts.TITLE,
|
CalendarContract.CalendarAlerts.TITLE,
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ data class EventInstance(
|
|||||||
val location: String?,
|
val location: String?,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this event has finished relative to [now] — its end is at or before
|
||||||
|
* the current instant. An in-progress event (already started but not yet ended)
|
||||||
|
* is *not* considered ended. All-day events end at the exclusive next-midnight,
|
||||||
|
* so they only count as ended once their day is fully over.
|
||||||
|
*/
|
||||||
|
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||||
|
|
||||||
data class EventDetail(
|
data class EventDetail(
|
||||||
val instance: EventInstance,
|
val instance: EventInstance,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
@@ -196,7 +196,9 @@ fun CalendarHost(
|
|||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
pendingDayIso = req.dateIso
|
||||||
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
|
// No widget source (an external date tap) roots over the default
|
||||||
|
// home view, so backing out of the day returns home then exits.
|
||||||
|
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
is WidgetNavRequest.OpenEvent -> {
|
is WidgetNavRequest.OpenEvent -> {
|
||||||
@@ -244,7 +246,7 @@ fun CalendarHost(
|
|||||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||||
// navigation, so it fades through rather than sliding — paging *within* a
|
// navigation, so it fades through rather than sliding — paging *within* a
|
||||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||||
val viewSwitch = fadeThrough()
|
val viewSwitch = calendarFadeThrough()
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = view,
|
targetState = view,
|
||||||
transitionSpec = { viewSwitch },
|
transitionSpec = { viewSwitch },
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
* detail-key channel and leave the base view untouched.)
|
* detail-key channel and leave the base view untouched.)
|
||||||
*/
|
*/
|
||||||
sealed interface WidgetNavRequest {
|
sealed interface WidgetNavRequest {
|
||||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
|
/**
|
||||||
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
|
* Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
|
||||||
|
* [source]. A null [source] means the request came from outside the app (a
|
||||||
|
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
|
||||||
|
* the default home view instead of a widget's view.
|
||||||
|
*/
|
||||||
|
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
|
||||||
|
|
||||||
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
||||||
data class OpenEvent(
|
data class OpenEvent(
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -53,20 +54,24 @@ 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.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
@@ -96,6 +101,7 @@ fun AgendaScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -178,6 +184,7 @@ fun AgendaScreen(
|
|||||||
}
|
}
|
||||||
AgendaContent(
|
AgendaContent(
|
||||||
state = state,
|
state = state,
|
||||||
|
pastDisplay = pastDisplay,
|
||||||
onRetry = viewModel::goToToday,
|
onRetry = viewModel::goToToday,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -275,6 +282,7 @@ private fun AgendaRangeBanner(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AgendaContent(
|
private fun AgendaContent(
|
||||||
state: AgendaUiState,
|
state: AgendaUiState,
|
||||||
|
pastDisplay: PastEventDisplay,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -284,19 +292,42 @@ private fun AgendaContent(
|
|||||||
is AgendaUiState.Failure -> Box(modifier) {
|
is AgendaUiState.Failure -> Box(modifier) {
|
||||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||||
}
|
}
|
||||||
is AgendaUiState.Success ->
|
is AgendaUiState.Success -> {
|
||||||
if (state.days.isEmpty()) {
|
val now by rememberCurrentMinute()
|
||||||
|
// Hiding drops finished events — and any day they leave empty; dimming
|
||||||
|
// keeps them but fades the row. Recomputed each minute so events fall
|
||||||
|
// away (or fade) as they end while the screen stays open.
|
||||||
|
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
|
state.days.mapNotNull { day ->
|
||||||
|
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||||
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.days
|
||||||
|
}
|
||||||
|
if (days.isEmpty()) {
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
AgendaList(
|
||||||
|
days = days,
|
||||||
|
today = state.today,
|
||||||
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
|
now = now,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaList(
|
private fun AgendaList(
|
||||||
state: AgendaUiState.Success,
|
days: List<AgendaDay>,
|
||||||
|
today: LocalDate,
|
||||||
|
dimPast: Boolean,
|
||||||
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -305,9 +336,9 @@ private fun AgendaList(
|
|||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
state.days.forEach { day ->
|
days.forEach { day ->
|
||||||
stickyHeader(key = "header-${day.date}") {
|
stickyHeader(key = "header-${day.date}") {
|
||||||
AgendaDayHeader(date = day.date, today = state.today)
|
AgendaDayHeader(date = day.date, today = today)
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = day.events,
|
items = day.events,
|
||||||
@@ -316,7 +347,8 @@ private fun AgendaList(
|
|||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
modifier = animateItemMotion(),
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
|
modifier = calendarAnimateItem(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -348,13 +380,14 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
|||||||
private fun AgendaEventRow(
|
private fun AgendaEventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
|
dimmed: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
modifier = modifier,
|
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
title = title,
|
title = title,
|
||||||
summary = agendaTimeSummary(event),
|
summary = agendaTimeSummary(event),
|
||||||
position = position,
|
position = position,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
@@ -53,6 +54,18 @@ class AgendaViewModel @Inject constructor(
|
|||||||
private val weekStartDay = settingsPrefs.weekStart
|
private val weekStartDay = settingsPrefs.weekStart
|
||||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How to treat events that already ended today (show / dim / hide). A display
|
||||||
|
* concern only, so it rides alongside the data state rather than re-querying;
|
||||||
|
* the screen combines it with a per-minute "now" to fade or drop past rows.
|
||||||
|
*/
|
||||||
|
val pastEventDisplay: StateFlow<PastEventDisplay> = settingsPrefs.pastEventDisplay
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = PastEventDisplay.SHOW,
|
||||||
|
)
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
|
|||||||
@@ -96,17 +96,17 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
|||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
@@ -253,7 +253,6 @@ private fun CalendarsList(
|
|||||||
title = stringResource(R.string.calendars_title),
|
title = stringResource(R.string.calendars_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
) {
|
||||||
// What the per-calendar / per-account switches below actually do.
|
// What the per-calendar / per-account switches below actually do.
|
||||||
HintText(stringResource(R.string.calendars_disable_hint))
|
HintText(stringResource(R.string.calendars_disable_hint))
|
||||||
@@ -665,8 +664,8 @@ private fun CalendarGroup(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = expanded,
|
visible = expanded,
|
||||||
enter = expandEnter(),
|
enter = calendarExpandEnter(),
|
||||||
exit = collapseExit(),
|
exit = calendarCollapseExit(),
|
||||||
) {
|
) {
|
||||||
Column(content = body)
|
Column(content = body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,22 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
|
||||||
|
/**
|
||||||
|
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||||
|
* - Keeps the hue (so users still recognise their calendars)
|
||||||
|
* - Caps saturation so harsh provider colors stop screaming
|
||||||
|
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
||||||
|
*/
|
||||||
|
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 calendar: a neutral chip holding a calendar glyph tinted
|
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||||
|
|||||||
@@ -37,9 +37,6 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,26 +1,56 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.BackEventCompat
|
||||||
|
import androidx.activity.compose.PredictiveBackHandler
|
||||||
import androidx.compose.animation.ContentTransform
|
import androidx.compose.animation.ContentTransform
|
||||||
|
import androidx.compose.animation.EnterTransition
|
||||||
|
import androidx.compose.animation.ExitTransition
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.lazy.LazyItemScope
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar-specific motion. The family's generic content transitions — section
|
* Whether the user has asked the system to remove animations (Settings →
|
||||||
* expand/collapse, item reveal, list relayout, cross-fade, predictive-back, and
|
* Accessibility → "Remove animations", which sets the global animator duration
|
||||||
* the reduce-motion check — now live in floret-kit's identity module
|
* scale to 0). Compose animations do *not* honour this platform flag on their
|
||||||
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
* own, so the shared motion helpers in this file check it and fall back to a
|
||||||
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
* quick cross-fade — opacity only, no spatial movement — to respect the
|
||||||
* here is only what's specific to paging the calendar grid: the directional
|
* vestibular intent of the setting while keeping state changes legible.
|
||||||
* month/week/day slide and the specs that feed it.
|
*
|
||||||
|
* Read once at composition; the scale changes rarely and only takes full effect
|
||||||
|
* after a process restart anyway.
|
||||||
*/
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberReduceMotion(): Boolean {
|
||||||
|
val resolver = LocalContext.current.contentResolver
|
||||||
|
return remember(resolver) {
|
||||||
|
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||||
@@ -68,3 +98,128 @@ fun calendarSlideTransition(
|
|||||||
return slideInHorizontally(spec) { w -> dir * w }
|
return slideInHorizontally(spec) { w -> dir * w }
|
||||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
|
||||||
|
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
|
||||||
|
* opacity, so it doubles as its own reduced-motion form.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun calendarFadeThrough(): ContentTransform {
|
||||||
|
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||||
|
return fadeIn(fade).togetherWith(fadeOut(fade))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter transition for a vertically-revealed section (expandable rows, inline
|
||||||
|
* fields): height grows from the top while fading in. Under reduced motion the
|
||||||
|
* height growth is dropped, leaving a quick fade.
|
||||||
|
*
|
||||||
|
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
|
||||||
|
* originally inlined in the event edit form, so every expandable surface in the
|
||||||
|
* app reveals the same way.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||||
|
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||||
|
return if (reduceMotion) {
|
||||||
|
fade
|
||||||
|
} else {
|
||||||
|
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
|
||||||
|
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||||
|
return if (reduceMotion) {
|
||||||
|
fade
|
||||||
|
} else {
|
||||||
|
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
|
||||||
|
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
|
||||||
|
* motion keeps the fade only.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||||
|
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||||
|
return if (reduceMotion) {
|
||||||
|
fade
|
||||||
|
} else {
|
||||||
|
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
|
||||||
|
* app's motion scheme instead of Compose's default spring. Returns a bare
|
||||||
|
* [Modifier] under reduced motion so rows snap into place. Requires the list to
|
||||||
|
* supply stable item keys.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
|
||||||
|
if (reduceMotion) {
|
||||||
|
Modifier
|
||||||
|
} else {
|
||||||
|
Modifier.animateItem(
|
||||||
|
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||||
|
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||||
|
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard Android predictive-back transform for a full-screen overlay: as the
|
||||||
|
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
||||||
|
* swiped edge and rounds its corners, previewing what's behind. Completing the
|
||||||
|
* gesture invokes [onBack]; cancelling springs it back.
|
||||||
|
*
|
||||||
|
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
|
||||||
|
* once and apply the returned [Modifier] to that screen's root so the preview
|
||||||
|
* respects the same back semantics. Under reduced motion the visual preview is
|
||||||
|
* skipped (the back still works); on API < 34 the system delivers no progress,
|
||||||
|
* so it degrades to a plain back.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.predictiveBack(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
reduceMotion: Boolean = rememberReduceMotion(),
|
||||||
|
): Modifier {
|
||||||
|
val progress = remember { Animatable(0f) }
|
||||||
|
var fromLeftEdge by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
PredictiveBackHandler(enabled = enabled) { events ->
|
||||||
|
try {
|
||||||
|
events.collect { event ->
|
||||||
|
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
|
||||||
|
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
||||||
|
}
|
||||||
|
onBack()
|
||||||
|
progress.snapTo(0f)
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
progress.animateTo(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reduceMotion) return this
|
||||||
|
return this.graphicsLayer {
|
||||||
|
val p = progress.value
|
||||||
|
val scale = 1f - 0.1f * p
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
|
||||||
|
shape = RoundedCornerShape(32.dp.toPx() * p)
|
||||||
|
clip = p > 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapping row of round colour swatches; the one matching [selected] is
|
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
||||||
|
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
||||||
|
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
||||||
|
* call site so it never reaches a release build. Non-interactive: it's a plain
|
||||||
|
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
||||||
|
*
|
||||||
|
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
||||||
|
* so it overlays the UI.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BoxScope.DebugRibbon() {
|
||||||
|
Text(
|
||||||
|
text = "DEBUG",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.zIndex(1f)
|
||||||
|
// Push the band out so its midline crosses the very corner, then
|
||||||
|
// rotate it to the classic 45° ribbon.
|
||||||
|
.offset(x = 36.dp, y = 24.dp)
|
||||||
|
.rotate(45f)
|
||||||
|
.background(Color(0xFFB23B00))
|
||||||
|
.width(140.dp)
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
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.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
|
||||||
|
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
|
||||||
|
* matches the card/grouped-row design language (not Material's outlined field).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DialogAmountField(
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
placeholder: String,
|
||||||
|
) {
|
||||||
|
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
|
||||||
|
// so anything lower vanishes.
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
InlineTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = { text ->
|
||||||
|
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
|
||||||
|
},
|
||||||
|
placeholder = placeholder,
|
||||||
|
textStyle = MaterialTheme.typography.titleMedium,
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(72.dp)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
|
||||||
|
@Composable
|
||||||
|
fun DialogUnitDropdown(
|
||||||
|
label: String,
|
||||||
|
entries: List<String>,
|
||||||
|
onPick: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
onClick = { open = true },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
Text(text = label, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||||
|
entries.forEachIndexed { index, entry ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(entry) },
|
||||||
|
onClick = {
|
||||||
|
onPick(index)
|
||||||
|
open = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LargeTopAppBar
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.ListItemDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
|
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.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position of a row within a grouped list, after the Android-15 settings
|
||||||
|
* pattern: a run of rows shares one rounded container, with full corners at the
|
||||||
|
* group's outer edges and small corners between, separated by small gaps.
|
||||||
|
*/
|
||||||
|
enum class Position { Top, Middle, Bottom, Alone }
|
||||||
|
|
||||||
|
/** Maps an index within a group of [count] rows to its [Position]. */
|
||||||
|
fun positionOf(index: Int, count: Int): Position = when {
|
||||||
|
count <= 1 -> Position.Alone
|
||||||
|
index == 0 -> Position.Top
|
||||||
|
index == count - 1 -> Position.Bottom
|
||||||
|
else -> Position.Middle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
|
||||||
|
* whose title shrinks into the bar (next to the back button) as the content
|
||||||
|
* scrolls. Content is a scrollable column that feeds the toolbar via nested
|
||||||
|
* scroll. Used by Settings and the calendar manager so they share one shell.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CollapsingScaffold(
|
||||||
|
title: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
snackbarHost: @Composable () -> Unit = {},
|
||||||
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
val scrollBehavior =
|
||||||
|
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier
|
||||||
|
.predictiveBack(onBack = onBack)
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
LargeTopAppBar(
|
||||||
|
title = { Text(title) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.settings_back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = actions,
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
snackbarHost = snackbarHost,
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
// Mark the scaffold's system-bar insets as consumed so the
|
||||||
|
// imePadding below adds only the keyboard height beyond them
|
||||||
|
// (max, not sum) — otherwise the nav-bar inset double-counts and
|
||||||
|
// leaves an empty strip above the keyboard.
|
||||||
|
.consumeWindowInsets(innerPadding)
|
||||||
|
.fillMaxSize()
|
||||||
|
// Paint the surface across the full area before imePadding carves
|
||||||
|
// into it, so any sliver above the keyboard reads as surface — not
|
||||||
|
// the dialog window's black — during the IME animation.
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
// Shrink the scroll viewport by the keyboard inset so a focused
|
||||||
|
// field (e.g. the custom-reminder amount) can scroll into view.
|
||||||
|
.imePadding()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(top = 8.dp, bottom = 24.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
||||||
|
* corner radii come from its [position] (so a run of rows reads as a single
|
||||||
|
* rounded card). Corners round further on press. A null [onClick] makes the
|
||||||
|
* row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
|
||||||
|
* summary to the M3 disabled emphasis while leaving the [trailing] control at
|
||||||
|
* full opacity — for rows that are present but switched off.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun GroupedRow(
|
||||||
|
title: String,
|
||||||
|
position: Position,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
summary: String? = null,
|
||||||
|
selected: Boolean = false,
|
||||||
|
dimmed: Boolean = false,
|
||||||
|
container: Color? = null,
|
||||||
|
minHeight: Dp = 72.dp,
|
||||||
|
leading: @Composable (() -> Unit)? = null,
|
||||||
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val interaction = remember { MutableInteractionSource() }
|
||||||
|
val pressed by interaction.collectIsPressedAsState()
|
||||||
|
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
||||||
|
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
||||||
|
val shape = when (position) {
|
||||||
|
Position.Alone -> RoundedCornerShape(full)
|
||||||
|
Position.Top -> RoundedCornerShape(
|
||||||
|
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
||||||
|
)
|
||||||
|
Position.Middle -> RoundedCornerShape(small)
|
||||||
|
Position.Bottom -> RoundedCornerShape(
|
||||||
|
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val gap = when (position) {
|
||||||
|
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||||
|
Position.Bottom, Position.Alone -> Modifier
|
||||||
|
}
|
||||||
|
val itemColors = if (selected) {
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
)
|
||||||
|
} else if (dimmed) {
|
||||||
|
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
|
||||||
|
// stays full-opacity so the toggle reads as live even on a faded row.
|
||||||
|
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
ListItemDefaults.colors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
headlineColor = muted,
|
||||||
|
leadingIconColor = muted,
|
||||||
|
supportingColor = muted,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||||
|
}
|
||||||
|
val item: @Composable () -> Unit = {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(title) },
|
||||||
|
supportingContent = summary?.let { text -> { Text(text) } },
|
||||||
|
leadingContent = leading,
|
||||||
|
trailingContent = trailing,
|
||||||
|
colors = itemColors,
|
||||||
|
modifier = Modifier.heightIn(min = minHeight),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val base = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.then(gap)
|
||||||
|
val containerColor = when {
|
||||||
|
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
container != null -> container
|
||||||
|
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
if (onClick != null) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
color = containerColor,
|
||||||
|
shape = shape,
|
||||||
|
interactionSource = interaction,
|
||||||
|
modifier = base,
|
||||||
|
) { item() }
|
||||||
|
} else {
|
||||||
|
Surface(color = containerColor, shape = shape, modifier = base) { item() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.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. This is the standard input across the app — we deliberately
|
||||||
|
* don't use Material's outlined/filled text fields, so anything that takes text
|
||||||
|
* (the event form, the calendar manager, dialogs) uses this inside a tonal
|
||||||
|
* [androidx.compose.material3.Surface].
|
||||||
|
*/
|
||||||
|
@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.None,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ private val LineThickness = 2.dp
|
|||||||
* the middle of a minute. Drives the "now" line in the day/week grids.
|
* the middle of a minute. Drives the "now" line in the day/week grids.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun rememberCurrentMinute(): State<Instant> {
|
fun rememberCurrentMinute(): State<Instant> {
|
||||||
val instant = remember { mutableStateOf(Clock.System.now()) }
|
val instant = remember { mutableStateOf(Clock.System.now()) }
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.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 — this is the
|
||||||
|
* only sanctioned selection-modal style (no radio rows, no bare text lists).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun OptionCard(
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
|
||||||
|
iconTint: Color = Color.Unspecified,
|
||||||
|
supportingText: String? = null,
|
||||||
|
selected: Boolean = false,
|
||||||
|
/** Label colour override, e.g. primary for an emphasised "Custom" 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cut-off instant before which a calendar event counts as "completed" and is
|
||||||
|
* drawn dimmed in the month/week grids — i.e. the current wall-clock minute when
|
||||||
|
* the "dim completed events" setting is on, or `null` when it is off (nothing
|
||||||
|
* dims). Provided per grid screen so only the event chips that read it recompose
|
||||||
|
* as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
|
||||||
|
*/
|
||||||
|
val LocalDimCutoff = compositionLocalOf<Instant?> { null }
|
||||||
|
|
||||||
|
/** Opacity applied to a completed/past event chip when it is dimmed. */
|
||||||
|
const val EventDimAlpha = 0.4f
|
||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.ui.common
|
|||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
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.ColumnScope
|
||||||
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.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -20,65 +22,81 @@ import androidx.compose.material3.SegmentedButtonDefaults
|
|||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import android.view.WindowManager
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
|
||||||
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.floret.reminders.ReminderUnit
|
|
||||||
import de.jeanlucmakiola.floret.reminders.decomposeReminderMinutes
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||||
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
|
||||||
* a "Custom" row that expands an inline number field plus a segmented unit
|
* picker is visually identical to a Settings sub-page and uses the full width.
|
||||||
* selector. Returns the choice as a [ReminderOverride].
|
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ReminderDefaultPicker(
|
fun FullScreenPicker(
|
||||||
title: String,
|
title: String,
|
||||||
presets: List<Int>,
|
|
||||||
selected: ReminderOverride,
|
|
||||||
allowInherit: Boolean,
|
|
||||||
onSelect: (ReminderOverride) -> Unit,
|
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
val selectedMinutes = (selected as? ReminderOverride.Minutes)?.minutes
|
Dialog(
|
||||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
onDismissRequest = onDismiss,
|
||||||
val seed = decomposeReminderMinutes(selectedMinutes?.takeIf { customSelected })
|
properties = DialogProperties(
|
||||||
|
usePlatformDefaultWidth = false,
|
||||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
decorFitsSystemWindows = false,
|
||||||
var amountText by rememberSaveable { mutableStateOf(seed.amount?.toString() ?: "") }
|
),
|
||||||
var unit by rememberSaveable { mutableStateOf(seed.unit) }
|
) {
|
||||||
|
// The dialog window pans by default when the keyboard opens, which —
|
||||||
val options = buildList {
|
// combined with the content's own imePadding — leaves a fixed black gap
|
||||||
if (allowInherit) add(ReminderOverride.Inherit)
|
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
||||||
add(ReminderOverride.None)
|
// full-screen and imePadding alone lifts the focused field.
|
||||||
presets.forEach { add(ReminderOverride.Minutes(it)) }
|
val view = LocalView.current
|
||||||
|
SideEffect {
|
||||||
|
(view.parent as? DialogWindowProvider)?.window
|
||||||
|
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||||
|
}
|
||||||
|
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
||||||
}
|
}
|
||||||
val rowCount = options.size + 1 // + the custom row
|
}
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
/**
|
||||||
|
* General single-select picker, full-screen: each option is a connected grouped
|
||||||
|
* row and the current one carries a check. Drop-in for the former dialog
|
||||||
|
* (theme, week start, language, …).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> OptionPicker(
|
||||||
|
title: String,
|
||||||
|
options: List<T>,
|
||||||
|
selected: T,
|
||||||
|
label: @Composable (T) -> String,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
header?.invoke(this)
|
||||||
options.forEachIndexed { index, option ->
|
options.forEachIndexed { index, option ->
|
||||||
val isSelected = option == selected
|
val isSelected = option == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = reminderOverrideLabel(option),
|
title = label(option),
|
||||||
position = positionOf(index, rowCount),
|
position = positionOf(index, options.size),
|
||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
trailing = if (isSelected) {
|
trailing = if (isSelected) {
|
||||||
{ SelectedCheck() }
|
{ SelectedCheck() }
|
||||||
@@ -91,30 +109,103 @@ fun ReminderDefaultPicker(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
|
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||||
|
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||||
|
* "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use
|
||||||
|
* default reminder" row above the list; choosing it defers to the global default
|
||||||
|
* and clears the checked times. With nothing checked and inherit off, the choice
|
||||||
|
* is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row
|
||||||
|
* expands an inline number field plus a unit selector to add an arbitrary lead
|
||||||
|
* time to the set. Changes apply live via [onSelect]; the user leaves via back.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ReminderDefaultPicker(
|
||||||
|
title: String,
|
||||||
|
presets: List<Int>,
|
||||||
|
selected: CalendarReminderOverride,
|
||||||
|
allowInherit: Boolean,
|
||||||
|
onSelect: (CalendarReminderOverride) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Optimistic local state: the chosen override is authoritative while the
|
||||||
|
// picker is open, so quick successive toggles compose on each other instead
|
||||||
|
// of racing the round-trip through the settings flow (which would drop a
|
||||||
|
// toggle made before the previous write echoes back). Seeded once on open;
|
||||||
|
// the picker leaves composition on dismiss, so reopening re-reads [selected].
|
||||||
|
var current by remember { mutableStateOf(selected) }
|
||||||
|
val inherits = current is CalendarReminderOverride.Inherit
|
||||||
|
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
|
// Presets plus any selected custom (non-preset) values, each as its own row.
|
||||||
|
val rows = presets + selectedMinutes.filter { it !in presets }.sorted()
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable { mutableStateOf("") }
|
||||||
|
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||||
|
|
||||||
|
fun apply(override: CalendarReminderOverride) {
|
||||||
|
current = override
|
||||||
|
onSelect(override)
|
||||||
|
}
|
||||||
|
// Emit the override for a new set of minutes: empty collapses to None, which
|
||||||
|
// (with inherit unchosen) reads as an explicit "no reminder".
|
||||||
|
fun emit(minutes: List<Int>) {
|
||||||
|
val norm = minutes.distinct().sorted()
|
||||||
|
apply(
|
||||||
|
if (norm.isEmpty()) {
|
||||||
|
CalendarReminderOverride.None
|
||||||
|
} else {
|
||||||
|
CalendarReminderOverride.Minutes(norm)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fun toggle(minute: Int) =
|
||||||
|
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
// Exclusive "use default" choice (per-calendar only): its own group above
|
||||||
|
// the multi-select list, since inheriting is mutually exclusive with
|
||||||
|
// picking specific times.
|
||||||
|
if (allowInherit) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.reminder_use_default),
|
||||||
|
position = Position.Alone,
|
||||||
|
selected = inherits,
|
||||||
|
trailing = if (inherits) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { apply(CalendarReminderOverride.Inherit) },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
val rowCount = rows.size + 1 // + the custom row
|
||||||
|
rows.forEachIndexed { index, minute ->
|
||||||
|
val checked = minute in selectedMinutes
|
||||||
|
GroupedRow(
|
||||||
|
title = reminderLeadTimeLabel(minute),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = checked,
|
||||||
|
trailing = { Checkbox(checked = checked, onCheckedChange = { toggle(minute) }) },
|
||||||
|
onClick = { toggle(minute) },
|
||||||
|
)
|
||||||
|
}
|
||||||
// When expanded, the Custom row connects downward into the editor card
|
// When expanded, the Custom row connects downward into the editor card
|
||||||
// so the two read as one grouped container (the per-calendar pattern).
|
// so the two read as one grouped container (the per-calendar pattern).
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = if (customSelected) {
|
title = stringResource(R.string.event_edit_reminder_custom),
|
||||||
stringResource(
|
position = if (customExpanded) Position.Top else positionOf(rows.size, rowCount),
|
||||||
R.string.reminder_custom_with_value,
|
|
||||||
reminderLeadTimeLabel(selectedMinutes!!),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.event_edit_reminder_custom)
|
|
||||||
},
|
|
||||||
position = if (customExpanded) Position.Top else positionOf(options.size, rowCount),
|
|
||||||
selected = customSelected,
|
|
||||||
trailing = if (customSelected) {
|
|
||||||
{ SelectedCheck() }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
onClick = { customExpanded = !customExpanded },
|
onClick = { customExpanded = !customExpanded },
|
||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = customExpanded,
|
visible = customExpanded,
|
||||||
enter = expandEnter(),
|
enter = calendarExpandEnter(),
|
||||||
exit = collapseExit(),
|
exit = calendarCollapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomReminderEditor(
|
CustomReminderEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -122,8 +213,9 @@ fun ReminderDefaultPicker(
|
|||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(ReminderOverride.Minutes(minutes))
|
emit(selectedMinutes + minutes)
|
||||||
onDismiss()
|
amountText = ""
|
||||||
|
customExpanded = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -260,7 +352,7 @@ fun AgendaRangePicker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
PickerDescription(description)
|
PickerDescription(description)
|
||||||
|
|
||||||
// Calendar-aligned windows.
|
// Calendar-aligned windows.
|
||||||
@@ -291,8 +383,8 @@ fun AgendaRangePicker(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = customExpanded,
|
visible = customExpanded,
|
||||||
enter = expandEnter(),
|
enter = calendarExpandEnter(),
|
||||||
exit = collapseExit(),
|
exit = calendarCollapseExit(),
|
||||||
) {
|
) {
|
||||||
CustomDaysEditor(
|
CustomDaysEditor(
|
||||||
amountText = amountText,
|
amountText = amountText,
|
||||||
@@ -361,9 +453,3 @@ fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
|||||||
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
|
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
|
||||||
}
|
}
|
||||||
|
|
||||||
@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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
|
||||||
|
|
||||||
/** Common reminder lead times offered as quick picks in the form and settings. */
|
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||||
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
||||||
|
|
||||||
|
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
||||||
|
enum class ReminderUnit(val minutesFactor: Int) {
|
||||||
|
Minutes(1),
|
||||||
|
Hours(60),
|
||||||
|
Days(1_440),
|
||||||
|
Weeks(10_080),
|
||||||
|
}
|
||||||
|
|
||||||
@StringRes
|
@StringRes
|
||||||
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
|
||||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A deliberately minimal, standalone surface for a captured crash report.
|
* A deliberately minimal, standalone surface for a captured crash report.
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.crash
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
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.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the user to send a captured crash report as an issue. The full report is
|
||||||
|
* shown verbatim in a scrollable panel — the user sees exactly what will leave
|
||||||
|
* the device before choosing to share it (the privacy backstop). [onSend] hands
|
||||||
|
* off to [submitCrashReport]; [onDismiss] declines.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CrashReportDialog(
|
||||||
|
report: String,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
|
||||||
|
title = { Text(stringResource(R.string.crash_dialog_title)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.crash_dialog_message),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = report,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.heightIn(max = 220.dp)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.crash
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hand the captured crash report off to the user's chosen channel: the report
|
||||||
|
* is copied to the clipboard (the reliable path for a full stack trace) and the
|
||||||
|
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
|
||||||
|
* sent automatically — the app has no network access; the user reviews and
|
||||||
|
* submits the issue themselves.
|
||||||
|
*/
|
||||||
|
fun submitCrashReport(context: Context, report: String) {
|
||||||
|
copyReportToClipboard(context, report)
|
||||||
|
val opened = runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
|
||||||
|
}.isSuccess
|
||||||
|
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
|
||||||
|
fun openIssueTracker(context: Context) {
|
||||||
|
val uri = context.getString(R.string.report_issue_choose_url).toUri()
|
||||||
|
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyReportToClipboard(context: Context, report: String) {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||||
|
val label = context.getString(R.string.crash_report_clip_label)
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
|
||||||
|
* can blow past URL-length limits, so an over-long one is left out of the link
|
||||||
|
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
|
||||||
|
* source of truth in that case.
|
||||||
|
*/
|
||||||
|
private fun buildIssueUri(context: Context, report: String) =
|
||||||
|
context.getString(R.string.report_issue_url).toUri().buildUpon()
|
||||||
|
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
|
||||||
|
.appendQueryParameter("body", buildIssueBody(context, report))
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun buildIssueBody(context: Context, report: String): String {
|
||||||
|
val block = if (report.length > MAX_URL_REPORT_CHARS) {
|
||||||
|
context.getString(R.string.crash_report_body_paste)
|
||||||
|
} else {
|
||||||
|
"```\n$report\n```"
|
||||||
|
}
|
||||||
|
return context.getString(R.string.crash_report_body_template, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the prefilled body comfortably under common URL-length ceilings. */
|
||||||
|
private const val MAX_URL_REPORT_CHARS = 6_000
|
||||||
@@ -76,9 +76,9 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
|||||||
@@ -91,13 +91,13 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
@@ -93,7 +95,6 @@ import androidx.compose.ui.text.TextStyle
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
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.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -115,30 +116,30 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
|||||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -417,8 +418,8 @@ private fun OptionalFormSection(
|
|||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
enter = expandEnter(),
|
enter = calendarExpandEnter(),
|
||||||
exit = collapseExit(),
|
exit = calendarCollapseExit(),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
||||||
}
|
}
|
||||||
@@ -480,6 +481,17 @@ private fun EventEditContent(
|
|||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
|
// Issue #10: on a fresh create form (setting on, nothing typed yet) focus the
|
||||||
|
// title and raise the keyboard so the user can type straight away. Keyed on
|
||||||
|
// Unit so it runs once per open — not on every keystroke — and guarded so
|
||||||
|
// editing or an imported/prefilled form never grabs focus.
|
||||||
|
val titleFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) {
|
||||||
|
runCatching { titleFocusRequester.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Per-event colour applicability for the resolved calendar:
|
// Per-event colour applicability for the resolved calendar:
|
||||||
// - palette calendars (Google, …) and local calendars always support it;
|
// - palette calendars (Google, …) and local calendars always support it;
|
||||||
// - synced calendars with no palette only when the user opted in, and even
|
// - synced calendars with no palette only when the user opted in, and even
|
||||||
@@ -507,6 +519,10 @@ private fun EventEditContent(
|
|||||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||||
textStyle = MaterialTheme.typography.headlineMedium
|
textStyle = MaterialTheme.typography.headlineMedium
|
||||||
.copy(fontWeight = FontWeight.SemiBold),
|
.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.focusRequester(titleFocusRequester),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Box(
|
Box(
|
||||||
@@ -1570,7 +1586,6 @@ private fun AddGuestInlineCard(
|
|||||||
value = text,
|
value = text,
|
||||||
onValueChange = { text = it },
|
onValueChange = { text = it },
|
||||||
placeholder = stringResource(R.string.event_edit_add_guest_hint),
|
placeholder = stringResource(R.string.event_edit_add_guest_hint),
|
||||||
capitalization = KeyboardCapitalization.None,
|
|
||||||
keyboardType = KeyboardType.Email,
|
keyboardType = KeyboardType.Email,
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
onImeAction = { commit() },
|
onImeAction = { commit() },
|
||||||
@@ -1857,7 +1872,6 @@ private fun InlineField(
|
|||||||
singleLine = singleLine,
|
singleLine = singleLine,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
capitalization = KeyboardCapitalization.None,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ data class EventEditUiState(
|
|||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
val hiddenFields: List<EventFormField> = emptyList(),
|
||||||
/** True while editing an existing event (the calendar is then fixed). */
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Whether to focus the title and raise the keyboard when this is a fresh
|
||||||
|
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||||
|
* on it for a new event with an empty title, never when editing/importing.
|
||||||
|
*/
|
||||||
|
val autofocusTitle: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* True while an edit changed the recurrence rule — the save-scope dialog
|
* True while an edit changed the recurrence rule — the save-scope dialog
|
||||||
* then drops "only this event" (an exception row can't carry a rule).
|
* then drops "only this event" (an exception row can't carry a rule).
|
||||||
|
|||||||
@@ -108,10 +108,10 @@ class EventEditViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private data class ReminderDefaults(
|
private data class ReminderDefaults(
|
||||||
val timed: Int?,
|
val timed: List<Int>,
|
||||||
val allDay: Int?,
|
val allDay: List<Int>,
|
||||||
val timedOverrides: Map<Long, Int?>,
|
val timedOverrides: Map<Long, List<Int>>,
|
||||||
val allDayOverrides: Map<Long, Int?>,
|
val allDayOverrides: Map<Long, List<Int>>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ExternalInputs(
|
private data class ExternalInputs(
|
||||||
@@ -119,6 +119,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
val defaultFields: Set<EventFormField>,
|
val defaultFields: Set<EventFormField>,
|
||||||
val allowColorOnUnsupported: Boolean,
|
val allowColorOnUnsupported: Boolean,
|
||||||
|
val autofocusTitle: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,6 +161,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
prefs.lastUsedCalendarId,
|
prefs.lastUsedCalendarId,
|
||||||
settingsPrefs.defaultFormFields,
|
settingsPrefs.defaultFormFields,
|
||||||
settingsPrefs.allowColorOnUnsupportedCalendars,
|
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||||
|
settingsPrefs.autofocusEventTitle,
|
||||||
::ExternalInputs,
|
::ExternalInputs,
|
||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
@@ -178,6 +180,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
|
autofocusTitle = external.autofocusTitle,
|
||||||
// A modified-occurrence exception can't carry its own rule, so
|
// A modified-occurrence exception can't carry its own rule, so
|
||||||
// the scope dialog drops "only this event" after a rule change.
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
recurrenceChanged = local.editTarget != null &&
|
recurrenceChanged = local.editTarget != null &&
|
||||||
@@ -257,7 +260,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
// Re-check after suspending: bail if the form closed or the user edited.
|
// Re-check after suspending: bail if the form closed or the user edited.
|
||||||
val form = _form.value ?: return@launch
|
val form = _form.value ?: return@launch
|
||||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||||
val default = resolveDefaultReminder(
|
val reminders = resolveDefaultReminder(
|
||||||
timedGlobal = defaults.timed,
|
timedGlobal = defaults.timed,
|
||||||
allDayGlobal = defaults.allDay,
|
allDayGlobal = defaults.allDay,
|
||||||
timedOverrides = defaults.timedOverrides,
|
timedOverrides = defaults.timedOverrides,
|
||||||
@@ -265,7 +268,6 @@ class EventEditViewModel @Inject constructor(
|
|||||||
calendarId = targetId,
|
calendarId = targetId,
|
||||||
isAllDay = form.isAllDay,
|
isAllDay = form.isAllDay,
|
||||||
)
|
)
|
||||||
val reminders = listOfNotNull(default)
|
|
||||||
_form.value = form.copy(reminders = reminders)
|
_form.value = form.copy(reminders = reminders)
|
||||||
// Surface the section so an auto-applied default is visible and
|
// Surface the section so an auto-applied default is visible and
|
||||||
// removable, even when Reminders isn't a default-shown field.
|
// removable, even when Reminders isn't a default-shown field.
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.floret.components.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles an opened/received `.ics` file. A single event is handed straight to
|
* Handles an opened/received `.ics` file. A single event is handed straight to
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -44,6 +46,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -60,18 +63,22 @@ 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.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -97,6 +104,14 @@ fun MonthScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
|
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||||
|
// screen while the setting is off (it stays null regardless of the tick).
|
||||||
|
val nowState = rememberCurrentMinute()
|
||||||
|
val dimCutoff by remember(dimCompleted) {
|
||||||
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -193,14 +208,16 @@ fun MonthScreen(
|
|||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
WeekdayHeader(weekStart = weekStart)
|
WeekdayHeader(weekStart = weekStart)
|
||||||
MonthContent(
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
state = state,
|
MonthContent(
|
||||||
slideDir = slideDir,
|
state = state,
|
||||||
onSwipeNext = goNext,
|
slideDir = slideDir,
|
||||||
onSwipePrev = goPrev,
|
onSwipeNext = goNext,
|
||||||
onRetry = jumpToToday,
|
onSwipePrev = goPrev,
|
||||||
onOpenDay = onOpenDay,
|
onRetry = jumpToToday,
|
||||||
)
|
onOpenDay = onOpenDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,6 +575,8 @@ private fun MonthBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
val shape = RoundedCornerShape(
|
val shape = RoundedCornerShape(
|
||||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
@@ -565,7 +584,7 @@ private fun MonthBar(
|
|||||||
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(pastelize(event.color, dark), shape)
|
.background(pastelize(event.color, dark), shape)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = DayOfWeek.MONDAY,
|
initialValue = DayOfWeek.MONDAY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Whether to fade events that have already finished (display concern only). */
|
||||||
|
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Lock
|
|
||||||
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.res.colorResource
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import de.jeanlucmakiola.calendula.R
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
|
||||||
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
|
||||||
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
|
||||||
* A lock badge overlays the corner when permission has been [denied].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun BrandHero(denied: Boolean) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(128.dp)
|
|
||||||
.clip(RoundedCornerShape(34.dp))
|
|
||||||
.background(colorResource(R.color.ic_launcher_background)),
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
|
||||||
contentDescription = stringResource(R.string.app_name),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (denied) {
|
|
||||||
// A small lock badge sits over the corner to signal "blocked".
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomEnd)
|
|
||||||
.offset(x = 10.dp, y = 10.dp)
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.errorContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Lock,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.colorResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
|
||||||
|
/** MD3 8dp spacing scale shared by the onboarding screens. */
|
||||||
|
internal object OnboardingSpace {
|
||||||
|
val xs = 8.dp
|
||||||
|
val sm = 16.dp
|
||||||
|
val md = 24.dp
|
||||||
|
val lg = 32.dp
|
||||||
|
val xl = 48.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared onboarding shell (calendar grant, reminder step): a scrollable,
|
||||||
|
* centred hero + body with the call(s) to action pinned to the bottom (clear
|
||||||
|
* of the navigation bar). The content slot is centred horizontally; benefit
|
||||||
|
* rows fill the width so their own content left-aligns.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun OnboardingScaffold(
|
||||||
|
hero: @Composable () -> Unit,
|
||||||
|
actions: @Composable ColumnScope.() -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
bottomBar = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = actions,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = OnboardingSpace.md),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||||
|
hero()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||||
|
body()
|
||||||
|
Spacer(Modifier.height(OnboardingSpace.md))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
||||||
|
@Composable
|
||||||
|
internal fun BrandHero(denied: Boolean) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(34.dp))
|
||||||
|
.background(colorResource(R.color.ic_launcher_background)),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (denied) {
|
||||||
|
// A small lock badge sits over the corner to signal "blocked".
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = 10.dp, y = 10.dp)
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Lock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
||||||
|
@Composable
|
||||||
|
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(OnboardingSpace.sm))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = body,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.permission
|
package de.jeanlucmakiola.calendula.ui.permission
|
||||||
|
|
||||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
|||||||
@@ -42,23 +42,22 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
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.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -102,7 +101,6 @@ fun SearchScreen(
|
|||||||
value = query,
|
value = query,
|
||||||
onValueChange = viewModel::setQuery,
|
onValueChange = viewModel::setQuery,
|
||||||
placeholder = stringResource(R.string.search_hint),
|
placeholder = stringResource(R.string.search_hint),
|
||||||
capitalization = KeyboardCapitalization.None,
|
|
||||||
imeAction = ImeAction.Search,
|
imeAction = ImeAction.Search,
|
||||||
onImeAction = { keyboard?.hide() },
|
onImeAction = { keyboard?.hide() },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -177,7 +175,7 @@ private fun SearchResults(
|
|||||||
SearchResultRow(
|
SearchResultRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, events.size),
|
position = positionOf(index, events.size),
|
||||||
modifier = animateItemMotion(),
|
modifier = calendarAnimateItem(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import org.xmlpull.v1.XmlPullParser
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
|
||||||
|
*
|
||||||
|
* That file is the single source of truth for which languages we ship: dropping
|
||||||
|
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
|
||||||
|
* language show up here and in the system per-app-language settings, with no
|
||||||
|
* other code change. The system-default choice is represented as `null`.
|
||||||
|
*
|
||||||
|
* On API 33+ this delegates to the platform per-app-languages API; below that
|
||||||
|
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
||||||
|
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
||||||
|
* activity, which re-reads the current value for the picker.
|
||||||
|
*/
|
||||||
|
object AppLanguage {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The BCP-47 tags the app ships translations for, in declaration order, as
|
||||||
|
* listed in locales_config.xml. Returns whatever could be parsed; a missing
|
||||||
|
* or malformed config yields an empty list (the picker then offers only the
|
||||||
|
* system-default entry rather than crashing).
|
||||||
|
*/
|
||||||
|
fun supportedTags(context: Context): List<String> {
|
||||||
|
val tags = mutableListOf<String>()
|
||||||
|
val parser = context.resources.getXml(R.xml.locales_config)
|
||||||
|
try {
|
||||||
|
var event = parser.eventType
|
||||||
|
while (event != XmlPullParser.END_DOCUMENT) {
|
||||||
|
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
|
||||||
|
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
|
||||||
|
}
|
||||||
|
event = parser.next()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Fall back to whatever was parsed before the failure.
|
||||||
|
} finally {
|
||||||
|
parser.close()
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
||||||
|
fun currentTag(): String? {
|
||||||
|
val locales = AppCompatDelegate.getApplicationLocales()
|
||||||
|
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
||||||
|
fun apply(tag: String?) {
|
||||||
|
val locales = if (tag == null) {
|
||||||
|
LocaleListCompat.getEmptyLocaleList()
|
||||||
|
} else {
|
||||||
|
LocaleListCompat.forLanguageTags(tag)
|
||||||
|
}
|
||||||
|
AppCompatDelegate.setApplicationLocales(locales)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The autonym for a tag — the language's own name in its own script, e.g.
|
||||||
|
* "Deutsch", "English", "Français" — so users find their language regardless
|
||||||
|
* of the current UI language. Capitalised per the language's own rules.
|
||||||
|
*/
|
||||||
|
fun displayName(tag: String): String {
|
||||||
|
val locale = Locale.forLanguageTag(tag)
|
||||||
|
return locale.getDisplayName(locale)
|
||||||
|
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -44,14 +45,16 @@ import androidx.compose.material.icons.filled.ExpandLess
|
|||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material.icons.filled.Gavel
|
import androidx.compose.material.icons.filled.Gavel
|
||||||
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.Translate
|
import androidx.compose.material.icons.filled.Translate
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -69,7 +72,6 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.colorResource
|
import androidx.compose.ui.res.colorResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.vectorResource
|
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -82,33 +84,32 @@ import androidx.lifecycle.LifecycleEventObserver
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.floret.components.AboutCard
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.floret.components.AboutLink
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
|
||||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||||
import de.jeanlucmakiola.floret.components.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
@@ -192,7 +193,7 @@ private fun SettingsHub(
|
|||||||
onOpenSection: (SettingsSection) -> Unit,
|
onOpenSection: (SettingsSection) -> Unit,
|
||||||
onManageCalendars: () -> Unit,
|
onManageCalendars: () -> Unit,
|
||||||
) {
|
) {
|
||||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
|
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
||||||
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
|
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -299,7 +300,7 @@ private fun LanguageRow(position: Position) {
|
|||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
|
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
|
||||||
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
@@ -312,7 +313,6 @@ private fun LanguageRow(position: Position) {
|
|||||||
if (showDialog) {
|
if (showDialog) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_language),
|
title = stringResource(R.string.settings_language),
|
||||||
predictiveBack = true,
|
|
||||||
options = options,
|
options = options,
|
||||||
selected = current,
|
selected = current,
|
||||||
label = { languageLabel(it) },
|
label = { languageLabel(it) },
|
||||||
@@ -339,30 +339,83 @@ private fun LanguageRow(position: Position) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AboutCard() {
|
private fun AboutCard() {
|
||||||
// The card layout lives in floret-kit (components.AboutCard); Calendula
|
val context = LocalContext.current
|
||||||
// supplies its own logo, author and the source / licence / support links.
|
val sourceUrl = stringResource(R.string.about_source_url)
|
||||||
AboutCard(
|
val licenseUrl = stringResource(R.string.about_license_url)
|
||||||
logo = { AppLogo() },
|
val supportUrl = stringResource(R.string.about_support_url)
|
||||||
appName = stringResource(R.string.app_name),
|
|
||||||
author = stringResource(R.string.settings_about_author),
|
Surface(
|
||||||
primaryLinks = listOf(
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
AboutLink(
|
shape = RoundedCornerShape(24.dp),
|
||||||
icon = ImageVector.vectorResource(R.drawable.ic_gitea),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = stringResource(R.string.settings_about_source),
|
) {
|
||||||
url = stringResource(R.string.about_source_url),
|
Column(
|
||||||
),
|
modifier = Modifier
|
||||||
AboutLink(
|
.fillMaxWidth()
|
||||||
icon = Icons.Default.Gavel,
|
.padding(16.dp),
|
||||||
label = stringResource(R.string.settings_license),
|
) {
|
||||||
url = stringResource(R.string.about_license_url),
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
),
|
AppLogo()
|
||||||
),
|
Spacer(Modifier.width(16.dp))
|
||||||
highlightLink = AboutLink(
|
Column(Modifier.weight(1f)) {
|
||||||
icon = Icons.Default.Favorite,
|
Text(
|
||||||
label = stringResource(R.string.settings_about_support),
|
text = stringResource(R.string.app_name),
|
||||||
url = stringResource(R.string.about_support_url),
|
style = MaterialTheme.typography.titleLarge,
|
||||||
),
|
)
|
||||||
)
|
Text(
|
||||||
|
text = stringResource(R.string.settings_about_author),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { openUrl(context, sourceUrl) },
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_gitea),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.settings_about_source))
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { openUrl(context, licenseUrl) },
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Gavel,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.settings_license))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { openUrl(context, supportUrl) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Favorite,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(stringResource(R.string.settings_about_support))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Plain centred version mark at the foot of the settings list (no card). */
|
/** Plain centred version mark at the foot of the settings list (no card). */
|
||||||
@@ -423,11 +476,11 @@ private fun AppearanceScreen(
|
|||||||
var showDefaultView by remember { mutableStateOf(false) }
|
var showDefaultView by remember { mutableStateOf(false) }
|
||||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||||
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
) {
|
||||||
// Theme & colour
|
// Theme & colour
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -482,7 +535,7 @@ private fun AppearanceScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_hour_lines),
|
title = stringResource(R.string.settings_hour_lines),
|
||||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||||
position = Position.Bottom,
|
position = Position.Middle,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.showHourLines,
|
checked = state.showHourLines,
|
||||||
@@ -491,16 +544,36 @@ private fun AppearanceScreen(
|
|||||||
},
|
},
|
||||||
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_dim_completed),
|
||||||
|
summary = stringResource(R.string.settings_dim_completed_summary),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.dimCompletedEvents,
|
||||||
|
onCheckedChange = viewModel::setDimCompletedEvents,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setDimCompletedEvents(!state.dimCompletedEvents) },
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// Agenda
|
// Agenda — the only group labelled, so its agenda-specific rows are easy
|
||||||
|
// to pick out among the otherwise unheadered Appearance settings.
|
||||||
|
SectionHeader(stringResource(R.string.settings_agenda_header))
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_agenda_range),
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
summary = agendaRangeLabel(state.agendaScreenRange),
|
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
onClick = { showAgendaScreenRange = true },
|
onClick = { showAgendaScreenRange = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_past_events),
|
||||||
|
summary = pastEventDisplayLabel(state.pastEventDisplay),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showPastEvents = true },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_agenda_widget_range),
|
title = stringResource(R.string.settings_agenda_widget_range),
|
||||||
summary = agendaRangeLabel(state.agendaWidgetRange),
|
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||||
@@ -524,7 +597,6 @@ private fun AppearanceScreen(
|
|||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
predictiveBack = true,
|
|
||||||
options = ThemeMode.entries,
|
options = ThemeMode.entries,
|
||||||
selected = state.themeMode,
|
selected = state.themeMode,
|
||||||
label = { themeLabel(it) },
|
label = { themeLabel(it) },
|
||||||
@@ -535,7 +607,6 @@ private fun AppearanceScreen(
|
|||||||
if (showWeekStart) {
|
if (showWeekStart) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
predictiveBack = true,
|
|
||||||
options = WEEK_START_OPTIONS,
|
options = WEEK_START_OPTIONS,
|
||||||
selected = state.weekStart,
|
selected = state.weekStart,
|
||||||
label = { weekStartLabel(it) },
|
label = { weekStartLabel(it) },
|
||||||
@@ -564,7 +635,6 @@ private fun AppearanceScreen(
|
|||||||
if (showTimeFormat) {
|
if (showTimeFormat) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_time_format),
|
title = stringResource(R.string.settings_time_format),
|
||||||
predictiveBack = true,
|
|
||||||
options = TimeFormatPref.entries,
|
options = TimeFormatPref.entries,
|
||||||
selected = state.timeFormat,
|
selected = state.timeFormat,
|
||||||
label = { timeFormatLabel(it) },
|
label = { timeFormatLabel(it) },
|
||||||
@@ -572,10 +642,19 @@ private fun AppearanceScreen(
|
|||||||
onDismiss = { showTimeFormat = false },
|
onDismiss = { showTimeFormat = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showPastEvents) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_past_events),
|
||||||
|
options = PastEventDisplay.entries,
|
||||||
|
selected = state.pastEventDisplay,
|
||||||
|
label = { pastEventDisplayLabel(it) },
|
||||||
|
onSelect = viewModel::setPastEventDisplay,
|
||||||
|
onDismiss = { showPastEvents = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showDefaultView) {
|
if (showDefaultView) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_default_view),
|
title = stringResource(R.string.settings_default_view),
|
||||||
predictiveBack = true,
|
|
||||||
options = IMPLEMENTED_VIEWS,
|
options = IMPLEMENTED_VIEWS,
|
||||||
selected = state.defaultView,
|
selected = state.defaultView,
|
||||||
label = { stringResource(it.labelRes) },
|
label = { stringResource(it.labelRes) },
|
||||||
@@ -594,7 +673,6 @@ private fun EventFormScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_event_form),
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.settings_form_fields_hint),
|
text = stringResource(R.string.settings_form_fields_hint),
|
||||||
@@ -628,6 +706,30 @@ private fun EventFormScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-focus the title on a new event (issue #10) — on by default, since
|
||||||
|
// most events get a title; raising the keyboard saves a tap. Off lets you
|
||||||
|
// set the time/calendar first without the keyboard in the way.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_autofocus_title),
|
||||||
|
summary = stringResource(R.string.settings_autofocus_title_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Keyboard,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.autofocusEventTitle,
|
||||||
|
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||||
|
)
|
||||||
|
|
||||||
// Per-event colour on calendars that publish no colour set (some
|
// Per-event colour on calendars that publish no colour set (some
|
||||||
// CalDAV) — off by default, with the honest caveat that the colour may
|
// CalDAV) — off by default, with the honest caveat that the colour may
|
||||||
// not survive their next sync. Local and palette calendars ignore it.
|
// not survive their next sync. Local and palette calendars ignore it.
|
||||||
@@ -705,7 +807,6 @@ private fun NotificationsScreen(
|
|||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_notifications),
|
title = stringResource(R.string.settings_section_notifications),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
predictiveBack = true,
|
|
||||||
) {
|
) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_reminders),
|
title = stringResource(R.string.settings_reminders),
|
||||||
@@ -796,8 +897,8 @@ private fun NotificationsScreen(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = calendarSectionExpanded,
|
visible = calendarSectionExpanded,
|
||||||
enter = expandEnter(),
|
enter = calendarExpandEnter(),
|
||||||
exit = collapseExit(),
|
exit = calendarCollapseExit(),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
state.writableCalendars.forEach { calendar ->
|
state.writableCalendars.forEach { calendar ->
|
||||||
@@ -826,8 +927,8 @@ private fun NotificationsScreen(
|
|||||||
)
|
)
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = expanded,
|
visible = expanded,
|
||||||
enter = expandEnter(),
|
enter = calendarExpandEnter(),
|
||||||
exit = collapseExit(),
|
exit = calendarCollapseExit(),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
||||||
@@ -855,7 +956,6 @@ private fun NotificationsScreen(
|
|||||||
if (showSnooze) {
|
if (showSnooze) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
predictiveBack = true,
|
|
||||||
options = SNOOZE_PRESETS,
|
options = SNOOZE_PRESETS,
|
||||||
selected = state.snoozeMinutes,
|
selected = state.snoozeMinutes,
|
||||||
label = { snoozeDurationLabel(it) },
|
label = { snoozeDurationLabel(it) },
|
||||||
@@ -869,7 +969,7 @@ private fun NotificationsScreen(
|
|||||||
presets = REMINDER_PRESETS,
|
presets = REMINDER_PRESETS,
|
||||||
selected = state.defaultReminderMinutes.toReminderChoice(),
|
selected = state.defaultReminderMinutes.toReminderChoice(),
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesOrNull()) },
|
onSelect = { viewModel.setDefaultReminderMinutes(it.toMinutesList()) },
|
||||||
onDismiss = { showDefaultReminder = false },
|
onDismiss = { showDefaultReminder = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -879,7 +979,7 @@ private fun NotificationsScreen(
|
|||||||
presets = ALLDAY_REMINDER_PRESETS,
|
presets = ALLDAY_REMINDER_PRESETS,
|
||||||
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
selected = state.defaultAllDayReminderMinutes.toReminderChoice(),
|
||||||
allowInherit = false,
|
allowInherit = false,
|
||||||
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesOrNull()) },
|
onSelect = { viewModel.setDefaultAllDayReminderMinutes(it.toMinutesList()) },
|
||||||
onDismiss = { showAllDayReminder = false },
|
onDismiss = { showAllDayReminder = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -928,13 +1028,13 @@ private fun NotificationsScreen(
|
|||||||
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
/** Which calendar + event kind a per-calendar reminder-override dialog targets. */
|
||||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||||
|
|
||||||
/** A global default (null = none) as a picker choice for selection highlighting. */
|
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
||||||
private fun Int?.toReminderChoice(): ReminderOverride =
|
private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
|
||||||
if (this == null) ReminderOverride.None else ReminderOverride.Minutes(this)
|
if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||||
|
|
||||||
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||||
private fun ReminderOverride.toMinutesOrNull(): Int? =
|
private fun CalendarReminderOverride.toMinutesList(): List<Int> =
|
||||||
(this as? ReminderOverride.Minutes)?.minutes
|
(this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||||
@@ -1009,27 +1109,33 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
|
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
|
||||||
private fun Map<Long, Int?>.choiceFor(calendarId: Long): ReminderOverride = when {
|
private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||||
!containsKey(calendarId) -> ReminderOverride.Inherit
|
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||||
this[calendarId] == null -> ReminderOverride.None
|
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||||
else -> ReminderOverride.Minutes(this.getValue(calendarId)!!)
|
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Label for a global-default choice: null → "None", else the lead time. */
|
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderChoiceLabel(minutes: Int?): String =
|
private fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||||
if (minutes == null) stringResource(R.string.reminder_none) else reminderLeadTimeLabel(minutes)
|
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
||||||
|
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
|
||||||
|
// than a (non-composable) joinToString transform.
|
||||||
|
val parts = ArrayList<String>(minutes.size)
|
||||||
|
for (m in minutes) parts.add(reminderLeadTimeLabel(m))
|
||||||
|
return parts.joinToString(", ")
|
||||||
|
}
|
||||||
|
|
||||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun calendarOverrideSummary(
|
private fun calendarOverrideSummary(
|
||||||
choice: ReminderOverride,
|
choice: CalendarReminderOverride,
|
||||||
globalDefault: Int?,
|
globalDefault: List<Int>,
|
||||||
): String = when (choice) {
|
): String = when (choice) {
|
||||||
ReminderOverride.Inherit ->
|
CalendarReminderOverride.Inherit ->
|
||||||
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is ReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1102,6 +1208,26 @@ private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** A small primary-coloured group label, matching the Calendars settings screen. */
|
||||||
|
@Composable
|
||||||
|
private fun SectionHeader(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun pastEventDisplayLabel(mode: PastEventDisplay): String = stringResource(
|
||||||
|
when (mode) {
|
||||||
|
PastEventDisplay.SHOW -> R.string.settings_past_events_show
|
||||||
|
PastEventDisplay.DIM -> R.string.settings_past_events_dim
|
||||||
|
PastEventDisplay.HIDE -> R.string.settings_past_events_hide
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun languageLabel(tag: String?): String =
|
private fun languageLabel(tag: String?): String =
|
||||||
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
if (tag == null) stringResource(R.string.settings_language_auto) else AppLanguage.displayName(tag)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
@@ -24,6 +25,10 @@ data class SettingsUiState(
|
|||||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||||
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||||
val showHourLines: Boolean = false,
|
val showHourLines: Boolean = false,
|
||||||
|
/** How the Agenda screen treats events that already ended today. */
|
||||||
|
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||||
|
/** Whether the month/week grids fade events that have already finished. */
|
||||||
|
val dimCompletedEvents: Boolean = false,
|
||||||
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
/** How far ahead the agenda widget shows events (v2.11). */
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
@@ -34,27 +39,29 @@ data class SettingsUiState(
|
|||||||
val defaultView: CalendarView = CalendarView.Week,
|
val defaultView: CalendarView = CalendarView.Week,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
|
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||||
|
val autofocusEventTitle: Boolean = true,
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* The default reminder lead time (minutes) prefilled on new timed events;
|
* The default reminder lead times (minutes) prefilled on new timed events;
|
||||||
* null = no default reminder. Per-calendar overrides take precedence.
|
* empty = no default reminder. Per-calendar overrides take precedence.
|
||||||
*/
|
*/
|
||||||
val defaultReminderMinutes: Int? = null,
|
val defaultReminderMinutes: List<Int> = emptyList(),
|
||||||
/** The default reminder lead time prefilled on new all-day events; null = none. */
|
/** The default reminder lead times prefilled on new all-day events; empty = none. */
|
||||||
val defaultAllDayReminderMinutes: Int? = null,
|
val defaultAllDayReminderMinutes: List<Int> = emptyList(),
|
||||||
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
/** Wall-clock time (minutes from midnight) all-day reminders fire at; default 09:00. */
|
||||||
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
val allDayReminderTimeMinutes: Int = SettingsPrefs.DEFAULT_ALLDAY_REMINDER_TIME,
|
||||||
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
|
/** How long the notification "Snooze" action defers a reminder; default 10 min. */
|
||||||
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
|
val snoozeMinutes: Int = SettingsPrefs.DEFAULT_SNOOZE_MINUTES,
|
||||||
/**
|
/**
|
||||||
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
* Per-calendar overrides of [defaultReminderMinutes] for timed events: a
|
||||||
* calendar present in the map overrides the global default (null value = no
|
* calendar present in the map overrides the global default (empty value = no
|
||||||
* reminder); absent = inherit the global default.
|
* reminder); absent = inherit the global default.
|
||||||
*/
|
*/
|
||||||
val perCalendarReminderOverride: Map<Long, Int?> = emptyMap(),
|
val perCalendarReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||||
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
/** Per-calendar overrides of [defaultAllDayReminderMinutes] for all-day events. */
|
||||||
val perCalendarAllDayReminderOverride: Map<Long, Int?> = emptyMap(),
|
val perCalendarAllDayReminderOverride: Map<Long, List<Int>> = emptyMap(),
|
||||||
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
/** Writable calendars, shown as per-calendar reminder-override rows. */
|
||||||
val writableCalendars: List<CalendarSource> = emptyList(),
|
val writableCalendars: List<CalendarSource> = emptyList(),
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
@@ -20,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
@@ -96,15 +98,24 @@ class SettingsViewModel @Inject constructor(
|
|||||||
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
||||||
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
||||||
},
|
},
|
||||||
prefs.agendaShowRangeBar,
|
combine(
|
||||||
) { base, defaults, overrides, views, showRangeBar ->
|
prefs.agendaShowRangeBar,
|
||||||
|
prefs.autofocusEventTitle,
|
||||||
|
prefs.pastEventDisplay,
|
||||||
|
prefs.dimCompletedEvents,
|
||||||
|
::MiscSettings,
|
||||||
|
),
|
||||||
|
) { base, defaults, overrides, views, misc ->
|
||||||
base.copy(
|
base.copy(
|
||||||
defaultView = views.defaultView,
|
defaultView = views.defaultView,
|
||||||
agendaScreenRange = views.agendaScreenRange,
|
agendaScreenRange = views.agendaScreenRange,
|
||||||
agendaWidgetRange = views.agendaWidgetRange,
|
agendaWidgetRange = views.agendaWidgetRange,
|
||||||
timeFormat = views.timeFormat,
|
timeFormat = views.timeFormat,
|
||||||
showHourLines = views.showHourLines,
|
showHourLines = views.showHourLines,
|
||||||
agendaShowRangeBar = showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
|
dimCompletedEvents = misc.dimCompletedEvents,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -128,15 +139,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
private data class ReminderDefaults(
|
private data class ReminderDefaults(
|
||||||
val allowColor: Boolean,
|
val allowColor: Boolean,
|
||||||
val defaultReminder: Int?,
|
val defaultReminder: List<Int>,
|
||||||
val allDayReminder: Int?,
|
val allDayReminder: List<Int>,
|
||||||
val allDayReminderTime: Int,
|
val allDayReminderTime: Int,
|
||||||
val snoozeMinutes: Int,
|
val snoozeMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class ReminderOverrides(
|
private data class ReminderOverrides(
|
||||||
val timed: Map<Long, Int?>,
|
val timed: Map<Long, List<Int>>,
|
||||||
val allDay: Map<Long, Int?>,
|
val allDay: Map<Long, List<Int>>,
|
||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,6 +159,13 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val showHourLines: Boolean,
|
val showHourLines: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class MiscSettings(
|
||||||
|
val showRangeBar: Boolean,
|
||||||
|
val autofocusEventTitle: Boolean,
|
||||||
|
val pastEventDisplay: PastEventDisplay,
|
||||||
|
val dimCompletedEvents: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
@@ -201,6 +219,26 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setPastEventDisplay(mode)
|
||||||
|
// The agenda widget honours this setting too, so push it into each
|
||||||
|
// instance's Glance state and recompose — same reliable-update path as
|
||||||
|
// setAgendaWidgetRange (updateAll alone won't re-run the data preamble).
|
||||||
|
widgetRefreshMutex.withLock {
|
||||||
|
val manager = GlanceAppWidgetManager(appContext)
|
||||||
|
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||||
|
updateAppWidgetState(appContext, id) { it[AGENDA_PAST_DISPLAY_KEY] = mode.name }
|
||||||
|
}
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDimCompletedEvents(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setDimCompletedEvents(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDefaultView(view: CalendarView) {
|
fun setDefaultView(view: CalendarView) {
|
||||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||||
}
|
}
|
||||||
@@ -213,11 +251,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDefaultReminderMinutes(minutes: Int?) {
|
fun setAutofocusEventTitle(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDefaultAllDayReminderMinutes(minutes: Int?) {
|
fun setDefaultAllDayReminderMinutes(minutes: List<Int>) {
|
||||||
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
viewModelScope.launch { prefs.setDefaultAllDayReminderMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,11 +271,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
|
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) {
|
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||||
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,49 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.theme
|
package de.jeanlucmakiola.calendula.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
|
import androidx.compose.material3.MotionScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calendula's theme: the family's [FloretExpressiveTheme] machinery — dynamic
|
* App theme. Honors:
|
||||||
* colour on API 31+, system light/dark, and the **standard** motion scheme
|
* - System light/dark.
|
||||||
* (spring choreography without the overshoot) — fed with Calendula's own
|
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
||||||
* identity: its seed-derived fallback schemes ([CalendulaLightFallback] /
|
* derived from [CalendulaSeed].
|
||||||
* [CalendulaDarkFallback]) and [CalendulaTypography].
|
|
||||||
*
|
*
|
||||||
* The mechanics live in floret-kit's identity module; the look stays here, so a
|
* The Settings screen (later) can override useDynamicColor and themePreference,
|
||||||
* sibling app reuses the same theming without inheriting Calendula's palette.
|
* but the V1 foundation just follows the system.
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendulaTheme(
|
fun CalendulaTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = true,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
FloretExpressiveTheme(
|
val colorScheme = when {
|
||||||
lightScheme = CalendulaLightFallback,
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
darkScheme = CalendulaDarkFallback,
|
val ctx = LocalContext.current
|
||||||
darkTheme = darkTheme,
|
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
|
||||||
dynamicColor = dynamicColor,
|
}
|
||||||
|
darkTheme -> CalendulaDarkFallback
|
||||||
|
else -> CalendulaLightFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaterialExpressiveTheme routes all component + custom motion through
|
||||||
|
// MaterialTheme.motionScheme (switches, chips, pickers, calendar slide,
|
||||||
|
// FAB, field reveal). The STANDARD scheme is a deliberate choice over
|
||||||
|
// expressive(): same spring choreography, but without the overshoot —
|
||||||
|
// the bouncy variant felt overdone in review (2026-06-11).
|
||||||
|
MaterialExpressiveTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
typography = CalendulaTypography,
|
typography = CalendulaTypography,
|
||||||
|
motionScheme = MotionScheme.standard(),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@@ -55,6 +57,7 @@ import androidx.compose.runtime.snapshotFlow
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -73,15 +76,19 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
@@ -90,7 +97,7 @@ import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -129,6 +136,14 @@ fun WeekScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||||
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
|
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||||
|
// screen while the setting is off (it stays null regardless of the tick).
|
||||||
|
val nowState = rememberCurrentMinute()
|
||||||
|
val dimCutoff by remember(dimCompleted) {
|
||||||
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -221,19 +236,21 @@ fun WeekScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
WeekContent(
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
state = state,
|
WeekContent(
|
||||||
slideDir = slideDir,
|
state = state,
|
||||||
topSectionColor = topSectionColor,
|
slideDir = slideDir,
|
||||||
onSwipeNext = goNext,
|
topSectionColor = topSectionColor,
|
||||||
onSwipePrev = goPrev,
|
onSwipeNext = goNext,
|
||||||
onRetry = jumpToToday,
|
onSwipePrev = goPrev,
|
||||||
onEventClick = onEventClick,
|
onRetry = jumpToToday,
|
||||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
.padding(innerPadding)
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.padding(innerPadding)
|
||||||
)
|
.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -550,8 +567,10 @@ private fun AllDayBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
@@ -692,6 +711,7 @@ private fun DayColumnCard(
|
|||||||
EventBlock(
|
EventBlock(
|
||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
height = height,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
@@ -712,6 +732,7 @@ private fun DayColumnCard(
|
|||||||
private fun EventBlock(
|
private fun EventBlock(
|
||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
|
height: Dp,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -720,9 +741,26 @@ private fun EventBlock(
|
|||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
minToHm(block.endMin, use24Hour, locale)
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val showTime = block.endMin - block.startMin >= 45
|
// Only full-width (non-overlapping) blocks that are tall enough show the time.
|
||||||
|
// On narrow overlapping columns we drop it so the title can wrap to fill the
|
||||||
|
// whole block, mirroring Google Calendar.
|
||||||
|
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val titleLineHeight = with(density) {
|
||||||
|
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||||
|
}
|
||||||
|
val timeLineHeight = with(density) {
|
||||||
|
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||||
|
}
|
||||||
|
// Wrap the title across as many lines as the block can fit (minus the 2.dp
|
||||||
|
// top/bottom padding and the reserved time line) instead of clipping it to a
|
||||||
|
// single character on slim, overlapping blocks.
|
||||||
|
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
|
||||||
|
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
|
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
.background(pastelize(block.event.color, dark), RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||||
@@ -732,18 +770,15 @@ private fun EventBlock(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = if (showTime) 1 else 2,
|
maxLines = titleMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.85f),
|
color = Color.Black.copy(alpha = 0.85f),
|
||||||
)
|
)
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
// Narrow columns can't fit "13:00–14:00" on one line, so let it
|
|
||||||
// wrap to a second line (after the dash) instead of clipping the
|
|
||||||
// end time.
|
|
||||||
Text(
|
Text(
|
||||||
text = timeLabel,
|
text = timeLabel,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 2,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.6f),
|
color = Color.Black.copy(alpha = 0.6f),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ class WeekViewModel @Inject constructor(
|
|||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
private val locale: Locale = Locale.getDefault()
|
private val locale: Locale = Locale.getDefault()
|
||||||
|
|
||||||
|
/** Whether to fade events that have already finished (display concern only). */
|
||||||
|
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.Manifest
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
@@ -24,6 +25,7 @@ import kotlinx.datetime.toInstant
|
|||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far either side of today the month widget pre-loads. The displayed month
|
* How far either side of today the month widget pre-loads. The displayed month
|
||||||
@@ -52,6 +54,8 @@ sealed interface AgendaWidgetData {
|
|||||||
* range is sliced from this in the composition (read reactively from
|
* range is sliced from this in the composition (read reactively from
|
||||||
* Glance state), so a range change is pure recomposition rather than a
|
* Glance state), so a range change is pure recomposition rather than a
|
||||||
* flaky widget session restart — the same approach the month widget uses.
|
* flaky widget session restart — the same approach the month widget uses.
|
||||||
|
* Kept unfiltered: hiding/dimming finished events also happens reactively
|
||||||
|
* in the composition, against [now], for the same reliable-update reason.
|
||||||
*/
|
*/
|
||||||
val days: List<AgendaDay>,
|
val days: List<AgendaDay>,
|
||||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||||
@@ -60,6 +64,10 @@ sealed interface AgendaWidgetData {
|
|||||||
val weekStart: DayOfWeek,
|
val weekStart: DayOfWeek,
|
||||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||||
val savedRange: AgendaRange,
|
val savedRange: AgendaRange,
|
||||||
|
/** Saved past-event display pref — the fallback before Glance state is set. */
|
||||||
|
val savedPastDisplay: PastEventDisplay,
|
||||||
|
/** Snapshot instant the data was read at, for "has this event ended?" tests. */
|
||||||
|
val now: Instant,
|
||||||
) : AgendaWidgetData
|
) : AgendaWidgetData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +116,7 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
val ep = widgetEntryPoint()
|
val ep = widgetEntryPoint()
|
||||||
val prefs = ep.settingsPrefs()
|
val prefs = ep.settingsPrefs()
|
||||||
val savedRange = prefs.agendaWidgetRange.first()
|
val savedRange = prefs.agendaWidgetRange.first()
|
||||||
|
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
// Load the widest selectable window once; the displayed range is sliced in
|
// Load the widest selectable window once; the displayed range is sliced in
|
||||||
// the composition from Glance state, so changing the range is a plain
|
// the composition from Glance state, so changing the range is a plain
|
||||||
@@ -122,6 +131,8 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
is24Hour = is24Hour,
|
is24Hour = is24Hour,
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
savedRange = savedRange,
|
savedRange = savedRange,
|
||||||
|
savedPastDisplay = savedPastDisplay,
|
||||||
|
now = Clock.System.now(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,13 +43,17 @@ import androidx.glance.text.Text
|
|||||||
import androidx.glance.text.TextStyle
|
import androidx.glance.text.TextStyle
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.parsePastEventDisplay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||||
@@ -79,6 +83,14 @@ import java.util.Locale
|
|||||||
*/
|
*/
|
||||||
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-instance Glance state key holding the past-event display mode (as
|
||||||
|
* [PastEventDisplay.name]). Read reactively in the composition for the same
|
||||||
|
* reason as [AGENDA_RANGE_KEY] — so toggling the setting reflects on the live
|
||||||
|
* widget without depending on the `provideGlance` preamble re-running.
|
||||||
|
*/
|
||||||
|
internal val AGENDA_PAST_DISPLAY_KEY = stringPreferencesKey("agenda_past_display")
|
||||||
|
|
||||||
class AgendaWidget : GlanceAppWidget() {
|
class AgendaWidget : GlanceAppWidget() {
|
||||||
|
|
||||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||||
@@ -125,11 +137,26 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
// to the saved pref for a freshly placed widget), then the wide
|
// to the saved pref for a freshly placed widget), then the wide
|
||||||
// pre-loaded window is sliced to it — pure recomposition.
|
// pre-loaded window is sliced to it — pure recomposition.
|
||||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||||
|
val pastDisplay =
|
||||||
|
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||||
val rangeEnd = data.today.plus(
|
val rangeEnd = data.today.plus(
|
||||||
range.dayCount(data.today, data.weekStart) - 1,
|
range.dayCount(data.today, data.weekStart) - 1,
|
||||||
DateTimeUnit.DAY,
|
DateTimeUnit.DAY,
|
||||||
)
|
)
|
||||||
val visibleDays = data.days.filter { it.date <= rangeEnd }
|
// Slice to the chosen range, then drop finished events (and any day
|
||||||
|
// they leave empty) when the mode is Hide — so "Upcoming" really is.
|
||||||
|
val visibleDays = data.days
|
||||||
|
.filter { it.date <= rangeEnd }
|
||||||
|
.let { days ->
|
||||||
|
if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
|
days.mapNotNull { day ->
|
||||||
|
val remaining = day.events.filterNot { it.hasEnded(data.now) }
|
||||||
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
days
|
||||||
|
}
|
||||||
|
}
|
||||||
if (visibleDays.isEmpty()) {
|
if (visibleDays.isEmpty()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title)
|
WidgetMessage(R.string.agenda_empty_title)
|
||||||
} else {
|
} else {
|
||||||
@@ -143,7 +170,13 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
items(rows.size) { index ->
|
items(rows.size) { index ->
|
||||||
when (val row = rows[index]) {
|
when (val row = rows[index]) {
|
||||||
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
is AgendaRow.Header -> DayHeaderRow(row.date, row.today)
|
||||||
is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour)
|
is AgendaRow.Event -> EventRow(
|
||||||
|
event = row.event,
|
||||||
|
dark = dark,
|
||||||
|
is24Hour = data.is24Hour,
|
||||||
|
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||||
|
row.event.hasEnded(data.now),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,9 +257,15 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) {
|
private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean, dimmed: Boolean) {
|
||||||
val context = androidx.glance.LocalContext.current
|
val context = androidx.glance.LocalContext.current
|
||||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
|
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||||
|
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||||
|
val stripeColor = pastelize(event.color, dark).let {
|
||||||
|
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||||
|
}
|
||||||
|
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||||
Row(
|
Row(
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -249,14 +288,14 @@ private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) {
|
|||||||
.width(5.dp)
|
.width(5.dp)
|
||||||
.height(36.dp)
|
.height(36.dp)
|
||||||
.cornerRadius(3.dp)
|
.cornerRadius(3.dp)
|
||||||
.background(pastelize(event.color, dark)),
|
.background(stripeColor),
|
||||||
) {}
|
) {}
|
||||||
Spacer(GlanceModifier.width(10.dp))
|
Spacer(GlanceModifier.width(10.dp))
|
||||||
Column(modifier = GlanceModifier.defaultWeight()) {
|
Column(modifier = GlanceModifier.defaultWeight()) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp),
|
style = TextStyle(color = titleColor, fontSize = 14.sp),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = eventTimeSummary(context, event, is24Hour),
|
text = eventTimeSummary(context, event, is24Hour),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import de.jeanlucmakiola.calendula.MainActivity
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.floret.components.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
|
|||||||
@@ -266,7 +266,6 @@
|
|||||||
<!-- Einstellungen (M4) -->
|
<!-- Einstellungen (M4) -->
|
||||||
<string name="settings_title">Einstellungen</string>
|
<string name="settings_title">Einstellungen</string>
|
||||||
<string name="settings_back">Zurück</string>
|
<string name="settings_back">Zurück</string>
|
||||||
<string name="back">Zurück</string>
|
|
||||||
<string name="settings_section_appearance">Darstellung</string>
|
<string name="settings_section_appearance">Darstellung</string>
|
||||||
<string name="settings_theme">Design</string>
|
<string name="settings_theme">Design</string>
|
||||||
<string name="settings_theme_system">System</string>
|
<string name="settings_theme_system">System</string>
|
||||||
@@ -303,6 +302,8 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_section_event_form">Termin-Formular</string>
|
<string name="settings_section_event_form">Termin-Formular</string>
|
||||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||||
|
<string name="settings_autofocus_title">Titel bei neuem Termin fokussieren</string>
|
||||||
|
<string name="settings_autofocus_title_hint">Beim Anlegen eines neuen Termins den Cursor direkt ins Titelfeld setzen und die Tastatur öffnen.</string>
|
||||||
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
||||||
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
|
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
|
||||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
@@ -315,7 +316,6 @@
|
|||||||
<string name="reminder_none">Keine</string>
|
<string name="reminder_none">Keine</string>
|
||||||
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
|
<string name="reminder_use_default">Standard-Erinnerung verwenden</string>
|
||||||
<string name="reminder_custom_amount">Anzahl</string>
|
<string name="reminder_custom_amount">Anzahl</string>
|
||||||
<string name="reminder_custom_with_value">Benutzerdefiniert (%1$s)</string>
|
|
||||||
<string name="reminder_custom_set">Übernehmen</string>
|
<string name="reminder_custom_set">Übernehmen</string>
|
||||||
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
|
<string name="settings_calendar_reminders_hint">Standard pro Kalender überschreiben — getrennt für Termine mit Uhrzeit und ganztägige Termine. Ein Kalender kann den Standard übernehmen, weglassen oder einen eigenen festlegen.</string>
|
||||||
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
|
<string name="settings_calendar_reminder_inherits">Standard (%1$s)</string>
|
||||||
@@ -400,14 +400,14 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
||||||
<string name="crash_dialog_title">%1$s ist abgestürzt</string>
|
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
|
||||||
<string name="crash_dialog_message">%1$s wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
||||||
<string name="crash_dialog_report">Melden</string>
|
<string name="crash_dialog_report">Melden</string>
|
||||||
<string name="crash_dialog_dismiss">Nicht jetzt</string>
|
<string name="crash_dialog_dismiss">Nicht jetzt</string>
|
||||||
<string name="crash_report_issue_title">Absturzbericht</string>
|
<string name="crash_report_issue_title">Absturzbericht</string>
|
||||||
<string name="crash_report_clip_label">%1$s-Absturzbericht</string>
|
<string name="crash_report_clip_label">Calendula-Absturzbericht</string>
|
||||||
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
|
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
|
||||||
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||||
<string name="crash_report_body_template">Danke, dass du einen Absturz in %1$s meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%2$s\n</string>
|
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
|
||||||
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -266,8 +266,6 @@
|
|||||||
<!-- Settings (M4) -->
|
<!-- Settings (M4) -->
|
||||||
<string name="settings_title">Settings</string>
|
<string name="settings_title">Settings</string>
|
||||||
<string name="settings_back">Back</string>
|
<string name="settings_back">Back</string>
|
||||||
<!-- Overrides floret-kit components' CollapsingScaffold back-button label. -->
|
|
||||||
<string name="back">Back</string>
|
|
||||||
<string name="settings_section_appearance">Appearance</string>
|
<string name="settings_section_appearance">Appearance</string>
|
||||||
<string name="settings_theme">Theme</string>
|
<string name="settings_theme">Theme</string>
|
||||||
<string name="settings_theme_system">System</string>
|
<string name="settings_theme_system">System</string>
|
||||||
@@ -284,6 +282,13 @@
|
|||||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||||
<string name="settings_hour_lines">Hour lines</string>
|
<string name="settings_hour_lines">Hour lines</string>
|
||||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||||
|
<string name="settings_dim_completed">Dim completed events</string>
|
||||||
|
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||||
|
<string name="settings_past_events">Past events</string>
|
||||||
|
<string name="settings_past_events_show">Show</string>
|
||||||
|
<string name="settings_past_events_dim">Dim</string>
|
||||||
|
<string name="settings_past_events_hide">Hide</string>
|
||||||
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
<string name="settings_agenda_range">Agenda range</string>
|
<string name="settings_agenda_range">Agenda range</string>
|
||||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||||
@@ -305,6 +310,8 @@
|
|||||||
</plurals>
|
</plurals>
|
||||||
<string name="settings_section_event_form">New event form</string>
|
<string name="settings_section_event_form">New event form</string>
|
||||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
|
<string name="settings_autofocus_title">Focus title on new event</string>
|
||||||
|
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
|
||||||
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||||
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
||||||
<string name="settings_section_notifications">Notifications</string>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
@@ -317,7 +324,6 @@
|
|||||||
<string name="reminder_none">None</string>
|
<string name="reminder_none">None</string>
|
||||||
<string name="reminder_use_default">Use default reminder</string>
|
<string name="reminder_use_default">Use default reminder</string>
|
||||||
<string name="reminder_custom_amount">Amount</string>
|
<string name="reminder_custom_amount">Amount</string>
|
||||||
<string name="reminder_custom_with_value">Custom (%1$s)</string>
|
|
||||||
<string name="reminder_custom_set">Set</string>
|
<string name="reminder_custom_set">Set</string>
|
||||||
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
<string name="settings_calendar_reminders_title">Per-calendar reminders</string>
|
||||||
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
<string name="settings_calendar_reminders_hint">Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own.</string>
|
||||||
@@ -444,15 +450,15 @@
|
|||||||
|
|
||||||
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||||
Gitea issue (the app sends nothing automatically). -->
|
Gitea issue (the app sends nothing automatically). -->
|
||||||
<string name="crash_dialog_title">%1$s crashed</string>
|
<string name="crash_dialog_title">Calendula crashed</string>
|
||||||
<string name="crash_dialog_message">%1$s closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
|
<string name="crash_dialog_message">Calendula closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
|
||||||
<string name="crash_dialog_report">Report</string>
|
<string name="crash_dialog_report">Report</string>
|
||||||
<string name="crash_dialog_dismiss">Not now</string>
|
<string name="crash_dialog_dismiss">Not now</string>
|
||||||
<string name="crash_report_issue_title">Crash report</string>
|
<string name="crash_report_issue_title">Crash report</string>
|
||||||
<string name="crash_report_clip_label">%1$s crash report</string>
|
<string name="crash_report_clip_label">Calendula crash report</string>
|
||||||
<string name="crash_report_copied">Report copied to your clipboard</string>
|
<string name="crash_report_copied">Report copied to your clipboard</string>
|
||||||
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
||||||
<string name="crash_report_body_template">Thanks for reporting a crash in %1$s. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%2$s\n</string>
|
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
|
||||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||||
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||||
|
|||||||
@@ -199,11 +199,12 @@ class EventWriteMapperTest {
|
|||||||
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
assertThat(values[CalendarContract.Events.TITLE]).isEqualTo("Moved")
|
||||||
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
|
assertThat(values[CalendarContract.Events.EVENT_LOCATION]).isEqualTo("Berlin")
|
||||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_781_164_800_000L)
|
||||||
assertThat(values[CalendarContract.Events.DTEND])
|
// The occurrence's length travels as DURATION, never DTEND — the provider
|
||||||
.isEqualTo(1_781_164_800_000L + 5_400_000L)
|
// rejects DTEND on an exception ("Exceptions can't overwrite dtend") and
|
||||||
// A single occurrence never carries its own rule.
|
// derives the end from DTSTART + DURATION, clearing the inherited RRULE.
|
||||||
|
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P5400S")
|
||||||
|
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DURATION)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.calendar
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class TimeBridgeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `epoch millis round-trips through Instant`() {
|
||||||
|
val original = 1_717_840_800_000L // 2024-06-08T10:00:00Z
|
||||||
|
val instant = original.toKotlinInstantFromEpochMillis()
|
||||||
|
assertThat(instant.toEpochMillis()).isEqualTo(original)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `zero millis maps to Instant epoch`() {
|
||||||
|
assertThat(0L.toKotlinInstantFromEpochMillis()).isEqualTo(Instant.fromEpochMilliseconds(0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `negative epoch millis is supported`() {
|
||||||
|
val original = -1_000_000L
|
||||||
|
assertThat(original.toKotlinInstantFromEpochMillis().toEpochMillis()).isEqualTo(original)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.crash
|
package de.jeanlucmakiola.calendula.data.crash
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.floret.crash.CrashContext
|
|
||||||
import de.jeanlucmakiola.floret.crash.buildCrashReport
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
/**
|
|
||||||
* Guards the privacy contract of the report Calendula ships: the report builder
|
|
||||||
* itself now lives in floret-kit's core-crash, but the allowlist — exactly the
|
|
||||||
* app/Android/device/locale/time header lines plus the stack trace, and nothing
|
|
||||||
* else — is what Calendula relies on, so it stays pinned here with Calendula's
|
|
||||||
* own app label.
|
|
||||||
*/
|
|
||||||
class CrashReportBuilderTest {
|
class CrashReportBuilderTest {
|
||||||
|
|
||||||
private val context = CrashContext(
|
private val context = CrashContext(
|
||||||
@@ -26,7 +17,7 @@ class CrashReportBuilderTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `report carries the allowlisted facts and the stack trace`() {
|
fun `report carries the allowlisted facts and the stack trace`() {
|
||||||
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L, appLabel = "Calendula")
|
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L)
|
||||||
|
|
||||||
assertThat(report).startsWith("Calendula crash report")
|
assertThat(report).startsWith("Calendula crash report")
|
||||||
assertThat(report).contains("App version: 2.7.0 (20700)")
|
assertThat(report).contains("App version: 2.7.0 (20700)")
|
||||||
@@ -42,7 +33,7 @@ class CrashReportBuilderTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `nested causes are included`() {
|
fun `nested causes are included`() {
|
||||||
val cause = NullPointerException("inner")
|
val cause = NullPointerException("inner")
|
||||||
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L, appLabel = "Calendula")
|
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L)
|
||||||
|
|
||||||
assertThat(report).contains("outer")
|
assertThat(report).contains("outer")
|
||||||
assertThat(report).contains("Caused by")
|
assertThat(report).contains("Caused by")
|
||||||
@@ -51,7 +42,7 @@ class CrashReportBuilderTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `report holds only the allowlisted lines before the stack trace`() {
|
fun `report holds only the allowlisted lines before the stack trace`() {
|
||||||
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L, appLabel = "Calendula")
|
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L)
|
||||||
val header = report.substringBefore("Stack trace:").trim().lines()
|
val header = report.substringBefore("Stack trace:").trim().lines()
|
||||||
|
|
||||||
// No identifiers, accounts, or extra fields ever creep into the header:
|
// No identifiers, accounts, or extra fields ever creep into the header:
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -91,6 +90,14 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.showHourLines.first()).isTrue()
|
assertThat(prefs.showHourLines.first()).isTrue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.autofocusEventTitle.first()).isTrue()
|
||||||
|
prefs.setAutofocusEventTitle(false)
|
||||||
|
assertThat(prefs.autofocusEventTitle.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
|
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
@@ -176,22 +183,38 @@ class SettingsPrefsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `default reminder is none until set`(@TempDir tempDir: Path) = runTest {
|
fun `default reminder is empty until set`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `default reminder round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
fun `default reminder round-trips none, single, and multiple lead times`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
prefs.setDefaultReminderMinutes(30)
|
prefs.setDefaultReminderMinutes(listOf(30))
|
||||||
assertThat(prefs.defaultReminderMinutes.first()).isEqualTo(30)
|
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
|
||||||
prefs.setDefaultReminderMinutes(null)
|
// Several lead times persist, normalised to distinct ascending order.
|
||||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
prefs.setDefaultReminderMinutes(listOf(10_080, 0, 30, 30))
|
||||||
|
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(0, 30, 10_080).inOrder()
|
||||||
|
prefs.setDefaultReminderMinutes(emptyList())
|
||||||
|
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `garbage stored default reminder reads as none`(@TempDir tempDir: Path) = runTest {
|
fun `legacy single-value default reminder parses as a one-element list`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
// The pre-multi-reminder format stored a bare integer string.
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "30"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.defaultReminderMinutes.first()).containsExactly(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage stored default reminder reads as empty`(@TempDir tempDir: Path) = runTest {
|
||||||
val store = newDataStore(tempDir)
|
val store = newDataStore(tempDir)
|
||||||
val prefs = SettingsPrefs(store)
|
val prefs = SettingsPrefs(store)
|
||||||
store.updateData { p ->
|
store.updateData { p ->
|
||||||
@@ -199,7 +222,7 @@ class SettingsPrefsTest {
|
|||||||
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
|
m[SettingsPrefs.DEFAULT_REMINDER_KEY] = "soon"
|
||||||
m
|
m
|
||||||
}
|
}
|
||||||
assertThat(prefs.defaultReminderMinutes.first()).isNull()
|
assertThat(prefs.defaultReminderMinutes.first()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -207,16 +230,16 @@ class SettingsPrefsTest {
|
|||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
||||||
|
|
||||||
prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(15))
|
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
|
||||||
prefs.setCalendarReminderOverride(9L, ReminderOverride.None)
|
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
|
||||||
prefs.perCalendarReminderOverride.first().let { map ->
|
prefs.perCalendarReminderOverride.first().let { map ->
|
||||||
assertThat(map).containsExactly(7L, 15, 9L, null)
|
assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inherit drops the override entirely (absent != null value).
|
// Inherit drops the override entirely (absent != an empty-list value).
|
||||||
prefs.setCalendarReminderOverride(9L, ReminderOverride.Inherit)
|
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
|
||||||
prefs.perCalendarReminderOverride.first().let { map ->
|
prefs.perCalendarReminderOverride.first().let { map ->
|
||||||
assertThat(map).containsExactly(7L, 15)
|
assertThat(map).containsExactly(7L, listOf(15, 10_080))
|
||||||
assertThat(map.containsKey(9L)).isFalse()
|
assertThat(map.containsKey(9L)).isFalse()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,48 +247,48 @@ class SettingsPrefsTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
|
||||||
prefs.setDefaultAllDayReminderMinutes(1_440)
|
prefs.setDefaultAllDayReminderMinutes(listOf(1_440))
|
||||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEqualTo(1_440)
|
assertThat(prefs.defaultAllDayReminderMinutes.first()).containsExactly(1_440)
|
||||||
prefs.setDefaultAllDayReminderMinutes(null)
|
prefs.setDefaultAllDayReminderMinutes(emptyList())
|
||||||
assertThat(prefs.defaultAllDayReminderMinutes.first()).isNull()
|
assertThat(prefs.defaultAllDayReminderMinutes.first()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(15))
|
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
|
||||||
prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Minutes(1_440))
|
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
|
||||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, 1_440)
|
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
|
||||||
// Clearing the all-day override leaves the timed one untouched.
|
// Clearing the all-day override leaves the timed one untouched.
|
||||||
prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Inherit)
|
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
|
||||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
||||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, 15)
|
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `resolveDefaultReminder picks the kind-matching override or global`() {
|
fun `resolveDefaultReminder picks the kind-matching override or global`() {
|
||||||
val timed = mapOf(7L to 15, 9L to null)
|
val timed = mapOf(7L to listOf(15, 10_080), 9L to emptyList<Int>())
|
||||||
val allDay = mapOf(7L to 2_880)
|
val allDay = mapOf(7L to listOf(2_880))
|
||||||
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
|
fun resolve(calendarId: Long?, isAllDay: Boolean) = resolveDefaultReminder(
|
||||||
timedGlobal = 30,
|
timedGlobal = listOf(30),
|
||||||
allDayGlobal = 1_440,
|
allDayGlobal = listOf(1_440),
|
||||||
timedOverrides = timed,
|
timedOverrides = timed,
|
||||||
allDayOverrides = allDay,
|
allDayOverrides = allDay,
|
||||||
calendarId = calendarId,
|
calendarId = calendarId,
|
||||||
isAllDay = isAllDay,
|
isAllDay = isAllDay,
|
||||||
)
|
)
|
||||||
// Timed: minutes override, explicit none, inherit global, no calendar.
|
// Timed: multi-value override, explicit none, inherit global, no calendar.
|
||||||
assertThat(resolve(7L, isAllDay = false)).isEqualTo(15)
|
assertThat(resolve(7L, isAllDay = false)).containsExactly(15, 10_080).inOrder()
|
||||||
assertThat(resolve(9L, isAllDay = false)).isNull()
|
assertThat(resolve(9L, isAllDay = false)).isEmpty()
|
||||||
assertThat(resolve(5L, isAllDay = false)).isEqualTo(30)
|
assertThat(resolve(5L, isAllDay = false)).containsExactly(30)
|
||||||
assertThat(resolve(null, isAllDay = false)).isEqualTo(30)
|
assertThat(resolve(null, isAllDay = false)).containsExactly(30)
|
||||||
// All-day: its own override wins; absent → all-day global; a timed-only
|
// All-day: its own override wins; absent → all-day global; a timed-only
|
||||||
// override (cal 9) does not bleed into all-day.
|
// override (cal 9) does not bleed into all-day.
|
||||||
assertThat(resolve(7L, isAllDay = true)).isEqualTo(2_880)
|
assertThat(resolve(7L, isAllDay = true)).containsExactly(2_880)
|
||||||
assertThat(resolve(9L, isAllDay = true)).isEqualTo(1_440)
|
assertThat(resolve(9L, isAllDay = true)).containsExactly(1_440)
|
||||||
assertThat(resolve(5L, isAllDay = true)).isEqualTo(1_440)
|
assertThat(resolve(5L, isAllDay = true)).containsExactly(1_440)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class PostableAlertsTest {
|
||||||
|
|
||||||
|
private fun alert(alertId: Long, calendarId: Long) = ReminderAlert(
|
||||||
|
alertId = alertId,
|
||||||
|
eventId = alertId * 10,
|
||||||
|
calendarId = calendarId,
|
||||||
|
beginMillis = 0L,
|
||||||
|
endMillis = 0L,
|
||||||
|
title = "Event $alertId",
|
||||||
|
location = null,
|
||||||
|
isAllDay = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps alerts when no calendar is disabled`() {
|
||||||
|
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200))
|
||||||
|
|
||||||
|
val postable = postableAlerts(due, disabledCalendarIds = emptySet())
|
||||||
|
|
||||||
|
assertThat(postable).isEqualTo(due)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `drops alerts for a disabled calendar`() {
|
||||||
|
val keep = alert(1, calendarId = 100)
|
||||||
|
val drop = alert(2, calendarId = 200)
|
||||||
|
|
||||||
|
val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200))
|
||||||
|
|
||||||
|
assertThat(postable).containsExactly(keep)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `drops every alert when all their calendars are disabled`() {
|
||||||
|
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
|
||||||
|
|
||||||
|
val postable = postableAlerts(due, disabledCalendarIds = setOf(100))
|
||||||
|
|
||||||
|
assertThat(postable).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps multiple alerts from the same enabled calendar`() {
|
||||||
|
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
|
||||||
|
|
||||||
|
val postable = postableAlerts(due, disabledCalendarIds = setOf(999))
|
||||||
|
|
||||||
|
assertThat(postable).isEqualTo(due)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,21 +43,11 @@ Builds:
|
|||||||
versionCode: 20705
|
versionCode: 20705
|
||||||
commit: v2.7.5
|
commit: v2.7.5
|
||||||
subdir: app
|
subdir: app
|
||||||
submodules: true
|
|
||||||
gradle:
|
gradle:
|
||||||
- yes
|
- yes
|
||||||
# No NDK / no flavors. The release buildType applies a signingConfig only
|
# No NDK / no flavors. The release buildType applies a signingConfig only
|
||||||
# when key.properties exists; on the buildserver it does not, so this
|
# when key.properties exists; on the buildserver it does not, so this
|
||||||
# produces the unsigned APK F-Droid compares against our binary.
|
# produces the unsigned APK F-Droid compares against our binary.
|
||||||
#
|
|
||||||
# submodules: true — REQUIRED from v2.11.0 onward, where the build pulls
|
|
||||||
# shared code from the `floret-kit` git submodule via a Gradle composite
|
|
||||||
# build (`includeBuild("floret-kit")`). Without it F-Droid checks out an
|
|
||||||
# empty floret-kit/ and the from-source build fails, stalling publishing.
|
|
||||||
# Inert for v2.7.5 (no submodule yet); kept here so AutoUpdateMode copies it
|
|
||||||
# onto every auto-generated future build entry. The kit is plain-Kotlin and
|
|
||||||
# carries no foojay toolchain resolver, so it clears the same reproducibility
|
|
||||||
# bar (enforced by scripts/check_reproducible_release.sh).
|
|
||||||
|
|
||||||
# SHA-256 of our app signing certificate (public; embedded in every published
|
# SHA-256 of our app signing certificate (public; embedded in every published
|
||||||
# APK). Locks F-Droid to publish only binaries signed with our key.
|
# APK). Locks F-Droid to publish only binaries signed with our key.
|
||||||
|
|||||||
6
fastlane/metadata/android/en-US/changelogs/21101.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/21101.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
### Fixed
|
||||||
|
- Calendula can now be set as your default calendar app. It registers the
|
||||||
|
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||||
|
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||||
|
that day. Android has no way for an app to make itself the default, so you pick
|
||||||
|
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||||
7
fastlane/metadata/android/en-US/changelogs/21102.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/21102.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
### Added
|
||||||
|
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||||
|
the cursor in the title field and opens the keyboard right away, so you can type
|
||||||
|
the name without an extra tap. It's on by default and only affects creating an
|
||||||
|
event — editing never grabs focus — and a new **Focus title on new event**
|
||||||
|
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#10]).
|
||||||
19
fastlane/metadata/android/en-US/changelogs/21200.txt
Normal file
19
fastlane/metadata/android/en-US/changelogs/21200.txt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
### Added
|
||||||
|
- See past events your way. Two new settings change how events that have already
|
||||||
|
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||||
|
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||||
|
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||||
|
(Settings → Appearance) separately fades finished events in the month and week
|
||||||
|
views. Both are off by default, an event only counts as finished once it has
|
||||||
|
actually ended (events still in progress are never dimmed), and the lists
|
||||||
|
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||||
|
([#12]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Readable titles on overlapping events. In the week and day views, events that
|
||||||
|
overlap split a day into slim columns where the title used to be clipped to a
|
||||||
|
character or two. The title now wraps across as many lines as the block can
|
||||||
|
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||||
|
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||||
|
for the suggestion ([#13]).
|
||||||
|
|
||||||
Submodule floret-kit deleted from d9e4e877cc
@@ -34,15 +34,8 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 2. The foojay toolchain resolver must not be present in any Gradle script.
|
# 2. The foojay toolchain resolver must not be present in any Gradle script.
|
||||||
# This includes the floret-kit submodule: it's an included build (composite
|
|
||||||
# build via `includeBuild`), so F-Droid evaluates its Gradle scripts too when
|
|
||||||
# building from source — the same offline-scanner bar applies to it.
|
|
||||||
gradle_files=("$SETTINGS" "$APP")
|
gradle_files=("$SETTINGS" "$APP")
|
||||||
[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts)
|
[ -f build.gradle.kts ] && gradle_files+=(build.gradle.kts)
|
||||||
if [ -d floret-kit ]; then
|
|
||||||
while IFS= read -r f; do gradle_files+=("$f"); done \
|
|
||||||
< <(find floret-kit -name '*.gradle.kts' -not -path '*/build/*')
|
|
||||||
fi
|
|
||||||
if grep -qi 'foojay' "${gradle_files[@]}"; then
|
if grep -qi 'foojay' "${gradle_files[@]}"; then
|
||||||
echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2
|
echo "ERROR: foojay toolchain resolver found in: $(grep -li foojay "${gradle_files[@]}" | tr '\n' ' ')" >&2
|
||||||
echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2
|
echo " F-Droid's source scanner rejects org.gradle.toolchains.foojay-resolver" >&2
|
||||||
|
|||||||
@@ -22,4 +22,3 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "Calendula"
|
rootProject.name = "Calendula"
|
||||||
include(":app")
|
include(":app")
|
||||||
includeBuild("floret-kit")
|
|
||||||
|
|||||||
Reference in New Issue
Block a user