Compare commits
72 Commits
release/v2
...
fdee311658
| Author | SHA1 | Date | |
|---|---|---|---|
| fdee311658 | |||
|
|
5844539db2 | ||
|
|
2cc83d7714 | ||
|
|
dada1bbe72 | ||
|
|
e061cbe127 | ||
|
|
67511ce9be | ||
|
|
2e90682874 | ||
|
|
2a4cb6a896 | ||
| 69460bc35a | |||
|
|
bf10deaf5c | ||
|
|
b9e861af62 | ||
|
|
2eadda937b | ||
|
|
71b90fa184 | ||
|
|
4e9cf88bd4 | ||
|
|
b999202629 | ||
|
|
6f0e13a4a2 | ||
|
|
9ef7b33800 | ||
|
|
d04b431f2c | ||
|
|
dbeeadff80 | ||
|
|
f4a169ef5f | ||
|
|
604099b3f2 | ||
|
|
a3e6541512 | ||
| fb2a40114a | |||
| e1f79df67a | |||
| e258a4a7f2 | |||
| 3f27a47d1e | |||
| feb87a1e73 | |||
| 28e464a616 | |||
| 93df6d6d62 | |||
| 49ddb9437b | |||
| e1cf00999d | |||
| 2d0d707a7d | |||
| de1fe31223 | |||
| 273cfce969 | |||
| b6a45b7264 | |||
| c7ef52e426 | |||
| b1151b6516 | |||
| 1664625bc5 | |||
| e8b87446f7 | |||
| dc478516cd | |||
| 8945b877b2 | |||
| 1424f0ffc0 | |||
| 548e581554 | |||
| a6ae4d7e1d | |||
| 614f4f2d75 | |||
| e6736b049a | |||
| 301904be6d | |||
| 3ca7200624 | |||
| c30a153552 | |||
| bb92d02000 | |||
| cd703e9d54 | |||
| f27b0e269d | |||
| 0f31981d26 | |||
| 6471c48528 | |||
| f8a2569831 | |||
| 6ba7f38887 | |||
| 366059b012 | |||
| 3cf1439850 | |||
| b2bfdc0f42 | |||
| 7c9830deda | |||
| 5e98059388 | |||
| 53c0482760 | |||
| f8efa4d3e4 | |||
| 93cc264fbf | |||
| 585cdac4ab | |||
| 9994e8c534 | |||
| 766c2ffcf8 | |||
| add88fbadf | |||
| 09a1aecb76 | |||
| c91608e48a | |||
| 47e8036000 | |||
| 07a8faf2df |
@@ -5,6 +5,7 @@ title: "Crash: "
|
|||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
- crash
|
- crash
|
||||||
|
- priority:high
|
||||||
---
|
---
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: Feature request
|
|||||||
about: Suggest an idea or improvement
|
about: Suggest an idea or improvement
|
||||||
title: ""
|
title: ""
|
||||||
labels:
|
labels:
|
||||||
- enhancement
|
- feat
|
||||||
---
|
---
|
||||||
|
|
||||||
### What would you like Calendula to do?
|
### What would you like Calendula to do?
|
||||||
|
|||||||
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: Question
|
||||||
|
about: Ask how something works or get help using Calendula
|
||||||
|
title: ""
|
||||||
|
labels:
|
||||||
|
- question
|
||||||
|
---
|
||||||
|
|
||||||
|
### Your question
|
||||||
|
|
||||||
|
|
||||||
|
### What you've tried
|
||||||
|
<!-- so far, if anything -->
|
||||||
|
|
||||||
|
|
||||||
|
### Context
|
||||||
|
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||||
|
- Android version:
|
||||||
|
- Device:
|
||||||
102
CHANGELOG.md
102
CHANGELOG.md
@@ -5,6 +5,98 @@ 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).
|
||||||
|
|
||||||
|
## [2.12.0] — 2026-06-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- See past events your way. Two new settings change how events that have already
|
||||||
|
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||||
|
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||||
|
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||||
|
(Settings → Appearance) separately fades finished events in the month and week
|
||||||
|
views. Both are off by default, an event only counts as finished once it has
|
||||||
|
actually ended (events still in progress are never dimmed), and the lists
|
||||||
|
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||||
|
([#12]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Readable titles on overlapping events. In the week and day views, events that
|
||||||
|
overlap split a day into slim columns where the title used to be clipped to a
|
||||||
|
character or two. The title now wraps across as many lines as the block can
|
||||||
|
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||||
|
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||||
|
for the suggestion ([#13]).
|
||||||
|
|
||||||
|
## [2.11.2] — 2026-06-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||||
|
the cursor in the title field and opens the keyboard right away, so you can type
|
||||||
|
the name without an extra tap. It's on by default and only affects creating an
|
||||||
|
event — editing never grabs focus — and a new **Focus title on new event**
|
||||||
|
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#10]).
|
||||||
|
|
||||||
|
## [2.11.1] — 2026-06-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Calendula can now be set as your default calendar app. It registers the
|
||||||
|
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||||
|
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||||
|
that day. Android has no way for an app to make itself the default, so you pick
|
||||||
|
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||||
|
|
||||||
|
## [2.11.0] — 2026-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Start the week on any day. The **Week starts on** setting (Settings →
|
||||||
|
Appearance) now offers every weekday — not just Monday or Sunday — alongside
|
||||||
|
the automatic, locale-based default. Thanks to @zmaherdev for the suggestion
|
||||||
|
([#3]).
|
||||||
|
- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings →
|
||||||
|
Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or
|
||||||
|
follow the system setting automatically. It applies everywhere times appear —
|
||||||
|
the week and day timelines, agenda, event details, search and reminders — so
|
||||||
|
the format is now consistent across the whole app. Thanks to @zmaherdev for
|
||||||
|
the suggestion ([#6]).
|
||||||
|
- Optional hour lines in the timeline. A new **Hour lines** switch (Settings →
|
||||||
|
Appearance) draws a faint separator at each hour in the week and day views,
|
||||||
|
making it easier to see when events start and end. Off by default. Thanks to
|
||||||
|
@zmaherdev for the suggestion ([#5]).
|
||||||
|
- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda
|
||||||
|
range** and **Agenda widget range** settings (Settings → Appearance) let the
|
||||||
|
agenda screen and its home-screen widget each show just today, the rest of this
|
||||||
|
week, the rest of this month, a rolling 7 or 30 days, or a custom number of
|
||||||
|
days — "This week" follows your week-start. The agenda also gains a bar at the
|
||||||
|
top naming the exact dates in view, with a button to switch the range just for
|
||||||
|
the current session (it resets when you reopen the app); the bar can be turned
|
||||||
|
off. Thanks to @zmaherdev for the suggestion ([#4]).
|
||||||
|
- Automatic backup of local calendars. A new **Automatic backup** option
|
||||||
|
(Settings → Calendars → Backup) periodically exports your local calendars to an
|
||||||
|
`.ics` file in a folder you choose, on an interval you set (from 30 minutes up).
|
||||||
|
Useful for file-based syncing such as Syncthing, or simply as a safety net — it
|
||||||
|
is a one-way export and never touches your synced accounts. Thanks to @shield
|
||||||
|
for the discussion that inspired it ([#7], [#8]).
|
||||||
|
- Field icons in the event-form settings. Each optional-field toggle (Settings →
|
||||||
|
Event form) now shows the same icon the field uses in the new-event form, so
|
||||||
|
the list is easier to scan.
|
||||||
|
- Help translate Calendula. A **Help translate** link at the top of **Settings →
|
||||||
|
App language** opens the project's Weblate, where you can add or improve a
|
||||||
|
language in your browser — no coding needed. Contributions in any language are
|
||||||
|
welcome.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
||||||
|
agenda), and the agenda's empty state now reads "You're all caught up".
|
||||||
|
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
|
||||||
|
into collapsible, source-branded cards — each account shows its app's icon —
|
||||||
|
with a per-account menu to enable or disable all of its calendars at once or
|
||||||
|
open the account in its source app.
|
||||||
|
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
|
||||||
|
moved up with the other global reminder options, and the per-calendar reminder
|
||||||
|
overrides now fold into a single expandable section.
|
||||||
|
- Moved **Add Quick Settings tile** out of the event-form section to its own
|
||||||
|
top-level entry in Settings.
|
||||||
|
|
||||||
## [2.10.0] — 2026-06-25
|
## [2.10.0] — 2026-06-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -627,3 +719,13 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
|
|
||||||
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
|
[#1]: https://codeberg.org/jlmakiola/calendula/issues/1
|
||||||
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
|
[#2]: https://codeberg.org/jlmakiola/calendula/issues/2
|
||||||
|
[#3]: https://codeberg.org/jlmakiola/calendula/issues/3
|
||||||
|
[#4]: https://codeberg.org/jlmakiola/calendula/issues/4
|
||||||
|
[#5]: https://codeberg.org/jlmakiola/calendula/issues/5
|
||||||
|
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
||||||
|
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
||||||
|
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||||
|
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
||||||
|
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
|
||||||
|
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
|
||||||
|
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -68,7 +68,8 @@ database, no sync stack reinvented.
|
|||||||
|
|
||||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||||
expressive motion and shapes, light/dark theme
|
expressive motion and shapes, light/dark theme
|
||||||
- German and English UI, per-app language setting
|
- German and English UI, per-app language setting — and [open to community
|
||||||
|
translations](#-translations)
|
||||||
- **Zero telemetry, zero analytics, no internet permission** — your data
|
- **Zero telemetry, zero analytics, no internet permission** — your data
|
||||||
never leaves the device
|
never leaves the device
|
||||||
|
|
||||||
@@ -108,6 +109,17 @@ without reinstalling. Or build from source — see below.
|
|||||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||||
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
||||||
|
|
||||||
|
## 🌍 Translations
|
||||||
|
|
||||||
|
Calendula ships in German and English, and you're warmly invited to add your
|
||||||
|
language. Translations are managed on a self-hosted **Weblate**:
|
||||||
|
|
||||||
|
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
||||||
|
|
||||||
|
No coding needed — register on the Weblate server, pick (or request) a language,
|
||||||
|
and translate the strings in your browser. You can also reach this link in the
|
||||||
|
app from the top of **Settings → App language**.
|
||||||
|
|
||||||
## 📜 License
|
## 📜 License
|
||||||
|
|
||||||
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
||||||
|
|||||||
@@ -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 = 21000
|
versionCode = 21200
|
||||||
versionName = "2.10.0"
|
versionName = "2.12.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -152,6 +152,9 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
implementation(libs.androidx.documentfile)
|
||||||
|
|
||||||
implementation(libs.androidx.glance.appwidget)
|
implementation(libs.androidx.glance.appwidget)
|
||||||
implementation(libs.androidx.glance.material3)
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,35 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Be selectable as the system calendar app. Android has no API for
|
||||||
|
an app to make itself the default, so registering the filters
|
||||||
|
launchers and the OS use is what lets the user pick Calendula
|
||||||
|
from the system chooser when a date action fires (issue #9).
|
||||||
|
APP_CALENDAR is the "open the calendar app" action; the VIEW
|
||||||
|
filters below catch a tapped date. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.APP_CALENDAR" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
|
||||||
|
time Uri (content://com.android.calendar/time/<epochMillis>);
|
||||||
|
some surfaces use the time/epoch mime type. We open the day view
|
||||||
|
on that date (MainActivity.calendarTimeDateOrNull). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data
|
||||||
|
android:scheme="content"
|
||||||
|
android:host="com.android.calendar"
|
||||||
|
android:pathPrefix="/time" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="time/epoch" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
package de.jeanlucmakiola.calendula
|
package de.jeanlucmakiola.calendula
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application entry point. Registered as android:name=".CalendulaApp"
|
* Application entry point. Registered as android:name=".CalendulaApp"
|
||||||
@@ -16,5 +24,24 @@ class CalendulaApp : Application() {
|
|||||||
// Install first thing so startup crashes are captured too (privacy-
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
// respecting, on-device; the user submits the report by hand).
|
// respecting, on-device; the user submits the report by hand).
|
||||||
CrashReporter.install(this)
|
CrashReporter.install(this)
|
||||||
|
reconcileAutoBackup()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring the scheduled auto-backup work back in line with the saved settings
|
||||||
|
* on every launch — re-arms it after a reinstall and, crucially, cancels any
|
||||||
|
* orphaned work once backup has been turned off.
|
||||||
|
*/
|
||||||
|
private fun reconcileAutoBackup() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java)
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
val prefs = deps.settingsPrefs()
|
||||||
|
BackupScheduler.apply(
|
||||||
|
context = this@CalendulaApp,
|
||||||
|
enabled = prefs.autoBackupEnabled.first(),
|
||||||
|
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||||
|
hasFolder = prefs.autoBackupFolderUri.first() != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ 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.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
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.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.content.IntentCompat
|
import androidx.core.content.IntentCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -20,7 +23,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
|
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.crash.CrashReportActivity
|
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||||
@@ -29,6 +35,9 @@ import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
|||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
@@ -81,19 +90,31 @@ class MainActivity : AppCompatActivity() {
|
|||||||
ThemeMode.LIGHT -> false
|
ThemeMode.LIGHT -> false
|
||||||
ThemeMode.DARK -> true
|
ThemeMode.DARK -> true
|
||||||
}
|
}
|
||||||
|
// The app-wide clock convention: the time-format preference resolved
|
||||||
|
// against the device's 24-hour system setting, provided once here so
|
||||||
|
// every time label reads it via LocalUse24HourFormat.
|
||||||
|
val context = LocalContext.current
|
||||||
|
val use24Hour = remember(settings.timeFormat, context) {
|
||||||
|
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
|
}
|
||||||
CalendulaTheme(
|
CalendulaTheme(
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = settings.dynamicColor,
|
dynamicColor = settings.dynamicColor,
|
||||||
) {
|
) {
|
||||||
RootScreen(
|
CompositionLocalProvider(
|
||||||
modifier = Modifier.fillMaxSize(),
|
LocalUse24HourFormat provides use24Hour,
|
||||||
requestedDetailKey = requestedDetailKey,
|
LocalShowHourLines provides settings.showHourLines,
|
||||||
onDetailKeyConsumed = { requestedDetailKey = null },
|
) {
|
||||||
widgetNavRequest = requestedNav,
|
RootScreen(
|
||||||
onWidgetNavConsumed = { requestedNav = null },
|
modifier = Modifier.fillMaxSize(),
|
||||||
requestedImportUri = requestedImportUri,
|
requestedDetailKey = requestedDetailKey,
|
||||||
onImportConsumed = { requestedImportUri = null },
|
onDetailKeyConsumed = { requestedDetailKey = null },
|
||||||
)
|
widgetNavRequest = requestedNav,
|
||||||
|
onWidgetNavConsumed = { requestedNav = null },
|
||||||
|
requestedImportUri = requestedImportUri,
|
||||||
|
onImportConsumed = { requestedImportUri = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
pendingCrashReport?.let { report ->
|
pendingCrashReport?.let { report ->
|
||||||
CrashReportDialog(
|
CrashReportDialog(
|
||||||
report = report,
|
report = report,
|
||||||
@@ -138,10 +159,33 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
else -> null
|
else -> null
|
||||||
} ?: return null
|
} ?: return null
|
||||||
|
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||||
|
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
|
||||||
|
if (uri.host == CALENDAR_PROVIDER_HOST) return null
|
||||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||||
|
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||||
|
* <epochMillis>`. Null for any other intent. The matching manifest filter is
|
||||||
|
* what lets users pick Calendula from the system calendar chooser (issue #9).
|
||||||
|
*/
|
||||||
|
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
|
||||||
|
if (action != Intent.ACTION_VIEW) return null
|
||||||
|
val uri = data ?: return null
|
||||||
|
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||||
|
val segments = uri.pathSegments
|
||||||
|
if (segments.firstOrNull() != "time") return null
|
||||||
|
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||||
|
return Instant.fromEpochMilliseconds(millis)
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
}
|
||||||
|
|
||||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||||
|
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||||
|
// the day view rooted over the default home view (OpenDate source = null).
|
||||||
|
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||||
val source = sourceViewOrNull()
|
val source = sourceViewOrNull()
|
||||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
return when {
|
return when {
|
||||||
@@ -183,6 +227,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
|
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||||
|
|
||||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||||
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.backup
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules and runs the automatic periodic `.ics` backup of local calendars
|
||||||
|
* (issue #8). One-way export only — it mirrors the manual "Back up" into a
|
||||||
|
* user-chosen folder on an interval; it is not a sync. Stays INTERNET-free:
|
||||||
|
* everything is local provider reads + a local file write.
|
||||||
|
*/
|
||||||
|
object BackupScheduler {
|
||||||
|
|
||||||
|
private const val WORK_NAME = "auto-backup"
|
||||||
|
private const val WORK_NAME_NOW = "auto-backup-now"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the scheduled work with the current settings: enqueue a unique
|
||||||
|
* periodic request when backup is on and a folder is set, otherwise cancel
|
||||||
|
* it. WorkManager persists the request across reboots on its own.
|
||||||
|
*
|
||||||
|
* The first run is delayed by one interval so it never overlaps an immediate
|
||||||
|
* [runNow] (two simultaneous writes would race and leave a "(1)" duplicate).
|
||||||
|
*/
|
||||||
|
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
if (!enabled || !hasFolder) {
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME)
|
||||||
|
// Also cancel any pending immediate run — otherwise a failing run-now
|
||||||
|
// keeps retrying after the user has turned backup off.
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// WorkManager's own floor is 15 min; the UI floors the user choice at 30.
|
||||||
|
val interval = intervalMinutes.coerceAtLeast(15L)
|
||||||
|
val request = PeriodicWorkRequestBuilder<BackupWorker>(interval, TimeUnit.MINUTES)
|
||||||
|
.setInitialDelay(interval, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one export immediately (e.g. right after enabling or changing the
|
||||||
|
* folder) for instant feedback. Unique + REPLACE so rapid taps coalesce into
|
||||||
|
* a single run rather than racing each other.
|
||||||
|
*/
|
||||||
|
fun runNow(context: Context) {
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
WORK_NAME_NOW,
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
OneTimeWorkRequestBuilder<BackupWorker>().build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
|
||||||
|
* overwriting the previous file. Pulls its collaborators through a Hilt
|
||||||
|
* [EntryPoint] so it works under WorkManager's default (no-arg) worker factory —
|
||||||
|
* no custom factory / Application wiring needed. Records the outcome for the
|
||||||
|
* settings status line, and notifies after repeated failures.
|
||||||
|
*/
|
||||||
|
class BackupWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun settingsPrefs(): SettingsPrefs
|
||||||
|
fun repository(): CalendarRepository
|
||||||
|
fun exporter(): IcsExporter
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||||
|
val prefs = deps.settingsPrefs()
|
||||||
|
// Respect the toggle even for already-queued work: if backup was turned
|
||||||
|
// off, no-op (and don't retry) so a lingering run can't revive itself.
|
||||||
|
if (!prefs.autoBackupEnabled.first()) return Result.success()
|
||||||
|
val folder = prefs.autoBackupFolderUri.first()
|
||||||
|
?: return Result.failure() // nothing to write to — leave scheduling to settings
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return try {
|
||||||
|
val events = deps.repository().exportEvents()
|
||||||
|
val content = IcsWriter().writeCalendar(events, Clock.System.now())
|
||||||
|
deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content)
|
||||||
|
prefs.recordAutoBackupRun(success = true, atMillis = now)
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
prefs.recordAutoBackupRun(success = false, atMillis = now)
|
||||||
|
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
|
||||||
|
notifyFailure(applicationContext)
|
||||||
|
}
|
||||||
|
Log.w(TAG, "Automatic backup failed", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyFailure(context: Context) {
|
||||||
|
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!canPost) return
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.backup_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { description = context.getString(R.string.backup_channel_description) },
|
||||||
|
)
|
||||||
|
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||||
|
val tap = launch?.let {
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
context, 0, it,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(context.getString(R.string.backup_failed_title))
|
||||||
|
.setContentText(context.getString(R.string.backup_failed_text))
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.apply { tap?.let(::setContentIntent) }
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
manager.notify(NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Could not post backup-failure notification", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val BACKUP_FILE_NAME = "calendula-backup.ics"
|
||||||
|
private const val FAILURE_NOTIFY_THRESHOLD = 2
|
||||||
|
private const val CHANNEL_ID = "backup"
|
||||||
|
private const val NOTIFICATION_ID = 2
|
||||||
|
private const val TAG = "BackupWorker"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -28,6 +29,36 @@ class IcsExporter @Inject constructor(
|
|||||||
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write [content] to [fileName] inside the persisted SAF tree [folder],
|
||||||
|
* overwriting it if it already exists (the automatic-backup destination).
|
||||||
|
* Requires a persisted write grant on [folder]. Throws on failure.
|
||||||
|
*/
|
||||||
|
fun writeToFolder(folder: Uri, fileName: String, content: String) {
|
||||||
|
val dir = DocumentFile.fromTreeUri(context, folder)
|
||||||
|
?: throw IOException("Backup folder is not accessible")
|
||||||
|
if (!dir.exists() || !dir.canWrite()) {
|
||||||
|
throw IOException("Backup folder is missing or not writable")
|
||||||
|
}
|
||||||
|
// Reuse the canonical file if present and clean up any "name (1).ics"
|
||||||
|
// duplicates SAF may have created if two runs ever raced — so we always
|
||||||
|
// converge on a single overwritten file.
|
||||||
|
val base = fileName.substringBeforeLast('.')
|
||||||
|
val ext = fileName.substringAfterLast('.', "")
|
||||||
|
var target: DocumentFile? = null
|
||||||
|
for (child in dir.listFiles()) {
|
||||||
|
val name = child.name ?: continue
|
||||||
|
when {
|
||||||
|
name == fileName -> target = child
|
||||||
|
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val file = target
|
||||||
|
?: dir.createFile(MIME_CALENDAR, fileName)
|
||||||
|
?: throw IOException("Could not create backup file in the chosen folder")
|
||||||
|
writeDocument(file.uri, content)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage [content] in a private cache file and return a shareable content
|
* Stage [content] in a private cache file and return a shareable content
|
||||||
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
||||||
@@ -42,5 +73,6 @@ class IcsExporter @Inject constructor(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SHARE_DIR = "shared_ics"
|
const val SHARE_DIR = "shared_ics"
|
||||||
|
const val MIME_CALENDAR = "text/calendar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,12 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
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.parseAgendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -19,18 +23,52 @@ import javax.inject.Singleton
|
|||||||
/** Light/dark override. SYSTEM follows the device setting. */
|
/** Light/dark override. SYSTEM follows the device setting. */
|
||||||
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
enum class ThemeMode { SYSTEM, LIGHT, DARK }
|
||||||
|
|
||||||
/** Week-start override. AUTO derives the first day from the active locale. */
|
/**
|
||||||
enum class WeekStartPref { AUTO, MONDAY, SUNDAY }
|
* Week-start override. [Auto] derives the first day from the active locale;
|
||||||
|
* [Day] pins a specific weekday (any of the seven).
|
||||||
|
*/
|
||||||
|
sealed interface WeekStartPref {
|
||||||
|
data object Auto : WeekStartPref
|
||||||
|
data class Day(val day: DayOfWeek) : WeekStartPref
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the preference to a concrete first-day-of-week. AUTO reads the
|
* Clock convention for time-of-day labels. AUTO follows the device's 24-hour
|
||||||
* locale's convention (e.g. Monday in DE, Sunday in en-US).
|
* system setting; the others force a 12- or 24-hour clock app-wide.
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
* device's `DateFormat.is24HourFormat` value).
|
||||||
|
*/
|
||||||
|
fun TimeFormatPref.is24Hour(systemIs24Hour: Boolean): Boolean = when (this) {
|
||||||
|
TimeFormatPref.AUTO -> systemIs24Hour
|
||||||
|
TimeFormatPref.TWELVE_HOUR -> false
|
||||||
|
TimeFormatPref.TWENTY_FOUR_HOUR -> true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the preference to a concrete first-day-of-week. [WeekStartPref.Auto]
|
||||||
|
* reads the locale's convention (e.g. Monday in DE, Sunday in en-US).
|
||||||
*/
|
*/
|
||||||
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
fun WeekStartPref.resolveFirstDay(locale: Locale): DayOfWeek = when (this) {
|
||||||
WeekStartPref.MONDAY -> DayOfWeek.MONDAY
|
is WeekStartPref.Day -> day
|
||||||
WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY
|
|
||||||
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
|
// java.time.DayOfWeek.value is ISO 1..7 (Mon..Sun) — same numbering kotlinx uses.
|
||||||
WeekStartPref.AUTO -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
WeekStartPref.Auto -> DayOfWeek(WeekFields.of(locale).firstDayOfWeek.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,7 +93,7 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
|
val weekStart: Flow<WeekStartPref> = store.data.map { prefs ->
|
||||||
prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO)
|
parseWeekStart(prefs[WEEK_START_KEY])
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setThemeMode(mode: ThemeMode) {
|
suspend fun setThemeMode(mode: ThemeMode) {
|
||||||
@@ -67,7 +105,87 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setWeekStart(pref: WeekStartPref) {
|
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||||
store.edit { it[WEEK_START_KEY] = pref.name }
|
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clock convention for time-of-day labels (v2.11). Defaults to AUTO (system). */
|
||||||
|
val timeFormat: Flow<TimeFormatPref> = store.data.map { prefs ->
|
||||||
|
prefs[TIME_FORMAT_KEY].toEnum(TimeFormatPref.AUTO)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setTimeFormat(pref: TimeFormatPref) {
|
||||||
|
store.edit { it[TIME_FORMAT_KEY] = pref.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the week/day timeline draws a faint separator line at each hour
|
||||||
|
* (v2.11). Defaults to OFF — the historical clean look; users opt in.
|
||||||
|
*/
|
||||||
|
val showHourLines: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[SHOW_HOUR_LINES_KEY] ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setShowHourLines(enabled: Boolean) {
|
||||||
|
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
|
||||||
|
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||||
|
* widget's [agendaWidgetRange].
|
||||||
|
*/
|
||||||
|
val agendaScreenRange: Flow<AgendaRange> = store.data.map { prefs ->
|
||||||
|
parseAgendaRange(prefs[AGENDA_SCREEN_RANGE_KEY], AgendaRange.Month)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAgendaScreenRange(range: AgendaRange) {
|
||||||
|
store.edit { it[AGENDA_SCREEN_RANGE_KEY] = range.storageValue() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far ahead the agenda **widget** shows events (v2.11). Defaults to Month. */
|
||||||
|
val agendaWidgetRange: Flow<AgendaRange> = store.data.map { prefs ->
|
||||||
|
parseAgendaRange(prefs[AGENDA_WIDGET_RANGE_KEY], AgendaRange.Month)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAgendaWidgetRange(range: AgendaRange) {
|
||||||
|
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||||
|
* the session range switcher (v2.11). Default ON.
|
||||||
|
*/
|
||||||
|
val agendaShowRangeBar: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[AGENDA_SHOW_RANGE_BAR_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAgendaShowRangeBar(enabled: Boolean) {
|
||||||
|
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,6 +220,20 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether opening the new-event form focuses the title field and raises the
|
||||||
|
* keyboard straight away (issue #10). Default ON — a new event almost always
|
||||||
|
* gets a title, so this saves a tap; users who set the time/calendar first
|
||||||
|
* can turn it off. Only the create form auto-focuses — editing never does.
|
||||||
|
*/
|
||||||
|
val autofocusEventTitle: Flow<Boolean> = store.data.map { prefs ->
|
||||||
|
prefs[AUTOFOCUS_EVENT_TITLE_KEY] ?: true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAutofocusEventTitle(enabled: Boolean) {
|
||||||
|
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||||
* for users whose only calendar app this is, reminders are essential; the
|
* for users whose only calendar app this is, reminders are essential; the
|
||||||
@@ -248,6 +380,52 @@ class SettingsPrefs @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Automatic backup (issue #8) ------------------------------------
|
||||||
|
|
||||||
|
/** Whether periodic automatic `.ics` export of local calendars is on. Default OFF. */
|
||||||
|
val autoBackupEnabled: Flow<Boolean> = store.data.map { it[AUTO_BACKUP_ENABLED_KEY] ?: false }
|
||||||
|
|
||||||
|
suspend fun setAutoBackupEnabled(enabled: Boolean) {
|
||||||
|
store.edit { it[AUTO_BACKUP_ENABLED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export interval in minutes. Default daily; floored at [MIN_BACKUP_INTERVAL]. */
|
||||||
|
val autoBackupIntervalMinutes: Flow<Long> = store.data.map { prefs ->
|
||||||
|
(prefs[AUTO_BACKUP_INTERVAL_KEY] ?: DEFAULT_BACKUP_INTERVAL).coerceAtLeast(MIN_BACKUP_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAutoBackupIntervalMinutes(minutes: Long) {
|
||||||
|
store.edit { it[AUTO_BACKUP_INTERVAL_KEY] = minutes.coerceAtLeast(MIN_BACKUP_INTERVAL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persisted SAF tree Uri of the destination folder, or null if unset. */
|
||||||
|
val autoBackupFolderUri: Flow<String?> = store.data.map { it[AUTO_BACKUP_FOLDER_KEY] }
|
||||||
|
|
||||||
|
suspend fun setAutoBackupFolderUri(uri: String?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (uri == null) prefs.remove(AUTO_BACKUP_FOLDER_KEY) else prefs[AUTO_BACKUP_FOLDER_KEY] = uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of the last automatic run, for the settings status line. */
|
||||||
|
val autoBackupStatus: Flow<BackupStatus> = store.data.map { prefs ->
|
||||||
|
BackupStatus(
|
||||||
|
lastRun = prefs[AUTO_BACKUP_LAST_RUN_KEY] ?: 0L,
|
||||||
|
lastSuccess = prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] ?: true,
|
||||||
|
consecutiveFailures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record an automatic run's outcome; resets the failure streak on success. */
|
||||||
|
suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[AUTO_BACKUP_LAST_RUN_KEY] = atMillis
|
||||||
|
prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] = success
|
||||||
|
val failures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0
|
||||||
|
prefs[AUTO_BACKUP_FAILURES_KEY] = if (success) 0 else failures + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -259,8 +437,16 @@ class SettingsPrefs @Inject constructor(
|
|||||||
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")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
|
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||||
|
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||||
|
internal val AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||||
|
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||||
|
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||||
|
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||||
|
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
|
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||||
@@ -282,9 +468,27 @@ class SettingsPrefs @Inject constructor(
|
|||||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
|
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||||
|
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||||
|
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||||
|
internal val AUTO_BACKUP_LAST_RUN_KEY = longPreferencesKey("auto_backup_last_run")
|
||||||
|
internal val AUTO_BACKUP_LAST_SUCCESS_KEY = booleanPreferencesKey("auto_backup_last_success")
|
||||||
|
internal val AUTO_BACKUP_FAILURES_KEY = intPreferencesKey("auto_backup_failures")
|
||||||
|
/** Default automatic-backup interval: daily. */
|
||||||
|
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
||||||
|
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
||||||
|
const val MIN_BACKUP_INTERVAL = 30L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
||||||
|
data class BackupStatus(
|
||||||
|
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||||
|
val lastRun: Long,
|
||||||
|
val lastSuccess: Boolean,
|
||||||
|
val consecutiveFailures: Int,
|
||||||
|
)
|
||||||
|
|
||||||
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||||
sealed interface CalendarReminderOverride {
|
sealed interface CalendarReminderOverride {
|
||||||
/** No override — the calendar uses the global default. */
|
/** No override — the calendar uses the global default. */
|
||||||
@@ -331,6 +535,25 @@ private fun MutableMap<Long, Int?>.applyOverride(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||||
|
private const val WEEK_START_AUTO = "AUTO"
|
||||||
|
|
||||||
|
private fun WeekStartPref.storageValue(): String = when (this) {
|
||||||
|
WeekStartPref.Auto -> WEEK_START_AUTO
|
||||||
|
is WeekStartPref.Day -> day.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the stored week-start value. "AUTO"/null/garbage → [WeekStartPref.Auto];
|
||||||
|
* a [DayOfWeek] name → [WeekStartPref.Day]. The legacy "MONDAY"/"SUNDAY" enum
|
||||||
|
* values migrate transparently, since both are valid day names.
|
||||||
|
*/
|
||||||
|
private fun parseWeekStart(stored: String?): WeekStartPref = when (stored) {
|
||||||
|
null, WEEK_START_AUTO -> WeekStartPref.Auto
|
||||||
|
else -> DayOfWeek.entries.firstOrNull { it.name == stored }
|
||||||
|
?.let { WeekStartPref.Day(it) } ?: WeekStartPref.Auto
|
||||||
|
}
|
||||||
|
|
||||||
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 = "="
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ 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)
|
due.forEach { notifier.post(it) }
|
||||||
alertStore.markFired(due.map { it.alertId }, now)
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import androidx.core.content.ContextCompat
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
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.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -29,6 +32,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
@@ -39,15 +43,18 @@ class ReminderNotifier @Inject constructor(
|
|||||||
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
return granted && NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun post(alert: ReminderAlert) {
|
suspend fun post(alert: ReminderAlert) {
|
||||||
ensureChannel()
|
ensureChannel()
|
||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
|
val is24Hour = settingsPrefs.timeFormat.first()
|
||||||
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
val time = reminderTimeText(
|
val time = reminderTimeText(
|
||||||
beginMillis = alert.beginMillis,
|
beginMillis = alert.beginMillis,
|
||||||
endMillis = alert.endMillis,
|
endMillis = alert.endMillis,
|
||||||
isAllDay = alert.isAllDay,
|
isAllDay = alert.isAllDay,
|
||||||
zone = ZoneId.systemDefault(),
|
zone = ZoneId.systemDefault(),
|
||||||
locale = Locale.getDefault(),
|
locale = Locale.getDefault(),
|
||||||
|
is24Hour = is24Hour,
|
||||||
)
|
)
|
||||||
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
val text = listOfNotNull(time, alert.location).joinToString(" · ")
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.data.reminders
|
package de.jeanlucmakiola.calendula.data.reminders
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZoneOffset
|
import java.time.ZoneOffset
|
||||||
@@ -25,6 +26,7 @@ fun reminderTimeText(
|
|||||||
isAllDay: Boolean,
|
isAllDay: Boolean,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
locale: Locale,
|
locale: Locale,
|
||||||
|
is24Hour: Boolean,
|
||||||
): String {
|
): String {
|
||||||
if (isAllDay) {
|
if (isAllDay) {
|
||||||
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
@@ -40,16 +42,18 @@ fun reminderTimeText(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
val timeFormat = timeOfDayFormatter(is24Hour, locale)
|
||||||
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
val begin = Instant.ofEpochMilli(beginMillis).atZone(zone)
|
||||||
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
val end = Instant.ofEpochMilli(endMillis).atZone(zone)
|
||||||
return if (begin.toLocalDate() == end.toLocalDate()) {
|
return if (begin.toLocalDate() == end.toLocalDate()) {
|
||||||
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
timeFormat.format(begin) + RANGE + timeFormat.format(end)
|
||||||
} else {
|
} else {
|
||||||
val dateTimeFormat = DateTimeFormatter
|
// Cross-day: medium date + the chosen short time, joined per side. Built
|
||||||
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
// from the two formatters (not ofLocalizedDateTime) so the 12/24h choice
|
||||||
.withLocale(locale)
|
// applies to the time portion too.
|
||||||
dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end)
|
val dateFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
|
val dateTime = { z: java.time.ZonedDateTime -> "${dateFormat.format(z)}, ${timeFormat.format(z)}" }
|
||||||
|
dateTime(begin) + RANGE + dateTime(end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
|||||||
@@ -196,7 +196,9 @@ fun CalendarHost(
|
|||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
pendingDayIso = req.dateIso
|
||||||
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
|
// No widget source (an external date tap) roots over the default
|
||||||
|
// home view, so backing out of the day returns home then exits.
|
||||||
|
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
is WidgetNavRequest.OpenEvent -> {
|
is WidgetNavRequest.OpenEvent -> {
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
* detail-key channel and leave the base view untouched.)
|
* detail-key channel and leave the base view untouched.)
|
||||||
*/
|
*/
|
||||||
sealed interface WidgetNavRequest {
|
sealed interface WidgetNavRequest {
|
||||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
|
/**
|
||||||
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
|
* Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
|
||||||
|
* [source]. A null [source] means the request came from outside the app (a
|
||||||
|
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
|
||||||
|
* the default home view instead of a widget's view.
|
||||||
|
*/
|
||||||
|
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
|
||||||
|
|
||||||
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
||||||
data class OpenEvent(
|
data class OpenEvent(
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import java.time.YearMonth
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far ahead the agenda (screen or widget) shows events, starting at today.
|
||||||
|
*
|
||||||
|
* Two flavours:
|
||||||
|
* - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days),
|
||||||
|
* [Month] (30 days), [Custom] (1–365 days). These never degenerate.
|
||||||
|
* - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs
|
||||||
|
* through the end of the current week (respecting the week-start preference —
|
||||||
|
* a Monday start means "everything before next Monday"); [ThisMonth] runs
|
||||||
|
* through the last day of the current month. These shrink as the period ends.
|
||||||
|
*/
|
||||||
|
sealed interface AgendaRange {
|
||||||
|
data object Day : AgendaRange
|
||||||
|
data object Week : AgendaRange
|
||||||
|
data object Month : AgendaRange
|
||||||
|
data object ThisWeek : AgendaRange
|
||||||
|
data object ThisMonth : AgendaRange
|
||||||
|
data class Custom(val days: Int) : AgendaRange
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Allowed bounds for a [Custom] day count. */
|
||||||
|
const val MIN_CUSTOM_DAYS = 1
|
||||||
|
const val MAX_CUSTOM_DAYS = 365
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DAYS_PER_WEEK = 7
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inclusive number of days the window spans, starting at (and including)
|
||||||
|
* [anchor]. The calendar-aligned ranges depend on the anchor day and, for
|
||||||
|
* [AgendaRange.ThisWeek], the [weekStart] preference.
|
||||||
|
*/
|
||||||
|
fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) {
|
||||||
|
AgendaRange.Day -> 1
|
||||||
|
AgendaRange.Week -> DAYS_PER_WEEK
|
||||||
|
AgendaRange.Month -> 30
|
||||||
|
AgendaRange.ThisWeek -> {
|
||||||
|
// Days elapsed since the week's first day (0 when today *is* the start),
|
||||||
|
// so the window is the rest of the week through the day before it repeats.
|
||||||
|
val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK
|
||||||
|
DAYS_PER_WEEK - sinceWeekStart
|
||||||
|
}
|
||||||
|
AgendaRange.ThisMonth -> {
|
||||||
|
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
|
||||||
|
daysInMonth - anchor.day + 1
|
||||||
|
}
|
||||||
|
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stored representation: a fixed token, or `CUSTOM:<days>`. */
|
||||||
|
fun AgendaRange.storageValue(): String = when (this) {
|
||||||
|
AgendaRange.Day -> "DAY"
|
||||||
|
AgendaRange.Week -> "WEEK"
|
||||||
|
AgendaRange.Month -> "MONTH"
|
||||||
|
AgendaRange.ThisWeek -> "THIS_WEEK"
|
||||||
|
AgendaRange.ThisMonth -> "THIS_MONTH"
|
||||||
|
is AgendaRange.Custom -> "$CUSTOM_PREFIX$days"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a stored value; unknown/garbage falls back to [default]. */
|
||||||
|
fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when {
|
||||||
|
stored == "DAY" -> AgendaRange.Day
|
||||||
|
stored == "WEEK" -> AgendaRange.Week
|
||||||
|
stored == "MONTH" -> AgendaRange.Month
|
||||||
|
stored == "THIS_WEEK" -> AgendaRange.ThisWeek
|
||||||
|
stored == "THIS_MONTH" -> AgendaRange.ThisMonth
|
||||||
|
stored != null && stored.startsWith(CUSTOM_PREFIX) -> {
|
||||||
|
val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull()
|
||||||
|
if (days != null) {
|
||||||
|
AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS))
|
||||||
|
} else {
|
||||||
|
default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> default
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The concrete span the range covers, starting at [start] through [end]
|
||||||
|
* (inclusive), for a human-readable header:
|
||||||
|
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
||||||
|
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||||
|
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
||||||
|
*/
|
||||||
|
fun agendaRangeWindowSummary(
|
||||||
|
range: AgendaRange,
|
||||||
|
start: LocalDate,
|
||||||
|
end: LocalDate,
|
||||||
|
locale: Locale,
|
||||||
|
): String {
|
||||||
|
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||||
|
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||||
|
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
|
return when (range) {
|
||||||
|
AgendaRange.Day -> medium.format(javaStart)
|
||||||
|
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
||||||
|
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CUSTOM_PREFIX = "CUSTOM:"
|
||||||
@@ -2,22 +2,26 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
|||||||
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
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.EventAvailable
|
import androidx.compose.material.icons.filled.Coffee
|
||||||
|
import androidx.compose.material.icons.filled.DateRange
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
@@ -35,9 +39,13 @@ import androidx.compose.material3.TopAppBarScrollBehavior
|
|||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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
|
||||||
@@ -46,18 +54,27 @@ 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.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.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.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -84,15 +101,18 @@ fun AgendaScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
val anchor by viewModel.anchor.collectAsStateWithLifecycle()
|
||||||
|
val pastDisplay by viewModel.pastEventDisplay.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
var showRangePicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val isOnToday = when (val s = state) {
|
val isOnToday = when (val s = state) {
|
||||||
is AgendaUiState.Success -> s.anchor == s.today
|
is AgendaUiState.Success -> s.anchor == s.today
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
val successState = state as? AgendaUiState.Success
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -135,21 +155,134 @@ fun AgendaScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
AgendaContent(
|
Column(
|
||||||
state = state,
|
|
||||||
onRetry = viewModel::goToToday,
|
|
||||||
onEventClick = onEventClick,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.fillMaxSize()
|
||||||
.fillMaxSize(),
|
.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
// One bar at the top: the "showing …" header on the left and the
|
||||||
|
// session range switcher on the right (one settings toggle).
|
||||||
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
AgendaRangeBanner(
|
||||||
|
range = s.range,
|
||||||
|
start = s.anchor,
|
||||||
|
end = s.rangeEnd,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
AgendaRangePill(
|
||||||
|
range = s.range,
|
||||||
|
isOverride = s.rangeIsOverride,
|
||||||
|
onClick = { showRangePicker = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AgendaContent(
|
||||||
|
state = state,
|
||||||
|
pastDisplay = pastDisplay,
|
||||||
|
onRetry = viewModel::goToToday,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showRangePicker) {
|
||||||
|
AgendaRangePicker(
|
||||||
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
description = stringResource(R.string.agenda_range_override_hint),
|
||||||
|
selected = successState?.range ?: AgendaRange.Month,
|
||||||
|
onSelect = viewModel::setRangeOverride,
|
||||||
|
onDismiss = { showRangePicker = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
||||||
|
* range picker as a session-only override. Filled with the primary container
|
||||||
|
* while an override is active, so the temporary state is obvious.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AgendaRangePill(
|
||||||
|
range: AgendaRange,
|
||||||
|
isOverride: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val container = if (isOverride) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
}
|
||||||
|
val content = if (isOverride) {
|
||||||
|
MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = container,
|
||||||
|
contentColor = content,
|
||||||
|
shape = RoundedCornerShape(50),
|
||||||
|
modifier = modifier.clickable(onClick = onClick),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.DateRange,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = agendaRangeLabel(range),
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A header naming the concrete window currently shown, e.g. "Showing all events
|
||||||
|
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun AgendaRangeBanner(
|
||||||
|
range: AgendaRange,
|
||||||
|
start: LocalDate,
|
||||||
|
end: LocalDate,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val locale = currentLocale()
|
||||||
|
val window = agendaRangeWindowSummary(range, start, end, locale)
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.agenda_range_showing_label),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${agendaRangeLabel(range)}, $window",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@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,
|
||||||
@@ -159,19 +292,42 @@ private fun AgendaContent(
|
|||||||
is AgendaUiState.Failure -> Box(modifier) {
|
is AgendaUiState.Failure -> Box(modifier) {
|
||||||
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||||
}
|
}
|
||||||
is AgendaUiState.Success ->
|
is AgendaUiState.Success -> {
|
||||||
if (state.days.isEmpty()) {
|
val now by rememberCurrentMinute()
|
||||||
|
// Hiding drops finished events — and any day they leave empty; dimming
|
||||||
|
// keeps them but fades the row. Recomputed each minute so events fall
|
||||||
|
// away (or fade) as they end while the screen stays open.
|
||||||
|
val days = if (pastDisplay == PastEventDisplay.HIDE) {
|
||||||
|
state.days.mapNotNull { day ->
|
||||||
|
val remaining = day.events.filterNot { it.hasEnded(now) }
|
||||||
|
if (remaining.isEmpty()) null else day.copy(events = remaining)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.days
|
||||||
|
}
|
||||||
|
if (days.isEmpty()) {
|
||||||
AgendaEmpty(modifier)
|
AgendaEmpty(modifier)
|
||||||
} else {
|
} else {
|
||||||
AgendaList(state = state, onEventClick = onEventClick, modifier = modifier)
|
AgendaList(
|
||||||
|
days = days,
|
||||||
|
today = state.today,
|
||||||
|
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||||
|
now = now,
|
||||||
|
onEventClick = onEventClick,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun AgendaList(
|
private fun AgendaList(
|
||||||
state: AgendaUiState.Success,
|
days: List<AgendaDay>,
|
||||||
|
today: LocalDate,
|
||||||
|
dimPast: Boolean,
|
||||||
|
now: Instant,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
@@ -180,9 +336,9 @@ private fun AgendaList(
|
|||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp),
|
||||||
) {
|
) {
|
||||||
state.days.forEach { day ->
|
days.forEach { day ->
|
||||||
stickyHeader(key = "header-${day.date}") {
|
stickyHeader(key = "header-${day.date}") {
|
||||||
AgendaDayHeader(date = day.date, today = state.today)
|
AgendaDayHeader(date = day.date, today = today)
|
||||||
}
|
}
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = day.events,
|
items = day.events,
|
||||||
@@ -191,6 +347,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) },
|
||||||
)
|
)
|
||||||
@@ -223,13 +380,14 @@ private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
|||||||
private fun AgendaEventRow(
|
private fun AgendaEventRow(
|
||||||
event: EventInstance,
|
event: EventInstance,
|
||||||
position: Position,
|
position: Position,
|
||||||
|
dimmed: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
modifier = modifier,
|
modifier = if (dimmed) modifier.alpha(EventDimAlpha) else modifier,
|
||||||
title = title,
|
title = title,
|
||||||
summary = agendaTimeSummary(event),
|
summary = agendaTimeSummary(event),
|
||||||
position = position,
|
position = position,
|
||||||
@@ -254,7 +412,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.EventAvailable,
|
imageVector = Icons.Filled.Coffee,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(48.dp),
|
modifier = Modifier.size(48.dp),
|
||||||
@@ -265,13 +423,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.agenda_empty_subtitle),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,15 +489,17 @@ private fun agendaTimeSummary(event: EventInstance): String {
|
|||||||
val time = if (event.isAllDay) {
|
val time = if (event.isAllDay) {
|
||||||
stringResource(R.string.event_detail_all_day)
|
stringResource(R.string.event_detail_all_day)
|
||||||
} else {
|
} else {
|
||||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
val is24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
"${formatTime(event.start, is24Hour, locale)} – ${formatTime(event.end, is24Hour, locale)}"
|
||||||
}
|
}
|
||||||
val location = event.location?.takeIf { it.isNotBlank() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
return if (location != null) "$time · $location" else time
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTime(instant: Instant): String {
|
private fun formatTime(instant: Instant, is24Hour: Boolean, locale: Locale): String {
|
||||||
val t = instant.toLocalDateTime(zone).time
|
val t = instant.toLocalDateTime(zone).time
|
||||||
return "%02d:%02d".format(t.hour, t.minute)
|
return formatTimeOfDay(t.hour, t.minute, is24Hour, locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatAgendaDate(date: LocalDate): String {
|
private fun formatAgendaDate(date: LocalDate): String {
|
||||||
|
|||||||
@@ -53,5 +53,13 @@ sealed interface AgendaUiState {
|
|||||||
val anchor: LocalDate,
|
val anchor: LocalDate,
|
||||||
val today: LocalDate,
|
val today: LocalDate,
|
||||||
val days: List<AgendaDay>,
|
val days: List<AgendaDay>,
|
||||||
|
/** The range currently in effect — the saved default or a session override. */
|
||||||
|
val range: AgendaRange,
|
||||||
|
/** True when [range] is a temporary in-view override of the saved default. */
|
||||||
|
val rangeIsOverride: Boolean,
|
||||||
|
/** Last day the current [range] covers (inclusive), for the range header. */
|
||||||
|
val rangeEnd: LocalDate,
|
||||||
|
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||||
|
val showRangeBar: Boolean,
|
||||||
) : AgendaUiState
|
) : AgendaUiState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ 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.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -17,10 +20,13 @@ import kotlinx.coroutines.flow.catch
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
import java.util.Locale
|
||||||
import kotlinx.datetime.atStartOfDayIn
|
import kotlinx.datetime.atStartOfDayIn
|
||||||
import kotlinx.datetime.atTime
|
import kotlinx.datetime.atTime
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
@@ -30,16 +36,36 @@ import kotlin.time.Clock
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** How far ahead the agenda loads events from its anchor day. */
|
|
||||||
internal const val AGENDA_WINDOW_DAYS = 60
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AgendaViewModel @Inject constructor(
|
class AgendaViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
// The saved agenda range plus the range-bar visibility toggle.
|
||||||
|
private val agendaSettings = combine(
|
||||||
|
settingsPrefs.agendaScreenRange,
|
||||||
|
settingsPrefs.agendaShowRangeBar,
|
||||||
|
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||||
|
|
||||||
|
// First day of the week, for the calendar-aligned "this week" range.
|
||||||
|
private val weekStartDay = settingsPrefs.weekStart
|
||||||
|
.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
|
||||||
@@ -48,14 +74,32 @@ class AgendaViewModel @Inject constructor(
|
|||||||
private val _anchor = MutableStateFlow(todayDate)
|
private val _anchor = MutableStateFlow(todayDate)
|
||||||
val anchor: StateFlow<LocalDate> = _anchor
|
val anchor: StateFlow<LocalDate> = _anchor
|
||||||
|
|
||||||
val state: StateFlow<AgendaUiState> = _anchor
|
// A transient, in-view override of the saved agenda range. Held in memory
|
||||||
.flatMapLatest { anchor ->
|
// (not persisted), so it survives view switches and rotation within the
|
||||||
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
|
// session but resets to the saved default when the app is relaunched.
|
||||||
|
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||||
|
|
||||||
|
val state: StateFlow<AgendaUiState> =
|
||||||
|
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
||||||
|
AgendaParams(
|
||||||
|
anchor = anchor,
|
||||||
|
range = override ?: settings.range,
|
||||||
|
rangeIsOverride = override != null && override != settings.range,
|
||||||
|
weekStart = weekStart,
|
||||||
|
showRangeBar = settings.showBar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.flatMapLatest { params ->
|
||||||
|
val window = agendaRange(
|
||||||
|
params.anchor,
|
||||||
|
params.range.dayCount(params.anchor, params.weekStart) - 1,
|
||||||
|
zone,
|
||||||
|
)
|
||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
repository.instances(range),
|
repository.instances(window),
|
||||||
) { calendars, instances ->
|
) { calendars, instances ->
|
||||||
buildState(anchor, calendars, instances)
|
buildState(params, calendars, instances)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||||
@@ -75,16 +119,47 @@ class AgendaViewModel @Inject constructor(
|
|||||||
_anchor.value = date
|
_anchor.value = date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Temporarily override the agenda range for this session (the bottom-left pill). */
|
||||||
|
fun setRangeOverride(range: AgendaRange) {
|
||||||
|
_rangeOverride.value = range
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AgendaSettings(
|
||||||
|
val range: AgendaRange,
|
||||||
|
val showBar: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class AgendaParams(
|
||||||
|
val anchor: LocalDate,
|
||||||
|
val range: AgendaRange,
|
||||||
|
val rangeIsOverride: Boolean,
|
||||||
|
val weekStart: DayOfWeek,
|
||||||
|
val showRangeBar: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
private fun buildState(
|
private fun buildState(
|
||||||
anchor: LocalDate,
|
params: AgendaParams,
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
instances: List<EventInstance>,
|
instances: List<EventInstance>,
|
||||||
): AgendaUiState {
|
): AgendaUiState {
|
||||||
if (calendars.isEmpty()) {
|
if (calendars.isEmpty()) {
|
||||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||||
}
|
}
|
||||||
|
val anchor = params.anchor
|
||||||
val days = groupAgendaDays(anchor, instances, zone)
|
val days = groupAgendaDays(anchor, instances, zone)
|
||||||
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
|
val rangeEnd = anchor.plus(
|
||||||
|
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||||
|
DateTimeUnit.DAY,
|
||||||
|
)
|
||||||
|
return AgendaUiState.Success(
|
||||||
|
anchor = anchor,
|
||||||
|
today = todayDate,
|
||||||
|
days = days,
|
||||||
|
range = params.range,
|
||||||
|
rangeIsOverride = params.rangeIsOverride,
|
||||||
|
rangeEnd = rangeEnd,
|
||||||
|
showRangeBar = params.showRangeBar,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import android.accounts.AccountManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.text.format.DateUtils
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -26,20 +30,28 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.CalendarMonth
|
import androidx.compose.material.icons.filled.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.FileDownload
|
import androidx.compose.material.icons.filled.FileDownload
|
||||||
import androidx.compose.material.icons.filled.OpenInNew
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
@@ -61,20 +73,33 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
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.BackupStatus
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
@@ -82,7 +107,6 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||||
@@ -104,6 +128,7 @@ fun CalendarsScreen(
|
|||||||
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||||
// [editorSession] bumps on every open so the editor's field state resets for
|
// [editorSession] bumps on every open so the editor's field state resets for
|
||||||
@@ -144,10 +169,15 @@ fun CalendarsScreen(
|
|||||||
backupResult = backupResult,
|
backupResult = backupResult,
|
||||||
onExportBackup = viewModel::exportBackup,
|
onExportBackup = viewModel::exportBackup,
|
||||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||||
|
autoBackup = autoBackup,
|
||||||
|
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||||
|
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
||||||
|
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
onSetDisabled = viewModel::setDisabled,
|
onSetDisabled = viewModel::setDisabled,
|
||||||
|
onSetAccountDisabled = viewModel::setAccountDisabled,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,13 +192,22 @@ private fun CalendarsList(
|
|||||||
backupResult: BackupResult?,
|
backupResult: BackupResult?,
|
||||||
onExportBackup: (android.net.Uri) -> Unit,
|
onExportBackup: (android.net.Uri) -> Unit,
|
||||||
onConsumeBackupResult: () -> Unit,
|
onConsumeBackupResult: () -> Unit,
|
||||||
|
autoBackup: AutoBackupUiState,
|
||||||
|
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
||||||
|
onSetAutoBackupInterval: (Long) -> Unit,
|
||||||
|
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
onSetDisabled: (Long, Boolean) -> Unit,
|
onSetDisabled: (Long, Boolean) -> Unit,
|
||||||
|
onSetAccountDisabled: (Collection<Long>, Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
// Accounts the user has folded shut; empty = all expanded (keeps every
|
||||||
|
// calendar visible by default, the section is collapsible for tidiness).
|
||||||
|
var collapsedAccounts by remember { mutableStateOf(emptySet<String>()) }
|
||||||
|
var localExpanded by remember { mutableStateOf(true) }
|
||||||
val writeErrorText = stringResource(R.string.calendars_write_error)
|
val writeErrorText = stringResource(R.string.calendars_write_error)
|
||||||
|
|
||||||
LaunchedEffect(error) {
|
LaunchedEffect(error) {
|
||||||
@@ -184,6 +223,13 @@ private fun CalendarsList(
|
|||||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
) { uri -> uri?.let(onExportBackup) }
|
) { uri -> uri?.let(onExportBackup) }
|
||||||
|
|
||||||
|
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||||
|
// write grant so background runs can keep writing to it.
|
||||||
|
val pickFolder = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri -> uri?.let(onSetAutoBackupFolder) }
|
||||||
|
var showInterval by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||||
LaunchedEffect(backupResult) {
|
LaunchedEffect(backupResult) {
|
||||||
when (val r = backupResult) {
|
when (val r = backupResult) {
|
||||||
@@ -208,70 +254,37 @@ private fun CalendarsList(
|
|||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
) {
|
) {
|
||||||
// Local (device-only) calendars — the calendars the app owns. The
|
// What the per-calendar / per-account switches below actually do.
|
||||||
// "Add calendar" entry closes the group as its final row.
|
|
||||||
SectionHeader(stringResource(R.string.calendars_local_header))
|
|
||||||
if (local.isEmpty()) {
|
|
||||||
HintText(stringResource(R.string.calendars_local_empty))
|
|
||||||
}
|
|
||||||
HintText(stringResource(R.string.calendars_disable_hint))
|
HintText(stringResource(R.string.calendars_disable_hint))
|
||||||
val localCount = local.size + 1
|
|
||||||
local.forEachIndexed { index, calendar ->
|
|
||||||
val disabled = calendar.id in disabledIds
|
|
||||||
GroupedRow(
|
|
||||||
title = calendar.displayName,
|
|
||||||
summary = calendar.description,
|
|
||||||
position = positionOf(index, localCount),
|
|
||||||
dimmed = disabled,
|
|
||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
|
||||||
trailing = {
|
|
||||||
EnableSwitch(
|
|
||||||
calendarName = calendar.displayName,
|
|
||||||
enabled = !disabled,
|
|
||||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = { onEdit(calendar) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_add),
|
|
||||||
position = positionOf(local.size, localCount),
|
|
||||||
leading = { AddAvatar() },
|
|
||||||
onClick = onAdd,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Backup — local calendars have no sync, so a .ics export is their only
|
// Local (device-only) calendars — one collapsible group. The header's
|
||||||
// safety net. Offered only when there is something to back up.
|
// "+" adds a calendar; the switch enables/disables them all at once;
|
||||||
if (local.isNotEmpty()) {
|
// tapping a calendar row opens its editor.
|
||||||
Spacer(Modifier.height(16.dp))
|
val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds }
|
||||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
CalendarGroup(
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
title = stringResource(R.string.calendars_local_header),
|
||||||
GroupedRow(
|
expanded = localExpanded,
|
||||||
title = stringResource(R.string.calendars_backup_action),
|
bodyHasRows = local.isNotEmpty(),
|
||||||
position = Position.Alone,
|
headerDisabled = localDisabled,
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } },
|
||||||
onClick = {
|
manageIcon = Icons.Default.Add,
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
manageLabel = stringResource(R.string.calendars_add),
|
||||||
},
|
onManage = onAdd,
|
||||||
)
|
onToggleExpand = { localExpanded = !localExpanded },
|
||||||
}
|
showToggleAll = local.isNotEmpty(),
|
||||||
|
allEnabled = local.none { it.id in disabledIds },
|
||||||
Spacer(Modifier.height(16.dp))
|
onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) },
|
||||||
|
) {
|
||||||
// Synced calendars — read-only, grouped by account, each with a
|
if (local.isEmpty()) {
|
||||||
// per-account "manage in source app" link.
|
HintText(stringResource(R.string.calendars_local_empty))
|
||||||
SectionHeader(stringResource(R.string.calendars_synced_header))
|
} else {
|
||||||
HintText(stringResource(R.string.calendars_synced_hint))
|
local.forEachIndexed { index, calendar ->
|
||||||
synced
|
|
||||||
.groupBy { it.accountName.ifBlank { it.accountType } }
|
|
||||||
.forEach { (account, cals) ->
|
|
||||||
AccountHeader(account = account, accountType = cals.first().accountType)
|
|
||||||
cals.forEachIndexed { index, calendar ->
|
|
||||||
val disabled = calendar.id in disabledIds
|
val disabled = calendar.id in disabledIds
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
position = positionOf(index, cals.size),
|
summary = calendar.description,
|
||||||
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = {
|
||||||
@@ -281,17 +294,118 @@ private fun CalendarsList(
|
|||||||
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onClick = { onEdit(calendar) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
}
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.calendars_add_account),
|
// Backup — local calendars have no sync, so a .ics export is their only
|
||||||
position = Position.Alone,
|
// safety net. Offered only when there is something to back up.
|
||||||
leading = { AddAvatar() },
|
if (local.isNotEmpty()) {
|
||||||
onClick = {
|
Spacer(Modifier.height(16.dp))
|
||||||
runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) }
|
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||||
},
|
HintText(stringResource(R.string.calendars_backup_hint))
|
||||||
|
// One connected card: the one-time export on top, then automatic
|
||||||
|
// backup (and its folder/interval rows when on).
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
|
position = Position.Top,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
|
onClick = {
|
||||||
|
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup),
|
||||||
|
summary = stringResource(R.string.calendars_auto_backup_hint),
|
||||||
|
position = if (autoBackup.enabled) Position.Middle else Position.Bottom,
|
||||||
|
leading = { LeadingAvatar(Icons.Default.Schedule) },
|
||||||
|
trailing = {
|
||||||
|
Switch(checked = autoBackup.enabled, onCheckedChange = onSetAutoBackupEnabled)
|
||||||
|
},
|
||||||
|
onClick = { onSetAutoBackupEnabled(!autoBackup.enabled) },
|
||||||
|
)
|
||||||
|
if (autoBackup.enabled) {
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_folder),
|
||||||
|
summary = rememberFolderName(autoBackup.folderUri)
|
||||||
|
?: stringResource(R.string.calendars_auto_backup_folder_unset),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { runCatching { pickFolder.launch(null) } },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.calendars_auto_backup_interval),
|
||||||
|
summary = backupIntervalLabel(autoBackup.intervalMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showInterval = true },
|
||||||
|
)
|
||||||
|
HintText(backupStatusText(autoBackup.status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Synced calendars — read-only, grouped by account. Each account is a
|
||||||
|
// collapsible group whose header opens the source app (icon) and toggles
|
||||||
|
// all of its calendars at once (switch).
|
||||||
|
SectionHeader(stringResource(R.string.calendars_synced_header))
|
||||||
|
HintText(stringResource(R.string.calendars_synced_hint))
|
||||||
|
synced
|
||||||
|
.groupBy { it.accountName.ifBlank { it.accountType } }
|
||||||
|
.forEach { (account, cals) ->
|
||||||
|
val expanded = account !in collapsedAccounts
|
||||||
|
val accountType = cals.first().accountType
|
||||||
|
val accountDisabled = cals.all { it.id in disabledIds }
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
CalendarGroup(
|
||||||
|
title = account,
|
||||||
|
expanded = expanded,
|
||||||
|
bodyHasRows = true,
|
||||||
|
headerDisabled = accountDisabled,
|
||||||
|
leading = { Box(dimIf(accountDisabled)) { SourceLogo(accountType) } },
|
||||||
|
manageIcon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||||
|
manageLabel = stringResource(R.string.calendars_manage_in_app),
|
||||||
|
onManage = {
|
||||||
|
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
||||||
|
},
|
||||||
|
onToggleExpand = {
|
||||||
|
collapsedAccounts = if (expanded) {
|
||||||
|
collapsedAccounts + account
|
||||||
|
} else {
|
||||||
|
collapsedAccounts - account
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showToggleAll = true,
|
||||||
|
allEnabled = cals.none { it.id in disabledIds },
|
||||||
|
onToggleAll = { enabled -> onSetAccountDisabled(cals.map { it.id }, !enabled) },
|
||||||
|
) {
|
||||||
|
cals.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = calendar.id in disabledIds
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
||||||
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
|
dimmed = disabled,
|
||||||
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
|
trailing = {
|
||||||
|
EnableSwitch(
|
||||||
|
calendarName = calendar.displayName,
|
||||||
|
enabled = !disabled,
|
||||||
|
onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showInterval) {
|
||||||
|
BackupIntervalDialog(
|
||||||
|
currentMinutes = autoBackup.intervalMinutes,
|
||||||
|
onConfirm = onSetAutoBackupInterval,
|
||||||
|
onDismiss = { showInterval = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -498,32 +612,178 @@ private fun EditorCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One collapsible calendar group rendered as a connected card. The header row is
|
||||||
|
* the card's top (tap to expand/collapse): a [leading] source mark (the account
|
||||||
|
* app's logo, or a device chip for local), the group [title] and a trailing
|
||||||
|
* overflow (⋮) menu holding the account-level actions — enable/disable every
|
||||||
|
* calendar at once (when [showToggleAll]) and the manage/add action
|
||||||
|
* ([manageIcon] / [manageLabel] → [onManage]). The group's calendars render as
|
||||||
|
* the rows below via [body].
|
||||||
|
*
|
||||||
|
* The header keeps the standard row colour; the calendars it reveals sit one tone
|
||||||
|
* darker, so the group reads as a header over nested content. Once [headerDisabled]
|
||||||
|
* (every calendar switched off) the header fades to the disabled emphasis so the
|
||||||
|
* whole group reads as off — not just via the menu. [bodyHasRows] is false when no
|
||||||
|
* calendar rows follow (e.g. an empty local group), so the header stays a
|
||||||
|
* standalone card rather than a top edge with nothing beneath it.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AccountHeader(account: String, accountType: String) {
|
private fun CalendarGroup(
|
||||||
val context = LocalContext.current
|
title: String,
|
||||||
Row(
|
expanded: Boolean,
|
||||||
modifier = Modifier
|
bodyHasRows: Boolean,
|
||||||
.fillMaxWidth()
|
headerDisabled: Boolean,
|
||||||
.padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
leading: @Composable () -> Unit,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
manageIcon: ImageVector,
|
||||||
|
manageLabel: String,
|
||||||
|
onManage: () -> Unit,
|
||||||
|
onToggleExpand: () -> Unit,
|
||||||
|
showToggleAll: Boolean,
|
||||||
|
allEnabled: Boolean,
|
||||||
|
onToggleAll: (Boolean) -> Unit,
|
||||||
|
body: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
GroupedRow(
|
||||||
|
title = title,
|
||||||
|
position = if (expanded && bodyHasRows) Position.Top else Position.Alone,
|
||||||
|
dimmed = headerDisabled,
|
||||||
|
leading = leading,
|
||||||
|
trailing = {
|
||||||
|
CalendarGroupMenu(
|
||||||
|
title = title,
|
||||||
|
showToggleAll = showToggleAll,
|
||||||
|
allEnabled = allEnabled,
|
||||||
|
onToggleAll = onToggleAll,
|
||||||
|
manageIcon = manageIcon,
|
||||||
|
manageLabel = manageLabel,
|
||||||
|
onManage = onManage,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = onToggleExpand,
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = expanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(content = body)
|
||||||
text = account,
|
}
|
||||||
style = MaterialTheme.typography.titleSmall,
|
}
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier = Modifier.weight(1f),
|
/**
|
||||||
)
|
* The account header's overflow (⋮) menu: the two account-level actions that
|
||||||
OutlinedButton(onClick = {
|
* don't fit on one line — "Enable/Disable all" (when [showToggleAll]) and the
|
||||||
runCatching { context.startActivity(sourceAppIntent(context, accountType)) }
|
* manage/add action. A rounded, tonal dropdown matching the app's surfaces, with
|
||||||
}) {
|
* the two actions divided for clear separation.
|
||||||
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
|
*/
|
||||||
Spacer(Modifier.width(6.dp))
|
@Composable
|
||||||
Text(stringResource(R.string.calendars_manage_in_app))
|
private fun CalendarGroupMenu(
|
||||||
|
title: String,
|
||||||
|
showToggleAll: Boolean,
|
||||||
|
allEnabled: Boolean,
|
||||||
|
onToggleAll: (Boolean) -> Unit,
|
||||||
|
manageIcon: ImageVector,
|
||||||
|
manageLabel: String,
|
||||||
|
onManage: () -> Unit,
|
||||||
|
) {
|
||||||
|
var open by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { open = true }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.calendars_account_menu_a11y, title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = open,
|
||||||
|
onDismissRequest = { open = false },
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
// A distinct tone + a lifted shadow so the menu reads as floating
|
||||||
|
// above the cards (which sit at surfaceContainerHigh) rather than
|
||||||
|
// blending into them.
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerLowest,
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
shadowElevation = 6.dp,
|
||||||
|
) {
|
||||||
|
if (showToggleAll) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (allEnabled) R.string.calendars_disable_all
|
||||||
|
else R.string.calendars_enable_all,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
if (allEnabled) Icons.Default.VisibilityOff else Icons.Default.Visibility,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onToggleAll(!allEnabled)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider(Modifier.padding(horizontal = 12.dp, vertical = 4.dp))
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(manageLabel) },
|
||||||
|
leadingIcon = { Icon(manageIcon, contentDescription = null) },
|
||||||
|
onClick = {
|
||||||
|
open = false
|
||||||
|
onManage()
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Neutral circular chip carrying an arbitrary icon — matches [AddAvatar]'s shape. */
|
/**
|
||||||
|
* The source app's launcher icon (Google Calendar, DAVx5, Nextcloud, …) as a
|
||||||
|
* round 40dp chip, so each synced account is recognisable at a glance. We load
|
||||||
|
* whatever app owns the account from [PackageManager] rather than bundling brand
|
||||||
|
* logos — always accurate, nothing to license. Falls back to a neutral cloud
|
||||||
|
* chip when no installed app resolves for the account.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SourceLogo(accountType: String) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val logo = remember(accountType) { sourceAppLogo(context, accountType) }
|
||||||
|
if (logo != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = logo,
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LeadingAvatar(Icons.Default.Cloud)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The launcher icon of the app backing [accountType], preferring the human-facing app. */
|
||||||
|
private fun sourceAppLogo(context: Context, accountType: String): ImageBitmap? {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val candidates = buildList {
|
||||||
|
curatedSourcePackage(accountType)?.let { add(it) }
|
||||||
|
AccountManager.get(context).authenticatorTypes
|
||||||
|
.firstOrNull { it.type.equals(accountType, ignoreCase = true) }
|
||||||
|
?.packageName
|
||||||
|
?.let { add(it) }
|
||||||
|
}
|
||||||
|
for (pkg in candidates) {
|
||||||
|
val bitmap = runCatching { pm.getApplicationIcon(pkg).toBitmap() }.getOrNull()
|
||||||
|
if (bitmap != null) return bitmap.asImageBitmap()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
||||||
@Composable
|
@Composable
|
||||||
private fun LeadingAvatar(icon: ImageVector) {
|
private fun LeadingAvatar(icon: ImageVector) {
|
||||||
Box(
|
Box(
|
||||||
@@ -542,25 +802,6 @@ private fun LeadingAvatar(icon: ImageVector) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Neutral circular chip with a "+" — the leading icon for add-actions. */
|
|
||||||
@Composable
|
|
||||||
private fun AddAvatar() {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Add,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionHeader(text: String) {
|
private fun SectionHeader(text: String) {
|
||||||
Text(
|
Text(
|
||||||
@@ -581,6 +822,95 @@ private fun HintText(text: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||||
|
@Composable
|
||||||
|
private fun rememberFolderName(uriString: String?): String? {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember(uriString) {
|
||||||
|
uriString?.let {
|
||||||
|
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||||
|
@Composable
|
||||||
|
private fun backupIntervalLabel(minutes: Long): String {
|
||||||
|
val duration = when {
|
||||||
|
minutes % MINUTES_PER_WEEK == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||||
|
minutes % MINUTES_PER_DAY == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||||
|
minutes % 60L == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||||
|
else ->
|
||||||
|
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||||
|
}
|
||||||
|
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||||
|
@Composable
|
||||||
|
private fun backupStatusText(status: BackupStatus): String {
|
||||||
|
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||||
|
val relative = DateUtils.getRelativeTimeSpanString(
|
||||||
|
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
).toString()
|
||||||
|
return if (status.lastSuccess) {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||||
|
@Composable
|
||||||
|
private fun BackupIntervalDialog(
|
||||||
|
currentMinutes: Long,
|
||||||
|
onConfirm: (Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
||||||
|
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||||
|
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||||
|
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||||
|
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||||
|
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||||
|
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||||
|
onDismiss()
|
||||||
|
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MINUTES_PER_DAY = 1_440L
|
||||||
|
private const val MINUTES_PER_WEEK = 10_080L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the app to open for managing a synced calendar's account. The account's
|
* Pick the app to open for managing a synced calendar's account. The account's
|
||||||
* own authenticator package (resolved from [AccountManager], no permission
|
* own authenticator package (resolved from [AccountManager], no permission
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.calendars
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
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.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -16,6 +22,7 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -33,9 +40,11 @@ import javax.inject.Inject
|
|||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CalendarsViewModel @Inject constructor(
|
class CalendarsViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
private val prefs: CalendarPrefs,
|
private val prefs: CalendarPrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -63,6 +72,22 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptySet(),
|
initialValue = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||||
|
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||||
|
settingsPrefs.autoBackupEnabled,
|
||||||
|
settingsPrefs.autoBackupIntervalMinutes,
|
||||||
|
settingsPrefs.autoBackupFolderUri,
|
||||||
|
settingsPrefs.autoBackupStatus,
|
||||||
|
) { enabled, interval, folder, status ->
|
||||||
|
AutoBackupUiState(enabled, interval, folder, status)
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = AutoBackupUiState(),
|
||||||
|
)
|
||||||
|
|
||||||
private val _error = MutableStateFlow(false)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
@@ -123,6 +148,63 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable every calendar of one account in a single write — the
|
||||||
|
* "toggle all" affordance on an account header. Done as one set update so the
|
||||||
|
* per-calendar [setDisabled] calls can't race each other.
|
||||||
|
*/
|
||||||
|
fun setAccountDisabled(ids: Collection<Long>, disabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val current = prefs.disabledCalendarIds.first()
|
||||||
|
val next = if (disabled) current + ids else current - ids.toSet()
|
||||||
|
if (next != current) prefs.setDisabledCalendarIds(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Automatic backup (issue #8) ------------------------------------
|
||||||
|
|
||||||
|
fun setAutoBackupEnabled(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsPrefs.setAutoBackupEnabled(enabled)
|
||||||
|
reschedule()
|
||||||
|
// Give immediate feedback when turning it on with a folder already set.
|
||||||
|
if (enabled && settingsPrefs.autoBackupFolderUri.first() != null) {
|
||||||
|
BackupScheduler.runNow(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutoBackupIntervalMinutes(minutes: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsPrefs.setAutoBackupIntervalMinutes(minutes)
|
||||||
|
reschedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the chosen destination folder (taking a durable write grant) and run once. */
|
||||||
|
fun setAutoBackupFolder(uri: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
settingsPrefs.setAutoBackupFolderUri(uri.toString())
|
||||||
|
reschedule()
|
||||||
|
if (settingsPrefs.autoBackupEnabled.first()) BackupScheduler.runNow(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reschedule() {
|
||||||
|
BackupScheduler.apply(
|
||||||
|
context = context,
|
||||||
|
enabled = settingsPrefs.autoBackupEnabled.first(),
|
||||||
|
intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(),
|
||||||
|
hasFolder = settingsPrefs.autoBackupFolderUri.first() != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private inline fun write(crossinline block: suspend () -> Unit) {
|
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -136,6 +218,14 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Automatic-backup settings + last-run status for the Backup section. */
|
||||||
|
data class AutoBackupUiState(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
|
||||||
|
val folderUri: String? = null,
|
||||||
|
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
|
||||||
|
)
|
||||||
|
|
||||||
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||||
sealed interface BackupResult {
|
sealed interface BackupResult {
|
||||||
data class Success(val eventCount: Int) : BackupResult
|
data class Success(val eventCount: Int) : BackupResult
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
|
import androidx.compose.material.icons.filled.EventAvailable
|
||||||
|
import androidx.compose.material.icons.filled.Lock
|
||||||
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.People
|
||||||
|
import androidx.compose.material.icons.filled.Place
|
||||||
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared label and icon for each optional [EventFormField]. Both the event
|
||||||
|
* editor (which shows these fields) and the settings screen (which toggles their
|
||||||
|
* defaults) draw from this one mapping, so a field reads the same wherever it
|
||||||
|
* appears.
|
||||||
|
*/
|
||||||
|
@StringRes
|
||||||
|
fun eventFormFieldLabel(field: EventFormField): Int = when (field) {
|
||||||
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
|
EventFormField.Description -> R.string.event_detail_description
|
||||||
|
EventFormField.Reminders -> R.string.event_detail_reminders
|
||||||
|
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
||||||
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
|
EventFormField.Attendees -> R.string.event_edit_attendees
|
||||||
|
}
|
||||||
|
|
||||||
|
fun eventFormFieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||||
|
EventFormField.Location -> Icons.Default.Place
|
||||||
|
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
||||||
|
EventFormField.Reminders -> Icons.Default.Notifications
|
||||||
|
EventFormField.Recurrence -> Icons.Default.Repeat
|
||||||
|
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||||
|
EventFormField.Visibility -> Icons.Default.Lock
|
||||||
|
EventFormField.Color -> Icons.Default.Palette
|
||||||
|
EventFormField.Attendees -> Icons.Default.People
|
||||||
|
}
|
||||||
@@ -140,6 +140,7 @@ fun GroupedRow(
|
|||||||
summary: String? = null,
|
summary: String? = null,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
dimmed: Boolean = false,
|
dimmed: Boolean = false,
|
||||||
|
container: Color? = null,
|
||||||
minHeight: Dp = 72.dp,
|
minHeight: Dp = 72.dp,
|
||||||
leading: @Composable (() -> Unit)? = null,
|
leading: @Composable (() -> Unit)? = null,
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
@@ -198,10 +199,10 @@ fun GroupedRow(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.then(gap)
|
.then(gap)
|
||||||
val containerColor = if (selected) {
|
val containerColor = when {
|
||||||
MaterialTheme.colorScheme.secondaryContainer
|
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
} else {
|
container != null -> container
|
||||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
}
|
}
|
||||||
if (onClick != null) {
|
if (onClick != null) {
|
||||||
Surface(
|
Surface(
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the week/day timeline draws an hour separator line, from the
|
||||||
|
* `showHourLines` preference. Provided once at the app root (like
|
||||||
|
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
|
||||||
|
* Defaults to off — the historical clean look.
|
||||||
|
*/
|
||||||
|
val LocalShowHourLines = staticCompositionLocalOf { false }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a faint separator line at the top of each hour (1..23) when [show] is
|
||||||
|
* true. Applied to a day column's content so each line sits over the column's
|
||||||
|
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
|
||||||
|
* height; [color] is resolved by the caller from the theme.
|
||||||
|
*/
|
||||||
|
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
|
||||||
|
if (!show) {
|
||||||
|
this
|
||||||
|
} else {
|
||||||
|
drawBehind {
|
||||||
|
for (hour in 1 until 24) {
|
||||||
|
val y = hour * hourHeightPx
|
||||||
|
drawLine(
|
||||||
|
color = color,
|
||||||
|
start = Offset(0f, y),
|
||||||
|
end = Offset(size.width, y),
|
||||||
|
strokeWidth = 1f,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -30,6 +31,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
@@ -37,6 +39,7 @@ import androidx.compose.ui.window.DialogProperties
|
|||||||
import androidx.compose.ui.window.DialogWindowProvider
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||||
@@ -83,8 +86,10 @@ fun <T> OptionPicker(
|
|||||||
label: @Composable (T) -> String,
|
label: @Composable (T) -> String,
|
||||||
onSelect: (T) -> Unit,
|
onSelect: (T) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
header?.invoke(this)
|
||||||
options.forEachIndexed { index, option ->
|
options.forEachIndexed { index, option ->
|
||||||
val isSelected = option == selected
|
val isSelected = option == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -260,6 +265,17 @@ private fun CustomReminderEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||||
|
@Composable
|
||||||
|
private fun PickerDescription(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SelectedCheck() {
|
private fun SelectedCheck() {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -269,6 +285,149 @@ private fun SelectedCheck() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||||
|
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||||
|
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||||
|
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AgendaRangePicker(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
selected: AgendaRange,
|
||||||
|
onSelect: (AgendaRange) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth)
|
||||||
|
val rolling = listOf(AgendaRange.Week, AgendaRange.Month)
|
||||||
|
val rollingRowCount = rolling.size + 1 // + the custom row
|
||||||
|
val customSelected = selected is AgendaRange.Custom
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable {
|
||||||
|
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = agendaRangeLabel(option),
|
||||||
|
position = position,
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
PickerDescription(description)
|
||||||
|
|
||||||
|
// Calendar-aligned windows.
|
||||||
|
calendarAligned.forEachIndexed { index, option ->
|
||||||
|
rangeRow(option, positionOf(index, calendarAligned.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Rolling windows + custom.
|
||||||
|
rolling.forEachIndexed { index, option ->
|
||||||
|
rangeRow(option, positionOf(index, rollingRowCount))
|
||||||
|
}
|
||||||
|
GroupedRow(
|
||||||
|
title = if (customSelected) {
|
||||||
|
agendaRangeLabel(selected)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.agenda_range_custom)
|
||||||
|
},
|
||||||
|
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||||
|
selected = customSelected,
|
||||||
|
trailing = if (customSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { customExpanded = !customExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = customExpanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
|
) {
|
||||||
|
CustomDaysEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
onConfirm = { days ->
|
||||||
|
onSelect(AgendaRange.Custom(days))
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */
|
||||||
|
@Composable
|
||||||
|
private fun CustomDaysEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val days = amountText.toIntOrNull()
|
||||||
|
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
|
placeholder = "30",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||||
|
?: stringResource(R.string.agenda_range_custom_hint),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { days?.let(onConfirm) },
|
||||||
|
enabled = days != null,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_custom_set))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||||
|
@Composable
|
||||||
|
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||||
|
AgendaRange.Day -> stringResource(R.string.agenda_range_day)
|
||||||
|
AgendaRange.ThisWeek -> stringResource(R.string.agenda_range_this_week)
|
||||||
|
AgendaRange.ThisMonth -> stringResource(R.string.agenda_range_this_month)
|
||||||
|
AgendaRange.Week -> stringResource(R.string.agenda_range_week)
|
||||||
|
AgendaRange.Month -> stringResource(R.string.agenda_range_month)
|
||||||
|
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||||
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import java.time.LocalTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resolved clock convention for the whole UI: `true` = 24-hour ("14:00"),
|
||||||
|
* `false` = 12-hour ("2:00 PM"). Provided once at the app root from the
|
||||||
|
* `TimeFormatPref` preference resolved against the device's 24-hour system
|
||||||
|
* setting, so every time label reads it without per-screen plumbing. Defaults
|
||||||
|
* to 24-hour for previews and any composition that forgets to provide it.
|
||||||
|
*/
|
||||||
|
val LocalUse24HourFormat = staticCompositionLocalOf { true }
|
||||||
|
|
||||||
|
private const val PATTERN_24 = "HH:mm"
|
||||||
|
private const val PATTERN_12 = "h:mm a"
|
||||||
|
private const val HOUR_PATTERN_12 = "h a"
|
||||||
|
|
||||||
|
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
||||||
|
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern(if (is24Hour) PATTERN_24 else PATTERN_12, locale)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a wall-clock time (hour 0..23, minute 0..59) honouring the resolved
|
||||||
|
* [is24Hour] convention and [locale]: 24h → "14:00", 12h → "2:00 PM". Pure, so
|
||||||
|
* it can be unit-tested and used off the main thread (widget, notifications).
|
||||||
|
*/
|
||||||
|
fun formatTimeOfDay(hour: Int, minute: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
|
LocalTime.of(hour.coerceIn(0, 23), minute.coerceIn(0, 59))
|
||||||
|
.format(timeOfDayFormatter(is24Hour, locale))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format minutes-from-midnight (0..1440) as a time label. The end-of-day value
|
||||||
|
* 1440 renders as "24:00" in 24h (its established reading) and as midnight
|
||||||
|
* ("12:00 AM") in 12h, which has no 24:00 equivalent.
|
||||||
|
*/
|
||||||
|
fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String = when {
|
||||||
|
minutes >= MINUTES_PER_DAY && is24Hour -> "24:00"
|
||||||
|
minutes >= MINUTES_PER_DAY -> formatTimeOfDay(0, 0, is24Hour = false, locale)
|
||||||
|
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
||||||
|
* the prior look); 12h → "1 PM".
|
||||||
|
*/
|
||||||
|
fun formatHourLabel(hour: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
|
if (is24Hour) {
|
||||||
|
"%02d".format(hour)
|
||||||
|
} else {
|
||||||
|
LocalTime.of(hour.coerceIn(0, 23), 0)
|
||||||
|
.format(DateTimeFormatter.ofPattern(HOUR_PATTERN_12, locale))
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MINUTES_PER_DAY = 1_440
|
||||||
@@ -80,7 +80,12 @@ import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
|||||||
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.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -466,6 +471,8 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||||
@@ -488,7 +495,7 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "%02d".format(h),
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -534,6 +541,8 @@ private fun DayColumnCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||||
|
val showHourLines = LocalShowHourLines.current
|
||||||
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular column — the soft corners come from the outer
|
// Plain rectangular column — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -546,6 +555,9 @@ private fun DayColumnCard(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
// Faint hour separators sit over the column background but under
|
||||||
|
// the event blocks (drawBehind paints before the children).
|
||||||
|
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||||
// Tap an empty slot to create an event there. Taps on event
|
// Tap an empty slot to create an event there. Taps on event
|
||||||
// blocks are consumed by their own click handler first, so this
|
// blocks are consumed by their own click handler first, so this
|
||||||
// only fires on the column background. Snaps to the tapped hour.
|
// only fires on the column background. Snaps to the tapped hour.
|
||||||
@@ -589,7 +601,10 @@ private fun EventBlock(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
val showTime = block.endMin - block.startMin >= 45
|
val showTime = block.endMin - block.startMin >= 45
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@@ -635,8 +650,8 @@ private fun DayLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun minToHm(min: Int): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatDayTitle(date: LocalDate): String {
|
private fun formatDayTitle(date: LocalDate): String {
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
@@ -781,7 +783,7 @@ private fun formatWhen(
|
|||||||
val zid = ZoneId.of(zone.id)
|
val zid = ZoneId.of(zone.id)
|
||||||
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(locale)
|
||||||
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
val dateMedium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
val timeShort = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
val timeShort = timeOfDayFormatter(LocalUse24HourFormat.current, locale)
|
||||||
|
|
||||||
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
val startLdt = instance.start.toJavaLocalDateTime(zid)
|
||||||
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
val allDayLabel = stringResource(R.string.event_detail_all_day)
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import androidx.compose.material.icons.filled.EventAvailable
|
|||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.People
|
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
import androidx.compose.material.icons.filled.PersonAdd
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
@@ -82,6 +81,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
@@ -123,6 +124,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
@@ -132,6 +135,8 @@ import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
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.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderUnitLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
@@ -476,6 +481,17 @@ private fun EventEditContent(
|
|||||||
?: MaterialTheme.colorScheme.primary
|
?: MaterialTheme.colorScheme.primary
|
||||||
val gap = 12.dp
|
val gap = 12.dp
|
||||||
|
|
||||||
|
// Issue #10: on a fresh create form (setting on, nothing typed yet) focus the
|
||||||
|
// title and raise the keyboard so the user can type straight away. Keyed on
|
||||||
|
// Unit so it runs once per open — not on every keystroke — and guarded so
|
||||||
|
// editing or an imported/prefilled form never grabs focus.
|
||||||
|
val titleFocusRequester = remember { FocusRequester() }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) {
|
||||||
|
runCatching { titleFocusRequester.requestFocus() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Per-event colour applicability for the resolved calendar:
|
// Per-event colour applicability for the resolved calendar:
|
||||||
// - palette calendars (Google, …) and local calendars always support it;
|
// - palette calendars (Google, …) and local calendars always support it;
|
||||||
// - synced calendars with no palette only when the user opted in, and even
|
// - synced calendars with no palette only when the user opted in, and even
|
||||||
@@ -503,6 +519,10 @@ private fun EventEditContent(
|
|||||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||||
textStyle = MaterialTheme.typography.headlineMedium
|
textStyle = MaterialTheme.typography.headlineMedium
|
||||||
.copy(fontWeight = FontWeight.SemiBold),
|
.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.focusRequester(titleFocusRequester),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(10.dp))
|
Spacer(Modifier.height(10.dp))
|
||||||
Box(
|
Box(
|
||||||
@@ -1017,9 +1037,9 @@ private fun FieldPickerDialog(
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
hiddenFields.forEach { field ->
|
hiddenFields.forEach { field ->
|
||||||
OptionCard(
|
OptionCard(
|
||||||
label = stringResource(fieldLabel(field)),
|
label = stringResource(eventFormFieldLabel(field)),
|
||||||
onClick = { onSelect(field) },
|
onClick = { onSelect(field) },
|
||||||
icon = fieldIcon(field),
|
icon = eventFormFieldIcon(field),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1636,28 +1656,6 @@ private fun readContactAddress(context: Context, uri: Uri): String? =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
|
||||||
EventFormField.Description -> R.string.event_detail_description
|
|
||||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
|
||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
|
||||||
EventFormField.Color -> R.string.event_edit_color
|
|
||||||
EventFormField.Attendees -> R.string.event_edit_attendees
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
|
||||||
EventFormField.Location -> Icons.Default.Place
|
|
||||||
EventFormField.Description -> Icons.AutoMirrored.Filled.Notes
|
|
||||||
EventFormField.Reminders -> Icons.Default.Notifications
|
|
||||||
EventFormField.Recurrence -> Icons.Default.Repeat
|
|
||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
|
||||||
EventFormField.Visibility -> Icons.Default.Lock
|
|
||||||
EventFormField.Color -> Icons.Default.Palette
|
|
||||||
EventFormField.Attendees -> Icons.Default.People
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Visibility selector: one card per level, each with its own icon; the
|
* Visibility selector: one card per level, each with its own icon; the
|
||||||
* current level is highlighted. Tap picks and closes.
|
* current level is highlighted. Tap picks and closes.
|
||||||
@@ -1891,8 +1889,9 @@ private fun ScheduleRow(
|
|||||||
val dateFormat = remember(locale) {
|
val dateFormat = remember(locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
}
|
}
|
||||||
val timeFormat = remember(locale) {
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
val timeFormat = remember(locale, use24Hour) {
|
||||||
|
timeOfDayFormatter(use24Hour, locale)
|
||||||
}
|
}
|
||||||
// Tappable values read as links (primary), like the location on the
|
// Tappable values read as links (primary), like the location on the
|
||||||
// detail screen; errors flip them to the error colour.
|
// detail screen; errors flip them to the error colour.
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ data class EventEditUiState(
|
|||||||
val hiddenFields: List<EventFormField> = emptyList(),
|
val hiddenFields: List<EventFormField> = emptyList(),
|
||||||
/** True while editing an existing event (the calendar is then fixed). */
|
/** True while editing an existing event (the calendar is then fixed). */
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Whether to focus the title and raise the keyboard when this is a fresh
|
||||||
|
* create form (issue #10). Mirrors the settings flag; the screen only acts
|
||||||
|
* on it for a new event with an empty title, never when editing/importing.
|
||||||
|
*/
|
||||||
|
val autofocusTitle: Boolean = true,
|
||||||
/**
|
/**
|
||||||
* True while an edit changed the recurrence rule — the save-scope dialog
|
* True while an edit changed the recurrence rule — the save-scope dialog
|
||||||
* then drops "only this event" (an exception row can't carry a rule).
|
* then drops "only this event" (an exception row can't carry a rule).
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
val defaultFields: Set<EventFormField>,
|
val defaultFields: Set<EventFormField>,
|
||||||
val allowColorOnUnsupported: Boolean,
|
val allowColorOnUnsupported: Boolean,
|
||||||
|
val autofocusTitle: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,6 +161,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
prefs.lastUsedCalendarId,
|
prefs.lastUsedCalendarId,
|
||||||
settingsPrefs.defaultFormFields,
|
settingsPrefs.defaultFormFields,
|
||||||
settingsPrefs.allowColorOnUnsupportedCalendars,
|
settingsPrefs.allowColorOnUnsupportedCalendars,
|
||||||
|
settingsPrefs.autofocusEventTitle,
|
||||||
::ExternalInputs,
|
::ExternalInputs,
|
||||||
).flowOn(io),
|
).flowOn(io),
|
||||||
colorPalette,
|
colorPalette,
|
||||||
@@ -178,6 +180,7 @@ class EventEditViewModel @Inject constructor(
|
|||||||
visibleFields = visibleFields,
|
visibleFields = visibleFields,
|
||||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||||
isEditing = local.editTarget != null,
|
isEditing = local.editTarget != null,
|
||||||
|
autofocusTitle = external.autofocusTitle,
|
||||||
// A modified-occurrence exception can't carry its own rule, so
|
// A modified-occurrence exception can't carry its own rule, so
|
||||||
// the scope dialog drops "only this event" after a rule change.
|
// the scope dialog drops "only this event" after a rule change.
|
||||||
recurrenceChanged = local.editTarget != null &&
|
recurrenceChanged = local.editTarget != null &&
|
||||||
|
|||||||
@@ -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,14 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
@@ -97,6 +104,14 @@ fun MonthScreen(
|
|||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
|
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||||
|
// screen while the setting is off (it stays null regardless of the tick).
|
||||||
|
val nowState = rememberCurrentMinute()
|
||||||
|
val dimCutoff by remember(dimCompleted) {
|
||||||
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -193,14 +208,16 @@ fun MonthScreen(
|
|||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
WeekdayHeader(weekStart = weekStart)
|
WeekdayHeader(weekStart = weekStart)
|
||||||
MonthContent(
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
state = state,
|
MonthContent(
|
||||||
slideDir = slideDir,
|
state = state,
|
||||||
onSwipeNext = goNext,
|
slideDir = slideDir,
|
||||||
onSwipePrev = goPrev,
|
onSwipeNext = goNext,
|
||||||
onRetry = jumpToToday,
|
onSwipePrev = goPrev,
|
||||||
onOpenDay = onOpenDay,
|
onRetry = jumpToToday,
|
||||||
)
|
onOpenDay = onOpenDay,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,6 +575,8 @@ private fun MonthBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
val shape = RoundedCornerShape(
|
val shape = RoundedCornerShape(
|
||||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||||
@@ -565,7 +584,7 @@ private fun MonthBar(
|
|||||||
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
bottomEnd = if (continuesRight) 0.dp else 4.dp,
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(pastelize(event.color, dark), shape)
|
.background(pastelize(event.color, dark), shape)
|
||||||
.padding(horizontal = 4.dp)
|
.padding(horizontal = 4.dp)
|
||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
|
|||||||
@@ -59,6 +59,14 @@ class MonthViewModel @Inject constructor(
|
|||||||
initialValue = DayOfWeek.MONDAY,
|
initialValue = DayOfWeek.MONDAY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Whether to fade events that have already finished (display concern only). */
|
||||||
|
val dimCompletedEvents: StateFlow<Boolean> = settingsPrefs.dimCompletedEvents
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = false,
|
||||||
|
)
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
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 java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
@@ -217,12 +219,11 @@ private fun searchSummary(event: EventInstance): String {
|
|||||||
val dateText = remember(locale) {
|
val dateText = remember(locale) {
|
||||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
}.format(start)
|
}.format(start)
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val timeText = if (event.isAllDay) {
|
val timeText = if (event.isAllDay) {
|
||||||
stringResource(R.string.event_detail_all_day)
|
stringResource(R.string.event_detail_all_day)
|
||||||
} else {
|
} else {
|
||||||
remember(locale) {
|
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale)
|
|
||||||
}.format(start)
|
|
||||||
}
|
}
|
||||||
val base = "$dateText · $timeText"
|
val base = "$dateText · $timeText"
|
||||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
||||||
|
|||||||
@@ -39,14 +39,17 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.BugReport
|
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.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
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
|
||||||
import androidx.compose.material.icons.filled.Gavel
|
import androidx.compose.material.icons.filled.Gavel
|
||||||
|
import androidx.compose.material.icons.filled.Keyboard
|
||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.Translate
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -83,7 +86,9 @@ 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.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
@@ -92,6 +97,8 @@ import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
|||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
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.agendaRangeLabel
|
||||||
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
|
||||||
@@ -105,7 +112,12 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalTime
|
import kotlinx.datetime.LocalTime
|
||||||
|
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. */
|
||||||
@@ -214,6 +226,12 @@ private fun SettingsHub(
|
|||||||
onClick = onManageCalendars,
|
onClick = onManageCalendars,
|
||||||
)
|
)
|
||||||
LanguageRow(position = Position.Middle)
|
LanguageRow(position = Position.Middle)
|
||||||
|
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
||||||
|
// is API 33+; on older versions the tile is still addable manually from
|
||||||
|
// the QS editor, so the row simply doesn't appear here.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
QuickSettingsTileRow(position = Position.Middle)
|
||||||
|
}
|
||||||
ReportProblemRow(position = Position.Bottom)
|
ReportProblemRow(position = Position.Bottom)
|
||||||
|
|
||||||
AppVersionText()
|
AppVersionText()
|
||||||
@@ -255,6 +273,24 @@ private fun ReportProblemRow(position: Position) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the system to add the "New event" Quick Settings tile (API 33+). A
|
||||||
|
* top-level action row, like [ReportProblemRow] — the tile is a system-surface
|
||||||
|
* shortcut, not part of any one settings category.
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||||
|
@Composable
|
||||||
|
private fun QuickSettingsTileRow(position: Position) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_qs_tile),
|
||||||
|
summary = stringResource(R.string.settings_qs_tile_hint),
|
||||||
|
position = position,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Dashboard, ChipAccent.Neutral) },
|
||||||
|
onClick = { requestAddQsTile(context) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LanguageRow(position: Position) {
|
private fun LanguageRow(position: Position) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -285,6 +321,18 @@ private fun LanguageRow(position: Position) {
|
|||||||
AppLanguage.apply(it)
|
AppLanguage.apply(it)
|
||||||
},
|
},
|
||||||
onDismiss = { showDialog = false },
|
onDismiss = { showDialog = false },
|
||||||
|
// Invite contributions right where users pick their language.
|
||||||
|
header = {
|
||||||
|
val translateUrl = stringResource(R.string.about_translate_url)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_translate),
|
||||||
|
summary = stringResource(R.string.settings_translate_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
|
||||||
|
onClick = { openUrl(context, translateUrl) },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,24 +472,23 @@ private fun AppearanceScreen(
|
|||||||
) {
|
) {
|
||||||
var showTheme by remember { mutableStateOf(false) }
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
var showWeekStart by remember { mutableStateOf(false) }
|
var showWeekStart by remember { mutableStateOf(false) }
|
||||||
|
var showTimeFormat by remember { mutableStateOf(false) }
|
||||||
var showDefaultView by remember { mutableStateOf(false) }
|
var showDefaultView by remember { mutableStateOf(false) }
|
||||||
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
|
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||||
|
var showPastEvents by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
) {
|
) {
|
||||||
|
// Theme & colour
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_theme),
|
title = stringResource(R.string.settings_theme),
|
||||||
summary = themeLabel(state.themeMode),
|
summary = themeLabel(state.themeMode),
|
||||||
position = Position.Top,
|
position = Position.Top,
|
||||||
onClick = { showTheme = true },
|
onClick = { showTheme = true },
|
||||||
)
|
)
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_default_view),
|
|
||||||
summary = stringResource(state.defaultView.labelRes),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { showDefaultView = true },
|
|
||||||
)
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_dynamic_color),
|
title = stringResource(R.string.settings_dynamic_color),
|
||||||
summary = if (state.dynamicColorAvailable) {
|
summary = if (state.dynamicColorAvailable) {
|
||||||
@@ -449,7 +496,7 @@ private fun AppearanceScreen(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.settings_dynamic_color_unavailable)
|
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||||
},
|
},
|
||||||
position = Position.Middle,
|
position = Position.Bottom,
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = state.dynamicColor,
|
checked = state.dynamicColor,
|
||||||
@@ -463,12 +510,88 @@ private fun AppearanceScreen(
|
|||||||
null
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Calendar — view, week, and timeline formatting
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_view),
|
||||||
|
summary = stringResource(state.defaultView.labelRes),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showDefaultView = true },
|
||||||
|
)
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
summary = weekStartLabel(state.weekStart),
|
summary = weekStartLabel(state.weekStart),
|
||||||
position = Position.Bottom,
|
position = Position.Middle,
|
||||||
onClick = { showWeekStart = true },
|
onClick = { showWeekStart = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_time_format),
|
||||||
|
summary = timeFormatLabel(state.timeFormat),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showTimeFormat = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_hour_lines),
|
||||||
|
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||||
|
position = Position.Middle,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.showHourLines,
|
||||||
|
onCheckedChange = viewModel::setShowHourLines,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
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))
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||||
|
position = Position.Top,
|
||||||
|
onClick = { showAgendaScreenRange = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_past_events),
|
||||||
|
summary = pastEventDisplayLabel(state.pastEventDisplay),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showPastEvents = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_widget_range),
|
||||||
|
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showAgendaWidgetRange = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_range_bar),
|
||||||
|
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||||
|
position = Position.Bottom,
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.agendaShowRangeBar,
|
||||||
|
onCheckedChange = viewModel::setAgendaShowRangeBar,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
@@ -484,13 +607,51 @@ private fun AppearanceScreen(
|
|||||||
if (showWeekStart) {
|
if (showWeekStart) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
options = WeekStartPref.entries,
|
options = WEEK_START_OPTIONS,
|
||||||
selected = state.weekStart,
|
selected = state.weekStart,
|
||||||
label = { weekStartLabel(it) },
|
label = { weekStartLabel(it) },
|
||||||
onSelect = viewModel::setWeekStart,
|
onSelect = viewModel::setWeekStart,
|
||||||
onDismiss = { showWeekStart = false },
|
onDismiss = { showWeekStart = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showAgendaScreenRange) {
|
||||||
|
AgendaRangePicker(
|
||||||
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
description = stringResource(R.string.settings_agenda_range_hint),
|
||||||
|
selected = state.agendaScreenRange,
|
||||||
|
onSelect = viewModel::setAgendaScreenRange,
|
||||||
|
onDismiss = { showAgendaScreenRange = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAgendaWidgetRange) {
|
||||||
|
AgendaRangePicker(
|
||||||
|
title = stringResource(R.string.settings_agenda_widget_range),
|
||||||
|
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||||
|
selected = state.agendaWidgetRange,
|
||||||
|
onSelect = viewModel::setAgendaWidgetRange,
|
||||||
|
onDismiss = { showAgendaWidgetRange = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showTimeFormat) {
|
||||||
|
OptionPicker(
|
||||||
|
title = stringResource(R.string.settings_time_format),
|
||||||
|
options = TimeFormatPref.entries,
|
||||||
|
selected = state.timeFormat,
|
||||||
|
label = { timeFormatLabel(it) },
|
||||||
|
onSelect = viewModel::setTimeFormat,
|
||||||
|
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),
|
||||||
@@ -524,8 +685,17 @@ private fun EventFormScreen(
|
|||||||
fields.forEachIndexed { index, field ->
|
fields.forEachIndexed { index, field ->
|
||||||
val checked = field in state.defaultFormFields
|
val checked = field in state.defaultFormFields
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(formFieldLabel(field)),
|
title = stringResource(eventFormFieldLabel(field)),
|
||||||
position = positionOf(index, fields.size),
|
position = positionOf(index, fields.size),
|
||||||
|
// Same icon the field carries in the new-event form, so a toggle
|
||||||
|
// is easy to match to the field it controls.
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = eventFormFieldIcon(field),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
@@ -536,6 +706,30 @@ private fun EventFormScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-focus the title on a new event (issue #10) — on by default, since
|
||||||
|
// most events get a title; raising the keyboard saves a tap. Off lets you
|
||||||
|
// set the time/calendar first without the keyboard in the way.
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_autofocus_title),
|
||||||
|
summary = stringResource(R.string.settings_autofocus_title_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Keyboard,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailing = {
|
||||||
|
Switch(
|
||||||
|
checked = state.autofocusEventTitle,
|
||||||
|
onCheckedChange = { viewModel.setAutofocusEventTitle(it) },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||||
|
)
|
||||||
|
|
||||||
// Per-event colour on calendars that publish no colour set (some
|
// Per-event colour on calendars that publish no colour set (some
|
||||||
// CalDAV) — off by default, with the honest caveat that the colour may
|
// CalDAV) — off by default, with the honest caveat that the colour may
|
||||||
// not survive their next sync. Local and palette calendars ignore it.
|
// not survive their next sync. Local and palette calendars ignore it.
|
||||||
@@ -556,20 +750,6 @@ private fun EventFormScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// One-tap add of the "New event" Quick Settings tile. The system prompt
|
|
||||||
// is API 33+; on older versions the tile is still addable manually from
|
|
||||||
// the QS editor, so the row simply doesn't appear there.
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
val context = LocalContext.current
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_qs_tile),
|
|
||||||
summary = stringResource(R.string.settings_qs_tile_hint),
|
|
||||||
position = Position.Alone,
|
|
||||||
onClick = { requestAddQsTile(context) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -621,6 +801,7 @@ private fun NotificationsScreen(
|
|||||||
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
var showAllDayReminderTime by remember { mutableStateOf(false) }
|
||||||
var showSnooze by remember { mutableStateOf(false) }
|
var showSnooze by remember { mutableStateOf(false) }
|
||||||
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
var overrideDialog by remember { mutableStateOf<OverrideTarget?>(null) }
|
||||||
|
var calendarSectionExpanded by remember { mutableStateOf(false) }
|
||||||
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
var expandedCalendars by remember { mutableStateOf(emptySet<Long>()) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
@@ -658,69 +839,11 @@ private fun NotificationsScreen(
|
|||||||
onClick = { showAllDayReminderTime = true },
|
onClick = { showAllDayReminderTime = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Per-calendar overrides: each writable calendar may keep, drop, or
|
// Delivery reliability + snooze: both are global reminder-delivery
|
||||||
// replace the global default — separately for timed and all-day events.
|
// settings, so they sit with the defaults above rather than below the
|
||||||
if (state.writableCalendars.isNotEmpty()) {
|
// long per-calendar list. Reliability is a soft, optional battery-
|
||||||
Spacer(Modifier.height(24.dp))
|
// optimisation exemption (system-settings deep-link, no special
|
||||||
Text(
|
// permission); shown as live status, reversible at any time.
|
||||||
text = stringResource(R.string.settings_calendar_reminders_hint),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
state.writableCalendars.forEach { calendar ->
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
val expanded = calendar.id in expandedCalendars
|
|
||||||
// Calendar card; tapping expands it into a grouped list of three
|
|
||||||
// (the card + the timed and all-day override rows).
|
|
||||||
GroupedRow(
|
|
||||||
title = calendar.displayName,
|
|
||||||
position = if (expanded) Position.Top else Position.Alone,
|
|
||||||
leading = { CalendarColorChip(calendar.color) },
|
|
||||||
trailing = {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
expandedCalendars = if (expanded) {
|
|
||||||
expandedCalendars - calendar.id
|
|
||||||
} else {
|
|
||||||
expandedCalendars + calendar.id
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = expanded,
|
|
||||||
enter = calendarExpandEnter(),
|
|
||||||
exit = calendarCollapseExit(),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_default_reminder),
|
|
||||||
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
|
||||||
position = Position.Middle,
|
|
||||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
|
||||||
)
|
|
||||||
val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id)
|
|
||||||
GroupedRow(
|
|
||||||
title = stringResource(R.string.settings_default_reminder_allday),
|
|
||||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
|
||||||
position = Position.Bottom,
|
|
||||||
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delivery reliability: Android's battery optimisation can delay or drop
|
|
||||||
// the calendar provider's reminder broadcast. A soft, optional exemption
|
|
||||||
// (system-settings deep-link, no special permission) improves on-time
|
|
||||||
// delivery; shown as live status, reversible by the user at any time.
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
val batteryExempt = rememberBatteryOptimizationExempt()
|
val batteryExempt = rememberBatteryOptimizationExempt()
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
@@ -730,7 +853,7 @@ private fun NotificationsScreen(
|
|||||||
} else {
|
} else {
|
||||||
stringResource(R.string.settings_reliable_delivery_hint)
|
stringResource(R.string.settings_reliable_delivery_hint)
|
||||||
},
|
},
|
||||||
position = Position.Alone,
|
position = Position.Top,
|
||||||
trailing = if (batteryExempt) {
|
trailing = if (batteryExempt) {
|
||||||
{
|
{
|
||||||
Icon(
|
Icon(
|
||||||
@@ -746,13 +869,88 @@ private fun NotificationsScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Snooze: how long the notification's "Snooze" action defers a reminder.
|
// Snooze: how long the notification's "Snooze" action defers a reminder.
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_snooze_duration),
|
title = stringResource(R.string.settings_snooze_duration),
|
||||||
summary = snoozeDurationLabel(state.snoozeMinutes),
|
summary = snoozeDurationLabel(state.snoozeMinutes),
|
||||||
position = Position.Alone,
|
position = Position.Bottom,
|
||||||
onClick = { showSnooze = true },
|
onClick = { showSnooze = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Per-calendar overrides: the whole section folds behind one header to
|
||||||
|
// keep the screen tidy. Expanded, each writable calendar gets its own
|
||||||
|
// expandable card that may keep, drop, or replace the global default —
|
||||||
|
// separately for timed and all-day events.
|
||||||
|
if (state.writableCalendars.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_calendar_reminders_title),
|
||||||
|
summary = stringResource(R.string.settings_calendar_reminders_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (calendarSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { calendarSectionExpanded = !calendarSectionExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = calendarSectionExpanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
state.writableCalendars.forEach { calendar ->
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
val expanded = calendar.id in expandedCalendars
|
||||||
|
// Calendar card; tapping expands it into a grouped list
|
||||||
|
// of three (the card + the timed and all-day rows).
|
||||||
|
GroupedRow(
|
||||||
|
title = calendar.displayName,
|
||||||
|
position = if (expanded) Position.Top else Position.Alone,
|
||||||
|
leading = { CalendarColorChip(calendar.color) },
|
||||||
|
trailing = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
expandedCalendars = if (expanded) {
|
||||||
|
expandedCalendars - calendar.id
|
||||||
|
} else {
|
||||||
|
expandedCalendars + calendar.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = expanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder),
|
||||||
|
summary = calendarOverrideSummary(timed, state.defaultReminderMinutes),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = false) },
|
||||||
|
)
|
||||||
|
val allDay = state.perCalendarAllDayReminderOverride.choiceFor(calendar.id)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_default_reminder_allday),
|
||||||
|
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { overrideDialog = OverrideTarget(calendar.id, isAllDay = true) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSnooze) {
|
if (showSnooze) {
|
||||||
@@ -973,17 +1171,6 @@ private fun openUrl(context: Context, url: String) {
|
|||||||
runCatching { context.startActivity(intent) }
|
runCatching { context.startActivity(intent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
|
||||||
EventFormField.Description -> R.string.event_detail_description
|
|
||||||
EventFormField.Reminders -> R.string.event_detail_reminders
|
|
||||||
EventFormField.Recurrence -> R.string.event_detail_recurrence
|
|
||||||
EventFormField.Availability -> R.string.event_edit_availability
|
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
|
||||||
EventFormField.Color -> R.string.event_edit_color
|
|
||||||
EventFormField.Attendees -> R.string.event_edit_attendees
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||||
when (mode) {
|
when (mode) {
|
||||||
@@ -993,12 +1180,45 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
|
||||||
|
private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
||||||
|
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun weekStartLabel(pref: WeekStartPref): String = stringResource(
|
private fun weekStartLabel(pref: WeekStartPref): String = when (pref) {
|
||||||
|
WeekStartPref.Auto -> stringResource(R.string.settings_week_start_auto)
|
||||||
|
// Localised full weekday name, so any of the seven days reads naturally
|
||||||
|
// without a per-day string resource.
|
||||||
|
is WeekStartPref.Day -> java.time.DayOfWeek.of(pref.day.ordinal + 1)
|
||||||
|
.getDisplayName(JavaTextStyle.FULL, currentLocale())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun timeFormatLabel(pref: TimeFormatPref): String = stringResource(
|
||||||
when (pref) {
|
when (pref) {
|
||||||
WeekStartPref.AUTO -> R.string.settings_week_start_auto
|
TimeFormatPref.AUTO -> R.string.settings_time_format_auto
|
||||||
WeekStartPref.MONDAY -> R.string.settings_week_start_monday
|
TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h
|
||||||
WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday
|
TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 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
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
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.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
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.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,11 +20,27 @@ data class SettingsUiState(
|
|||||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
val dynamicColorAvailable: Boolean = true,
|
val dynamicColorAvailable: Boolean = true,
|
||||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
val weekStart: WeekStartPref = WeekStartPref.Auto,
|
||||||
|
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */
|
||||||
|
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||||
|
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||||
|
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). */
|
||||||
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
|
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||||
|
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||||
|
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,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||||
|
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||||
|
val autofocusEventTitle: Boolean = true,
|
||||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||||
val remindersEnabled: Boolean = true,
|
val remindersEnabled: Boolean = true,
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.settings
|
package de.jeanlucmakiola.calendula.ui.settings
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||||
|
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||||
|
import androidx.glance.appwidget.updateAll
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import 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.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
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.storageValue
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||||
|
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||||
|
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -20,12 +33,15 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: SettingsPrefs,
|
private val prefs: SettingsPrefs,
|
||||||
repository: CalendarRepository,
|
repository: CalendarRepository,
|
||||||
|
@ApplicationContext private val appContext: Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
@@ -73,10 +89,33 @@ class SettingsViewModel @Inject constructor(
|
|||||||
) { overrides, allDayOverrides, calendars ->
|
) { overrides, allDayOverrides, calendars ->
|
||||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||||
},
|
},
|
||||||
prefs.defaultView,
|
combine(
|
||||||
) { base, defaults, overrides, defaultView ->
|
prefs.defaultView,
|
||||||
|
prefs.agendaScreenRange,
|
||||||
|
prefs.agendaWidgetRange,
|
||||||
|
prefs.timeFormat,
|
||||||
|
prefs.showHourLines,
|
||||||
|
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
||||||
|
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
||||||
|
},
|
||||||
|
combine(
|
||||||
|
prefs.agendaShowRangeBar,
|
||||||
|
prefs.autofocusEventTitle,
|
||||||
|
prefs.pastEventDisplay,
|
||||||
|
prefs.dimCompletedEvents,
|
||||||
|
::MiscSettings,
|
||||||
|
),
|
||||||
|
) { base, defaults, overrides, views, misc ->
|
||||||
base.copy(
|
base.copy(
|
||||||
defaultView = defaultView,
|
defaultView = views.defaultView,
|
||||||
|
agendaScreenRange = views.agendaScreenRange,
|
||||||
|
agendaWidgetRange = views.agendaWidgetRange,
|
||||||
|
timeFormat = views.timeFormat,
|
||||||
|
showHourLines = views.showHourLines,
|
||||||
|
agendaShowRangeBar = misc.showRangeBar,
|
||||||
|
autofocusEventTitle = misc.autofocusEventTitle,
|
||||||
|
pastEventDisplay = misc.pastEventDisplay,
|
||||||
|
dimCompletedEvents = misc.dimCompletedEvents,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -92,6 +131,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
|
||||||
|
// updateAll calls at once — concurrent calls coalesce around a stale read
|
||||||
|
// and can leave the widget on the intermediate value. Held sequentially,
|
||||||
|
// the last call reads the final committed pref and renders it.
|
||||||
|
private val widgetRefreshMutex = Mutex()
|
||||||
|
|
||||||
private data class ReminderDefaults(
|
private data class ReminderDefaults(
|
||||||
val allowColor: Boolean,
|
val allowColor: Boolean,
|
||||||
val defaultReminder: Int?,
|
val defaultReminder: Int?,
|
||||||
@@ -106,6 +151,21 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ViewSettings(
|
||||||
|
val defaultView: CalendarView,
|
||||||
|
val agendaScreenRange: AgendaRange,
|
||||||
|
val agendaWidgetRange: AgendaRange,
|
||||||
|
val timeFormat: TimeFormatPref,
|
||||||
|
val showHourLines: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class MiscSettings(
|
||||||
|
val showRangeBar: Boolean,
|
||||||
|
val autofocusEventTitle: Boolean,
|
||||||
|
val pastEventDisplay: PastEventDisplay,
|
||||||
|
val dimCompletedEvents: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
@@ -115,7 +175,68 @@ class SettingsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setWeekStart(pref: WeekStartPref) {
|
fun setWeekStart(pref: WeekStartPref) {
|
||||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
viewModelScope.launch {
|
||||||
|
prefs.setWeekStart(pref)
|
||||||
|
// Both widgets depend on the week start (month weekday header; the
|
||||||
|
// agenda widget's "this week" range).
|
||||||
|
widgetRefreshMutex.withLock {
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
MonthWidget().updateAll(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAgendaScreenRange(range: AgendaRange) {
|
||||||
|
viewModelScope.launch { prefs.setAgendaScreenRange(range) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAgendaWidgetRange(range: AgendaRange) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
prefs.setAgendaWidgetRange(range)
|
||||||
|
widgetRefreshMutex.withLock {
|
||||||
|
// Push the range into each instance's Glance state and recompose.
|
||||||
|
// The widget reads it via currentState, so this reflects reliably
|
||||||
|
// even when updateAll only recomposes a live session (it does not
|
||||||
|
// re-run provideGlance's data preamble).
|
||||||
|
val manager = GlanceAppWidgetManager(appContext)
|
||||||
|
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||||
|
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
|
||||||
|
}
|
||||||
|
AgendaWidget().updateAll(appContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAgendaShowRangeBar(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTimeFormat(pref: TimeFormatPref) {
|
||||||
|
viewModelScope.launch { prefs.setTimeFormat(pref) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowHourLines(enabled: Boolean) {
|
||||||
|
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) {
|
||||||
@@ -130,6 +251,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAutofocusEventTitle(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDefaultReminderMinutes(minutes: Int?) {
|
fun setDefaultReminderMinutes(minutes: Int?) {
|
||||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
viewModelScope.launch { prefs.setDefaultReminderMinutes(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,16 +76,25 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.common.next
|
import de.jeanlucmakiola.calendula.ui.common.next
|
||||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
@@ -124,6 +136,14 @@ fun WeekScreen(
|
|||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStartDate.collectAsStateWithLifecycle()
|
||||||
|
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||||
|
// The instant before which an event counts as completed, or null when dimming
|
||||||
|
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||||
|
// screen while the setting is off (it stays null regardless of the tick).
|
||||||
|
val nowState = rememberCurrentMinute()
|
||||||
|
val dimCutoff by remember(dimCompleted) {
|
||||||
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
@@ -216,19 +236,21 @@ fun WeekScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
WeekContent(
|
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||||
state = state,
|
WeekContent(
|
||||||
slideDir = slideDir,
|
state = state,
|
||||||
topSectionColor = topSectionColor,
|
slideDir = slideDir,
|
||||||
onSwipeNext = goNext,
|
topSectionColor = topSectionColor,
|
||||||
onSwipePrev = goPrev,
|
onSwipeNext = goNext,
|
||||||
onRetry = jumpToToday,
|
onSwipePrev = goPrev,
|
||||||
onEventClick = onEventClick,
|
onRetry = jumpToToday,
|
||||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||||
.padding(innerPadding)
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.padding(innerPadding)
|
||||||
)
|
.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -545,8 +567,10 @@ private fun AllDayBar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
|
val dimCutoff = LocalDimCutoff.current
|
||||||
|
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||||
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
.background(pastelize(event.color, dark), RoundedCornerShape(4.dp))
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
@@ -572,6 +596,8 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
val totalHeight = HOUR_HEIGHT * 24
|
val totalHeight = HOUR_HEIGHT * 24
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
|
val locale = currentLocale()
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||||
@@ -595,7 +621,7 @@ private fun Timeline(
|
|||||||
) {
|
) {
|
||||||
if (h > 0) {
|
if (h > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = "%02d".format(h),
|
text = formatHourLabel(h, use24Hour, locale),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -650,6 +676,8 @@ private fun DayColumnCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||||
|
val showHourLines = LocalShowHourLines.current
|
||||||
|
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||||
Card(
|
Card(
|
||||||
// Plain rectangular columns — the soft corners come from the outer
|
// Plain rectangular columns — the soft corners come from the outer
|
||||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||||
@@ -662,6 +690,9 @@ private fun DayColumnCard(
|
|||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
// Faint hour separators sit over the column background but under
|
||||||
|
// the event blocks (drawBehind paints before the children).
|
||||||
|
.hourSeparatorLines(showHourLines, hourPx, hourLineColor)
|
||||||
// Tap an empty slot to create an event there; taps on event
|
// Tap an empty slot to create an event there; taps on event
|
||||||
// blocks are consumed by their own handler first. Snaps to hour.
|
// blocks are consumed by their own handler first. Snaps to hour.
|
||||||
.pointerInput(date) {
|
.pointerInput(date) {
|
||||||
@@ -680,6 +711,7 @@ private fun DayColumnCard(
|
|||||||
EventBlock(
|
EventBlock(
|
||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
|
height = height,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.offset(x = laneWidth * block.lane, y = top)
|
.offset(x = laneWidth * block.lane, y = top)
|
||||||
@@ -700,14 +732,35 @@ 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,
|
||||||
) {
|
) {
|
||||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||||
val timeLabel = "${minToHm(block.startMin)}–${minToHm(block.endMin)}"
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val showTime = block.endMin - block.startMin >= 45
|
val locale = currentLocale()
|
||||||
|
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||||
|
minToHm(block.endMin, use24Hour, locale)
|
||||||
|
// 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)
|
||||||
@@ -717,18 +770,15 @@ private fun EventBlock(
|
|||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = if (showTime) 1 else 2,
|
maxLines = titleMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.85f),
|
color = Color.Black.copy(alpha = 0.85f),
|
||||||
)
|
)
|
||||||
if (showTime) {
|
if (showTime) {
|
||||||
// Narrow columns can't fit "13:00–14:00" on one line, so let it
|
|
||||||
// wrap to a second line (after the dash) instead of clipping the
|
|
||||||
// end time.
|
|
||||||
Text(
|
Text(
|
||||||
text = timeLabel,
|
text = timeLabel,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 2,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = Color.Black.copy(alpha = 0.6f),
|
color = Color.Black.copy(alpha = 0.6f),
|
||||||
)
|
)
|
||||||
@@ -773,8 +823,8 @@ private fun WeekLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun minToHm(min: Int): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String =
|
||||||
if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatWeekRange(weekStart: LocalDate): String {
|
private fun formatWeekRange(weekStart: LocalDate): String {
|
||||||
val locale = Locale.getDefault()
|
val locale = Locale.getDefault()
|
||||||
|
|||||||
@@ -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,9 +4,12 @@ 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.resolveFirstDay
|
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -22,9 +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 ahead the agenda widget loads (a month of upcoming events). */
|
|
||||||
private const val AGENDA_WIDGET_DAYS = 30
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -46,7 +47,28 @@ internal fun Context.hasCalendarPermission(): Boolean =
|
|||||||
sealed interface AgendaWidgetData {
|
sealed interface AgendaWidgetData {
|
||||||
/** Calendar permission not granted — the widget can't read events. */
|
/** Calendar permission not granted — the widget can't read events. */
|
||||||
data object NeedsPermission : AgendaWidgetData
|
data object NeedsPermission : AgendaWidgetData
|
||||||
data class Ready(val today: LocalDate, val days: List<AgendaDay>) : AgendaWidgetData
|
data class Ready(
|
||||||
|
val today: LocalDate,
|
||||||
|
/**
|
||||||
|
* Days with events across the **widest** selectable window. The displayed
|
||||||
|
* range is sliced from this in the composition (read reactively from
|
||||||
|
* Glance state), so a range change is pure recomposition rather than a
|
||||||
|
* 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>,
|
||||||
|
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||||
|
val is24Hour: Boolean,
|
||||||
|
/** First day of the week, for the calendar-aligned "this week" range. */
|
||||||
|
val weekStart: DayOfWeek,
|
||||||
|
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,9 +113,27 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
val repo = widgetEntryPoint().calendarRepository()
|
val ep = widgetEntryPoint()
|
||||||
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
val prefs = ep.settingsPrefs()
|
||||||
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
|
val savedRange = prefs.agendaWidgetRange.first()
|
||||||
|
val savedPastDisplay = prefs.pastEventDisplay.first()
|
||||||
|
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||||
|
// Load the widest selectable window once; the displayed range is sliced in
|
||||||
|
// the composition from Glance state, so changing the range is a plain
|
||||||
|
// recomposition and never depends on the widget session restarting.
|
||||||
|
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
||||||
|
val instances = ep.calendarRepository().instances(window).first()
|
||||||
|
val is24Hour = prefs.timeFormat.first()
|
||||||
|
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||||
|
return AgendaWidgetData.Ready(
|
||||||
|
today = anchor,
|
||||||
|
days = groupAgendaDays(anchor, instances, zone),
|
||||||
|
is24Hour = is24Hour,
|
||||||
|
weekStart = weekStart,
|
||||||
|
savedRange = savedRange,
|
||||||
|
savedPastDisplay = savedPastDisplay,
|
||||||
|
now = Clock.System.now(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
/** One-shot wide read backing the month widget's grid for any nearby month. */
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.glance.ColorFilter
|
import androidx.glance.ColorFilter
|
||||||
import androidx.glance.GlanceId
|
import androidx.glance.GlanceId
|
||||||
import androidx.glance.GlanceModifier
|
import androidx.glance.GlanceModifier
|
||||||
@@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items
|
|||||||
import androidx.glance.appwidget.provideContent
|
import androidx.glance.appwidget.provideContent
|
||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.appwidget.updateAll
|
||||||
import androidx.glance.background
|
import androidx.glance.background
|
||||||
|
import androidx.glance.currentState
|
||||||
|
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||||
import androidx.glance.layout.Alignment
|
import androidx.glance.layout.Alignment
|
||||||
import androidx.glance.layout.Box
|
import androidx.glance.layout.Box
|
||||||
import androidx.glance.layout.Column
|
import androidx.glance.layout.Column
|
||||||
@@ -40,8 +43,16 @@ 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.dayCount
|
||||||
|
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.pastelize
|
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||||
@@ -62,8 +73,28 @@ import java.util.Locale
|
|||||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
||||||
|
* The range is read reactively in the composition ([currentState]) so a settings
|
||||||
|
* change is reflected by plain recomposition — `updateAll` does NOT reliably
|
||||||
|
* re-run the `provideGlance` preamble for a live widget session, so loading the
|
||||||
|
* range there and slicing it would silently keep the stale value (mirrors the
|
||||||
|
* month widget's MONTH_INDEX_KEY approach).
|
||||||
|
*/
|
||||||
|
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 suspend fun provideGlance(context: Context, id: GlanceId) {
|
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||||
val data = context.loadAgendaWidgetData()
|
val data = context.loadAgendaWidgetData()
|
||||||
val dark = (context.resources.configuration.uiMode and
|
val dark = (context.resources.configuration.uiMode and
|
||||||
@@ -101,12 +132,36 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
|||||||
Spacer(GlanceModifier.height(4.dp))
|
Spacer(GlanceModifier.height(4.dp))
|
||||||
when (data) {
|
when (data) {
|
||||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
||||||
is AgendaWidgetData.Ready ->
|
is AgendaWidgetData.Ready -> {
|
||||||
if (data.days.isEmpty()) {
|
// Range read reactively from per-instance Glance state (falls back
|
||||||
|
// to the saved pref for a freshly placed widget), then the wide
|
||||||
|
// pre-loaded window is sliced to it — pure recomposition.
|
||||||
|
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||||
|
val pastDisplay =
|
||||||
|
parsePastEventDisplay(currentState(AGENDA_PAST_DISPLAY_KEY), data.savedPastDisplay)
|
||||||
|
val rangeEnd = data.today.plus(
|
||||||
|
range.dayCount(data.today, data.weekStart) - 1,
|
||||||
|
DateTimeUnit.DAY,
|
||||||
|
)
|
||||||
|
// 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()) {
|
||||||
WidgetMessage(R.string.agenda_empty_title)
|
WidgetMessage(R.string.agenda_empty_title)
|
||||||
} else {
|
} else {
|
||||||
val rows = buildList {
|
val rows = buildList {
|
||||||
data.days.forEach { day ->
|
visibleDays.forEach { day ->
|
||||||
add(AgendaRow.Header(day.date, data.today))
|
add(AgendaRow.Header(day.date, data.today))
|
||||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||||
}
|
}
|
||||||
@@ -115,11 +170,18 @@ 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)
|
is AgendaRow.Event -> EventRow(
|
||||||
|
event = row.event,
|
||||||
|
dark = dark,
|
||||||
|
is24Hour = data.is24Hour,
|
||||||
|
dimmed = pastDisplay == PastEventDisplay.DIM &&
|
||||||
|
row.event.hasEnded(data.now),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,9 +257,15 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventRow(event: EventInstance, dark: 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()
|
||||||
@@ -220,17 +288,17 @@ private fun EventRow(event: EventInstance, dark: 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),
|
text = eventTimeSummary(context, event, is24Hour),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp),
|
||||||
)
|
)
|
||||||
@@ -269,17 +337,17 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate):
|
|||||||
return if (relative != null) "$relative · $formatted" else formatted
|
return if (relative != null) "$relative · $formatted" else formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun eventTimeSummary(context: Context, event: EventInstance): String {
|
private fun eventTimeSummary(context: Context, event: EventInstance, is24Hour: Boolean): String {
|
||||||
val time = if (event.isAllDay) {
|
val time = if (event.isAllDay) {
|
||||||
context.getString(R.string.event_detail_all_day)
|
context.getString(R.string.event_detail_all_day)
|
||||||
} else {
|
} else {
|
||||||
"${formatTime(event.start)} – ${formatTime(event.end)}"
|
"${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}"
|
||||||
}
|
}
|
||||||
val location = event.location?.takeIf { it.isNotBlank() }
|
val location = event.location?.takeIf { it.isNotBlank() }
|
||||||
return if (location != null) "$time · $location" else time
|
return if (location != null) "$time · $location" else time
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTime(instant: Instant): String {
|
private fun formatTime(instant: Instant, is24Hour: Boolean): String {
|
||||||
val t = instant.toLocalDateTime(zone()).time
|
val t = instant.toLocalDateTime(zone()).time
|
||||||
return "%02d:%02d".format(t.hour, t.minute)
|
return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,24 +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">Nichts geplant</string>
|
<string name="agenda_empty_title">Alles erledigt</string>
|
||||||
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</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>
|
||||||
@@ -240,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>
|
||||||
@@ -251,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>
|
||||||
@@ -276,10 +254,35 @@
|
|||||||
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
<string name="settings_dynamic_color_unavailable">Erfordert Android 12 oder neuer</string>
|
||||||
<string name="settings_week_start">Wochenstart</string>
|
<string name="settings_week_start">Wochenstart</string>
|
||||||
<string name="settings_week_start_auto">Automatisch</string>
|
<string name="settings_week_start_auto">Automatisch</string>
|
||||||
<string name="settings_week_start_monday">Montag</string>
|
<string name="settings_time_format">Zeitformat</string>
|
||||||
<string name="settings_week_start_sunday">Sonntag</string>
|
<string name="settings_time_format_auto">Automatisch</string>
|
||||||
|
<string name="settings_time_format_12h">12-Stunden (2:00 PM)</string>
|
||||||
|
<string name="settings_time_format_24h">24-Stunden (14:00)</string>
|
||||||
|
<string name="settings_hour_lines">Stundenlinien</string>
|
||||||
|
<string name="settings_hour_lines_summary">Trennlinie zu jeder vollen Stunde in Wochen- und Tagesansicht anzeigen</string>
|
||||||
|
<string name="settings_agenda_range">Agenda-Zeitraum</string>
|
||||||
|
<string name="settings_agenda_range_hint">Wie weit im Voraus die Agenda-Ansicht Termine anzeigt.</string>
|
||||||
|
<string name="settings_agenda_widget_range">Agenda-Widget-Zeitraum</string>
|
||||||
|
<string name="settings_agenda_widget_range_hint">Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt.</string>
|
||||||
|
<string name="settings_agenda_range_bar">Zeitraum-Leiste</string>
|
||||||
|
<string name="settings_agenda_range_bar_hint">Eine Leiste oben in der Agenda anzeigen, die den dargestellten Zeitraum benennt, mit einer Schaltfläche zum Wechseln des Zeitraums für die Sitzung</string>
|
||||||
|
<string name="agenda_range_day">Heute</string>
|
||||||
|
<string name="agenda_range_this_week">Diese Woche</string>
|
||||||
|
<string name="agenda_range_this_month">Dieser Monat</string>
|
||||||
|
<string name="agenda_range_week">Nächste 7 Tage</string>
|
||||||
|
<string name="agenda_range_month">Nächste 30 Tage</string>
|
||||||
|
<string name="agenda_range_custom">Benutzerdefiniert…</string>
|
||||||
|
<string name="agenda_range_custom_hint">Tage</string>
|
||||||
|
<string name="agenda_range_override_hint">Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt.</string>
|
||||||
|
<string name="agenda_range_showing_label">Alle anstehenden Termine für</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d Tag</item>
|
||||||
|
<item quantity="other">%d Tage</item>
|
||||||
|
</plurals>
|
||||||
<string name="settings_section_event_form">Termin-Formular</string>
|
<string name="settings_section_event_form">Termin-Formular</string>
|
||||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||||
|
<string name="settings_autofocus_title">Titel bei neuem Termin fokussieren</string>
|
||||||
|
<string name="settings_autofocus_title_hint">Beim Anlegen eines neuen Termins den Cursor direkt ins Titelfeld setzen und die Tastatur öffnen.</string>
|
||||||
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
||||||
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
|
<string name="settings_color_unsupported_hint">Manche Kalender (z. B. bestimmte CalDAV) stellen keine Farbpalette bereit; eine eigene Terminfarbe wird dort bei der nächsten Synchronisierung unter Umständen verworfen oder überschrieben. Das ist eine Einschränkung dieser Kalender und kann von Calendula nicht behoben werden.</string>
|
||||||
<string name="settings_section_notifications">Benachrichtigungen</string>
|
<string name="settings_section_notifications">Benachrichtigungen</string>
|
||||||
@@ -320,7 +323,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>
|
||||||
@@ -375,7 +377,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>
|
||||||
|
|||||||
422
app/src/main/res/values-es/strings.xml
Normal file
422
app/src/main/res/values-es/strings.xml
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
<?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>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="app_tagline">Un calendario moderno.</string>
|
||||||
|
<string name="state_loading">Cargando…</string>
|
||||||
|
<string name="state_retry">Vuelve a intentarlo</string>
|
||||||
|
<string name="state_failure_unknown">Algo salio mal.</string>
|
||||||
|
<string name="state_failure_permission">Se requiere acceso al calendario.</string>
|
||||||
|
<string name="state_failure_permission_action">Permite el acceso</string>
|
||||||
|
<string name="state_failure_no_calendars">No se han configurado calendarios.</string>
|
||||||
|
<string name="state_failure_no_calendars_action">Abrir los ajustes del calendario del sistema</string>
|
||||||
|
<string name="state_failure_provider">No se pudo leer el calendario.</string>
|
||||||
|
<string name="permission_rationale_title">Mira todos sus eventos, a la perfección</string>
|
||||||
|
<string name="permission_rationale_body">Calendula necesita acceso a tu calendario para mostrarte y administrar eventos. Ese es el único permiso que pedirá.</string>
|
||||||
|
<string name="permission_request_button">Conceder el acceso al calendario</string>
|
||||||
|
<string name="permission_denied_title">Acceso a calendario denegado</string>
|
||||||
|
<string name="permission_denied_body">Calendula no puede mostrar eventos sin acceso al calendario. Puedes concederlo en ajustes del sistema.</string>
|
||||||
|
<string name="permission_open_settings_button">Abrir ajustes del sistema</string>
|
||||||
|
<string name="permission_retry_button">Intentarlo de nuevo</string>
|
||||||
|
<string name="permission_benefit_private_title">Permanece en tu dispositivo</string>
|
||||||
|
<string name="permission_benefit_private_body">Tus calendarios se leen localmente y nunca abandonan tu celular.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Todos tus calendarios, juntos</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, local — todo lo sincronizado en el dispositivo aparecerá.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Sin rastreo, jamás</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Sin telemetría, sin analíticas, sin anuncios.</string>
|
||||||
|
<string name="permission_privacy_footnote">Permanece en tu dispositivo, no necesita acceso a internet</string>
|
||||||
|
<string name="month_prev">Mes anterior</string>
|
||||||
|
<string name="month_next">Siguiente mes</string>
|
||||||
|
<string name="month_today_action">Hoy</string>
|
||||||
|
<string name="month_more_actions">Mas acciones</string>
|
||||||
|
<string name="month_open_menu">Abrir menu</string>
|
||||||
|
<string name="month_action_settings">Ajustes</string>
|
||||||
|
<string name="month_a11y_today_prefix">Hoy</string>
|
||||||
|
<string name="week_today_action">Esta semana</string>
|
||||||
|
<string name="week_number_label">Sem</string>
|
||||||
|
<string name="day_today_action">Hoy</string>
|
||||||
|
<string name="event_detail_back">Atras</string>
|
||||||
|
<string name="event_detail_edit">Editar</string>
|
||||||
|
<string name="event_detail_delete">Eliminar</string>
|
||||||
|
<string name="event_detail_share">Compartir</string>
|
||||||
|
<string name="event_share_chooser_title">Compartir evento</string>
|
||||||
|
<string name="event_share_failed">No se pudo compartir el evento.</string>
|
||||||
|
<string name="event_delete_title">¿Eliminar evento?</string>
|
||||||
|
<string name="event_delete_body">El evento se elimino de tu calendario y de todos los dispositivos con los cuales se sincroniza.</string>
|
||||||
|
<string name="event_delete_recurring_title">Eliminar evento recurrente</string>
|
||||||
|
<string name="event_delete_option_occurrence">Solo este evento</string>
|
||||||
|
<string name="event_delete_option_following">Este y todos los eventos siguientes</string>
|
||||||
|
<string name="event_delete_option_series">Todos los eventos en la serie</string>
|
||||||
|
<string name="event_edit_recurring_title">Editar evento recurrente</string>
|
||||||
|
<string name="event_delete_failed">No se pudo eliminar el evento</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula necesita acceso de escritura para eliminar eventos</string>
|
||||||
|
<string name="dialog_cancel">Cancelar</string>
|
||||||
|
<string name="dialog_ok">Okey</string>
|
||||||
|
<string name="event_edit_new_title">Nuevo evento</string>
|
||||||
|
<string name="event_edit_close">Cerrar</string>
|
||||||
|
<string name="event_edit_save">Guardar</string>
|
||||||
|
<string name="event_edit_title_hint">Añadir título</string>
|
||||||
|
<string name="event_edit_starts">Inicio</string>
|
||||||
|
<string name="event_edit_ends">Final</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Termina antes de comenzar</string>
|
||||||
|
<string name="event_edit_error_no_calendar">No hay ningún calendario en donde se pueda escribir disponible</string>
|
||||||
|
<string name="event_edit_save_failed">No se pudo guardar el evento</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula necesita acceso de escritura para crear eventos</string>
|
||||||
|
<string name="event_edit_more_fields">Mas campos</string>
|
||||||
|
<string name="event_edit_add">Añadir</string>
|
||||||
|
<string name="event_edit_add_reminder">Añadir recordatorio</string>
|
||||||
|
<string name="event_edit_remove_reminder">Eliminar recordatorio</string>
|
||||||
|
<string name="event_edit_attendees">Invitados</string>
|
||||||
|
<string name="event_edit_add_guest">Añadir invitados</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Añadir invitados por correo…</string>
|
||||||
|
<string name="event_edit_add_guest_from_contacts">Añadir desde contactos</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Seleccionar dirección desde contactos</string>
|
||||||
|
<string name="event_edit_remove_guest">Eliminar invitado</string>
|
||||||
|
<string name="event_edit_attendee_required">Obligatorio</string>
|
||||||
|
<string name="event_edit_attendee_optional">Opcional</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula no envia invitaciones. Tu cuenta de calendario puede enviar correos a los invitados cuando se sincronice.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Guardado en el dispositivo. No se avisa a nadie.</string>
|
||||||
|
<string name="event_edit_reminder_custom">Personalizado</string>
|
||||||
|
<string name="reminder_unit_minutes">minutos</string>
|
||||||
|
<string name="reminder_unit_hours">horas</string>
|
||||||
|
<string name="reminder_unit_days">días</string>
|
||||||
|
<string name="reminder_unit_weeks">semanas</string>
|
||||||
|
<string name="event_edit_availability">Disponibilidad</string>
|
||||||
|
<string name="event_edit_visibility">Visibilidad</string>
|
||||||
|
<string name="event_edit_color">Color</string>
|
||||||
|
<string name="event_edit_color_default">Color del calendario</string>
|
||||||
|
<string name="event_edit_color_custom">Color personalizado</string>
|
||||||
|
<string name="event_edit_color_reset">Reiniciar</string>
|
||||||
|
<string name="event_edit_color_unsupported">No disponible para este calendario</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">Este calendario no admite un ser de colores. Puedes permitir colores personalizados para este tipo de calendarios en ajustes.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Este calendario puede eliminar o cambiar el color la próxima vez que se sincronice.</string>
|
||||||
|
<string name="event_edit_conflict_title">El evento fue cambiado por otra fuente</string>
|
||||||
|
<string name="event_edit_conflict_body">Mientras lo editabas, este evento fue cambiado — debido a la sincronización u otra aplicación ¿Que deberia le ocurrir a tus cambios?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Guardar mis cambios</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Sobreescribir el cambio externo en solo los aspectos que he cambiado</string>
|
||||||
|
<string name="event_edit_conflict_discard">Descartar todos mis cambios</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">El evento permanece tal y como esta ahora</string>
|
||||||
|
<string name="event_edit_gone_title">Evento eliminado</string>
|
||||||
|
<string name="event_edit_gone_body">Este evento fue eliminado durante el proceso, en otro dispositivo o aplicación por ejemplo. Tus cambios no pudieron ser guardados.</string>
|
||||||
|
<string name="event_edit_recurrence_none">No se repite</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Personalizado</string>
|
||||||
|
<string name="event_edit_recurrence_every">Cada</string>
|
||||||
|
<string name="recurrence_unit_days">días</string>
|
||||||
|
<string name="recurrence_unit_weeks">semanas</string>
|
||||||
|
<string name="recurrence_unit_months">meses</string>
|
||||||
|
<string name="recurrence_unit_years">años</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Finaliza</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Nunca</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">En una fecha</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">Después de cierto numero de veces</string>
|
||||||
|
<string name="event_edit_recurrence_times">veces</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">El evento se deja de repetir antes de que este comience</string>
|
||||||
|
<string name="event_availability_busy">Ocupado</string>
|
||||||
|
<string name="event_access_default">Por defecto</string>
|
||||||
|
<string name="event_access_public">Público</string>
|
||||||
|
<string name="event_detail_all_day">Todo el día</string>
|
||||||
|
<string name="event_detail_calendar">Calendario</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Calendario desconocido</string>
|
||||||
|
<string name="event_detail_location">Ubicación</string>
|
||||||
|
<string name="event_detail_description">Descripción</string>
|
||||||
|
<string name="event_detail_attendees">Participantes</string>
|
||||||
|
<string name="event_detail_recurrence">Recurrencia</string>
|
||||||
|
<string name="agenda_today_action">Hoy</string>
|
||||||
|
<string name="agenda_header_today">Hoy</string>
|
||||||
|
<string name="search_back">Atras</string>
|
||||||
|
<string name="widget_new_event">Nuevo evento</string>
|
||||||
|
<string name="widget_prev_month">Mes anterior</string>
|
||||||
|
<string name="widget_next_month">Siguiente mes</string>
|
||||||
|
<string name="widget_today">Hoy</string>
|
||||||
|
<string name="filter_title">Calendarios</string>
|
||||||
|
<string name="settings_title">Ajustes</string>
|
||||||
|
<string name="settings_back">Atras</string>
|
||||||
|
<string name="agenda_range_day">Hoy</string>
|
||||||
|
<string name="agenda_range_this_week">Esta semana</string>
|
||||||
|
<string name="settings_section_calendars">Calendarios</string>
|
||||||
|
<string name="calendars_title">Calendarios</string>
|
||||||
|
<string name="calendars_color_label">Color</string>
|
||||||
|
<string name="import_close">Cerrar</string>
|
||||||
|
<string name="shortcut_new_event_short">Nuevo evento</string>
|
||||||
|
<string name="qs_tile_new_event_label">Nuevo evento</string>
|
||||||
|
<string name="event_detail_recurring">Repetir el evento</string>
|
||||||
|
<string name="recurrence_daily">Todos los días</string>
|
||||||
|
<string name="recurrence_weekly">Todas las semanas</string>
|
||||||
|
<string name="recurrence_monthly">Todos los meses</string>
|
||||||
|
<string name="recurrence_yearly">Todos los años</string>
|
||||||
|
<string name="recurrence_every_n_days">Cada %1$d días</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Cada %1$d semanas</string>
|
||||||
|
<string name="recurrence_every_n_months">Cada %1$d meses</string>
|
||||||
|
<string name="recurrence_every_n_years">Cada %1$d años</string>
|
||||||
|
<string name="event_detail_not_found">Este evento ya no existe.</string>
|
||||||
|
<string name="event_attendee_accepted">Aceptado</string>
|
||||||
|
<string name="event_attendee_declined">Rechazado</string>
|
||||||
|
<string name="event_attendee_tentative">Tentativo</string>
|
||||||
|
<string name="event_attendee_needs_action">Sin respuesta</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">Recordatorios</string>
|
||||||
|
<string name="event_detail_timezone">Zona horaria</string>
|
||||||
|
<string name="event_status_tentative">Tentativo</string>
|
||||||
|
<string name="event_status_cancelled">Cancelado</string>
|
||||||
|
<string name="event_availability_free">Disponible</string>
|
||||||
|
<string name="event_access_private">Privado</string>
|
||||||
|
<string name="event_access_confidential">Confidencial</string>
|
||||||
|
<string name="event_attendee_organizer">Organizador</string>
|
||||||
|
<string name="event_attendee_optional">Opcional</string>
|
||||||
|
<string name="agenda_range_custom">Personalizado…</string>
|
||||||
|
<string name="calendars_backup_header">Respaldo</string>
|
||||||
|
<string name="backup_channel_name">Respaldo</string>
|
||||||
|
<string name="event_detail_self_response">Tu respuesta: %1$s</string>
|
||||||
|
<string name="reminder_at_time">Al la hora del evento</string>
|
||||||
|
<string name="reminder_default">Recordatorio por defecto</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minuto antes</item>
|
||||||
|
<item quantity="many">%d minutos antes</item>
|
||||||
|
<item quantity="other">%d minutos antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d hora antes</item>
|
||||||
|
<item quantity="many">%d horas antes</item>
|
||||||
|
<item quantity="other">%d horas antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d día antes</item>
|
||||||
|
<item quantity="many">%d días antes</item>
|
||||||
|
<item quantity="other">%d días antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d semana antes</item>
|
||||||
|
<item quantity="many">%d semanas antes</item>
|
||||||
|
<item quantity="other">%d semanas antes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minuto</item>
|
||||||
|
<item quantity="many">%d minutos</item>
|
||||||
|
<item quantity="other">%d minutos</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d hora</item>
|
||||||
|
<item quantity="many">%d horas</item>
|
||||||
|
<item quantity="other">%d horas</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d dia</item>
|
||||||
|
<item quantity="many">%d días</item>
|
||||||
|
<item quantity="other">%d días</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d semana</item>
|
||||||
|
<item quantity="many">%d semanas</item>
|
||||||
|
<item quantity="other">%d semanas</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(Sin título)</string>
|
||||||
|
<string name="reminder_channel_name">Recordatorios del evento</string>
|
||||||
|
<string name="reminder_channel_description">Notificaciones a la hora de los recordatorios de tus eventos</string>
|
||||||
|
<string name="reminder_onboarding_title">Nunca te pierdas un evento</string>
|
||||||
|
<string name="reminder_onboarding_body">Android no muestra recordatorios de eventos por su cuenta — una app de calendario tiene que hacerlo. Deja que calendula cumpla ese trabajo.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Recordatorios, enviados</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Cada recordatorio de tus eventos aparece como notificación, justo a tiempo.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">¿Utilizas otra aplicación de calendario?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Si otra aplicación tambien muestra recordatorios, los veras dos veces — desactivalos en esta o la otra aplicación.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Cambialo en cualquier momento</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">El interruptor se encuentra en Ajustes, en la sección de Notificaciones.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Activar recordatorios</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Mas tarde</string>
|
||||||
|
<string name="reminder_action_snooze">Posponer</string>
|
||||||
|
<string name="reminder_action_dismiss">Descartar</string>
|
||||||
|
<string name="view_month">Mes</string>
|
||||||
|
<string name="view_week">Semana</string>
|
||||||
|
<string name="view_day">Día</string>
|
||||||
|
<string name="view_agenda">Agenda</string>
|
||||||
|
<string name="view_section">Vista</string>
|
||||||
|
<string name="drawer_jump_to_date">Ir a la fecha</string>
|
||||||
|
<string name="agenda_header_tomorrow">Mañana</string>
|
||||||
|
<string name="agenda_empty_title">Ya estas al día</string>
|
||||||
|
<string name="search_action">Buscar</string>
|
||||||
|
<string name="search_hint">Buscar eventos</string>
|
||||||
|
<string name="search_clear">Limpiar</string>
|
||||||
|
<string name="search_idle_hint">Buscar eventos por titulo, ubicación o notas.</string>
|
||||||
|
<string name="search_empty">No evento coincide con “%1$s”.</string>
|
||||||
|
<string name="widget_agenda_title">Próximamente</string>
|
||||||
|
<string name="widget_agenda_label">Calendula agenda</string>
|
||||||
|
<string name="widget_month_label">Calendula mes</string>
|
||||||
|
<string name="widget_refresh">Actualizar</string>
|
||||||
|
<string name="widget_needs_permission">Abre Calendula para conceder acceso al calendario</string>
|
||||||
|
<string name="settings_section_appearance">Apariencia</string>
|
||||||
|
<string name="settings_theme">Tema</string>
|
||||||
|
<string name="settings_theme_system">Igual que el sistema</string>
|
||||||
|
<string name="settings_theme_light">Claro</string>
|
||||||
|
<string name="settings_theme_dark">Oscuro</string>
|
||||||
|
<string name="settings_default_view">Vista por defecto</string>
|
||||||
|
<string name="settings_dynamic_color">Color dinámico</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Requiere android 12 o una versión posterior</string>
|
||||||
|
<string name="settings_week_start">La semana empieza en</string>
|
||||||
|
<string name="settings_week_start_auto">Automático</string>
|
||||||
|
<string name="settings_time_format">Formato de la hora</string>
|
||||||
|
<string name="settings_time_format_auto">Automático</string>
|
||||||
|
<string name="settings_time_format_12h">12-horas (2:00 PM)</string>
|
||||||
|
<string name="settings_time_format_24h">24-horas (14:00)</string>
|
||||||
|
<string name="settings_hour_lines">Lineas entre horas</string>
|
||||||
|
<string name="settings_hour_lines_summary">Muestra una linea que separa cada hora en la vista diaria y semanal</string>
|
||||||
|
<string name="settings_dim_completed">Oscurecer eventos completados</string>
|
||||||
|
<string name="settings_dim_completed_summary">Oscurece eventos que ya han terminado en vista semanal y mensual</string>
|
||||||
|
<string name="settings_past_events">Eventos pasados</string>
|
||||||
|
<string name="settings_past_events_show">Mostrar</string>
|
||||||
|
<string name="settings_past_events_dim">Oscurecer</string>
|
||||||
|
<string name="settings_past_events_hide">Ocultar</string>
|
||||||
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
|
<string name="settings_agenda_range">Rango de agenda</string>
|
||||||
|
<string name="settings_agenda_range_hint">Cuanto tiempo abarca la agenda al mostrar eventos.</string>
|
||||||
|
<string name="settings_agenda_widget_range">Rango del widget de agenda</string>
|
||||||
|
<string name="settings_agenda_widget_range_hint">Cuanto tiempo abarca el widget de agenda en la pantalla principal al mostrar eventos.</string>
|
||||||
|
<string name="settings_agenda_range_bar">Barra de tiempo</string>
|
||||||
|
<string name="settings_agenda_range_bar_hint">Muestra una barra en la parte superior de la agenda con las fechas mostradas, con un botón para cambiar el rango durante la sesión</string>
|
||||||
|
<string name="agenda_range_this_month">Este mes</string>
|
||||||
|
<string name="agenda_range_week">Próximos 7 días</string>
|
||||||
|
<string name="agenda_range_month">Próximos 30 días</string>
|
||||||
|
<string name="agenda_range_custom_hint">Días</string>
|
||||||
|
<string name="agenda_range_override_hint">Solo por ahora — reinicia tu rango guardado cuando reabras calendula.</string>
|
||||||
|
<string name="agenda_range_showing_label">Mostrando todos los próximos eventos por</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d dia</item>
|
||||||
|
<item quantity="many">%d dias</item>
|
||||||
|
<item quantity="other">%d dias</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_section_event_form">Nueva formulario de evento</string>
|
||||||
|
<string name="settings_form_fields_hint">Campos mostrados por defecto — todo lo demás se encuentra bajo \"mas campos\"</string>
|
||||||
|
<string name="settings_autofocus_title">Enfocar titulo en nuevos eventos</string>
|
||||||
|
<string name="settings_autofocus_title_hint">Cuando comienzas un nuevo evento, coloca el cursor en el titulo y abre el teclado inmediatamente.</string>
|
||||||
|
<string name="settings_color_unsupported">Permitir colores en calendarios no admitidos</string>
|
||||||
|
<string name="settings_color_unsupported_hint">Algunas calendarios (como algunos CalDAV) no publican un set de colores; un color customizado puede ser eliminado o sobreescrito por su servicio de sincronización. Eso es una limitacion de estos calendarios, no algo que calendula pueda solucionar.</string>
|
||||||
|
<string name="settings_section_notifications">Notificaciones</string>
|
||||||
|
<string name="settings_reminders">Recordatorios de eventos</string>
|
||||||
|
<string name="settings_reminders_hint">Los recordatorios aparecen dos veces? Otra aplicación de calendario tambien los esta generando — desactivarlo en una de las dos apps.</string>
|
||||||
|
<string name="settings_default_reminder">Recordatorio por defecto</string>
|
||||||
|
<string name="settings_default_reminder_allday">Eventos que duran todo el dia</string>
|
||||||
|
<string name="settings_allday_reminder_time">Hora del recordatorio de todo el día</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">Los recordatorios para eventos de todo el día se generaran a las %1$s</string>
|
||||||
|
<string name="reminder_none">Ninguno</string>
|
||||||
|
<string name="reminder_use_default">Usar recordatorio por defecto</string>
|
||||||
|
<string name="reminder_custom_amount">Cantidad</string>
|
||||||
|
<string name="reminder_custom_with_value">Personalizado (%1$s)</string>
|
||||||
|
<string name="reminder_custom_set">Guardar</string>
|
||||||
|
<string name="settings_calendar_reminders_title">Recordatorios por calendario</string>
|
||||||
|
<string name="settings_calendar_reminders_hint">Sobreescribe el por recordatorio defecto por calendario — De forma separada para eventos con duración fija o que duran todo el dia. Un calendario puede mantener el por defecto o tener su propia configuracion.</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">Por defecto (%1$s)</string>
|
||||||
|
<string name="settings_reliable_delivery">Notificaciones confiables</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">Android puede atrasar los recordatorios para guardar batería. Excluye calendula de la optimización de batería para que se puedan generar a tiempo.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">Excluido de la optimización de batería — los recordatorios llegaran a tiempo.</string>
|
||||||
|
<string name="settings_snooze_duration">Duración al posponer</string>
|
||||||
|
<string name="settings_manage_calendars">Administrar calendarios</string>
|
||||||
|
<string name="settings_manage_calendars_hint">Crear calendarios locales; administrar calendarios sincronizados</string>
|
||||||
|
<string name="settings_section_language">Idioma</string>
|
||||||
|
<string name="settings_language">Idioma de la aplicación</string>
|
||||||
|
<string name="settings_language_auto">Igual que el sistema</string>
|
||||||
|
<string name="settings_translate">Ayuda a traducir</string>
|
||||||
|
<string name="settings_translate_hint">Añade o mejora un idioma en Weblate</string>
|
||||||
|
<string name="settings_appearance_subtitle">Tema, vista por defecto, inicio de semana</string>
|
||||||
|
<string name="settings_event_form_subtitle">Campos por defecto en nuevos eventos</string>
|
||||||
|
<string name="settings_notifications_subtitle">Recordatorios de eventos</string>
|
||||||
|
<string name="settings_license">Licencia</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_about_author">Por Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">Fuente</string>
|
||||||
|
<string name="settings_about_support">Ayuda al desarrollo</string>
|
||||||
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Icono de la aplicación calendula</string>
|
||||||
|
<string name="settings_report_problem">Reportar un problema</string>
|
||||||
|
<string name="settings_report_problem_hint">Enviar un reporte de error o abre el sistema de seguimiento de errores</string>
|
||||||
|
<string name="calendars_local_header">Tus calendarios</string>
|
||||||
|
<string name="calendars_local_empty">Sin calendarios locales todavia. Crea uno para mantener eventos solo en este dispositivo.</string>
|
||||||
|
<string name="calendars_add">Añadir calendario</string>
|
||||||
|
<string name="calendars_disable_hint">Desactiva un calendario para removerlo de la aplicación — sus eventos, filtros y selectores. Nada se eliminara, y puedes reactivarlo en cualquier momento.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Mostrar \"%1$s\" en la aplicación</string>
|
||||||
|
<string name="calendars_synced_header">Calendarios sincronizados</string>
|
||||||
|
<string name="calendars_synced_hint">Estos provienen de cuentas en tu dispositivo. Crea o editalos en su propia aplicación.</string>
|
||||||
|
<string name="calendars_manage_in_app">Gestionar en aplicación</string>
|
||||||
|
<string name="calendars_account_menu_a11y">Mas opciones para %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Habilitar todos</string>
|
||||||
|
<string name="calendars_disable_all">Desactivar todos</string>
|
||||||
|
<string name="calendars_add_account">Añadir cuenta</string>
|
||||||
|
<string name="calendars_new_title">Nuevo calendario</string>
|
||||||
|
<string name="calendars_edit_title">Editar calendario</string>
|
||||||
|
<string name="calendars_name_label">Nombre</string>
|
||||||
|
<string name="calendars_description_hint">Añadir descripción</string>
|
||||||
|
<string name="calendars_delete_confirm_title">¿Eliminar calendario?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">\"%1$s\" y todos sus eventos serán eliminados permanentemente de este dispositivo.</string>
|
||||||
|
<string name="calendars_write_error">No se pudieron guardar los cambios.</string>
|
||||||
|
<string name="calendars_backup_hint">Los calendarios locales no se sincronizan, exportalos como un archivo .ics para mantener una copia.</string>
|
||||||
|
<string name="calendars_backup_action">Exportar como archivo .ics</string>
|
||||||
|
<string name="calendars_auto_backup">Respaldo automático</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Exporta tus calendarios a una carpeta como un archivo .ics de forma periodica.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Carpeta de respaldo</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Toca para seleccionar una carpeta</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Intervalo</string>
|
||||||
|
<string name="calendars_auto_backup_every">Cada %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Mínimo 30 minutos.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">Sin respaldos automáticos todavía</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Ultimo respaldo: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Ultimo respaldo fallido: %1$s</string>
|
||||||
|
<string name="backup_channel_description">Advierte si los respaldos automáticos fallan repetidamente.</string>
|
||||||
|
<string name="backup_failed_title">El respaldo automático fallo</string>
|
||||||
|
<string name="backup_failed_text">Calendula no pudo generar el archivo de respaldo, comprueba la carpeta de respaldo en ajustes.</string>
|
||||||
|
<string name="calendars_backup_failed">No se pudo exportar el respaldo.</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="one">Se exporto %d evento.</item>
|
||||||
|
<item quantity="many">Se exportaron %d eventos.</item>
|
||||||
|
<item quantity="other">Se exportaron %d eventos.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_title">Importar eventos</string>
|
||||||
|
<string name="import_target_header">Añadir a calendario</string>
|
||||||
|
<string name="import_empty">No se encontraron eventos en este archivo.</string>
|
||||||
|
<string name="import_failed">No se pudo leer el archivo.</string>
|
||||||
|
<string name="import_no_calendar">No hay ningún calendario editable en el cual importar los eventos. Crea un calendario local primero.</string>
|
||||||
|
<string name="import_done_title">Importación completa</string>
|
||||||
|
<string name="import_warning_recurrence">Se omitieron algunas ocurrencias editadas de eventos recurrentes.</string>
|
||||||
|
<string name="import_warning_no_start">Se omitió un evento sin hora de inicio.</string>
|
||||||
|
<string name="import_warning_attendees">No se importaron las listas de invitados.</string>
|
||||||
|
<string name="import_warning_timezone">Se reemplazo una zona horaria desconocida con la de tu dispositivo.</string>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="one">%d evento en este archivo.</item>
|
||||||
|
<item quantity="many">%d eventos en este archivo.</item>
|
||||||
|
<item quantity="other">%d eventos en este archivo.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="one">Importar %d evento</item>
|
||||||
|
<item quantity="many">Importar %d eventos</item>
|
||||||
|
<item quantity="other">Importar %d eventos</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="one">%d evento importado.</item>
|
||||||
|
<item quantity="many">%d eventos importados.</item>
|
||||||
|
<item quantity="other">%d eventos importados.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<item quantity="one">Se omitió %d archivo ya presente en este calendario.</item>
|
||||||
|
<item quantity="many">Se omitieron %d archivos ya presentes en este calendario.</item>
|
||||||
|
<item quantity="other">Se omitieron %d archivos ya presentes en este calendario.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="shortcut_new_event_long">Crear un nuevo evento</string>
|
||||||
|
<string name="settings_qs_tile">Añadir mosaico de ajustes rápidos</string>
|
||||||
|
<string name="settings_qs_tile_hint">Añadir un recuadro de \"nuevo evento\" al panel de ajustes rápidos.</string>
|
||||||
|
<string name="crash_dialog_title">Calendula ha fallado</string>
|
||||||
|
<string name="crash_dialog_message">Calendula se cerro de forma inesperada la ultima vez. Puedes ayudar a solucionarlo enviando un reporte del problema. Este permanece en tu dispositivo hasta que decidas enviarlo, y no contiene información personal o contenido de tu calendario — solo los detalles técnicos de mas abajo.</string>
|
||||||
|
<string name="crash_dialog_report">Reportar</string>
|
||||||
|
<string name="crash_dialog_dismiss">No por ahora</string>
|
||||||
|
<string name="crash_report_issue_title">Reporte de error</string>
|
||||||
|
<string name="crash_report_clip_label">Reporte de error de Calendula</string>
|
||||||
|
<string name="crash_report_copied">Reporte copiado a tu porta papeles</string>
|
||||||
|
<string name="crash_report_open_failed">No se pudo abrir el sistema de seguimiento de errores. El reporte es en tu portapapeles.</string>
|
||||||
|
<string name="crash_report_body_template">Gracias por reportar un error en Calendula. Por favor añade todo lo que recuerdas sobre lo que estabas haciendo y luego publicalo.\n\n### que sucedió\n\n\n### reporte de error\n%1$s\n</string>
|
||||||
|
<string name="crash_report_body_paste">_(el reporte es demasiado largo para este link — pegalo aquí desde su portapeles.)_</string>
|
||||||
|
<string name="event_attendee_resource">Recurso</string>
|
||||||
|
<string name="settings_section_about">Acerca de</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d veces</string>
|
||||||
|
<string name="recurrence_with_until">%1$s hasta %2$s</string>
|
||||||
|
<string name="recurrence_on_days">%1$s los %2$s</string>
|
||||||
|
</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>
|
||||||
422
app/src/main/res/values-it/strings.xml
Normal file
422
app/src/main/res/values-it/strings.xml
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
<?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>
|
||||||
|
<string name="app_name">Calendula</string>
|
||||||
|
<string name="app_tagline">Un calendario moderno.</string>
|
||||||
|
<string name="state_loading">Caricamento…</string>
|
||||||
|
<string name="state_retry">Riprova</string>
|
||||||
|
<string name="state_failure_unknown">Qualcosa è andato storto.</string>
|
||||||
|
<string name="state_failure_permission">È richiesto l\'accesso al calendario.</string>
|
||||||
|
<string name="state_failure_permission_action">Concedi l\'accesso</string>
|
||||||
|
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
|
||||||
|
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||||
|
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
||||||
|
<string name="permission_rationale_title">Guarda tutti i tuoi eventi, magnificamente</string>
|
||||||
|
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.</string>
|
||||||
|
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
||||||
|
<string name="permission_denied_title">Accesso al calendario negato</string>
|
||||||
|
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
||||||
|
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
||||||
|
<string name="permission_retry_button">Riprova</string>
|
||||||
|
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
||||||
|
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.</string>
|
||||||
|
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
||||||
|
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.</string>
|
||||||
|
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
||||||
|
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
|
||||||
|
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
|
||||||
|
<string name="month_prev">Mese precedente</string>
|
||||||
|
<string name="month_next">Mese prossimo</string>
|
||||||
|
<string name="month_today_action">Oggi</string>
|
||||||
|
<string name="month_more_actions">Altre azioni</string>
|
||||||
|
<string name="month_open_menu">Apri il menù</string>
|
||||||
|
<string name="month_action_settings">Impostazioni</string>
|
||||||
|
<string name="month_a11y_today_prefix">Oggi</string>
|
||||||
|
<string name="week_today_action">Questa settimana</string>
|
||||||
|
<string name="week_number_label">Wk</string>
|
||||||
|
<string name="day_today_action">Oggi</string>
|
||||||
|
<string name="event_detail_back">Indietro</string>
|
||||||
|
<string name="event_detail_edit">Modifica</string>
|
||||||
|
<string name="event_detail_delete">Elimina</string>
|
||||||
|
<string name="event_detail_share">Condividi</string>
|
||||||
|
<string name="event_share_chooser_title">Condividi evento</string>
|
||||||
|
<string name="event_share_failed">Impossibile condividere questo evento.</string>
|
||||||
|
<string name="event_delete_title">Eliminare l\'evento?</string>
|
||||||
|
<string name="event_delete_body">Questo evento viene rimosso dal tuo calendario e da tutti i dispositivi sincronizzati.</string>
|
||||||
|
<string name="event_delete_recurring_title">Elimina ricorrenza</string>
|
||||||
|
<string name="event_delete_option_occurrence">Solo questo evento</string>
|
||||||
|
<string name="event_delete_option_following">Questo e gli eventi successivi</string>
|
||||||
|
<string name="event_delete_option_series">Tutti gli eventi della serie</string>
|
||||||
|
<string name="event_edit_recurring_title">Modifica ricorrenza</string>
|
||||||
|
<string name="event_delete_failed">Impossibile eliminare l\'evento</string>
|
||||||
|
<string name="event_delete_write_denied">Calendula ha bisogno dei permessi di scrittura per eliminare gli eventi</string>
|
||||||
|
<string name="dialog_cancel">Annulla</string>
|
||||||
|
<string name="dialog_ok">Ok</string>
|
||||||
|
<string name="event_edit_new_title">Nuovo evento</string>
|
||||||
|
<string name="event_edit_close">Chiudi</string>
|
||||||
|
<string name="event_edit_save">Salva</string>
|
||||||
|
<string name="event_edit_title_hint">Aggiungi titolo</string>
|
||||||
|
<string name="event_edit_starts">Inizio</string>
|
||||||
|
<string name="event_edit_ends">Fine</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Fine antecedente l\'inizio</string>
|
||||||
|
<string name="event_edit_error_no_calendar">Non sono disponibili calendari</string>
|
||||||
|
<string name="event_edit_save_failed">Impossibile salvare l\'evento</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula ha bisogno dei permessi di scrittura per creare gli eventi</string>
|
||||||
|
<string name="event_edit_more_fields">Altri campi</string>
|
||||||
|
<string name="event_edit_add">Aggiungi</string>
|
||||||
|
<string name="event_edit_add_reminder">Aggiungi promemoria</string>
|
||||||
|
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
|
||||||
|
<string name="event_edit_attendees">Partecipanti</string>
|
||||||
|
<string name="event_edit_add_guest">Aggiungi partecipante</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Aggiungi un partecipante via email…</string>
|
||||||
|
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
|
||||||
|
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
|
||||||
|
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
|
||||||
|
<string name="event_edit_attendee_required">Obbligatorio</string>
|
||||||
|
<string name="event_edit_attendee_optional">Facoltativo</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno ha ricevuto notifiche.</string>
|
||||||
|
<string name="event_edit_reminder_custom">Personalizza</string>
|
||||||
|
<string name="reminder_unit_minutes">minuti</string>
|
||||||
|
<string name="reminder_unit_hours">ore</string>
|
||||||
|
<string name="reminder_unit_days">giorni</string>
|
||||||
|
<string name="reminder_unit_weeks">settimane</string>
|
||||||
|
<string name="event_edit_availability">Disponibilità</string>
|
||||||
|
<string name="event_edit_visibility">Visibilità</string>
|
||||||
|
<string name="event_edit_color">Colore</string>
|
||||||
|
<string name="event_edit_color_default">Colore calendario</string>
|
||||||
|
<string name="event_edit_color_custom">Colore personalizzato</string>
|
||||||
|
<string name="event_edit_color_reset">Reset</string>
|
||||||
|
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
|
||||||
|
<string name="event_edit_color_unsupported_hint">I colori non sono ammessi per questo calendario. Puoi abilitare i colori personalizzati per questo tipo di calendario dalle Impostazioni.</string>
|
||||||
|
<string name="event_edit_color_sync_warning">Il colore per questo calendario potrà andare perso o essere sovrascritto alla prossima sincronizzazione.</string>
|
||||||
|
<string name="event_edit_conflict_title">Evento modificato altrove</string>
|
||||||
|
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
|
||||||
|
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
|
||||||
|
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
|
||||||
|
<string name="event_edit_conflict_discard">Annulla le mie modifiche</string>
|
||||||
|
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
|
||||||
|
<string name="event_edit_gone_title">Evento eliminato</string>
|
||||||
|
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
|
||||||
|
<string name="event_edit_recurrence_none">Non si ripete</string>
|
||||||
|
<string name="event_edit_recurrence_custom">Personalizza</string>
|
||||||
|
<string name="event_edit_recurrence_every">La serie</string>
|
||||||
|
<string name="recurrence_unit_days">giorni</string>
|
||||||
|
<string name="recurrence_unit_weeks">settimane</string>
|
||||||
|
<string name="recurrence_unit_months">mesi</string>
|
||||||
|
<string name="recurrence_unit_years">anni</string>
|
||||||
|
<string name="event_edit_recurrence_ends">Fine</string>
|
||||||
|
<string name="event_edit_recurrence_end_never">Mai</string>
|
||||||
|
<string name="event_edit_recurrence_end_until">Fino alla data</string>
|
||||||
|
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
|
||||||
|
<string name="event_edit_recurrence_times">occorrenze</string>
|
||||||
|
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
|
||||||
|
<string name="event_availability_busy">Occupato</string>
|
||||||
|
<string name="event_access_default">Default</string>
|
||||||
|
<string name="event_access_public">Pubblico</string>
|
||||||
|
<string name="event_detail_all_day">Tutto il giorno</string>
|
||||||
|
<string name="event_detail_calendar">Calendario</string>
|
||||||
|
<string name="event_detail_calendar_unknown">Calendario sconosciuto</string>
|
||||||
|
<string name="event_detail_location">Luogo</string>
|
||||||
|
<string name="event_detail_description">Descrizione</string>
|
||||||
|
<string name="event_detail_attendees">Partecipanti</string>
|
||||||
|
<string name="recurrence_daily">Ogni giorni</string>
|
||||||
|
<string name="recurrence_weekly">Ogni settimana</string>
|
||||||
|
<string name="recurrence_monthly">Ogni mese</string>
|
||||||
|
<string name="recurrence_yearly">Ogni anno</string>
|
||||||
|
<string name="recurrence_every_n_days">Ogni %1$d giorni</string>
|
||||||
|
<string name="recurrence_every_n_weeks">Ogni %1$d settimane</string>
|
||||||
|
<string name="recurrence_every_n_months">Ogni %1$d mesi</string>
|
||||||
|
<string name="recurrence_every_n_years">Ogni %1$d anni</string>
|
||||||
|
<string name="event_detail_not_found">Questo evento è stato eliminato.</string>
|
||||||
|
<string name="event_attendee_accepted">Accettato</string>
|
||||||
|
<string name="event_attendee_declined">Rifiutato</string>
|
||||||
|
<string name="event_attendee_tentative">Provvisorio</string>
|
||||||
|
<string name="event_attendee_needs_action">Nessuna risposta</string>
|
||||||
|
<string name="event_attendee_unknown">—</string>
|
||||||
|
<string name="event_detail_reminders">Promemoria</string>
|
||||||
|
<string name="event_detail_timezone">Fuso orario</string>
|
||||||
|
<string name="event_status_tentative">Provvisorio</string>
|
||||||
|
<string name="event_status_cancelled">Annullato</string>
|
||||||
|
<string name="event_availability_free">Disponibile</string>
|
||||||
|
<string name="event_access_private">Privato</string>
|
||||||
|
<string name="event_access_confidential">Confidenziale</string>
|
||||||
|
<string name="event_attendee_organizer">Organizzatore</string>
|
||||||
|
<string name="event_attendee_optional">Facoltativo</string>
|
||||||
|
<string name="event_detail_self_response">La tua risposta: %1$s</string>
|
||||||
|
<string name="reminder_at_time">All\'ora dell\'evento</string>
|
||||||
|
<string name="reminder_default">Promemoria standard</string>
|
||||||
|
<plurals name="reminder_minutes">
|
||||||
|
<item quantity="one">%d minuto prima</item>
|
||||||
|
<item quantity="many">%d minuti prima</item>
|
||||||
|
<item quantity="other">%d minuti prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_hours">
|
||||||
|
<item quantity="one">%d ora prima</item>
|
||||||
|
<item quantity="many">%d ore prima</item>
|
||||||
|
<item quantity="other">%d ore prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_days">
|
||||||
|
<item quantity="one">%d giorno prima</item>
|
||||||
|
<item quantity="many">%d giorni prima</item>
|
||||||
|
<item quantity="other">%d giorni prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="reminder_weeks">
|
||||||
|
<item quantity="one">%d settimana prima</item>
|
||||||
|
<item quantity="many">%d settimanw prima</item>
|
||||||
|
<item quantity="other">%d settimane prima</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_minutes">
|
||||||
|
<item quantity="one">%d minuto</item>
|
||||||
|
<item quantity="many">%d minuti</item>
|
||||||
|
<item quantity="other">%d minuti</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_hours">
|
||||||
|
<item quantity="one">%d ora</item>
|
||||||
|
<item quantity="many">%d ore</item>
|
||||||
|
<item quantity="other">%d ore</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d giorno</item>
|
||||||
|
<item quantity="many">%d giorni</item>
|
||||||
|
<item quantity="other">%d giorni</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d settimana</item>
|
||||||
|
<item quantity="many">%d settimane</item>
|
||||||
|
<item quantity="other">%d settimane</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="event_untitled">(nessun titolo)</string>
|
||||||
|
<string name="reminder_channel_name">Promemoria evento</string>
|
||||||
|
<string name="event_attendee_resource">Risorsa</string>
|
||||||
|
<string name="reminder_onboarding_title">Non perderti nessun evento</string>
|
||||||
|
<string name="reminder_onboarding_body">Android non mostra i promemoria per gli eventi autonomamente, serve un\'app. Lascia che sia Calendula a farlo.</string>
|
||||||
|
<string name="reminder_benefit_delivery_title">Promemoria, eccoli</string>
|
||||||
|
<string name="reminder_benefit_delivery_body">Tutti i promemoria dei tuoi eventi arrivano tramite notifica, proprio al momento giusto.</string>
|
||||||
|
<string name="reminder_benefit_duplicates_title">Stai usando un\'altra app calendario?</string>
|
||||||
|
<string name="reminder_benefit_duplicates_body">Se un\'altra app manda promemoria li vedrai due volte. Disattivali qui o nell\'altra app.</string>
|
||||||
|
<string name="reminder_benefit_reversible_title">Cambialo in ogni momento</string>
|
||||||
|
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
|
||||||
|
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
|
||||||
|
<string name="reminder_onboarding_skip_button">Non ora</string>
|
||||||
|
<string name="reminder_action_snooze">Posponi</string>
|
||||||
|
<string name="reminder_action_dismiss">Ignora</string>
|
||||||
|
<string name="view_month">Mese</string>
|
||||||
|
<string name="view_week">Settimana</string>
|
||||||
|
<string name="view_day">Giorno</string>
|
||||||
|
<string name="view_agenda">Agenda</string>
|
||||||
|
<string name="view_section">Vista</string>
|
||||||
|
<string name="drawer_jump_to_date">Vai alla data</string>
|
||||||
|
<string name="agenda_today_action">Oggi</string>
|
||||||
|
<string name="agenda_header_tomorrow">Domani</string>
|
||||||
|
<string name="agenda_empty_title">Hai fatto tutto</string>
|
||||||
|
<string name="search_action">Cerca</string>
|
||||||
|
<string name="search_hint">Cerca eventi</string>
|
||||||
|
<string name="search_back">Indietro</string>
|
||||||
|
<string name="search_clear">Pulisci</string>
|
||||||
|
<string name="search_idle_hint">Cerca gli eventi per titolo, luogo o note.</string>
|
||||||
|
<string name="search_empty">Nessun risultato per \"%1$s\".</string>
|
||||||
|
<string name="widget_agenda_title">Prossimamente</string>
|
||||||
|
<string name="widget_agenda_label">Agenda Calendula</string>
|
||||||
|
<string name="widget_month_label">Mese Calendula</string>
|
||||||
|
<string name="widget_refresh">Aggiorna</string>
|
||||||
|
<string name="widget_new_event">Nuovo evento</string>
|
||||||
|
<string name="widget_needs_permission">Apri Calendula per concedere l\'accesso al calendario</string>
|
||||||
|
<string name="widget_prev_month">Mese precedente</string>
|
||||||
|
<string name="filter_title">Calendari</string>
|
||||||
|
<string name="settings_section_appearance">Aspetto</string>
|
||||||
|
<string name="settings_theme">Tema</string>
|
||||||
|
<string name="settings_theme_system">Sistema</string>
|
||||||
|
<string name="settings_theme_light">Chiaro</string>
|
||||||
|
<string name="settings_theme_dark">Scuro</string>
|
||||||
|
<string name="settings_default_view">Vista di default</string>
|
||||||
|
<string name="settings_dynamic_color">Colori dinamici</string>
|
||||||
|
<string name="settings_dynamic_color_unavailable">Richiede Android 12 o superiore</string>
|
||||||
|
<string name="settings_week_start">La settimana comincia di</string>
|
||||||
|
<string name="settings_week_start_auto">Automatico</string>
|
||||||
|
<string name="settings_time_format">Formato ora</string>
|
||||||
|
<string name="settings_time_format_auto">Automatico</string>
|
||||||
|
<string name="settings_time_format_12h">12 ore</string>
|
||||||
|
<string name="settings_time_format_24h">24 ore</string>
|
||||||
|
<string name="settings_hour_lines">Righe per le ore</string>
|
||||||
|
<string name="settings_hour_lines_summary">Mostra una riga separatrice per ogni ora nella vista Settimana e Giorno</string>
|
||||||
|
<string name="settings_dim_completed">Attenua gli eventi completati</string>
|
||||||
|
<string name="settings_dim_completed_summary">Attenua gli eventi conclusi nella vista Mese e Settimana</string>
|
||||||
|
<string name="settings_past_events">Eventi passati</string>
|
||||||
|
<string name="settings_past_events_show">Mostra</string>
|
||||||
|
<string name="settings_past_events_dim">Attenua</string>
|
||||||
|
<string name="settings_past_events_hide">Nascondi</string>
|
||||||
|
<string name="settings_agenda_header">Agenda</string>
|
||||||
|
<string name="settings_agenda_range">Intervallo dell\'Agenda</string>
|
||||||
|
<string name="settings_agenda_range_hint">Anticipo con cui vengono visualizzati gli eventi nell\'Agenda.</string>
|
||||||
|
<string name="settings_agenda_widget_range">Intervallo del widget Agenda</string>
|
||||||
|
<string name="settings_agenda_widget_range_hint">Anticipo con cui vengono visualizzati gli eventi nel widget Agenda.</string>
|
||||||
|
<string name="settings_agenda_range_bar">Barra dell\'intervallo</string>
|
||||||
|
<string name="settings_agenda_range_bar_hint">Mostra una barra sopra l\'agenda che indica le date mostrate, con un pulsante per cambiare l\'intervallo scelto per la sessione</string>
|
||||||
|
<string name="agenda_range_day">Oggi</string>
|
||||||
|
<string name="agenda_range_this_month">Questo mese</string>
|
||||||
|
<string name="agenda_range_week">Prossimi 7 giorni</string>
|
||||||
|
<string name="agenda_range_month">Prossimi 30 giorni</string>
|
||||||
|
<string name="agenda_range_custom">Personalizza…</string>
|
||||||
|
<string name="agenda_range_custom_hint">Giorni</string>
|
||||||
|
<string name="agenda_range_override_hint">Solo questa volta, ripristina l\'intervallo salvato al riavvio di Calendula.</string>
|
||||||
|
<string name="agenda_range_showing_label">Mostra tutti gli eventi in arrivo per</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d giorno</item>
|
||||||
|
<item quantity="many">%d giorni</item>
|
||||||
|
<item quantity="other">%d giorni</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="settings_section_event_form">Scheda Nuovo Evento</string>
|
||||||
|
<string name="settings_form_fields_hint">Campi mostrati di default, tutti gli altri saranno in \"Altri campi\"</string>
|
||||||
|
<string name="settings_autofocus_title">Subito al titolo su un nuovo evento</string>
|
||||||
|
<string name="settings_autofocus_title_hint">Quando crei un nuovo evento, posiziona subito il cursore sul campo Titolo e mostra la tastiera.</string>
|
||||||
|
<string name="settings_color_unsupported">Abilita i colori sui calendari non supportati</string>
|
||||||
|
<string name="settings_color_unsupported_hint">Alcuni calendari (e.g. alcuni CalDAV) non pubblicano il set di colori di cui sono dotati; i colori personalizzati assegnati a un evento possono essere persi o sovrascritti alla prossima sincronizzazione. È una limitazione di questo tipo di calendari, Calendula non può farci nulla.</string>
|
||||||
|
<string name="settings_section_notifications">Notifiche</string>
|
||||||
|
<string name="settings_reminders_hint">Vedi promemoria duplicati? Vuol dire che li sta mostrando anche un\'altra app. Disattivali qui o nell\'altra app.</string>
|
||||||
|
<string name="settings_default_reminder_allday">Eventi giornalieri</string>
|
||||||
|
<string name="settings_allday_reminder_time">Ora per i promemoria degli eventi giornalieri</string>
|
||||||
|
<string name="settings_allday_reminder_time_hint">I promemoria per gli eventi giornalieri vengono mandati alle %1$s</string>
|
||||||
|
<string name="reminder_none">Nessuno</string>
|
||||||
|
<string name="reminder_use_default">Usa il promemoria standard</string>
|
||||||
|
<string name="reminder_custom_amount">Valore</string>
|
||||||
|
<string name="reminder_custom_with_value">Personalizza (%1$s)</string>
|
||||||
|
<string name="reminder_custom_set">Imposta</string>
|
||||||
|
<string name="settings_calendar_reminders_title">Promemoria per calendario</string>
|
||||||
|
<string name="settings_calendar_reminders_hint">Sovrascrivi i reminder standard con reminder specifici per calendario, separati per eventi giornalieri ed eventi ad ore. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
|
||||||
|
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||||
|
<string name="settings_reliable_delivery">Notifiche affidabili</string>
|
||||||
|
<string name="settings_reliable_delivery_hint">Android potrebbe posticipare le notifiche per risparmiare batteria. Escludi Calendula così che le notifiche possano arrivare puntuali.</string>
|
||||||
|
<string name="settings_reliable_delivery_exempt">Escludi dall\'ottimizzazione batteria. I promemoria arriveranno puntuali.</string>
|
||||||
|
<string name="settings_snooze_duration">Durata del posticipo</string>
|
||||||
|
<string name="settings_section_calendars">Calendari</string>
|
||||||
|
<string name="settings_manage_calendars">Gestisci calendari</string>
|
||||||
|
<string name="settings_manage_calendars_hint">Crea calendari locali; gestisci quelli sincronizzati</string>
|
||||||
|
<string name="settings_section_language">Lingua</string>
|
||||||
|
<string name="settings_language">Lingua dell\'app</string>
|
||||||
|
<string name="settings_language_auto">Lingua di sistema</string>
|
||||||
|
<string name="settings_translate">Aiuta a tradurre</string>
|
||||||
|
<string name="settings_translate_hint">Aggiungi o migliora una traduzione su Weblate</string>
|
||||||
|
<string name="settings_appearance_subtitle">Tema, vista di default, inizio settimana</string>
|
||||||
|
<string name="settings_event_form_subtitle">Campi di default per nuovi eventi</string>
|
||||||
|
<string name="settings_section_about">A proposito</string>
|
||||||
|
<string name="settings_license">Licenza</string>
|
||||||
|
<string name="settings_license_value">MIT</string>
|
||||||
|
<string name="settings_about_author">da Jean-Luc Makiola</string>
|
||||||
|
<string name="settings_about_source">Sorgente</string>
|
||||||
|
<string name="settings_about_support">Sostieni lo sviluppo</string>
|
||||||
|
<string name="settings_about_version">Versione %1$s</string>
|
||||||
|
<string name="settings_about_logo_desc">Icona dell\'app</string>
|
||||||
|
<string name="settings_report_problem">Segnala un problema</string>
|
||||||
|
<string name="settings_report_problem_hint">Invia un crash report o apri il gestore dei problemi</string>
|
||||||
|
<string name="calendars_title">Calendari</string>
|
||||||
|
<string name="calendars_local_header">I tuoi calendari</string>
|
||||||
|
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno per avere gli eventi solo su questo dispositivo.</string>
|
||||||
|
<string name="calendars_add">Aggiungi calendario</string>
|
||||||
|
<string name="calendars_disable_hint">.Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
|
||||||
|
<string name="calendars_show_in_app_a11y">Mostra \"%1$s\" nell\'app</string>
|
||||||
|
<string name="calendars_synced_header">Calendari sincronizzati</string>
|
||||||
|
<string name="calendars_synced_hint">Questi vengono dagli account presenti sul tuo dispositivo. Creali e modificali nella loro app.</string>
|
||||||
|
<string name="calendars_manage_in_app">Gestisci in app</string>
|
||||||
|
<string name="calendars_account_menu_a11y">Altre opzioni per %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Attiva tutti</string>
|
||||||
|
<string name="calendars_disable_all">Disattiva tutti</string>
|
||||||
|
<string name="calendars_add_account">Aggiungi account</string>
|
||||||
|
<string name="calendars_new_title">Nuovo calendario</string>
|
||||||
|
<string name="calendars_edit_title">Modifica calendario</string>
|
||||||
|
<string name="calendars_name_label">Nome</string>
|
||||||
|
<string name="calendars_color_label">Colore</string>
|
||||||
|
<string name="calendars_description_hint">Aggiungi descrizione</string>
|
||||||
|
<string name="calendars_delete_confirm_title">Eliminare calendario?</string>
|
||||||
|
<string name="calendars_delete_confirm_message">\"%1$s\" e tutti i suoi eventi saranno definitivamente rimossi da questo dispositivo.</string>
|
||||||
|
<string name="calendars_write_error">Impossibile salvare le modifiche.</string>
|
||||||
|
<string name="calendars_backup_header">Backup</string>
|
||||||
|
<string name="calendars_backup_hint">I calendari locali non vengono sincronizzati da nessuna parte, esportali in un file .ics per conservarne una copia.</string>
|
||||||
|
<string name="calendars_backup_action">Esporta come file .ics</string>
|
||||||
|
<string name="calendars_auto_backup">Backup automatico</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Esporta periodicamente in una cartella i tuoi calendari in formato .ics.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Cartella di backup</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Scegli una cartella</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Intervallo</string>
|
||||||
|
<string name="calendars_auto_backup_every">Ogni %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Minimo 30 minuti.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">Backup automatico non attivo</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Ultimo backup: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Ultimo backup fallito: %1$s</string>
|
||||||
|
<string name="backup_channel_name">Backup</string>
|
||||||
|
<string name="backup_channel_description">Avvisa se i backup automatici falliscono ripetutamente.</string>
|
||||||
|
<string name="backup_failed_title">Backup automatico fallito</string>
|
||||||
|
<string name="backup_failed_text">Calendula non è riuscito a esportare il file di backup. Controlla la cartella di backup nelle Impostazioni.</string>
|
||||||
|
<string name="calendars_backup_failed">Impossibile esportare il backup.</string>
|
||||||
|
<plurals name="calendars_backup_done">
|
||||||
|
<item quantity="one">Esportato %d evento.</item>
|
||||||
|
<item quantity="many">Esportati %d eventi.</item>
|
||||||
|
<item quantity="other">Esportati %d eventi.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="import_title">Importa eventi</string>
|
||||||
|
<string name="import_target_header">Aggiungi al calendario</string>
|
||||||
|
<string name="import_empty">Non ci sono eventi in questo file.</string>
|
||||||
|
<string name="import_failed">Impossibile leggere il file.</string>
|
||||||
|
<string name="import_no_calendar">Non ci sono calendari su cui importare. Crea prima un calendario locale.</string>
|
||||||
|
<string name="import_done_title">Import completato</string>
|
||||||
|
<string name="import_close">Chiudi</string>
|
||||||
|
<string name="import_warning_recurrence">Alcune occorrenze degli eventi ricorrenti, che erano state modificate, sono state saltate.</string>
|
||||||
|
<string name="import_warning_no_start">Un evento senza orario di inizio è stato saltato.</string>
|
||||||
|
<string name="import_warning_attendees">Gli elenchi dei partecipanti non sono stati importati.</string>
|
||||||
|
<string name="import_warning_timezone">Un fuso orario sconosciuto è tornato sul tuo dispositivo.</string>
|
||||||
|
<plurals name="import_event_count">
|
||||||
|
<item quantity="one">%d evento in questo file.</item>
|
||||||
|
<item quantity="many">%d eventi in questo file.</item>
|
||||||
|
<item quantity="other">%d eventi in questo file.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_action">
|
||||||
|
<item quantity="one">Importa %d evento</item>
|
||||||
|
<item quantity="many">Importa %d eventi</item>
|
||||||
|
<item quantity="other">Importa %d eventi</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_imported">
|
||||||
|
<item quantity="one">Importato %d evento.</item>
|
||||||
|
<item quantity="many">Importati %d eventi.</item>
|
||||||
|
<item quantity="other">Importati %d eventi.</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="import_done_skipped">
|
||||||
|
<item quantity="one">Saltato %d evento già presente nel calendario.</item>
|
||||||
|
<item quantity="many">SaltatI %d eventi già presentI nel calendario.</item>
|
||||||
|
<item quantity="other">SaltatI %d eventi già presentI nel calendario.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="shortcut_new_event_long">Crea un nuovo evento</string>
|
||||||
|
<string name="qs_tile_new_event_label">Nuovo evento</string>
|
||||||
|
<string name="settings_qs_tile">Aggiungi un toggle nelle Impostazioni Rapide</string>
|
||||||
|
<string name="settings_qs_tile_hint">Aggiungi un toggle \"Nuovo evento\" nel pannello delle Impostazioni Rapide.</string>
|
||||||
|
<string name="crash_dialog_title">Calendula è crashato</string>
|
||||||
|
<string name="crash_dialog_message">Calendula si è chiuso inaspettatamente l\'ultima volta. Puoi contribuire allo sviluppo inviando questo report, che rimane sul tuo dispositivo finchè non scegli di inviarlo e non include informazioni personali o sui tuoi calendari:solo le informazioni tecniche qui sotto.</string>
|
||||||
|
<string name="crash_dialog_report">Report</string>
|
||||||
|
<string name="crash_dialog_dismiss">Non ora</string>
|
||||||
|
<string name="crash_report_issue_title">Crash report</string>
|
||||||
|
<string name="crash_report_clip_label">Calendula crash report</string>
|
||||||
|
<string name="crash_report_copied">Report copiato negli Appunti</string>
|
||||||
|
<string name="crash_report_open_failed">Impossibile aprire il gestore dei problemi. Il report è sugli Appunti.</string>
|
||||||
|
<string name="crash_report_body_template">Grazie per riportare un crash di Calendula. Per favore, aggiungi ogni informazione che ricordi in merito a quello che stavi facendo, quindi invia\n\n### Cosa è successo\n\n\n###Crash report\n%1$s\n</string>
|
||||||
|
<string name="crash_report_body_paste">_(Il report era troppo lungo per questo link - copialo qui dai tuoi Appunti.)_</string>
|
||||||
|
<string name="event_detail_recurrence">Ricorrenza</string>
|
||||||
|
<string name="event_detail_recurring">Evento ricorrente</string>
|
||||||
|
<string name="recurrence_on_days">%1$s da %2$s</string>
|
||||||
|
<string name="recurrence_with_until">%1$s fino a %2$s</string>
|
||||||
|
<string name="recurrence_with_count">%1$s, %2$d volte</string>
|
||||||
|
<string name="reminder_channel_description">Notifiche al momento del promemoria dei tuoi eventi</string>
|
||||||
|
<string name="agenda_header_today">Oggi</string>
|
||||||
|
<string name="widget_next_month">Mese prossimo</string>
|
||||||
|
<string name="widget_today">Oggi</string>
|
||||||
|
<string name="settings_title">Impostazioni</string>
|
||||||
|
<string name="settings_back">Indietro</string>
|
||||||
|
<string name="agenda_range_this_week">Questa settimana</string>
|
||||||
|
<string name="settings_reminders">Promemoria evento</string>
|
||||||
|
<string name="settings_default_reminder">Promemoria standard</string>
|
||||||
|
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
||||||
|
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||||
|
</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>
|
||||||
@@ -197,6 +197,14 @@
|
|||||||
<item quantity="one">%d hour</item>
|
<item quantity="one">%d hour</item>
|
||||||
<item quantity="other">%d hours</item>
|
<item quantity="other">%d hours</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d day</item>
|
||||||
|
<item quantity="other">%d days</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d week</item>
|
||||||
|
<item quantity="other">%d weeks</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
@@ -231,8 +239,7 @@
|
|||||||
<string name="agenda_today_action">Today</string>
|
<string name="agenda_today_action">Today</string>
|
||||||
<string name="agenda_header_today">Today</string>
|
<string name="agenda_header_today">Today</string>
|
||||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||||
<string name="agenda_empty_title">Nothing scheduled</string>
|
<string name="agenda_empty_title">You\'re all caught up</string>
|
||||||
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
|
|
||||||
|
|
||||||
<!-- Event search -->
|
<!-- Event search -->
|
||||||
<string name="search_action">Search</string>
|
<string name="search_action">Search</string>
|
||||||
@@ -269,10 +276,42 @@
|
|||||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||||
<string name="settings_week_start">Week starts on</string>
|
<string name="settings_week_start">Week starts on</string>
|
||||||
<string name="settings_week_start_auto">Automatic</string>
|
<string name="settings_week_start_auto">Automatic</string>
|
||||||
<string name="settings_week_start_monday">Monday</string>
|
<string name="settings_time_format">Time format</string>
|
||||||
<string name="settings_week_start_sunday">Sunday</string>
|
<string name="settings_time_format_auto">Automatic</string>
|
||||||
|
<string name="settings_time_format_12h">12-hour (2:00 PM)</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_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_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_hint">How far ahead the agenda home-screen widget lists events.</string>
|
||||||
|
<string name="settings_agenda_range_bar">Range bar</string>
|
||||||
|
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
||||||
|
<string name="agenda_range_day">Today</string>
|
||||||
|
<string name="agenda_range_this_week">This week</string>
|
||||||
|
<string name="agenda_range_this_month">This month</string>
|
||||||
|
<string name="agenda_range_week">Next 7 days</string>
|
||||||
|
<string name="agenda_range_month">Next 30 days</string>
|
||||||
|
<string name="agenda_range_custom">Custom…</string>
|
||||||
|
<string name="agenda_range_custom_hint">Days</string>
|
||||||
|
<string name="agenda_range_override_hint">Just for now — resets to your saved range when you reopen Calendula.</string>
|
||||||
|
<string name="agenda_range_showing_label">Showing all upcoming events for</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d day</item>
|
||||||
|
<item quantity="other">%d days</item>
|
||||||
|
</plurals>
|
||||||
<string name="settings_section_event_form">New event form</string>
|
<string name="settings_section_event_form">New event form</string>
|
||||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
|
<string name="settings_autofocus_title">Focus title on new event</string>
|
||||||
|
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
|
||||||
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||||
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
||||||
<string name="settings_section_notifications">Notifications</string>
|
<string name="settings_section_notifications">Notifications</string>
|
||||||
@@ -287,6 +326,7 @@
|
|||||||
<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_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_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>
|
||||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||||
<string name="settings_reliable_delivery">Reliable delivery</string>
|
<string name="settings_reliable_delivery">Reliable delivery</string>
|
||||||
@@ -299,6 +339,8 @@
|
|||||||
<string name="settings_section_language">Language</string>
|
<string name="settings_section_language">Language</string>
|
||||||
<string name="settings_language">App language</string>
|
<string name="settings_language">App language</string>
|
||||||
<string name="settings_language_auto">System default</string>
|
<string name="settings_language_auto">System default</string>
|
||||||
|
<string name="settings_translate">Help translate</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_event_form_subtitle">Default fields for new events</string>
|
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||||
@@ -323,7 +365,10 @@
|
|||||||
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
|
<string name="calendars_show_in_app_a11y">Show \"%1$s\" in the app</string>
|
||||||
<string name="calendars_synced_header">Synced calendars</string>
|
<string name="calendars_synced_header">Synced calendars</string>
|
||||||
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
||||||
<string name="calendars_manage_in_app">Manage</string>
|
<string name="calendars_manage_in_app">Manage in app</string>
|
||||||
|
<string name="calendars_account_menu_a11y">More options for %1$s</string>
|
||||||
|
<string name="calendars_enable_all">Enable all</string>
|
||||||
|
<string name="calendars_disable_all">Disable all</string>
|
||||||
<string name="calendars_add_account">Add account</string>
|
<string name="calendars_add_account">Add account</string>
|
||||||
<string name="calendars_new_title">New calendar</string>
|
<string name="calendars_new_title">New calendar</string>
|
||||||
<string name="calendars_edit_title">Edit calendar</string>
|
<string name="calendars_edit_title">Edit calendar</string>
|
||||||
@@ -337,6 +382,26 @@
|
|||||||
<string name="calendars_backup_header">Backup</string>
|
<string name="calendars_backup_header">Backup</string>
|
||||||
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
||||||
<string name="calendars_backup_action">Export as .ics file</string>
|
<string name="calendars_backup_action">Export as .ics file</string>
|
||||||
|
<string name="calendars_auto_backup">Automatic backup</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Tap to choose a folder</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Interval</string>
|
||||||
|
<string name="calendars_auto_backup_every">Every %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">No automatic backup yet</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Last backup: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Last backup failed: %1$s</string>
|
||||||
|
<string name="backup_channel_name">Backup</string>
|
||||||
|
<string name="backup_channel_description">Warns if automatic backups fail repeatedly.</string>
|
||||||
|
<string name="backup_failed_title">Automatic backup failed</string>
|
||||||
|
<string name="backup_failed_text">Calendula couldn\'t write the backup file. Check the backup folder in Settings.</string>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
<string name="calendars_backup_failed">Couldn\'t export the backup.</string>
|
<string name="calendars_backup_failed">Couldn\'t export the backup.</string>
|
||||||
<plurals name="calendars_backup_done">
|
<plurals name="calendars_backup_done">
|
||||||
<item quantity="one">Exported %d event.</item>
|
<item quantity="one">Exported %d event.</item>
|
||||||
@@ -382,6 +447,7 @@
|
|||||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
||||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||||
|
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>
|
||||||
|
|
||||||
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||||
Gitea issue (the app sends nothing automatically). -->
|
Gitea issue (the app sends nothing automatically). -->
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
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 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
|
||||||
@@ -25,7 +26,7 @@ class SettingsPrefsTest {
|
|||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
|
||||||
assertThat(prefs.dynamicColor.first()).isTrue()
|
assertThat(prefs.dynamicColor.first()).isTrue()
|
||||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.AUTO)
|
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -43,10 +44,70 @@ class SettingsPrefsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `week start round-trips`(@TempDir tempDir: Path) = runTest {
|
fun `week start round-trips any day`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
prefs.setWeekStart(WeekStartPref.SUNDAY)
|
prefs.setWeekStart(WeekStartPref.Day(DayOfWeek.SATURDAY))
|
||||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY)
|
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.SATURDAY))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `legacy MONDAY week-start value migrates to Day`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.WEEK_START_KEY] = "MONDAY"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Day(DayOfWeek.MONDAY))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage week-start value falls back to Auto`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val store = newDataStore(tempDir)
|
||||||
|
val prefs = SettingsPrefs(store)
|
||||||
|
store.updateData { p ->
|
||||||
|
val m = p.toMutablePreferences()
|
||||||
|
m[SettingsPrefs.WEEK_START_KEY] = "FUNDAY"
|
||||||
|
m
|
||||||
|
}
|
||||||
|
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `time format defaults to auto and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.timeFormat.first()).isEqualTo(TimeFormatPref.AUTO)
|
||||||
|
prefs.setTimeFormat(TimeFormatPref.TWELVE_HOUR)
|
||||||
|
assertThat(prefs.timeFormat.first()).isEqualTo(TimeFormatPref.TWELVE_HOUR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hour lines default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.showHourLines.first()).isFalse()
|
||||||
|
prefs.setShowHourLines(true)
|
||||||
|
assertThat(prefs.showHourLines.first()).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.autofocusEventTitle.first()).isTrue()
|
||||||
|
prefs.setAutofocusEventTitle(false)
|
||||||
|
assertThat(prefs.autofocusEventTitle.first()).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
|
||||||
|
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month)
|
||||||
|
|
||||||
|
prefs.setAgendaScreenRange(AgendaRange.Custom(14))
|
||||||
|
prefs.setAgendaWidgetRange(AgendaRange.Week)
|
||||||
|
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14))
|
||||||
|
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -252,13 +313,15 @@ class SettingsPrefsTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
|
||||||
assertThat(WeekStartPref.SUNDAY.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.SUNDAY)
|
.isEqualTo(DayOfWeek.MONDAY)
|
||||||
|
assertThat(WeekStartPref.Day(DayOfWeek.SATURDAY).resolveFirstDay(Locale.GERMANY))
|
||||||
|
.isEqualTo(DayOfWeek.SATURDAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `auto week start follows the locale convention`() {
|
fun `auto week start follows the locale convention`() {
|
||||||
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY)
|
||||||
assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,24 @@ class ReminderTimeTextTest {
|
|||||||
isAllDay = false,
|
isAllDay = false,
|
||||||
zone = berlin,
|
zone = berlin,
|
||||||
locale = Locale.GERMANY,
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("09:30 – 10:00")
|
assertThat(text).isEqualTo("09:30 – 10:00")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `12-hour preference renders an am-pm time range`() {
|
||||||
|
val text = reminderTimeText(
|
||||||
|
beginMillis = millisAt(LocalDateTime.of(2026, 6, 11, 14, 0), berlin),
|
||||||
|
endMillis = millisAt(LocalDateTime.of(2026, 6, 11, 15, 0), berlin),
|
||||||
|
isAllDay = false,
|
||||||
|
zone = berlin,
|
||||||
|
locale = Locale.US,
|
||||||
|
is24Hour = false,
|
||||||
|
)
|
||||||
|
assertThat(text).isEqualTo("2:00 PM – 3:00 PM")
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `timed event crossing midnight includes both dates`() {
|
fun `timed event crossing midnight includes both dates`() {
|
||||||
val text = reminderTimeText(
|
val text = reminderTimeText(
|
||||||
@@ -38,6 +52,7 @@ class ReminderTimeTextTest {
|
|||||||
isAllDay = false,
|
isAllDay = false,
|
||||||
zone = berlin,
|
zone = berlin,
|
||||||
locale = Locale.GERMANY,
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).contains("11.06.2026")
|
assertThat(text).contains("11.06.2026")
|
||||||
assertThat(text).contains("12.06.2026")
|
assertThat(text).contains("12.06.2026")
|
||||||
@@ -54,6 +69,7 @@ class ReminderTimeTextTest {
|
|||||||
// 02:00 in Berlin — naive local reading would shift the day.
|
// 02:00 in Berlin — naive local reading would shift the day.
|
||||||
zone = berlin,
|
zone = berlin,
|
||||||
locale = Locale.GERMANY,
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026")
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
}
|
}
|
||||||
@@ -66,6 +82,7 @@ class ReminderTimeTextTest {
|
|||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
zone = berlin,
|
zone = berlin,
|
||||||
locale = Locale.GERMANY,
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
assertThat(text).isEqualTo("11.06.2026 – 12.06.2026")
|
||||||
}
|
}
|
||||||
@@ -79,6 +96,7 @@ class ReminderTimeTextTest {
|
|||||||
isAllDay = true,
|
isAllDay = true,
|
||||||
zone = berlin,
|
zone = berlin,
|
||||||
locale = Locale.GERMANY,
|
locale = Locale.GERMANY,
|
||||||
|
is24Hour = true,
|
||||||
)
|
)
|
||||||
assertThat(text).isEqualTo("11.06.2026")
|
assertThat(text).isEqualTo("11.06.2026")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import kotlinx.datetime.DayOfWeek
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class AgendaRangeTest {
|
||||||
|
|
||||||
|
// A reference anchor: 2026-06-17 is a Wednesday.
|
||||||
|
private val wednesday = LocalDate(2026, 6, 17)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rolling ranges have fixed 1, 7, 30 day counts regardless of anchor`() {
|
||||||
|
assertThat(AgendaRange.Day.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(1)
|
||||||
|
assertThat(AgendaRange.Week.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(7)
|
||||||
|
assertThat(AgendaRange.Month.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `this week runs through the day before the week repeats`() {
|
||||||
|
// Mon-start week: Wed → Wed,Thu,Fri,Sat,Sun = 5 days (everything before next Monday).
|
||||||
|
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(5)
|
||||||
|
// On the first day of the week the whole 7 days remain.
|
||||||
|
val monday = LocalDate(2026, 6, 15)
|
||||||
|
assertThat(AgendaRange.ThisWeek.dayCount(monday, DayOfWeek.MONDAY)).isEqualTo(7)
|
||||||
|
// A Sunday-start week shifts the boundary: Wed → 4 days left (through Saturday).
|
||||||
|
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.SUNDAY)).isEqualTo(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `this month runs through the last day of the month`() {
|
||||||
|
// June has 30 days: the 17th leaves 14 days (17..30 inclusive).
|
||||||
|
assertThat(AgendaRange.ThisMonth.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(14)
|
||||||
|
// February 2026 (non-leap) has 28 days.
|
||||||
|
assertThat(AgendaRange.ThisMonth.dayCount(LocalDate(2026, 2, 20), DayOfWeek.MONDAY))
|
||||||
|
.isEqualTo(9)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `custom day count is clamped to bounds`() {
|
||||||
|
assertThat(AgendaRange.Custom(45).dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(45)
|
||||||
|
assertThat(AgendaRange.Custom(0).dayCount(wednesday, DayOfWeek.MONDAY))
|
||||||
|
.isEqualTo(AgendaRange.MIN_CUSTOM_DAYS)
|
||||||
|
assertThat(AgendaRange.Custom(9_999).dayCount(wednesday, DayOfWeek.MONDAY))
|
||||||
|
.isEqualTo(AgendaRange.MAX_CUSTOM_DAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fixed ranges round-trip through storage`() {
|
||||||
|
listOf(
|
||||||
|
AgendaRange.Day,
|
||||||
|
AgendaRange.Week,
|
||||||
|
AgendaRange.Month,
|
||||||
|
AgendaRange.ThisWeek,
|
||||||
|
AgendaRange.ThisMonth,
|
||||||
|
).forEach { range ->
|
||||||
|
assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day))
|
||||||
|
.isEqualTo(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `custom range round-trips through storage`() {
|
||||||
|
val range = AgendaRange.Custom(14)
|
||||||
|
assertThat(range.storageValue()).isEqualTo("CUSTOM:14")
|
||||||
|
assertThat(parseAgendaRange("CUSTOM:14", default = AgendaRange.Day))
|
||||||
|
.isEqualTo(AgendaRange.Custom(14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage and null fall back to the default`() {
|
||||||
|
assertThat(parseAgendaRange(null, default = AgendaRange.Month)).isEqualTo(AgendaRange.Month)
|
||||||
|
assertThat(parseAgendaRange("YEAR", default = AgendaRange.Week)).isEqualTo(AgendaRange.Week)
|
||||||
|
assertThat(parseAgendaRange("CUSTOM:abc", default = AgendaRange.Day)).isEqualTo(AgendaRange.Day)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `out-of-range custom value clamps on parse`() {
|
||||||
|
assertThat(parseAgendaRange("CUSTOM:9999", default = AgendaRange.Day))
|
||||||
|
.isEqualTo(AgendaRange.Custom(AgendaRange.MAX_CUSTOM_DAYS))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class TimeFormatTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `24-hour format is zero-padded HH mm`() {
|
||||||
|
assertThat(formatTimeOfDay(9, 5, is24Hour = true, Locale.US)).isEqualTo("09:05")
|
||||||
|
assertThat(formatTimeOfDay(14, 0, is24Hour = true, Locale.US)).isEqualTo("14:00")
|
||||||
|
assertThat(formatTimeOfDay(0, 0, is24Hour = true, Locale.US)).isEqualTo("00:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `12-hour format adds a localised am-pm marker`() {
|
||||||
|
assertThat(formatTimeOfDay(14, 0, is24Hour = false, Locale.US)).isEqualTo("2:00 PM")
|
||||||
|
assertThat(formatTimeOfDay(0, 30, is24Hour = false, Locale.US)).isEqualTo("12:30 AM")
|
||||||
|
assertThat(formatTimeOfDay(12, 0, is24Hour = false, Locale.US)).isEqualTo("12:00 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `minute-of-day end-of-day renders 24 00 in 24h and midnight in 12h`() {
|
||||||
|
assertThat(formatMinuteOfDay(1_440, is24Hour = true, Locale.US)).isEqualTo("24:00")
|
||||||
|
assertThat(formatMinuteOfDay(1_440, is24Hour = false, Locale.US)).isEqualTo("12:00 AM")
|
||||||
|
assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hour label is zero-padded in 24h and compact am-pm in 12h`() {
|
||||||
|
assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13")
|
||||||
|
assertThat(formatHourLabel(13, is24Hour = false, Locale.US)).isEqualTo("1 PM")
|
||||||
|
assertThat(formatHourLabel(1, is24Hour = false, Locale.US)).isEqualTo("1 AM")
|
||||||
|
assertThat(formatHourLabel(0, is24Hour = false, Locale.US)).isEqualTo("12 AM")
|
||||||
|
}
|
||||||
|
}
|
||||||
50
fastlane/metadata/android/en-US/changelogs/21100.txt
Normal file
50
fastlane/metadata/android/en-US/changelogs/21100.txt
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
### Added
|
||||||
|
- Start the week on any day. The **Week starts on** setting (Settings →
|
||||||
|
Appearance) now offers every weekday — not just Monday or Sunday — alongside
|
||||||
|
the automatic, locale-based default. Thanks to @zmaherdev for the suggestion
|
||||||
|
([#3]).
|
||||||
|
- Choose a 12- or 24-hour clock. A new **Time format** setting (Settings →
|
||||||
|
Appearance) lets you force a 12-hour (2:00 PM) or 24-hour (14:00) clock, or
|
||||||
|
follow the system setting automatically. It applies everywhere times appear —
|
||||||
|
the week and day timelines, agenda, event details, search and reminders — so
|
||||||
|
the format is now consistent across the whole app. Thanks to @zmaherdev for
|
||||||
|
the suggestion ([#6]).
|
||||||
|
- Optional hour lines in the timeline. A new **Hour lines** switch (Settings →
|
||||||
|
Appearance) draws a faint separator at each hour in the week and day views,
|
||||||
|
making it easier to see when events start and end. Off by default. Thanks to
|
||||||
|
@zmaherdev for the suggestion ([#5]).
|
||||||
|
- Limit how far ahead the agenda looks, and switch it on the fly. New **Agenda
|
||||||
|
range** and **Agenda widget range** settings (Settings → Appearance) let the
|
||||||
|
agenda screen and its home-screen widget each show just today, the rest of this
|
||||||
|
week, the rest of this month, a rolling 7 or 30 days, or a custom number of
|
||||||
|
days — "This week" follows your week-start. The agenda also gains a bar at the
|
||||||
|
top naming the exact dates in view, with a button to switch the range just for
|
||||||
|
the current session (it resets when you reopen the app); the bar can be turned
|
||||||
|
off. Thanks to @zmaherdev for the suggestion ([#4]).
|
||||||
|
- Automatic backup of local calendars. A new **Automatic backup** option
|
||||||
|
(Settings → Calendars → Backup) periodically exports your local calendars to an
|
||||||
|
`.ics` file in a folder you choose, on an interval you set (from 30 minutes up).
|
||||||
|
Useful for file-based syncing such as Syncthing, or simply as a safety net — it
|
||||||
|
is a one-way export and never touches your synced accounts. Thanks to @shield
|
||||||
|
for the discussion that inspired it ([#7], [#8]).
|
||||||
|
- Field icons in the event-form settings. Each optional-field toggle (Settings →
|
||||||
|
Event form) now shows the same icon the field uses in the new-event form, so
|
||||||
|
the list is easier to scan.
|
||||||
|
- Help translate Calendula. A **Help translate** link at the top of **Settings →
|
||||||
|
App language** opens the project's Weblate, where you can add or improve a
|
||||||
|
language in your browser — no coding needed. Contributions in any language are
|
||||||
|
welcome.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
||||||
|
agenda), and the agenda's empty state now reads "You're all caught up".
|
||||||
|
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
|
||||||
|
into collapsible, source-branded cards — each account shows its app's icon —
|
||||||
|
with a per-account menu to enable or disable all of its calendars at once or
|
||||||
|
open the account in its source app.
|
||||||
|
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
|
||||||
|
moved up with the other global reminder options, and the per-calendar reminder
|
||||||
|
overrides now fold into a single expandable section.
|
||||||
|
- Moved **Add Quick Settings tile** out of the event-form section to its own
|
||||||
|
top-level entry in Settings.
|
||||||
|
|
||||||
6
fastlane/metadata/android/en-US/changelogs/21101.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/21101.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
### Fixed
|
||||||
|
- Calendula can now be set as your default calendar app. It registers the
|
||||||
|
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||||
|
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||||
|
that day. Android has no way for an app to make itself the default, so you pick
|
||||||
|
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||||
7
fastlane/metadata/android/en-US/changelogs/21102.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/21102.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
### Added
|
||||||
|
- Jump straight to typing a title. When you start a new event, Calendula now puts
|
||||||
|
the cursor in the title field and opens the keyboard right away, so you can type
|
||||||
|
the name without an extra tap. It's on by default and only affects creating an
|
||||||
|
event — editing never grabs focus — and a new **Focus title on new event**
|
||||||
|
switch (Settings → New event form) turns it off. Thanks to @abrossimow for the
|
||||||
|
suggestion ([#10]).
|
||||||
19
fastlane/metadata/android/en-US/changelogs/21200.txt
Normal file
19
fastlane/metadata/android/en-US/changelogs/21200.txt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
### Added
|
||||||
|
- See past events your way. Two new settings change how events that have already
|
||||||
|
ended are shown. **Past events** (Settings → Appearance → Agenda) lets the
|
||||||
|
agenda — both in the app and the home-screen "Upcoming" widget — keep them as
|
||||||
|
usual, dim them, or hide them from the list entirely. **Dim completed events**
|
||||||
|
(Settings → Appearance) separately fades finished events in the month and week
|
||||||
|
views. Both are off by default, an event only counts as finished once it has
|
||||||
|
actually ended (events still in progress are never dimmed), and the lists
|
||||||
|
update on their own as the day goes on. Thanks to @ptab for the suggestion
|
||||||
|
([#12]).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Readable titles on overlapping events. In the week and day views, events that
|
||||||
|
overlap split a day into slim columns where the title used to be clipped to a
|
||||||
|
character or two. The title now wraps across as many lines as the block can
|
||||||
|
fit, and the time is hidden on those narrow blocks so the name can fill the
|
||||||
|
space — so you can tell events apart without opening each one. Thanks to @ptab
|
||||||
|
for the suggestion ([#13]).
|
||||||
|
|
||||||
@@ -26,10 +26,14 @@ lifecycleCompose = "2.10.0"
|
|||||||
androidxTestRules = "1.7.0"
|
androidxTestRules = "1.7.0"
|
||||||
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||||
glance = "1.1.1"
|
glance = "1.1.1"
|
||||||
|
work = "2.10.1"
|
||||||
|
documentfile = "1.0.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# AndroidX core
|
# AndroidX core
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
|
||||||
|
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
|
||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
|||||||
Reference in New Issue
Block a user