Compare commits
42 Commits
v2.11.2
...
b5895b190e
| Author | SHA1 | Date | |
|---|---|---|---|
| b5895b190e | |||
| 37f0e22911 | |||
| 4c26450ad4 | |||
| e5aa90bdd6 | |||
| 580dc5a669 | |||
| b9e800e9fc | |||
| 40d70d9f2b | |||
| 14b19cd902 | |||
| 8d530c5681 | |||
| 225f4c3491 | |||
| 11578a5588 | |||
| 5d887524b1 | |||
| 405ff16233 | |||
| 048f407ba1 | |||
| 69460bc35a | |||
|
|
bf10deaf5c | ||
|
|
b9e861af62 | ||
|
|
2eadda937b | ||
|
|
71b90fa184 | ||
|
|
4e9cf88bd4 | ||
|
|
b999202629 | ||
|
|
6f0e13a4a2 | ||
|
|
9ef7b33800 | ||
|
|
d04b431f2c | ||
|
|
dbeeadff80 | ||
|
|
f4a169ef5f | ||
|
|
604099b3f2 | ||
|
|
a3e6541512 | ||
| a89560953d | |||
| 4e498de051 | |||
| f2fb3d6279 | |||
| c2d88e744e | |||
| 2ae4c818ba | |||
| fb2a40114a | |||
| e1f79df67a | |||
| e258a4a7f2 | |||
| 3f27a47d1e | |||
| feb87a1e73 | |||
| 28e464a616 | |||
| 93df6d6d62 | |||
| 49ddb9437b | |||
| e1cf00999d |
62
CHANGELOG.md
62
CHANGELOG.md
@@ -5,6 +5,61 @@ 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]).
|
||||||
|
- Widget headers now open the app. Tapping the month/year title on the month
|
||||||
|
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||||
|
the agenda widget opens it on your default view — so there's a one-tap way
|
||||||
|
back into the app that lands where you'd expect, instead of only through a day
|
||||||
|
or event. On the month widget, tapping anywhere on a day — not just the small
|
||||||
|
date number — now opens that day, and the "today" button snaps the grid back
|
||||||
|
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||||
|
suggestions ([#18], [#20]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Month widget arrows and "today" button work again. On release builds the
|
||||||
|
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||||
|
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||||
|
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||||
|
- 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
|
## [2.11.2] — 2026-06-28
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -706,3 +761,10 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||||
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
||||||
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
[#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
|
||||||
|
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||||
|
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||||
|
|||||||
@@ -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 = 21102
|
versionCode = 21200
|
||||||
versionName = "2.11.2"
|
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
|
||||||
|
|||||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -16,6 +16,18 @@
|
|||||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||||
-dontwarn androidx.room.paging.**
|
-dontwarn androidx.room.paging.**
|
||||||
|
|
||||||
|
# Glance runs an @Composable's `actionRunCallback<T>()` by persisting the
|
||||||
|
# callback's fully-qualified class name into the click PendingIntent, then
|
||||||
|
# reflectively instantiating it (Class.forName(name).newInstance()) when the tap
|
||||||
|
# fires. Under R8 full mode (AGP 9 default) these ActionCallback classes — only
|
||||||
|
# ever referenced reflectively — get renamed or have their no-arg constructor
|
||||||
|
# stripped, so the lookup fails silently and the tap does nothing. In the month
|
||||||
|
# and agenda widgets that broke every run-callback control (the prev/next/today
|
||||||
|
# month arrows and the agenda refresh) in release builds while actionStartActivity
|
||||||
|
# taps, which ride a PendingIntent and need no reflection, kept working. Keep
|
||||||
|
# every ActionCallback's name and constructor intact.
|
||||||
|
-keep class * implements androidx.glance.appwidget.action.ActionCallback { <init>(...); }
|
||||||
|
|
||||||
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
||||||
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
||||||
# keep both its name and a no-arg constructor. Glance renders every widget
|
# keep both its name and a no-arg constructor. Glance renders every widget
|
||||||
|
|||||||
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>
|
||||||
@@ -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
|
||||||
@@ -29,6 +30,7 @@ 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.CrashReportDialog
|
||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
@@ -101,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(
|
||||||
@@ -186,6 +193,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
// An external date tap (launcher/clock) has no widget source, so it opens
|
// 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).
|
// the day view rooted over the default home view (OpenDate source = null).
|
||||||
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||||
|
// A widget header tap: open a top-level view with no date drill-in. The
|
||||||
|
// empty string carried by [openViewIntent] means "the default home view".
|
||||||
|
if (hasExtra(EXTRA_OPEN_VIEW)) {
|
||||||
|
val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty()
|
||||||
|
return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name })
|
||||||
|
}
|
||||||
val source = sourceViewOrNull()
|
val source = sourceViewOrNull()
|
||||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
return when {
|
return when {
|
||||||
@@ -237,6 +250,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||||
|
|
||||||
|
// A widget header tap asking to open a top-level view (no date drill-in).
|
||||||
|
// Its value is the target [CalendarView] name, or "" for the default view.
|
||||||
|
private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW"
|
||||||
|
|
||||||
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
||||||
// in-app back stack in that view; absent for non-widget launches (reminders).
|
// in-app back stack in that view; absent for non-widget launches (reminders).
|
||||||
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
||||||
@@ -300,5 +317,19 @@ class MainActivity : AppCompatActivity() {
|
|||||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app on a top-level [view] with no date drill-in — a widget
|
||||||
|
* header tap. A null [view] opens the user's default home view (the agenda
|
||||||
|
* widget's "Upcoming" title); a concrete view roots there over the default
|
||||||
|
* home (the month widget's month/year title → [CalendarView.Month]). The
|
||||||
|
* per-view data URI keeps distinct headers' PendingIntents from collapsing.
|
||||||
|
*/
|
||||||
|
fun openViewIntent(context: Context, view: CalendarView?): Intent =
|
||||||
|
Intent(context, MainActivity::class.java).apply {
|
||||||
|
data = "calendula://view/${view?.name ?: "default"}".toUri()
|
||||||
|
putExtra(EXTRA_OPEN_VIEW, view?.name ?: "")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ 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.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
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
|
||||||
@@ -38,6 +40,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).
|
||||||
@@ -116,6 +131,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
|
||||||
@@ -164,6 +204,34 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
|
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick-switch button customisation (#24): the ordered full view list plus
|
||||||
|
* which views are enabled in the cycle. Stored comma-joined by enum name, a
|
||||||
|
* "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing
|
||||||
|
* views are appended enabled and unknown names dropped, so a future view
|
||||||
|
* defaults into the cycle. An absent key means [QuickSwitchConfig.Default].
|
||||||
|
*/
|
||||||
|
val quickSwitchConfig: Flow<QuickSwitchConfig> = store.data.map { prefs ->
|
||||||
|
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
||||||
|
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
|
||||||
|
* are appended in default order and unknown names dropped. Absent key means
|
||||||
|
* [IMPLEMENTED_VIEWS] (the historical fixed order).
|
||||||
|
*/
|
||||||
|
val drawerViewOrder: Flow<List<CalendarView>> = store.data.map { prefs ->
|
||||||
|
parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY])
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
|
store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional event-form fields shown by default (the rest hide behind
|
* Optional event-form fields shown by default (the rest hide behind
|
||||||
* "more fields"). Stored comma-joined by enum name: an absent key means
|
* "more fields"). Stored comma-joined by enum name: an absent key means
|
||||||
@@ -238,35 +306,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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -303,13 +372,13 @@ 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 ->
|
||||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +395,7 @@ 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 ->
|
||||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,6 +464,40 @@ class SettingsPrefs @Inject constructor(
|
|||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse a plain comma-joined view order, completed to every implemented view. */
|
||||||
|
private fun parseViewOrder(stored: String?): List<CalendarView> =
|
||||||
|
completeViewOrder(
|
||||||
|
stored?.split(',').orEmpty()
|
||||||
|
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
|
||||||
|
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
|
||||||
|
if (stored == null) return QuickSwitchConfig.Default
|
||||||
|
val parsed = stored.split(',').mapNotNull { raw ->
|
||||||
|
val token = raw.trim()
|
||||||
|
val disabled = token.startsWith("!")
|
||||||
|
val name = if (disabled) token.drop(1) else token
|
||||||
|
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
|
||||||
|
}
|
||||||
|
val order = completeViewOrder(parsed.map { it.first })
|
||||||
|
// Only views explicitly stored disabled are excluded; anything appended
|
||||||
|
// (a view added in a later release) defaults into the cycle.
|
||||||
|
val disabled = parsed.filter { it.second }.map { it.first }.toSet()
|
||||||
|
return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeQuickSwitch(config: QuickSwitchConfig): String =
|
||||||
|
completeViewOrder(config.order).joinToString(",") { view ->
|
||||||
|
if (view in config.enabled) view.name else "!${view.name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the given order (de-duplicated), then append any views it omits. */
|
||||||
|
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||||
|
val ordered = seen.distinct()
|
||||||
|
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
@@ -404,7 +507,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 QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||||
|
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||||
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 AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
@@ -455,43 +562,46 @@ sealed interface CalendarReminderOverride {
|
|||||||
data object Inherit : CalendarReminderOverride
|
data object Inherit : CalendarReminderOverride
|
||||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||||
data object None : CalendarReminderOverride
|
data object None : CalendarReminderOverride
|
||||||
/** A specific lead time in minutes before the event start. */
|
/** Specific lead times in minutes before the event start (non-empty). */
|
||||||
data class Minutes(val minutes: Int) : CalendarReminderOverride
|
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). */
|
/**
|
||||||
private fun MutableMap<Long, Int?>.applyOverride(
|
* 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,
|
calendarId: Long,
|
||||||
override: CalendarReminderOverride,
|
override: CalendarReminderOverride,
|
||||||
) {
|
) {
|
||||||
when (override) {
|
when (override) {
|
||||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||||
CalendarReminderOverride.None -> put(calendarId, null)
|
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
||||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes)
|
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,24 +627,36 @@ 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 ENTRY_SEP = ";"
|
||||||
private const val KEY_VALUE_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()
|
|
||||||
|
/**
|
||||||
|
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||||
|
* list; a comma-joined list → its minutes; a legacy single value ("30") → a
|
||||||
|
* one-element list. Non-numeric parts are dropped defensively.
|
||||||
|
*/
|
||||||
|
private fun String?.toReminderList(): List<Int> = when {
|
||||||
|
this == null || isEmpty() || this == NONE -> emptyList()
|
||||||
|
else -> split(LIST_SEP).mapNotNull { it.trim().toIntOrNull() }.normalizeReminders()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseReminderOverrides(stored: String?): Map<Long, Int?> {
|
/** 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()
|
if (stored.isNullOrBlank()) return emptyMap()
|
||||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||||
val value = if (parts[1] == NONE) null else parts[1].toIntOrNull() ?: return@mapNotNull null
|
id to parts[1].toReminderList()
|
||||||
id to value
|
|
||||||
}.toMap()
|
}.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun serializeReminderOverrides(map: Map<Long, Int?>): String =
|
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
||||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes ?: NONE}" }
|
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?,
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ fun CalendarHost(
|
|||||||
// correcting it. Brief blank first frame, matching the onboarding gate above.
|
// correcting it. Brief blank first frame, matching the onboarding gate above.
|
||||||
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
|
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
|
||||||
|
|
||||||
|
// View customisation (#24): the quick-switch cycle and drawer order. Both have
|
||||||
|
// sensible non-empty initial values, so they're ready before the first frame.
|
||||||
|
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||||
|
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||||
|
|
||||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||||
mutableStateOf(listOf(defaultView))
|
mutableStateOf(listOf(defaultView))
|
||||||
}
|
}
|
||||||
@@ -212,6 +217,17 @@ fun CalendarHost(
|
|||||||
detailKey = key
|
detailKey = key
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
|
is WidgetNavRequest.OpenView -> {
|
||||||
|
// A widget header tap: land on a top-level view with no date
|
||||||
|
// drill-in. Reveal it by dropping any covering overlay, then root
|
||||||
|
// the stack on the target (null → the default home) over the
|
||||||
|
// default home — so backing out returns to the default, then exits.
|
||||||
|
dismissCoveringOverlays()
|
||||||
|
createDateIso = null
|
||||||
|
pendingDayIso = null
|
||||||
|
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||||
|
onWidgetNavConsumed()
|
||||||
|
}
|
||||||
is WidgetNavRequest.Create -> {
|
is WidgetNavRequest.Create -> {
|
||||||
// External "new event" entries (QS tile / launcher shortcut /
|
// External "new event" entries (QS tile / launcher shortcut /
|
||||||
// widget) must land on top of whatever is open — the form overlay
|
// widget) must land on top of whatever is open — the form overlay
|
||||||
@@ -260,6 +276,8 @@ fun CalendarHost(
|
|||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -269,6 +287,8 @@ fun CalendarHost(
|
|||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -277,6 +297,8 @@ fun CalendarHost(
|
|||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
CalendarView.Agenda -> AgendaScreen(
|
CalendarView.Agenda -> AgendaScreen(
|
||||||
selectedView = currentView,
|
selectedView = currentView,
|
||||||
@@ -285,6 +307,8 @@ fun CalendarHost(
|
|||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onCreateEvent = onCreateEvent,
|
onCreateEvent = onCreateEvent,
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
|
drawerViewOrder = drawerViewOrder,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -26,4 +28,21 @@ class CalendarHostViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
initialValue = null,
|
initialValue = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
|
||||||
|
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
|
||||||
|
.map { it.cycle }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Order of the views in the navigation drawer (#24); every view always shown. */
|
||||||
|
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = IMPLEMENTED_VIEWS,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,4 +31,13 @@ sealed interface WidgetNavRequest {
|
|||||||
|
|
||||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the app rooted on a top-level [view] with no date drill-in — a widget
|
||||||
|
* header tap. A null [view] means "the user's default home view" (the agenda
|
||||||
|
* widget's "Upcoming" title, issue #20); a concrete view roots there over the
|
||||||
|
* default home (the month widget's month/year title → [CalendarView.Month],
|
||||||
|
* issue #18), so backing out returns to the default view, then exits.
|
||||||
|
*/
|
||||||
|
data class OpenView(val view: CalendarView?) : WidgetNavRequest
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,7 +54,9 @@ 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.calendula.ui.common.calendarAnimateItem
|
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||||
@@ -61,12 +64,15 @@ 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.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
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.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.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
|
||||||
@@ -91,11 +97,14 @@ fun AgendaScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: AgendaViewModel = hiltViewModel(),
|
viewModel: AgendaViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
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)
|
||||||
@@ -114,6 +123,7 @@ fun AgendaScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = anchor,
|
currentDate = anchor,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -134,7 +144,7 @@ fun AgendaScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
AgendaTopBar(
|
AgendaTopBar(
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -178,6 +188,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 +286,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 +296,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 +340,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,6 +351,7 @@ private fun AgendaList(
|
|||||||
AgendaEventRow(
|
AgendaEventRow(
|
||||||
event = event,
|
event = event,
|
||||||
position = positionOf(index, day.events.size),
|
position = positionOf(index, day.events.size),
|
||||||
|
dimmed = dimPast && event.hasEnded(now),
|
||||||
modifier = calendarAnimateItem(),
|
modifier = calendarAnimateItem(),
|
||||||
onClick = { onEventClick(event) },
|
onClick = { onEventClick(event) },
|
||||||
)
|
)
|
||||||
@@ -348,13 +384,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
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ fun CalendarDrawer(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
onSettings: () -> Unit,
|
onSettings: () -> Unit,
|
||||||
|
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
) {
|
) {
|
||||||
var showDatePicker by remember { mutableStateOf(false) }
|
var showDatePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -73,10 +74,10 @@ fun CalendarDrawer(
|
|||||||
DrawerHeader()
|
DrawerHeader()
|
||||||
|
|
||||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
viewOrder.forEachIndexed { index, view ->
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(view.labelRes),
|
title = stringResource(view.labelRes),
|
||||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
position = positionOf(index, viewOrder.size),
|
||||||
selected = view == currentView,
|
selected = view == currentView,
|
||||||
minHeight = 56.dp,
|
minHeight = 56.dp,
|
||||||
leading = { Icon(view.icon, contentDescription = null) },
|
leading = { Icon(view.icon, contentDescription = null) },
|
||||||
|
|||||||
@@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List<CalendarView> =
|
|||||||
|
|
||||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||||
|
if (available.isEmpty()) return this
|
||||||
val i = available.indexOf(this)
|
val i = available.indexOf(this)
|
||||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
if (i < 0) return available.first()
|
||||||
return available[(i + 1) % available.size]
|
return available[(i + 1) % available.size]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's customisation of the top-bar quick-switch button (#24): which views
|
||||||
|
* it cycles through ([enabled]) and in what [order]. [order] always lists every
|
||||||
|
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||||
|
* is the subset the pill actually steps through, in [order]. The navigation
|
||||||
|
* drawer keeps its own separate order and always lists every view, so a view
|
||||||
|
* disabled here stays reachable there.
|
||||||
|
*/
|
||||||
|
data class QuickSwitchConfig(
|
||||||
|
val order: List<CalendarView>,
|
||||||
|
val enabled: Set<CalendarView>,
|
||||||
|
) {
|
||||||
|
/** Views the pill steps through, in [order]. */
|
||||||
|
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** All views, in default order, all enabled. */
|
||||||
|
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fewest views that keep the switch meaningful — a "switch" needs at
|
||||||
|
* least two targets, so the settings screen blocks disabling below this.
|
||||||
|
*/
|
||||||
|
const val MIN_ENABLED = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The top-level view back stack (bottom → top): the [default] home view always
|
* The top-level view back stack (bottom → top): the [default] home view always
|
||||||
* sits at the bottom. Pressing back pops one level until only the home view
|
* sits at the bottom. Pressing back pops one level until only the home view
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -142,6 +142,9 @@ fun GroupedRow(
|
|||||||
dimmed: Boolean = false,
|
dimmed: Boolean = false,
|
||||||
container: Color? = null,
|
container: Color? = null,
|
||||||
minHeight: Dp = 72.dp,
|
minHeight: Dp = 72.dp,
|
||||||
|
// The 2.dp separation between rows in a run. Suppressed by the reorderable
|
||||||
|
// list, which owns uniform spacing itself so every slot has the same pitch.
|
||||||
|
gapBelow: Boolean = true,
|
||||||
leading: @Composable (() -> Unit)? = null,
|
leading: @Composable (() -> Unit)? = null,
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
onClick: (() -> Unit)? = null,
|
onClick: (() -> Unit)? = null,
|
||||||
@@ -160,9 +163,10 @@ fun GroupedRow(
|
|||||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val gap = when (position) {
|
val gap = when {
|
||||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
!gapBelow -> Modifier
|
||||||
Position.Bottom, Position.Alone -> Modifier
|
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||||
|
else -> Modifier
|
||||||
}
|
}
|
||||||
val itemColors = if (selected) {
|
val itemColors = if (selected) {
|
||||||
ListItemDefaults.colors(
|
ListItemDefaults.colors(
|
||||||
|
|||||||
@@ -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,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
|
||||||
@@ -13,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
|
||||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.SideEffect
|
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
|
||||||
@@ -111,10 +113,15 @@ fun <T> OptionPicker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reminder-default picker, full-screen: the grouped list (with an optional "Use
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
* default reminder" row and a "None" row), the [presets] as lead-time rows, and
|
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||||
* a "Custom" row that expands an inline number field plus a segmented unit
|
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||||
* selector. Returns the choice as a [CalendarReminderOverride].
|
* "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
|
@Composable
|
||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
@@ -125,57 +132,74 @@ fun ReminderDefaultPicker(
|
|||||||
onSelect: (CalendarReminderOverride) -> Unit,
|
onSelect: (CalendarReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val selectedMinutes = (selected as? CalendarReminderOverride.Minutes)?.minutes
|
// Optimistic local state: the chosen override is authoritative while the
|
||||||
val customSelected = selectedMinutes != null && selectedMinutes !in presets
|
// picker is open, so quick successive toggles compose on each other instead
|
||||||
val seed = decomposeReminder(selectedMinutes?.takeIf { customSelected })
|
// 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 customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var amountText by rememberSaveable { mutableStateOf(seed.first) }
|
var amountText by rememberSaveable { mutableStateOf("") }
|
||||||
var unit by rememberSaveable { mutableStateOf(seed.second) }
|
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||||
|
|
||||||
val options = buildList {
|
fun apply(override: CalendarReminderOverride) {
|
||||||
if (allowInherit) add(CalendarReminderOverride.Inherit)
|
current = override
|
||||||
add(CalendarReminderOverride.None)
|
onSelect(override)
|
||||||
presets.forEach { add(CalendarReminderOverride.Minutes(it)) }
|
|
||||||
}
|
}
|
||||||
val rowCount = options.size + 1 // + the custom row
|
// 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) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
options.forEachIndexed { index, option ->
|
// Exclusive "use default" choice (per-calendar only): its own group above
|
||||||
val isSelected = option == selected
|
// the multi-select list, since inheriting is mutually exclusive with
|
||||||
|
// picking specific times.
|
||||||
|
if (allowInherit) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = reminderOverrideLabel(option),
|
title = stringResource(R.string.reminder_use_default),
|
||||||
position = positionOf(index, rowCount),
|
position = Position.Alone,
|
||||||
selected = isSelected,
|
selected = inherits,
|
||||||
trailing = if (isSelected) {
|
trailing = if (inherits) {
|
||||||
{ SelectedCheck() }
|
{ SelectedCheck() }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = { apply(CalendarReminderOverride.Inherit) },
|
||||||
onSelect(option)
|
)
|
||||||
onDismiss()
|
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(
|
||||||
@@ -189,8 +213,9 @@ fun ReminderDefaultPicker(
|
|||||||
unit = unit,
|
unit = unit,
|
||||||
onUnitChange = { unit = it },
|
onUnitChange = { unit = it },
|
||||||
onConfirm = { minutes ->
|
onConfirm = { minutes ->
|
||||||
onSelect(CalendarReminderOverride.Minutes(minutes))
|
emit(selectedMinutes + minutes)
|
||||||
onDismiss()
|
amountText = ""
|
||||||
|
customExpanded = false
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -428,18 +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: CalendarReminderOverride): String = when (override) {
|
|
||||||
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
|
||||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
|
||||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(override.minutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seed the custom editor: the largest exact unit for [minutes] (null → empty). */
|
|
||||||
private fun decomposeReminder(minutes: Int?): Pair<String, ReminderUnit> = when {
|
|
||||||
minutes == null -> "" to ReminderUnit.Minutes
|
|
||||||
minutes % 10_080 == 0 -> (minutes / 10_080).toString() to ReminderUnit.Weeks
|
|
||||||
minutes % 1_440 == 0 -> (minutes / 1_440).toString() to ReminderUnit.Days
|
|
||||||
minutes % 60 == 0 -> (minutes / 60).toString() to ReminderUnit.Hours
|
|
||||||
else -> minutes.toString() to ReminderUnit.Minutes
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
|
||||||
|
val ReorderableRowHeight: Dp = 64.dp
|
||||||
|
private val RowGap: Dp = 2.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A vertical list whose rows can be dragged into a new order by their handle.
|
||||||
|
*
|
||||||
|
* Built for the short, fixed grouped-card lists in Settings (#24) — no external
|
||||||
|
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
|
||||||
|
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
|
||||||
|
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
||||||
|
* uniform gap, so a row's target slot is simply how many whole pitches it has
|
||||||
|
* been dragged. The held row follows the finger while the others slide out of
|
||||||
|
* its way (animated); on release the held row settles into its slot, then the
|
||||||
|
* order is committed with a single [onReorder] call.
|
||||||
|
*
|
||||||
|
* [rowContent] receives the [Position] for the row's place in the order (to reuse
|
||||||
|
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
||||||
|
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <T> ReorderableColumn(
|
||||||
|
items: List<T>,
|
||||||
|
keyOf: (T) -> Any,
|
||||||
|
onReorder: (List<T>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Local working copy; re-seeded when the incoming list changes (including the
|
||||||
|
// echo of our own committed order).
|
||||||
|
var order by remember(items) { mutableStateOf(items) }
|
||||||
|
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
||||||
|
// Live translation of the held row from its slot (px); also drives the
|
||||||
|
// release settle, animated back onto a slot before the order is committed.
|
||||||
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
|
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
||||||
|
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
|
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||||
|
// Whole slots dragged → the slot the held row currently hovers over.
|
||||||
|
val targetIndex = draggedIndex?.let {
|
||||||
|
(it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
|
||||||
|
order.forEachIndexed { index, item ->
|
||||||
|
val key = keyOf(item)
|
||||||
|
val isDragged = key == draggedKey
|
||||||
|
|
||||||
|
// Slide neighbours by one pitch to open the gap the held row will drop
|
||||||
|
// into. Snap (not animate) once idle, so committing the new order — which
|
||||||
|
// moves each row's slot — doesn't visibly fight a lingering animation.
|
||||||
|
val shift = when {
|
||||||
|
draggedIndex == null || targetIndex == null || isDragged -> 0f
|
||||||
|
index in (draggedIndex + 1)..targetIndex -> -pitchPx
|
||||||
|
index in targetIndex until draggedIndex -> pitchPx
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
val animatedShift by animateFloatAsState(
|
||||||
|
targetValue = shift,
|
||||||
|
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
|
||||||
|
label = "reorderShift",
|
||||||
|
)
|
||||||
|
|
||||||
|
val dragHandle = Modifier.pointerInput(key) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = {
|
||||||
|
settleJob?.cancel()
|
||||||
|
draggedKey = key
|
||||||
|
dragOffset = 0f
|
||||||
|
},
|
||||||
|
onDrag = { change, amount ->
|
||||||
|
change.consume()
|
||||||
|
dragOffset += amount.y
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val from = order.indexOfFirst { keyOf(it) == key }
|
||||||
|
if (from < 0) return@detectDragGestures
|
||||||
|
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||||
|
settleJob = scope.launch {
|
||||||
|
// Settle the held row onto its target slot, then commit —
|
||||||
|
// resetting offset and slot in the same frame, so nothing jumps.
|
||||||
|
Animatable(dragOffset).animateTo((to - from) * pitchPx, tween(160)) {
|
||||||
|
dragOffset = value
|
||||||
|
}
|
||||||
|
if (to != from) {
|
||||||
|
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
||||||
|
onReorder(order)
|
||||||
|
}
|
||||||
|
draggedKey = null
|
||||||
|
dragOffset = 0f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
settleJob = scope.launch {
|
||||||
|
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||||
|
draggedKey = null
|
||||||
|
dragOffset = 0f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.height(ReorderableRowHeight)
|
||||||
|
.zIndex(if (isDragged) 1f else 0f)
|
||||||
|
.graphicsLayer {
|
||||||
|
translationY = if (isDragged) dragOffset else animatedShift
|
||||||
|
if (isDragged) {
|
||||||
|
scaleX = 1.02f
|
||||||
|
scaleY = 1.02f
|
||||||
|
shadowElevation = 8.dp.toPx()
|
||||||
|
shape = RoundedCornerShape(20.dp)
|
||||||
|
clip = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@ 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.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
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
|
||||||
@@ -116,6 +117,8 @@ fun DayScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -175,6 +178,7 @@ fun DayScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = date,
|
currentDate = date,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -196,7 +200,7 @@ fun DayScreen(
|
|||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -260,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,
|
||||||
@@ -268,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.
|
||||||
|
|||||||
@@ -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,10 +63,15 @@ 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.IMPLEMENTED_VIEWS
|
||||||
|
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
|
||||||
@@ -91,12 +99,22 @@ fun MonthScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
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)
|
||||||
@@ -142,6 +160,7 @@ fun MonthScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = LocalDate(month.year, month.month, 1),
|
currentDate = LocalDate(month.year, month.month, 1),
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -163,7 +182,7 @@ fun MonthScreen(
|
|||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
month = month,
|
month = month,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -193,14 +212,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 +579,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 +588,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
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import androidx.compose.material.icons.filled.BugReport
|
|||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Dashboard
|
import androidx.compose.material.icons.filled.Dashboard
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
|
import androidx.compose.material.icons.filled.SwapVert
|
||||||
import androidx.compose.material.icons.filled.ExpandLess
|
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
|
||||||
@@ -86,6 +88,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
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
|
||||||
@@ -98,9 +101,14 @@ import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
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.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.ReorderableRowHeight
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||||
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.calendula.ui.common.OptionPicker
|
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||||
@@ -120,7 +128,7 @@ import java.time.format.TextStyle as JavaTextStyle
|
|||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
|
|
||||||
/** The settings sub-screens reached from the hub's category rows. */
|
/** The settings sub-screens reached from the hub's category rows. */
|
||||||
private enum class SettingsSection { Appearance, EventForm, Notifications }
|
private enum class SettingsSection { Appearance, Views, EventForm, Notifications }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token-based accent for a leading icon chip (container / on-container pair).
|
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||||
@@ -165,6 +173,13 @@ fun SettingsScreen(
|
|||||||
) {
|
) {
|
||||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
}
|
}
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = section == SettingsSection.Views,
|
||||||
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
|
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||||
|
) {
|
||||||
|
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||||
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = section == SettingsSection.EventForm,
|
visible = section == SettingsSection.EventForm,
|
||||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||||
@@ -203,6 +218,13 @@ private fun SettingsHub(
|
|||||||
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
|
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
|
||||||
onClick = { onOpenSection(SettingsSection.Appearance) },
|
onClick = { onOpenSection(SettingsSection.Appearance) },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_section_views),
|
||||||
|
summary = stringResource(R.string.settings_views_subtitle),
|
||||||
|
position = Position.Middle,
|
||||||
|
leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Neutral) },
|
||||||
|
onClick = { onOpenSection(SettingsSection.Views) },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_section_event_form),
|
title = stringResource(R.string.settings_section_event_form),
|
||||||
summary = stringResource(R.string.settings_event_form_subtitle),
|
summary = stringResource(R.string.settings_event_form_subtitle),
|
||||||
@@ -475,6 +497,7 @@ 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),
|
||||||
@@ -533,7 +556,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,
|
||||||
@@ -542,16 +565,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),
|
||||||
@@ -620,6 +663,16 @@ 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),
|
||||||
@@ -632,6 +685,131 @@ private fun AppearanceScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Views (#24): reorder the top-bar quick-switch cycle and choose which views it
|
||||||
|
* steps through, plus reorder the navigation-drawer list. Two independent lists —
|
||||||
|
* a view disabled in the quick-switch cycle is still reachable from the drawer,
|
||||||
|
* which always lists every view. The switch needs at least two targets, so the
|
||||||
|
* last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ViewsScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
viewModel: SettingsViewModel,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
CollapsingScaffold(
|
||||||
|
title = stringResource(R.string.settings_section_views),
|
||||||
|
onBack = onBack,
|
||||||
|
) {
|
||||||
|
val config = state.quickSwitchConfig
|
||||||
|
|
||||||
|
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||||
|
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
// Turning a view off is blocked once only the minimum remain enabled.
|
||||||
|
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
|
||||||
|
ReorderableColumn(
|
||||||
|
items = config.order,
|
||||||
|
keyOf = { it },
|
||||||
|
onReorder = { viewModel.setQuickSwitchConfig(config.copy(order = it)) },
|
||||||
|
) { view, position, dragHandle, isDragging ->
|
||||||
|
val checked = view in config.enabled
|
||||||
|
ViewRow(
|
||||||
|
view = view,
|
||||||
|
position = position,
|
||||||
|
isDragging = isDragging,
|
||||||
|
dragHandle = dragHandle,
|
||||||
|
dimmed = !checked,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
// Keep the last two on: with fewer, the pill can't switch.
|
||||||
|
enabled = !checked || canDisable,
|
||||||
|
onCheckedChange = { on ->
|
||||||
|
val enabled = if (on) config.enabled + view else config.enabled - view
|
||||||
|
viewModel.setQuickSwitchConfig(config.copy(enabled = enabled))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SectionHeader(stringResource(R.string.settings_drawer_order_header))
|
||||||
|
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
ReorderableColumn(
|
||||||
|
items = state.drawerViewOrder,
|
||||||
|
keyOf = { it },
|
||||||
|
onReorder = { viewModel.setDrawerViewOrder(it) },
|
||||||
|
) { view, position, dragHandle, isDragging ->
|
||||||
|
ViewRow(
|
||||||
|
view = view,
|
||||||
|
position = position,
|
||||||
|
isDragging = isDragging,
|
||||||
|
dragHandle = dragHandle,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
||||||
|
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
|
||||||
|
@Composable
|
||||||
|
private fun ViewRow(
|
||||||
|
view: CalendarView,
|
||||||
|
position: Position,
|
||||||
|
isDragging: Boolean,
|
||||||
|
dragHandle: Modifier,
|
||||||
|
dimmed: Boolean = false,
|
||||||
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(view.labelRes),
|
||||||
|
position = position,
|
||||||
|
dimmed = dimmed,
|
||||||
|
minHeight = ReorderableRowHeight,
|
||||||
|
// The reorderable column owns the inter-row spacing (uniform pitch).
|
||||||
|
gapBelow = false,
|
||||||
|
container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null,
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = view.icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
trailing?.invoke()
|
||||||
|
if (trailing != null) Spacer(Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = dragHandle.size(48.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DragHandle,
|
||||||
|
contentDescription = stringResource(R.string.reorder_drag_handle),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
|
||||||
|
@Composable
|
||||||
|
private fun SettingsHint(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventFormScreen(
|
private fun EventFormScreen(
|
||||||
state: SettingsUiState,
|
state: SettingsUiState,
|
||||||
@@ -937,7 +1115,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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -947,7 +1125,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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -996,13 +1174,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(): CalendarReminderOverride =
|
private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
|
||||||
if (this == null) CalendarReminderOverride.None else CalendarReminderOverride.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 CalendarReminderOverride.toMinutesOrNull(): Int? =
|
private fun CalendarReminderOverride.toMinutesList(): List<Int> =
|
||||||
(this as? CalendarReminderOverride.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
|
||||||
@@ -1077,27 +1255,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): CalendarReminderOverride = when {
|
private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||||
this[calendarId] == null -> CalendarReminderOverride.None
|
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||||
else -> CalendarReminderOverride.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: CalendarReminderOverride,
|
choice: CalendarReminderOverride,
|
||||||
globalDefault: Int?,
|
globalDefault: List<Int>,
|
||||||
): String = when (choice) {
|
): String = when (choice) {
|
||||||
CalendarReminderOverride.Inherit ->
|
CalendarReminderOverride.Inherit ->
|
||||||
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||||
is CalendarReminderOverride.Minutes -> reminderLeadTimeLabel(choice.minutes)
|
is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1170,6 +1354,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
|
||||||
@@ -8,6 +9,8 @@ import de.jeanlucmakiola.calendula.domain.CalendarSource
|
|||||||
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.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||||
@@ -24,6 +27,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). */
|
||||||
@@ -32,6 +39,10 @@ data class SettingsUiState(
|
|||||||
val agendaShowRangeBar: Boolean = true,
|
val agendaShowRangeBar: Boolean = true,
|
||||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||||
val defaultView: CalendarView = CalendarView.Week,
|
val defaultView: CalendarView = CalendarView.Week,
|
||||||
|
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||||
|
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||||
|
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||||
|
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
/** 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). */
|
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||||
@@ -39,24 +50,24 @@ data class SettingsUiState(
|
|||||||
/** 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(),
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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.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.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
@@ -20,6 +21,8 @@ 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.ui.common.QuickSwitchConfig
|
||||||
|
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
|
||||||
@@ -99,8 +102,16 @@ class SettingsViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
prefs.agendaShowRangeBar,
|
prefs.agendaShowRangeBar,
|
||||||
prefs.autofocusEventTitle,
|
prefs.autofocusEventTitle,
|
||||||
::MiscSettings,
|
prefs.pastEventDisplay,
|
||||||
),
|
prefs.dimCompletedEvents,
|
||||||
|
// View customisation (#24) folded into one flow so it fits this
|
||||||
|
// group — the outer combine is already at its five-arg limit.
|
||||||
|
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
|
||||||
|
ViewCustomization(quickSwitch, drawer)
|
||||||
|
},
|
||||||
|
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||||
|
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||||
|
},
|
||||||
) { base, defaults, overrides, views, misc ->
|
) { base, defaults, overrides, views, misc ->
|
||||||
base.copy(
|
base.copy(
|
||||||
defaultView = views.defaultView,
|
defaultView = views.defaultView,
|
||||||
@@ -110,6 +121,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
showHourLines = views.showHourLines,
|
showHourLines = views.showHourLines,
|
||||||
agendaShowRangeBar = misc.showRangeBar,
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
autofocusEventTitle = misc.autofocusEventTitle,
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
|
dimCompletedEvents = misc.dimCompletedEvents,
|
||||||
|
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||||
|
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -133,15 +148,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>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -156,6 +171,14 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private data class MiscSettings(
|
private data class MiscSettings(
|
||||||
val showRangeBar: Boolean,
|
val showRangeBar: Boolean,
|
||||||
val autofocusEventTitle: Boolean,
|
val autofocusEventTitle: Boolean,
|
||||||
|
val pastEventDisplay: PastEventDisplay,
|
||||||
|
val dimCompletedEvents: Boolean,
|
||||||
|
val viewCustomization: ViewCustomization,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class ViewCustomization(
|
||||||
|
val quickSwitch: QuickSwitchConfig,
|
||||||
|
val drawerOrder: List<CalendarView>,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
@@ -211,6 +234,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) }
|
||||||
}
|
}
|
||||||
@@ -219,6 +262,14 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
||||||
|
viewModelScope.launch { prefs.setQuickSwitchConfig(config) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
|
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setRemindersEnabled(enabled: Boolean) {
|
fun setRemindersEnabled(enabled: Boolean) {
|
||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
@@ -227,11 +278,11 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDefaultReminderMinutes(minutes: Int?) {
|
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +76,16 @@ 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.IMPLEMENTED_VIEWS
|
||||||
|
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
|
||||||
@@ -124,11 +132,21 @@ fun WeekScreen(
|
|||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
|
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
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)
|
||||||
@@ -180,6 +198,7 @@ fun WeekScreen(
|
|||||||
CalendarDrawer(
|
CalendarDrawer(
|
||||||
currentView = selectedView,
|
currentView = selectedView,
|
||||||
currentDate = weekStart,
|
currentDate = weekStart,
|
||||||
|
viewOrder = drawerViewOrder,
|
||||||
onSelectView = { view ->
|
onSelectView = { view ->
|
||||||
onSelectView(view)
|
onSelectView(view)
|
||||||
scope.launch { drawerState.close() }
|
scope.launch { drawerState.close() }
|
||||||
@@ -201,7 +220,7 @@ fun WeekScreen(
|
|||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next()) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
@@ -221,19 +240,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 +571,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 +715,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 +736,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 +745,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 +774,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,11 +43,15 @@ 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.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,6 +193,8 @@ private fun AgendaHeader() {
|
|||||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
// Tap the "Upcoming" title to open the app on the user's default view
|
||||||
|
// (issue #20) — a null target resolves to the default home in the host.
|
||||||
Text(
|
Text(
|
||||||
text = context.getString(R.string.widget_agenda_title),
|
text = context.getString(R.string.widget_agenda_title),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -167,7 +202,11 @@ private fun AgendaHeader() {
|
|||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
),
|
),
|
||||||
modifier = GlanceModifier.defaultWeight(),
|
modifier = GlanceModifier
|
||||||
|
.defaultWeight()
|
||||||
|
.clickable(
|
||||||
|
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
IconButton(
|
IconButton(
|
||||||
resId = R.drawable.ic_widget_refresh,
|
resId = R.drawable.ic_widget_refresh,
|
||||||
@@ -224,9 +263,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 +294,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),
|
||||||
|
|||||||
@@ -194,6 +194,8 @@ private fun MonthHeader(label: String) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// Tap the month/year title to open the app on the month view (issue #18),
|
||||||
|
// rooted over the default home so backing out returns there.
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -204,16 +206,17 @@ private fun MonthHeader(label: String) {
|
|||||||
),
|
),
|
||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.defaultWeight()
|
.defaultWeight()
|
||||||
.clickable(actionRunCallback<ResetMonthAction>()),
|
.clickable(
|
||||||
|
actionStartActivity(
|
||||||
|
MainActivity.openViewIntent(context, CalendarView.Month),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
// The "today" button snaps the grid back to the current month in place.
|
||||||
HeaderIcon(
|
HeaderIcon(
|
||||||
resId = R.drawable.ic_widget_today,
|
resId = R.drawable.ic_widget_today,
|
||||||
contentDescription = context.getString(R.string.widget_today),
|
contentDescription = context.getString(R.string.widget_today),
|
||||||
onClick = GlanceModifier.clickable(
|
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||||
actionStartActivity(
|
|
||||||
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
HeaderIcon(
|
HeaderIcon(
|
||||||
resId = R.drawable.ic_widget_chevron_right,
|
resId = R.drawable.ic_widget_chevron_right,
|
||||||
@@ -301,6 +304,15 @@ private fun WeekRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||||
|
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||||
|
* Shared by every tappable part of a day column so, as in the app, a tap anywhere
|
||||||
|
* on a day opens it; only an event bar on top opts out to open its own detail.
|
||||||
|
*/
|
||||||
|
private fun openDayAction(context: Context, date: LocalDate) =
|
||||||
|
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -308,11 +320,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
|||||||
modifier = GlanceModifier
|
modifier = GlanceModifier
|
||||||
.width(colW)
|
.width(colW)
|
||||||
.height(DAY_NUMBER_HEIGHT)
|
.height(DAY_NUMBER_HEIGHT)
|
||||||
// Tap a day number to open that day, rooted in the month view so back
|
.clickable(openDayAction(context, date)),
|
||||||
// returns to the month grid.
|
|
||||||
.clickable(
|
|
||||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
@@ -339,6 +347,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
var col = 0
|
var col = 0
|
||||||
while (col < 7) {
|
while (col < 7) {
|
||||||
@@ -352,7 +361,14 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
|||||||
if (timed != null) {
|
if (timed != null) {
|
||||||
SpanBar(event = timed, dark = dark, width = colW)
|
SpanBar(event = timed, dark = dark, width = colW)
|
||||||
} else {
|
} else {
|
||||||
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
|
// Empty lane cell: a tap opens that day, so blank space in a
|
||||||
|
// day column is a day-open target just like the number is.
|
||||||
|
Box(
|
||||||
|
GlanceModifier
|
||||||
|
.width(colW)
|
||||||
|
.height(LANE_HEIGHT)
|
||||||
|
.clickable(openDayAction(context, week.days[col])),
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
col += 1
|
col += 1
|
||||||
}
|
}
|
||||||
@@ -401,14 +417,20 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||||
|
val context = LocalContext.current
|
||||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||||
week.days.forEachIndexed { col, date ->
|
week.days.forEachIndexed { col, date ->
|
||||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||||
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||||
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||||
|
// The overflow row is part of the day column too: tapping it (whether
|
||||||
|
// it shows "+N" or is blank) opens that day, same as the app.
|
||||||
Box(
|
Box(
|
||||||
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
|
modifier = GlanceModifier
|
||||||
|
.width(colW)
|
||||||
|
.height(LANE_HEIGHT)
|
||||||
|
.clickable(openDayAction(context, date)),
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
if (hidden > 0) {
|
if (hidden > 0) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">Calendula</string>
|
<string name="app_name">Calendula</string>
|
||||||
<string name="app_tagline">Ein moderner Kalender.</string>
|
<string name="app_tagline">Ein moderner Kalender.</string>
|
||||||
|
|
||||||
<string name="state_loading">Lädt…</string>
|
<string name="state_loading">Lädt…</string>
|
||||||
<string name="state_retry">Erneut versuchen</string>
|
<string name="state_retry">Erneut versuchen</string>
|
||||||
<string name="state_failure_unknown">Etwas ist schiefgelaufen.</string>
|
<string name="state_failure_unknown">Etwas ist schiefgelaufen.</string>
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
|
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
|
||||||
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
|
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
|
||||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||||
|
|
||||||
<!-- Permission-Flow (F1) -->
|
<!-- Permission-Flow (F1) -->
|
||||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
|
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
|
||||||
@@ -26,7 +25,6 @@
|
|||||||
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
||||||
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
|
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
|
||||||
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
|
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
|
||||||
|
|
||||||
<!-- Monatsansicht (S1) -->
|
<!-- Monatsansicht (S1) -->
|
||||||
<string name="month_prev">Vorheriger Monat</string>
|
<string name="month_prev">Vorheriger Monat</string>
|
||||||
<string name="month_next">Nächster Monat</string>
|
<string name="month_next">Nächster Monat</string>
|
||||||
@@ -35,14 +33,11 @@
|
|||||||
<string name="month_open_menu">Menü öffnen</string>
|
<string name="month_open_menu">Menü öffnen</string>
|
||||||
<string name="month_action_settings">Einstellungen</string>
|
<string name="month_action_settings">Einstellungen</string>
|
||||||
<string name="month_a11y_today_prefix">Heute</string>
|
<string name="month_a11y_today_prefix">Heute</string>
|
||||||
|
|
||||||
<!-- Wochenansicht (S2) -->
|
<!-- Wochenansicht (S2) -->
|
||||||
<string name="week_today_action">Diese Woche</string>
|
<string name="week_today_action">Diese Woche</string>
|
||||||
<string name="week_number_label">KW</string>
|
<string name="week_number_label">KW</string>
|
||||||
|
|
||||||
<!-- Tagesansicht (S3) -->
|
<!-- Tagesansicht (S3) -->
|
||||||
<string name="day_today_action">Heute</string>
|
<string name="day_today_action">Heute</string>
|
||||||
|
|
||||||
<!-- Event-Detail-Screen (S4) -->
|
<!-- Event-Detail-Screen (S4) -->
|
||||||
<string name="event_detail_back">Zurück</string>
|
<string name="event_detail_back">Zurück</string>
|
||||||
<string name="event_detail_edit">Bearbeiten</string>
|
<string name="event_detail_edit">Bearbeiten</string>
|
||||||
@@ -61,7 +56,6 @@
|
|||||||
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
||||||
<string name="dialog_cancel">Abbrechen</string>
|
<string name="dialog_cancel">Abbrechen</string>
|
||||||
<string name="dialog_ok">OK</string>
|
<string name="dialog_ok">OK</string>
|
||||||
|
|
||||||
<!-- Termin-Formular (v1.2 Erstellen) -->
|
<!-- Termin-Formular (v1.2 Erstellen) -->
|
||||||
<string name="event_edit_new_title">Neuer Termin</string>
|
<string name="event_edit_new_title">Neuer Termin</string>
|
||||||
<string name="event_edit_close">Schließen</string>
|
<string name="event_edit_close">Schließen</string>
|
||||||
@@ -94,7 +88,6 @@
|
|||||||
<string name="reminder_unit_weeks">Wochen</string>
|
<string name="reminder_unit_weeks">Wochen</string>
|
||||||
<string name="event_edit_availability">Verfügbarkeit</string>
|
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||||
<string name="event_edit_visibility">Sichtbarkeit</string>
|
<string name="event_edit_visibility">Sichtbarkeit</string>
|
||||||
|
|
||||||
<!-- Termin-Formular — eigene Terminfarbe -->
|
<!-- Termin-Formular — eigene Terminfarbe -->
|
||||||
<string name="event_edit_color">Farbe</string>
|
<string name="event_edit_color">Farbe</string>
|
||||||
<string name="event_edit_color_default">Kalenderfarbe</string>
|
<string name="event_edit_color_default">Kalenderfarbe</string>
|
||||||
@@ -103,7 +96,6 @@
|
|||||||
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
|
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
|
||||||
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
|
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
|
||||||
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
|
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
|
||||||
|
|
||||||
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
||||||
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
||||||
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
||||||
@@ -113,7 +105,6 @@
|
|||||||
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
|
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
|
||||||
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
|
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
|
||||||
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
|
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
|
||||||
|
|
||||||
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
|
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
|
||||||
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
||||||
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
||||||
@@ -156,7 +147,6 @@
|
|||||||
<string name="event_attendee_tentative">Vorläufig</string>
|
<string name="event_attendee_tentative">Vorläufig</string>
|
||||||
<string name="event_attendee_needs_action">Keine Antwort</string>
|
<string name="event_attendee_needs_action">Keine Antwort</string>
|
||||||
<string name="event_attendee_unknown">—</string>
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
|
||||||
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
|
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
|
||||||
<string name="event_detail_reminders">Erinnerungen</string>
|
<string name="event_detail_reminders">Erinnerungen</string>
|
||||||
<string name="event_detail_timezone">Zeitzone</string>
|
<string name="event_detail_timezone">Zeitzone</string>
|
||||||
@@ -196,10 +186,8 @@
|
|||||||
<item quantity="one">%d Stunde</item>
|
<item quantity="one">%d Stunde</item>
|
||||||
<item quantity="other">%d Stunden</item>
|
<item quantity="other">%d Stunden</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
<!-- Geteilte Event-Strings -->
|
<!-- Geteilte Event-Strings -->
|
||||||
<string name="event_untitled">(Ohne Titel)</string>
|
<string name="event_untitled">(Ohne Titel)</string>
|
||||||
|
|
||||||
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
|
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
|
||||||
<string name="reminder_channel_name">Termin-Erinnerungen</string>
|
<string name="reminder_channel_name">Termin-Erinnerungen</string>
|
||||||
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
|
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
|
||||||
@@ -215,23 +203,19 @@
|
|||||||
<string name="reminder_onboarding_skip_button">Später</string>
|
<string name="reminder_onboarding_skip_button">Später</string>
|
||||||
<string name="reminder_action_snooze">Schlummern</string>
|
<string name="reminder_action_snooze">Schlummern</string>
|
||||||
<string name="reminder_action_dismiss">Verwerfen</string>
|
<string name="reminder_action_dismiss">Verwerfen</string>
|
||||||
|
|
||||||
<!-- View-Switcher (M1) -->
|
<!-- View-Switcher (M1) -->
|
||||||
<string name="view_month">Monat</string>
|
<string name="view_month">Monat</string>
|
||||||
<string name="view_week">Woche</string>
|
<string name="view_week">Woche</string>
|
||||||
<string name="view_day">Tag</string>
|
<string name="view_day">Tag</string>
|
||||||
<string name="view_agenda">Agenda</string>
|
<string name="view_agenda">Agenda</string>
|
||||||
<string name="view_section">Ansicht</string>
|
<string name="view_section">Ansicht</string>
|
||||||
|
|
||||||
<!-- Zu Datum springen (Navigationsleiste) -->
|
<!-- Zu Datum springen (Navigationsleiste) -->
|
||||||
<string name="drawer_jump_to_date">Zu Datum springen</string>
|
<string name="drawer_jump_to_date">Zu Datum springen</string>
|
||||||
|
|
||||||
<!-- Agenda-Ansicht -->
|
<!-- Agenda-Ansicht -->
|
||||||
<string name="agenda_today_action">Heute</string>
|
<string name="agenda_today_action">Heute</string>
|
||||||
<string name="agenda_header_today">Heute</string>
|
<string name="agenda_header_today">Heute</string>
|
||||||
<string name="agenda_header_tomorrow">Morgen</string>
|
<string name="agenda_header_tomorrow">Morgen</string>
|
||||||
<string name="agenda_empty_title">Alles erledigt</string>
|
<string name="agenda_empty_title">Alles erledigt</string>
|
||||||
|
|
||||||
<!-- Terminsuche -->
|
<!-- Terminsuche -->
|
||||||
<string name="search_action">Suchen</string>
|
<string name="search_action">Suchen</string>
|
||||||
<string name="search_hint">Termine suchen</string>
|
<string name="search_hint">Termine suchen</string>
|
||||||
@@ -239,7 +223,6 @@
|
|||||||
<string name="search_clear">Löschen</string>
|
<string name="search_clear">Löschen</string>
|
||||||
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
|
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
|
||||||
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
|
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
|
||||||
|
|
||||||
<!-- Startbildschirm-Widgets -->
|
<!-- Startbildschirm-Widgets -->
|
||||||
<string name="widget_agenda_title">Anstehend</string>
|
<string name="widget_agenda_title">Anstehend</string>
|
||||||
<string name="widget_agenda_label">Calendula Agenda</string>
|
<string name="widget_agenda_label">Calendula Agenda</string>
|
||||||
@@ -250,19 +233,15 @@
|
|||||||
<string name="widget_prev_month">Vorheriger Monat</string>
|
<string name="widget_prev_month">Vorheriger Monat</string>
|
||||||
<string name="widget_next_month">Nächster Monat</string>
|
<string name="widget_next_month">Nächster Monat</string>
|
||||||
<string name="widget_today">Heute</string>
|
<string name="widget_today">Heute</string>
|
||||||
|
|
||||||
<!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
|
<!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
|
||||||
<string name="shortcut_new_event_short">Neuer Termin</string>
|
<string name="shortcut_new_event_short">Neuer Termin</string>
|
||||||
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
||||||
|
|
||||||
<!-- Schnelleinstellungen-Kachel -->
|
<!-- Schnelleinstellungen-Kachel -->
|
||||||
<string name="qs_tile_new_event_label">Neuer Termin</string>
|
<string name="qs_tile_new_event_label">Neuer Termin</string>
|
||||||
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
|
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
|
||||||
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
|
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
|
||||||
|
|
||||||
<!-- Kalender-Filter (M3) -->
|
<!-- Kalender-Filter (M3) -->
|
||||||
<string name="filter_title">Kalender</string>
|
<string name="filter_title">Kalender</string>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
@@ -316,7 +295,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>
|
||||||
@@ -344,7 +322,6 @@
|
|||||||
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
||||||
<string name="settings_report_problem">Problem melden</string>
|
<string name="settings_report_problem">Problem melden</string>
|
||||||
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
|
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
|
||||||
|
|
||||||
<!-- Calendar manager -->
|
<!-- Calendar manager -->
|
||||||
<string name="calendars_title">Kalender</string>
|
<string name="calendars_title">Kalender</string>
|
||||||
<string name="calendars_local_header">Deine Kalender</string>
|
<string name="calendars_local_header">Deine Kalender</string>
|
||||||
@@ -399,7 +376,6 @@
|
|||||||
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
|
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
|
||||||
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
|
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
|
||||||
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
||||||
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
|
<string name="crash_dialog_title">Calendula ist abgestürzt</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_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>
|
||||||
|
|||||||
9
app/src/main/res/values-es/strings.xml
Normal file
9
app/src/main/res/values-es/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values-fr/strings.xml
Normal file
9
app/src/main/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values-it/strings.xml
Normal file
9
app/src/main/res/values-it/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values-pl/strings.xml
Normal file
9
app/src/main/res/values-pl/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values-pt/strings.xml
Normal file
9
app/src/main/res/values-pt/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values-ru/strings.xml
Normal file
9
app/src/main/res/values-ru/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
|
</resources>
|
||||||
@@ -282,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>
|
||||||
@@ -301,6 +308,12 @@
|
|||||||
<item quantity="one">%d day</item>
|
<item quantity="one">%d day</item>
|
||||||
<item quantity="other">%d days</item>
|
<item quantity="other">%d days</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="settings_section_views">Views</string>
|
||||||
|
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||||
|
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
||||||
|
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||||
|
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
|
||||||
|
<string name="reorder_drag_handle">Drag to reorder</string>
|
||||||
<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">Focus title on new event</string>
|
||||||
@@ -317,7 +330,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>
|
||||||
@@ -336,6 +348,7 @@
|
|||||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||||
|
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
|
||||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||||
<string name="settings_section_about">About</string>
|
<string name="settings_section_about">About</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
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ 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.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
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
|
||||||
@@ -149,6 +152,78 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.defaultFormFields.first()).isEmpty()
|
assertThat(prefs.defaultFormFields.first()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `quick-switch defaults to every view enabled in default order`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
val config = prefs.quickSwitchConfig.first()
|
||||||
|
assertThat(config.order).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||||
|
assertThat(config.enabled).containsExactlyElementsIn(IMPLEMENTED_VIEWS)
|
||||||
|
assertThat(config.cycle).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `quick-switch config round-trips order and disabled views`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
val config = QuickSwitchConfig(
|
||||||
|
order = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day),
|
||||||
|
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
|
||||||
|
)
|
||||||
|
prefs.setQuickSwitchConfig(config)
|
||||||
|
val loaded = prefs.quickSwitchConfig.first()
|
||||||
|
assertThat(loaded.order).containsExactly(
|
||||||
|
CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day,
|
||||||
|
).inOrder()
|
||||||
|
assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month)
|
||||||
|
assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `quick-switch parse appends missing views enabled and drops unknowns`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
// Only Day (disabled) and Week stored, plus a bogus name; Month & Agenda absent.
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.QUICK_SWITCH_VIEWS_KEY] = "!Day,Week,Hologram"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
val config = prefs.quickSwitchConfig.first()
|
||||||
|
// Stored order first (Day, Week), then the omitted views in default order.
|
||||||
|
assertThat(config.order).containsExactly(
|
||||||
|
CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
|
||||||
|
).inOrder()
|
||||||
|
// Day was explicitly disabled; the appended Month & Agenda default enabled.
|
||||||
|
assertThat(config.enabled).containsExactly(
|
||||||
|
CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.drawerViewOrder.first()).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||||
|
prefs.setDrawerViewOrder(
|
||||||
|
listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month),
|
||||||
|
)
|
||||||
|
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
||||||
|
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `drawer order parse appends missing views and drops unknowns`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.DRAWER_VIEW_ORDER_KEY] = "Agenda,Nope,Week"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
||||||
|
CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day,
|
||||||
|
).inOrder()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
|
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
|
||||||
val store = newDataStore(tempDir)
|
val store = newDataStore(tempDir)
|
||||||
@@ -183,22 +258,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 ->
|
||||||
@@ -206,7 +297,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
|
||||||
@@ -214,16 +305,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, CalendarReminderOverride.Minutes(15))
|
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
|
||||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.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, CalendarReminderOverride.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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,48 +322,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, CalendarReminderOverride.Minutes(15))
|
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
|
||||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.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, CalendarReminderOverride.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,4 +91,33 @@ class ViewBackStackTest {
|
|||||||
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
|
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
|
||||||
.containsExactly(CalendarView.Agenda)
|
.containsExactly(CalendarView.Agenda)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next cycles through the configured views in order, wrapping around`() {
|
||||||
|
val cycle = listOf(CalendarView.Month, CalendarView.Agenda)
|
||||||
|
assertThat(CalendarView.Month.next(cycle)).isEqualTo(CalendarView.Agenda)
|
||||||
|
// Wraps back to the first from the last.
|
||||||
|
assertThat(CalendarView.Agenda.next(cycle)).isEqualTo(CalendarView.Month)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next from a view outside the cycle lands on the first enabled view`() {
|
||||||
|
// Day was disabled in the quick-switch cycle but is still the current view.
|
||||||
|
val cycle = listOf(CalendarView.Week, CalendarView.Month)
|
||||||
|
assertThat(CalendarView.Day.next(cycle)).isEqualTo(CalendarView.Week)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `next on an empty cycle stays put`() {
|
||||||
|
assertThat(CalendarView.Week.next(emptyList())).isEqualTo(CalendarView.Week)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `quick-switch cycle keeps enabled views in their configured order`() {
|
||||||
|
val config = QuickSwitchConfig(
|
||||||
|
order = listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Month, CalendarView.Week),
|
||||||
|
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
|
||||||
|
)
|
||||||
|
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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]).
|
||||||
|
|
||||||
Reference in New Issue
Block a user