diff --git a/.gitea/ISSUE_TEMPLATE/crash_report.md b/.gitea/ISSUE_TEMPLATE/crash_report.md index c76b1b7..6828788 100644 --- a/.gitea/ISSUE_TEMPLATE/crash_report.md +++ b/.gitea/ISSUE_TEMPLATE/crash_report.md @@ -5,6 +5,7 @@ title: "Crash: " labels: - bug - crash + - priority:high --- + + +### Context +- Calendula version: +- Android version: +- Device: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e9ac45..db69497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,58 @@ 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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [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 ### Added @@ -627,3 +679,9 @@ automatically, with zero telemetry and no internet permission. [#1]: https://codeberg.org/jlmakiola/calendula/issues/1 [#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 diff --git a/README.md b/README.md index 994ecd2..f909377 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ database, no sync stack reinvented. - Real Material 3 Expressive throughout — dynamic color (Android 12+), 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 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 - **[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 [MIT](LICENSE) — Jean-Luc Makiola, 2026 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ad97564..46440cf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 21000 - versionName = "2.10.0" + versionCode = 21100 + versionName = "2.11.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -152,6 +152,9 @@ dependencies { implementation(libs.androidx.datastore.preferences) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.documentfile) + implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index 2ec6c24..ac300d1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -1,8 +1,16 @@ package de.jeanlucmakiola.calendula import android.app.Application +import dagger.hilt.android.EntryPointAccessors 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 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" @@ -16,5 +24,24 @@ class CalendulaApp : Application() { // Install first thing so startup crashes are captured too (privacy- // respecting, on-device; the user submits the report by hand). 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, + ) + } } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt index cbacda4..840f09b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/MainActivity.kt @@ -9,10 +9,13 @@ import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.core.content.IntentCompat import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel @@ -20,7 +23,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import dagger.hilt.android.AndroidEntryPoint import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.prefs.ThemeMode +import de.jeanlucmakiola.calendula.data.prefs.is24Hour 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.common.CalendarView import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity @@ -81,19 +87,31 @@ class MainActivity : AppCompatActivity() { ThemeMode.LIGHT -> false 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( darkTheme = darkTheme, dynamicColor = settings.dynamicColor, ) { - RootScreen( - modifier = Modifier.fillMaxSize(), - requestedDetailKey = requestedDetailKey, - onDetailKeyConsumed = { requestedDetailKey = null }, - widgetNavRequest = requestedNav, - onWidgetNavConsumed = { requestedNav = null }, - requestedImportUri = requestedImportUri, - onImportConsumed = { requestedImportUri = null }, - ) + CompositionLocalProvider( + LocalUse24HourFormat provides use24Hour, + LocalShowHourLines provides settings.showHourLines, + ) { + RootScreen( + modifier = Modifier.fillMaxSize(), + requestedDetailKey = requestedDetailKey, + onDetailKeyConsumed = { requestedDetailKey = null }, + widgetNavRequest = requestedNav, + onWidgetNavConsumed = { requestedNav = null }, + requestedImportUri = requestedImportUri, + onImportConsumed = { requestedImportUri = null }, + ) + } pendingCrashReport?.let { report -> CrashReportDialog( report = report, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt new file mode 100644 index 0000000..64f5091 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/backup/AutoBackup.kt @@ -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(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().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" + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt index 70b0f9a..5ad0967 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/ics/IcsExporter.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics import android.content.Context import android.net.Uri import androidx.core.content.FileProvider +import androidx.documentfile.provider.DocumentFile import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.IOException @@ -28,6 +29,36 @@ class IcsExporter @Inject constructor( } ?: 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 * Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to @@ -42,5 +73,6 @@ class IcsExporter @Inject constructor( private companion object { const val SHARE_DIR = "shared_ics" + const val MIME_CALENDAR = "text/calendar" } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 2b3cb82..0fec684 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -5,8 +5,12 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import 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 kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -19,18 +23,39 @@ import javax.inject.Singleton /** Light/dark override. SYSTEM follows the device setting. */ 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 - * locale's convention (e.g. Monday in DE, Sunday in en-US). + * Clock convention for time-of-day labels. AUTO follows the device's 24-hour + * system setting; the others force a 12- or 24-hour clock app-wide. + */ +enum class TimeFormatPref { AUTO, TWELVE_HOUR, TWENTY_FOUR_HOUR } + +/** + * 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) { - WeekStartPref.MONDAY -> DayOfWeek.MONDAY - WeekStartPref.SUNDAY -> DayOfWeek.SUNDAY + is WeekStartPref.Day -> day // 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 +80,7 @@ class SettingsPrefs @Inject constructor( } val weekStart: Flow = store.data.map { prefs -> - prefs[WEEK_START_KEY].toEnum(WeekStartPref.AUTO) + parseWeekStart(prefs[WEEK_START_KEY]) } suspend fun setThemeMode(mode: ThemeMode) { @@ -67,7 +92,62 @@ class SettingsPrefs @Inject constructor( } 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 = 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 = store.data.map { prefs -> + prefs[SHOW_HOUR_LINES_KEY] ?: false + } + + suspend fun setShowHourLines(enabled: Boolean) { + store.edit { it[SHOW_HOUR_LINES_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 = 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 = 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 = 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 } } /** @@ -248,6 +328,52 @@ class SettingsPrefs @Inject constructor( } } + // --- Automatic backup (issue #8) ------------------------------------ + + /** Whether periodic automatic `.ics` export of local calendars is on. Default OFF. */ + val autoBackupEnabled: Flow = 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 = 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 = 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 = 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 = when (stored) { null -> DEFAULT_FORM_FIELDS else -> stored.split(',') @@ -259,6 +385,11 @@ class SettingsPrefs @Inject constructor( internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") 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 DEFAULT_VIEW_KEY = stringPreferencesKey("default_view") internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields") internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled") @@ -282,9 +413,27 @@ class SettingsPrefs @Inject constructor( stringPreferencesKey("per_calendar_allday_reminder_override") internal val DEFAULT_FORM_FIELDS = 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]). */ sealed interface CalendarReminderOverride { /** No override — the calendar uses the global default. */ @@ -331,6 +480,25 @@ private fun MutableMap.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 ENTRY_SEP = ";" private const val KEY_VALUE_SEP = "=" diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt index a5ed60c..71af6a3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/EventReminderReceiver.kt @@ -46,7 +46,7 @@ class EventReminderReceiver : BroadcastReceiver() { if (settingsPrefs.remindersEnabled.first()) { val now = System.currentTimeMillis() val due = alertStore.dueAlerts(now) - due.forEach(notifier::post) + due.forEach { notifier.post(it) } alertStore.markFired(due.map { it.alertId }, now) } } finally { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt index 358d9ca..7b92a9d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderNotifier.kt @@ -14,6 +14,9 @@ import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.MainActivity 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.util.Locale import javax.inject.Inject @@ -29,6 +32,7 @@ import javax.inject.Singleton @Singleton class ReminderNotifier @Inject constructor( @ApplicationContext private val context: Context, + private val settingsPrefs: SettingsPrefs, ) { /** 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() } - fun post(alert: ReminderAlert) { + suspend fun post(alert: ReminderAlert) { ensureChannel() 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( beginMillis = alert.beginMillis, endMillis = alert.endMillis, isAllDay = alert.isAllDay, zone = ZoneId.systemDefault(), locale = Locale.getDefault(), + is24Hour = is24Hour, ) val text = listOfNotNull(time, alert.location).joinToString(" · ") val notification = NotificationCompat.Builder(context, CHANNEL_ID) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt index 70b1354..ce7fd21 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeText.kt @@ -1,5 +1,6 @@ package de.jeanlucmakiola.calendula.data.reminders +import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter import java.time.Instant import java.time.ZoneId import java.time.ZoneOffset @@ -25,6 +26,7 @@ fun reminderTimeText( isAllDay: Boolean, zone: ZoneId, locale: Locale, + is24Hour: Boolean, ): String { if (isAllDay) { 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 end = Instant.ofEpochMilli(endMillis).atZone(zone) return if (begin.toLocalDate() == end.toLocalDate()) { timeFormat.format(begin) + RANGE + timeFormat.format(end) } else { - val dateTimeFormat = DateTimeFormatter - .ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) - .withLocale(locale) - dateTimeFormat.format(begin) + RANGE + dateTimeFormat.format(end) + // Cross-day: medium date + the chosen short time, joined per side. Built + // from the two formatters (not ofLocalizedDateTime) so the 12/24h choice + // applies to the time portion too. + 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) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt new file mode 100644 index 0000000..1ae2046 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRange.kt @@ -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:`. */ +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:" diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index ee72a1b..8c6073c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -2,22 +2,26 @@ package de.jeanlucmakiola.calendula.ui.agenda import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.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.Search import androidx.compose.material3.DrawerValue @@ -35,7 +39,10 @@ import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.rememberDrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -47,6 +54,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +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.CalendarDrawer import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn @@ -58,6 +67,9 @@ import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.positionOf +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.datetime.DateTimeUnit import kotlinx.datetime.LocalDate @@ -88,11 +100,13 @@ fun AgendaScreen( val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val drawerState = rememberDrawerState(DrawerValue.Closed) val scope = rememberCoroutineScope() + var showRangePicker by remember { mutableStateOf(false) } val isOnToday = when (val s = state) { is AgendaUiState.Success -> s.anchor == s.today else -> true } + val successState = state as? AgendaUiState.Success ModalNavigationDrawer( drawerState = drawerState, @@ -135,18 +149,129 @@ fun AgendaScreen( ) }, ) { innerPadding -> - AgendaContent( - state = state, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, + Column( 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, + 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 private fun AgendaContent( state: AgendaUiState, @@ -254,7 +379,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( - imageVector = Icons.Filled.EventAvailable, + imageVector = Icons.Filled.Coffee, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(48.dp), @@ -265,13 +390,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) { style = MaterialTheme.typography.titleMedium, 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 +456,17 @@ private fun agendaTimeSummary(event: EventInstance): String { val time = if (event.isAllDay) { stringResource(R.string.event_detail_all_day) } 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() } 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 - return "%02d:%02d".format(t.hour, t.minute) + return formatTimeOfDay(t.hour, t.minute, is24Hour, locale) } private fun formatAgendaDate(date: LocalDate): String { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index 2ba5c3b..110a528 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -53,5 +53,13 @@ sealed interface AgendaUiState { val anchor: LocalDate, val today: LocalDate, val days: List, + /** 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 226938d..f088e4e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.di.IoDispatcher +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.EventInstance import de.jeanlucmakiola.calendula.domain.FailureReason @@ -17,10 +19,13 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone +import java.util.Locale import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.atTime import kotlinx.datetime.plus @@ -30,16 +35,24 @@ import kotlin.time.Clock import kotlin.time.Instant 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) @HiltViewModel class AgendaViewModel @Inject constructor( private val repository: CalendarRepository, + settingsPrefs: SettingsPrefs, @IoDispatcher private val io: CoroutineDispatcher, ) : 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()) } + private val zone = TimeZone.currentSystemDefault() private val todayDate: LocalDate @@ -48,14 +61,32 @@ class AgendaViewModel @Inject constructor( private val _anchor = MutableStateFlow(todayDate) val anchor: StateFlow = _anchor - val state: StateFlow = _anchor - .flatMapLatest { anchor -> - val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone) + // A transient, in-view override of the saved agenda range. Held in memory + // (not persisted), so it survives view switches and rotation within the + // session but resets to the saved default when the app is relaunched. + private val _rangeOverride = MutableStateFlow(null) + + val state: StateFlow = + 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( repository.calendars(), - repository.instances(range), + repository.instances(window), ) { calendars, instances -> - buildState(anchor, calendars, instances) + buildState(params, calendars, instances) } } .catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) } @@ -75,16 +106,47 @@ class AgendaViewModel @Inject constructor( _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( - anchor: LocalDate, + params: AgendaParams, calendars: List, instances: List, ): AgendaUiState { if (calendars.isEmpty()) { return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured) } + val anchor = params.anchor 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, + ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt index db9dfde..4900a71 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsScreen.kt @@ -4,13 +4,17 @@ import android.accounts.AccountManager import android.content.Context import android.content.Intent import android.provider.Settings +import android.text.format.DateUtils import androidx.activity.compose.rememberLauncherForActivityResult 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.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -26,20 +30,28 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.CalendarMonth 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.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.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.Button +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost 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.clip 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.layout.ContentScale 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.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization 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.lifecycle.compose.collectAsStateWithLifecycle 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.ui.common.CALENDAR_COLOR_PALETTE 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.CollapsingScaffold 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.Position import de.jeanlucmakiola.calendula.ui.common.pastelize -import de.jeanlucmakiola.calendula.ui.common.positionOf import java.time.LocalDate /** Sentinel [editorId] meaning "the editor is composing a new calendar". */ @@ -104,6 +128,7 @@ fun CalendarsScreen( val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle() val error by viewModel.error.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. // [editorSession] bumps on every open so the editor's field state resets for @@ -144,10 +169,15 @@ fun CalendarsScreen( backupResult = backupResult, onExportBackup = viewModel::exportBackup, onConsumeBackupResult = viewModel::consumeBackupResult, + autoBackup = autoBackup, + onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled, + onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes, + onSetAutoBackupFolder = viewModel::setAutoBackupFolder, onBack = onBack, onAdd = { editorSession++; editorId = NEW_CALENDAR_ID }, onEdit = { calendar -> editorSession++; editorId = calendar.id }, onSetDisabled = viewModel::setDisabled, + onSetAccountDisabled = viewModel::setAccountDisabled, ) } } @@ -162,13 +192,22 @@ private fun CalendarsList( backupResult: BackupResult?, onExportBackup: (android.net.Uri) -> Unit, onConsumeBackupResult: () -> Unit, + autoBackup: AutoBackupUiState, + onSetAutoBackupEnabled: (Boolean) -> Unit, + onSetAutoBackupInterval: (Long) -> Unit, + onSetAutoBackupFolder: (android.net.Uri) -> Unit, onBack: () -> Unit, onAdd: () -> Unit, onEdit: (CalendarSource) -> Unit, onSetDisabled: (Long, Boolean) -> Unit, + onSetAccountDisabled: (Collection, Boolean) -> Unit, ) { val context = LocalContext.current 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()) } + var localExpanded by remember { mutableStateOf(true) } val writeErrorText = stringResource(R.string.calendars_write_error) LaunchedEffect(error) { @@ -184,6 +223,13 @@ private fun CalendarsList( contract = ActivityResultContracts.CreateDocument("text/calendar"), ) { 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) LaunchedEffect(backupResult) { when (val r = backupResult) { @@ -208,70 +254,37 @@ private fun CalendarsList( onBack = onBack, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { - // Local (device-only) calendars — the calendars the app owns. The - // "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)) - } + // What the per-calendar / per-account switches below actually do. 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 - // safety net. Offered only when there is something to back up. - if (local.isNotEmpty()) { - Spacer(Modifier.height(16.dp)) - SectionHeader(stringResource(R.string.calendars_backup_header)) - HintText(stringResource(R.string.calendars_backup_hint)) - GroupedRow( - title = stringResource(R.string.calendars_backup_action), - position = Position.Alone, - leading = { LeadingAvatar(Icons.Default.FileDownload) }, - onClick = { - runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") } - }, - ) - } - - Spacer(Modifier.height(16.dp)) - - // Synced calendars — read-only, grouped by account, each with a - // per-account "manage in source app" link. - SectionHeader(stringResource(R.string.calendars_synced_header)) - HintText(stringResource(R.string.calendars_synced_hint)) - synced - .groupBy { it.accountName.ifBlank { it.accountType } } - .forEach { (account, cals) -> - AccountHeader(account = account, accountType = cals.first().accountType) - cals.forEachIndexed { index, calendar -> + // Local (device-only) calendars — one collapsible group. The header's + // "+" adds a calendar; the switch enables/disables them all at once; + // tapping a calendar row opens its editor. + val localDisabled = local.isNotEmpty() && local.all { it.id in disabledIds } + CalendarGroup( + title = stringResource(R.string.calendars_local_header), + expanded = localExpanded, + bodyHasRows = local.isNotEmpty(), + headerDisabled = localDisabled, + leading = { Box(dimIf(localDisabled)) { LeadingAvatar(Icons.Default.PhoneAndroid) } }, + manageIcon = Icons.Default.Add, + manageLabel = stringResource(R.string.calendars_add), + onManage = onAdd, + onToggleExpand = { localExpanded = !localExpanded }, + showToggleAll = local.isNotEmpty(), + allEnabled = local.none { it.id in disabledIds }, + onToggleAll = { enabled -> onSetAccountDisabled(local.map { it.id }, !enabled) }, + ) { + if (local.isEmpty()) { + HintText(stringResource(R.string.calendars_local_empty)) + } else { + local.forEachIndexed { index, calendar -> val disabled = calendar.id in disabledIds GroupedRow( 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, leading = { CalendarColorChip(calendar.color, dimIf(disabled)) }, trailing = { @@ -281,17 +294,118 @@ private fun CalendarsList( onToggle = { enabled -> onSetDisabled(calendar.id, !enabled) }, ) }, + onClick = { onEdit(calendar) }, ) } } - Spacer(Modifier.height(8.dp)) - GroupedRow( - title = stringResource(R.string.calendars_add_account), - position = Position.Alone, - leading = { AddAvatar() }, - onClick = { - runCatching { context.startActivity(Intent(Settings.ACTION_ADD_ACCOUNT)) } - }, + } + + // Backup — local calendars have no sync, so a .ics export is their only + // safety net. Offered only when there is something to back up. + if (local.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + 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 -private fun AccountHeader(account: String, accountType: String) { - val context = LocalContext.current - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 28.dp, end = 16.dp, top = 16.dp, bottom = 4.dp), - verticalAlignment = Alignment.CenterVertically, +private fun CalendarGroup( + title: String, + expanded: Boolean, + bodyHasRows: Boolean, + headerDisabled: Boolean, + leading: @Composable () -> Unit, + 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( - text = account, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.weight(1f), - ) - OutlinedButton(onClick = { - runCatching { context.startActivity(sourceAppIntent(context, accountType)) } - }) { - Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(Modifier.width(6.dp)) - Text(stringResource(R.string.calendars_manage_in_app)) + Column(content = body) + } +} + +/** + * The account header's overflow (⋮) menu: the two account-level actions that + * don't fit on one line — "Enable/Disable all" (when [showToggleAll]) and the + * manage/add action. A rounded, tonal dropdown matching the app's surfaces, with + * the two actions divided for clear separation. + */ +@Composable +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 private fun LeadingAvatar(icon: ImageVector) { 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 private fun SectionHeader(text: String) { 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 * own authenticator package (resolved from [AccountManager], no permission diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt index 17451f7..d56ee9a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/calendars/CalendarsViewModel.kt @@ -1,13 +1,19 @@ package de.jeanlucmakiola.calendula.ui.calendars +import android.content.Context +import android.content.Intent import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.di.IoDispatcher 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.SettingsPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.ics.IcsWriter import kotlinx.coroutines.CoroutineDispatcher @@ -16,6 +22,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn @@ -33,9 +40,11 @@ import javax.inject.Inject */ @HiltViewModel class CalendarsViewModel @Inject constructor( + @ApplicationContext private val context: Context, private val repository: CalendarRepository, private val icsExporter: IcsExporter, private val prefs: CalendarPrefs, + private val settingsPrefs: SettingsPrefs, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { @@ -63,6 +72,22 @@ class CalendarsViewModel @Inject constructor( initialValue = emptySet(), ) + /** Automatic-backup settings + last-run status, for the Backup section UI. */ + val autoBackup: StateFlow = 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) val error: StateFlow = _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, 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) { viewModelScope.launch { 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. */ sealed interface BackupResult { data class Success(val eventCount: Int) : BackupResult diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt new file mode 100644 index 0000000..aa07543 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/EventFormFieldVisuals.kt @@ -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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt index 7479753..c183aab 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/GroupedList.kt @@ -140,6 +140,7 @@ fun GroupedRow( summary: String? = null, selected: Boolean = false, dimmed: Boolean = false, + container: Color? = null, minHeight: Dp = 72.dp, leading: @Composable (() -> Unit)? = null, trailing: @Composable (() -> Unit)? = null, @@ -198,10 +199,10 @@ fun GroupedRow( .fillMaxWidth() .padding(horizontal = 16.dp) .then(gap) - val containerColor = if (selected) { - MaterialTheme.colorScheme.secondaryContainer - } else { - MaterialTheme.colorScheme.surfaceContainerHigh + val containerColor = when { + selected -> MaterialTheme.colorScheme.secondaryContainer + container != null -> container + else -> MaterialTheme.colorScheme.surfaceContainerHigh } if (onClick != null) { Surface( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourLines.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourLines.kt new file mode 100644 index 0000000..6ae2d71 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/HourLines.kt @@ -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, + ) + } + } + } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt index d5d72d2..ce6abae 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/Picker.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape @@ -30,6 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -37,6 +39,7 @@ import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider import de.jeanlucmakiola.calendula.R 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 @@ -83,8 +86,10 @@ fun OptionPicker( label: @Composable (T) -> String, onSelect: (T) -> Unit, onDismiss: () -> Unit, + header: (@Composable ColumnScope.() -> Unit)? = null, ) { FullScreenPicker(title = title, onDismiss = onDismiss) { + header?.invoke(this) options.forEachIndexed { index, option -> val isSelected = option == selected 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 private fun SelectedCheck() { 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 private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) { CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt new file mode 100644 index 0000000..362efb1 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimeFormat.kt @@ -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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 6354122..97cbdce 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -80,7 +80,12 @@ import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.pastelize 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 kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -466,6 +471,8 @@ private fun Timeline( ) { val totalHeight = HOUR_HEIGHT * 24 val dark = isSystemInDarkTheme() + val use24Hour = LocalUse24HourFormat.current + val locale = currentLocale() Box(modifier = Modifier.fillMaxSize()) { // Gutter and day column are two scroll viewports that SHARE one scroll @@ -488,7 +495,7 @@ private fun Timeline( ) { if (h > 0) { Text( - text = "%02d".format(h), + text = formatHourLabel(h, use24Hour, locale), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier @@ -534,6 +541,8 @@ private fun DayColumnCard( modifier: Modifier = Modifier, ) { val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } + val showHourLines = LocalShowHourLines.current + val hourLineColor = MaterialTheme.colorScheme.outlineVariant Card( // Plain rectangular column — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -546,6 +555,9 @@ private fun DayColumnCard( BoxWithConstraints( modifier = Modifier .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 // blocks are consumed by their own click handler first, so this // only fires on the column background. Snaps to the tapped hour. @@ -589,7 +601,10 @@ private fun EventBlock( modifier: Modifier = Modifier, ) { 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 Box( modifier = modifier @@ -635,8 +650,8 @@ private fun DayLoading() { } } -private fun minToHm(min: Int): String = - if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60) +private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String = + formatMinuteOfDay(min, is24Hour, locale) private fun formatDayTitle(date: LocalDate): String { val locale = Locale.getDefault() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 35dc26b..c187ac3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -95,6 +95,8 @@ import de.jeanlucmakiola.calendula.ui.common.predictiveBack import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.OptionCard 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.recurrenceText import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel @@ -781,7 +783,7 @@ private fun formatWhen( val zid = ZoneId.of(zone.id) val dateFull = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).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 allDayLabel = stringResource(R.string.event_detail_all_day) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index b29a75d..22d5ac6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -43,7 +43,6 @@ 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.Person import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Place @@ -123,6 +122,8 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow import de.jeanlucmakiola.calendula.ui.common.DialogAmountField 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.InlineTextField import de.jeanlucmakiola.calendula.ui.common.OptionCard @@ -132,6 +133,8 @@ import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS import de.jeanlucmakiola.calendula.ui.common.ReminderUnit import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.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.reminderUnitLabel import de.jeanlucmakiola.calendula.ui.common.pastelize @@ -1017,9 +1020,9 @@ private fun FieldPickerDialog( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { hiddenFields.forEach { field -> OptionCard( - label = stringResource(fieldLabel(field)), + label = stringResource(eventFormFieldLabel(field)), onClick = { onSelect(field) }, - icon = fieldIcon(field), + icon = eventFormFieldIcon(field), ) } } @@ -1636,28 +1639,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 * current level is highlighted. Tap picks and closes. @@ -1891,8 +1872,9 @@ private fun ScheduleRow( val dateFormat = remember(locale) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) } - val timeFormat = remember(locale) { - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) + val use24Hour = LocalUse24HourFormat.current + val timeFormat = remember(locale, use24Hour) { + timeOfDayFormatter(use24Hour, locale) } // Tappable values read as links (primary), like the location on the // detail screen; errors flip them to the error colour. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 17d467f..c015173 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -54,6 +54,8 @@ import de.jeanlucmakiola.calendula.ui.common.GroupedRow import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.Position 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.positionOf import java.time.Instant as JavaInstant @@ -217,12 +219,11 @@ private fun searchSummary(event: EventInstance): String { val dateText = remember(locale) { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) }.format(start) + val use24Hour = LocalUse24HourFormat.current val timeText = if (event.isAllDay) { stringResource(R.string.event_detail_all_day) } else { - remember(locale) { - DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) - }.format(start) + remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start) } val base = "$dateText · $timeText" return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index 21a8f7a..fc1447e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -39,6 +39,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BugReport 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.ExpandLess import androidx.compose.material.icons.filled.ExpandMore @@ -47,6 +48,7 @@ import androidx.compose.material.icons.filled.Gavel import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Translate import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon @@ -84,6 +86,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride 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.domain.EventFormField import de.jeanlucmakiola.calendula.qs.NewEventTileService @@ -92,6 +95,8 @@ import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit 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.GroupedRow import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS @@ -105,7 +110,12 @@ import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert import de.jeanlucmakiola.calendula.ui.common.positionOf import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel 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 java.time.format.TextStyle as JavaTextStyle import java.util.Calendar /** The settings sub-screens reached from the hub's category rows. */ @@ -214,6 +224,12 @@ private fun SettingsHub( onClick = onManageCalendars, ) 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) AppVersionText() @@ -255,6 +271,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 private fun LanguageRow(position: Position) { val context = LocalContext.current @@ -285,6 +319,18 @@ private fun LanguageRow(position: Position) { AppLanguage.apply(it) }, 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 +470,22 @@ private fun AppearanceScreen( ) { var showTheme by remember { mutableStateOf(false) } var showWeekStart by remember { mutableStateOf(false) } + var showTimeFormat by remember { mutableStateOf(false) } var showDefaultView by remember { mutableStateOf(false) } + var showAgendaScreenRange by remember { mutableStateOf(false) } + var showAgendaWidgetRange by remember { mutableStateOf(false) } CollapsingScaffold( title = stringResource(R.string.settings_section_appearance), onBack = onBack, ) { + // Theme & colour GroupedRow( title = stringResource(R.string.settings_theme), summary = themeLabel(state.themeMode), position = Position.Top, onClick = { showTheme = true }, ) - GroupedRow( - title = stringResource(R.string.settings_default_view), - summary = stringResource(state.defaultView.labelRes), - position = Position.Middle, - onClick = { showDefaultView = true }, - ) GroupedRow( title = stringResource(R.string.settings_dynamic_color), summary = if (state.dynamicColorAvailable) { @@ -449,7 +493,7 @@ private fun AppearanceScreen( } else { stringResource(R.string.settings_dynamic_color_unavailable) }, - position = Position.Middle, + position = Position.Bottom, trailing = { Switch( checked = state.dynamicColor, @@ -463,12 +507,68 @@ private fun AppearanceScreen( 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( title = stringResource(R.string.settings_week_start), summary = weekStartLabel(state.weekStart), - position = Position.Bottom, + position = Position.Middle, 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.Bottom, + trailing = { + Switch( + checked = state.showHourLines, + onCheckedChange = viewModel::setShowHourLines, + ) + }, + onClick = { viewModel.setShowHourLines(!state.showHourLines) }, + ) + + Spacer(Modifier.height(16.dp)) + + // Agenda + GroupedRow( + title = stringResource(R.string.settings_agenda_range), + summary = agendaRangeLabel(state.agendaScreenRange), + position = Position.Top, + onClick = { showAgendaScreenRange = 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) { @@ -484,13 +584,41 @@ private fun AppearanceScreen( if (showWeekStart) { OptionPicker( title = stringResource(R.string.settings_week_start), - options = WeekStartPref.entries, + options = WEEK_START_OPTIONS, selected = state.weekStart, label = { weekStartLabel(it) }, onSelect = viewModel::setWeekStart, 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 (showDefaultView) { OptionPicker( title = stringResource(R.string.settings_default_view), @@ -524,8 +652,17 @@ private fun EventFormScreen( fields.forEachIndexed { index, field -> val checked = field in state.defaultFormFields GroupedRow( - title = stringResource(formFieldLabel(field)), + title = stringResource(eventFormFieldLabel(field)), 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 = { Switch( checked = checked, @@ -556,20 +693,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 +744,7 @@ private fun NotificationsScreen( var showAllDayReminderTime by remember { mutableStateOf(false) } var showSnooze by remember { mutableStateOf(false) } var overrideDialog by remember { mutableStateOf(null) } + var calendarSectionExpanded by remember { mutableStateOf(false) } var expandedCalendars by remember { mutableStateOf(emptySet()) } CollapsingScaffold( @@ -658,69 +782,11 @@ private fun NotificationsScreen( onClick = { showAllDayReminderTime = true }, ) - // Per-calendar overrides: each writable calendar may keep, drop, or - // replace the global default — separately for timed and all-day events. - if (state.writableCalendars.isNotEmpty()) { - Spacer(Modifier.height(24.dp)) - Text( - 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. + // Delivery reliability + snooze: both are global reminder-delivery + // settings, so they sit with the defaults above rather than below the + // long per-calendar list. Reliability is a soft, optional battery- + // optimisation exemption (system-settings deep-link, no special + // permission); shown as live status, reversible at any time. Spacer(Modifier.height(24.dp)) val batteryExempt = rememberBatteryOptimizationExempt() GroupedRow( @@ -730,7 +796,7 @@ private fun NotificationsScreen( } else { stringResource(R.string.settings_reliable_delivery_hint) }, - position = Position.Alone, + position = Position.Top, trailing = if (batteryExempt) { { Icon( @@ -746,13 +812,88 @@ private fun NotificationsScreen( ) // Snooze: how long the notification's "Snooze" action defers a reminder. - Spacer(Modifier.height(24.dp)) GroupedRow( title = stringResource(R.string.settings_snooze_duration), summary = snoozeDurationLabel(state.snoozeMinutes), - position = Position.Alone, + position = Position.Bottom, 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) { @@ -973,17 +1114,6 @@ private fun openUrl(context: Context, url: String) { 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 private fun themeLabel(mode: ThemeMode): String = stringResource( when (mode) { @@ -993,12 +1123,25 @@ private fun themeLabel(mode: ThemeMode): String = stringResource( }, ) +/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */ +private val WEEK_START_OPTIONS: List = + listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) } + @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) { - WeekStartPref.AUTO -> R.string.settings_week_start_auto - WeekStartPref.MONDAY -> R.string.settings_week_start_monday - WeekStartPref.SUNDAY -> R.string.settings_week_start_sunday + TimeFormatPref.AUTO -> R.string.settings_time_format_auto + TimeFormatPref.TWELVE_HOUR -> R.string.settings_time_format_12h + TimeFormatPref.TWENTY_FOUR_HOUR -> R.string.settings_time_format_24h }, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index 1dac3bb..0507e6a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -2,9 +2,11 @@ package de.jeanlucmakiola.calendula.ui.settings import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs 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.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import de.jeanlucmakiola.calendula.ui.common.CalendarView /** @@ -17,7 +19,17 @@ data class SettingsUiState( val themeMode: ThemeMode = ThemeMode.SYSTEM, val dynamicColor: 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 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). */ val defaultView: CalendarView = CalendarView.Week, /** Optional event-form fields shown by default (rest behind "more fields"). */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index 45a3160..b9516a6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -1,17 +1,28 @@ package de.jeanlucmakiola.calendula.ui.settings +import android.content.Context 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.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs 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.domain.CalendarSource 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.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.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -20,12 +31,15 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val prefs: SettingsPrefs, repository: CalendarRepository, + @ApplicationContext private val appContext: Context, ) : ViewModel() { private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S @@ -73,10 +87,24 @@ class SettingsViewModel @Inject constructor( ) { overrides, allDayOverrides, calendars -> ReminderOverrides(overrides, allDayOverrides, calendars) }, - prefs.defaultView, - ) { base, defaults, overrides, defaultView -> + combine( + prefs.defaultView, + prefs.agendaScreenRange, + prefs.agendaWidgetRange, + prefs.timeFormat, + prefs.showHourLines, + ) { view, screenRange, widgetRange, timeFormat, showHourLines -> + ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines) + }, + prefs.agendaShowRangeBar, + ) { base, defaults, overrides, views, showRangeBar -> base.copy( - defaultView = defaultView, + defaultView = views.defaultView, + agendaScreenRange = views.agendaScreenRange, + agendaWidgetRange = views.agendaWidgetRange, + timeFormat = views.timeFormat, + showHourLines = views.showHourLines, + agendaShowRangeBar = showRangeBar, allowColorOnUnsupportedCalendars = defaults.allowColor, defaultReminderMinutes = defaults.defaultReminder, defaultAllDayReminderMinutes = defaults.allDayReminder, @@ -92,6 +120,12 @@ class SettingsViewModel @Inject constructor( 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( val allowColor: Boolean, val defaultReminder: Int?, @@ -106,6 +140,14 @@ class SettingsViewModel @Inject constructor( val calendars: List, ) + private data class ViewSettings( + val defaultView: CalendarView, + val agendaScreenRange: AgendaRange, + val agendaWidgetRange: AgendaRange, + val timeFormat: TimeFormatPref, + val showHourLines: Boolean, + ) + fun setThemeMode(mode: ThemeMode) { viewModelScope.launch { prefs.setThemeMode(mode) } } @@ -115,7 +157,48 @@ class SettingsViewModel @Inject constructor( } 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 setDefaultView(view: CalendarView) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 8d9a311..63bbe74 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -83,6 +83,11 @@ import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion 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.next import de.jeanlucmakiola.calendula.ui.common.pastelize @@ -572,6 +577,8 @@ private fun Timeline( ) { val totalHeight = HOUR_HEIGHT * 24 val dark = isSystemInDarkTheme() + val use24Hour = LocalUse24HourFormat.current + val locale = currentLocale() Box(modifier = Modifier.fillMaxSize()) { // Gutter and day columns are two scroll viewports that SHARE one scroll @@ -595,7 +602,7 @@ private fun Timeline( ) { if (h > 0) { Text( - text = "%02d".format(h), + text = formatHourLabel(h, use24Hour, locale), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier @@ -650,6 +657,8 @@ private fun DayColumnCard( modifier: Modifier = Modifier, ) { val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() } + val showHourLines = LocalShowHourLines.current + val hourLineColor = MaterialTheme.colorScheme.outlineVariant Card( // Plain rectangular columns — the soft corners come from the outer // rounded scroll viewport, so inner rounding would look odd at the edges. @@ -662,6 +671,9 @@ private fun DayColumnCard( BoxWithConstraints( modifier = Modifier .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 // blocks are consumed by their own handler first. Snaps to hour. .pointerInput(date) { @@ -704,7 +716,10 @@ private fun EventBlock( modifier: Modifier = Modifier, ) { 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 Box( modifier = modifier @@ -773,8 +788,8 @@ private fun WeekLoading() { } } -private fun minToHm(min: Int): String = - if (min >= MINUTES_PER_DAY) "24:00" else "%02d:%02d".format(min / 60, min % 60) +private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): String = + formatMinuteOfDay(min, is24Hour, locale) private fun formatWeekRange(weekStart: LocalDate): String { val locale = Locale.getDefault() diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt index 854a5e0..468939b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/WidgetData.kt @@ -4,9 +4,11 @@ import android.Manifest import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat +import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay import de.jeanlucmakiola.calendula.domain.EventInstance 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.groupAgendaDays import kotlinx.coroutines.flow.first @@ -23,9 +25,6 @@ import kotlinx.datetime.toLocalDateTime import java.util.Locale import kotlin.time.Clock -/** 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 * is chosen reactively in the composition, so one wide read covers ~13 months of @@ -46,7 +45,22 @@ internal fun Context.hasCalendarPermission(): Boolean = sealed interface AgendaWidgetData { /** Calendar permission not granted — the widget can't read events. */ data object NeedsPermission : AgendaWidgetData - data class Ready(val today: LocalDate, val days: List) : 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. + */ + val days: List, + /** 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, + ) : AgendaWidgetData } /** @@ -91,9 +105,24 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData { if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission val zone = systemZone() val anchor = today(zone) - val repo = widgetEntryPoint().calendarRepository() - val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first() - return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone)) + val ep = widgetEntryPoint() + val prefs = ep.settingsPrefs() + val savedRange = prefs.agendaWidgetRange.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, + ) } /** One-shot wide read backing the month widget's grid for any nearby month. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt index 036ac13..4d5c77d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/widget/agenda/AgendaWidget.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.glance.ColorFilter import androidx.glance.GlanceId import androidx.glance.GlanceModifier @@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.updateAll import androidx.glance.background +import androidx.glance.currentState +import androidx.glance.state.PreferencesGlanceStateDefinition import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column @@ -41,7 +44,11 @@ import androidx.glance.text.TextStyle import de.jeanlucmakiola.calendula.MainActivity import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +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.formatTimeOfDay import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.widget.AgendaWidgetData import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme @@ -62,8 +69,20 @@ import java.util.Locale * of events grouped under day headers (the Google "Schedule" widget model). * 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") + class AgendaWidget : GlanceAppWidget() { + override val stateDefinition = PreferencesGlanceStateDefinition + override suspend fun provideGlance(context: Context, id: GlanceId) { val data = context.loadAgendaWidgetData() val dark = (context.resources.configuration.uiMode and @@ -101,12 +120,21 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { Spacer(GlanceModifier.height(4.dp)) when (data) { AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission) - is AgendaWidgetData.Ready -> - if (data.days.isEmpty()) { + is AgendaWidgetData.Ready -> { + // 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 rangeEnd = data.today.plus( + range.dayCount(data.today, data.weekStart) - 1, + DateTimeUnit.DAY, + ) + val visibleDays = data.days.filter { it.date <= rangeEnd } + if (visibleDays.isEmpty()) { WidgetMessage(R.string.agenda_empty_title) } else { val rows = buildList { - data.days.forEach { day -> + visibleDays.forEach { day -> add(AgendaRow.Header(day.date, data.today)) day.events.forEach { add(AgendaRow.Event(it)) } } @@ -115,11 +143,12 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) { items(rows.size) { index -> when (val row = rows[index]) { is AgendaRow.Header -> DayHeaderRow(row.date, row.today) - is AgendaRow.Event -> EventRow(row.event, dark) + is AgendaRow.Event -> EventRow(row.event, dark, data.is24Hour) } } } } + } } } } @@ -195,7 +224,7 @@ private fun DayHeaderRow(date: LocalDate, today: LocalDate) { } @Composable -private fun EventRow(event: EventInstance, dark: Boolean) { +private fun EventRow(event: EventInstance, dark: Boolean, is24Hour: Boolean) { val context = androidx.glance.LocalContext.current val title = event.title.ifBlank { context.getString(R.string.event_untitled) } Row( @@ -230,7 +259,7 @@ private fun EventRow(event: EventInstance, dark: Boolean) { style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp), ) Text( - text = eventTimeSummary(context, event), + text = eventTimeSummary(context, event, is24Hour), maxLines = 1, style = TextStyle(color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp), ) @@ -269,17 +298,17 @@ private fun agendaDayLabel(context: Context, date: LocalDate, today: LocalDate): 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) { context.getString(R.string.event_detail_all_day) } else { - "${formatTime(event.start)} – ${formatTime(event.end)}" + "${formatTime(event.start, is24Hour)} – ${formatTime(event.end, is24Hour)}" } val location = event.location?.takeIf { it.isNotBlank() } 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 - return "%02d:%02d".format(t.hour, t.minute) + return formatTimeOfDay(t.hour, t.minute, is24Hour, Locale.getDefault()) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index aa24a30..348ba61 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -230,8 +230,7 @@ Heute Heute Morgen - Nichts geplant - Anstehende Termine erscheinen hier. + Alles erledigt Suchen @@ -276,8 +275,31 @@ Erfordert Android 12 oder neuer Wochenstart Automatisch - Montag - Sonntag + Zeitformat + Automatisch + 12-Stunden (2:00 PM) + 24-Stunden (14:00) + Stundenlinien + Trennlinie zu jeder vollen Stunde in Wochen- und Tagesansicht anzeigen + Agenda-Zeitraum + Wie weit im Voraus die Agenda-Ansicht Termine anzeigt. + Agenda-Widget-Zeitraum + Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt. + Zeitraum-Leiste + Eine Leiste oben in der Agenda anzeigen, die den dargestellten Zeitraum benennt, mit einer Schaltfläche zum Wechseln des Zeitraums für die Sitzung + Heute + Diese Woche + Dieser Monat + Nächste 7 Tage + Nächste 30 Tage + Benutzerdefiniert… + Tage + Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt. + Alle anstehenden Termine für + + %d Tag + %d Tage + Termin-Formular Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\" Farben auf nicht unterstützten Kalendern erlauben diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 644286f..bf0b417 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -197,6 +197,14 @@ %d hour %d hours + + %d day + %d days + + + %d week + %d weeks + (No title) @@ -231,8 +239,7 @@ Today Today Tomorrow - Nothing scheduled - Upcoming events will show up here. + You\'re all caught up Search @@ -269,8 +276,31 @@ Requires Android 12 or newer Week starts on Automatic - Monday - Sunday + Time format + Automatic + 12-hour (2:00 PM) + 24-hour (14:00) + Hour lines + Show a separator line at each hour in week and day view + Agenda range + How far ahead the Agenda screen lists events. + Agenda widget range + How far ahead the agenda home-screen widget lists events. + Range bar + Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session + Today + This week + This month + Next 7 days + Next 30 days + Custom… + Days + Just for now — resets to your saved range when you reopen Calendula. + Showing all upcoming events for + + %d day + %d days + New event form Fields shown by default — everything else sits behind \"More fields\" Allow colors on unsupported calendars @@ -287,6 +317,7 @@ Amount Custom (%1$s) Set + Per-calendar reminders Override the default per calendar — separately for timed and all-day events. A calendar can keep the default, drop it, or set its own. Default (%1$s) Reliable delivery @@ -299,6 +330,8 @@ Language App language System default + Help translate + Add or improve a language on Weblate Theme, default view, week start Default fields for new events @@ -323,7 +356,10 @@ Show \"%1$s\" in the app Synced calendars These come from accounts on your device. Create and edit them in their own app. - Manage + Manage in app + More options for %1$s + Enable all + Disable all Add account New calendar Edit calendar @@ -337,6 +373,26 @@ Backup Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy. Export as .ics file + Automatic backup + Periodically export your local calendars to a folder as an .ics file. + Backup folder + Tap to choose a folder + Interval + Every %1$s + Minimum 30 minutes. + No automatic backup yet + Last backup: %1$s + Last backup failed: %1$s + Backup + Warns if automatic backups fail repeatedly. + Automatic backup failed + Calendula couldn\'t write the backup file. Check the backup folder in Settings. + + Minutes + Hours + Days + Weeks + Couldn\'t export the backup. Exported %d event. @@ -382,6 +438,7 @@ https://gitea.jeanlucmakiola.de/makiolaj/calendula https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE https://ko-fi.com/jeanlucmakiola + https://weblate.dev.jeanlucmakiola.de/engage/calendula/ diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index dee5554..012b75d 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.domain.EventFormField +import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -25,7 +26,7 @@ class SettingsPrefsTest { val prefs = SettingsPrefs(newDataStore(tempDir)) assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM) assertThat(prefs.dynamicColor.first()).isTrue() - assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.AUTO) + assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.Auto) } @Test @@ -43,10 +44,62 @@ class SettingsPrefsTest { } @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)) - prefs.setWeekStart(WeekStartPref.SUNDAY) - assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY) + prefs.setWeekStart(WeekStartPref.Day(DayOfWeek.SATURDAY)) + 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 `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 @@ -252,13 +305,15 @@ class SettingsPrefsTest { @Test fun `explicit week-start prefs resolve regardless of locale`() { - assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY) - assertThat(WeekStartPref.SUNDAY.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.SUNDAY) + assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US)) + .isEqualTo(DayOfWeek.MONDAY) + assertThat(WeekStartPref.Day(DayOfWeek.SATURDAY).resolveFirstDay(Locale.GERMANY)) + .isEqualTo(DayOfWeek.SATURDAY) } @Test fun `auto week start follows the locale convention`() { - assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY) - assertThat(WeekStartPref.AUTO.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY) + assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.GERMANY)).isEqualTo(DayOfWeek.MONDAY) + assertThat(WeekStartPref.Auto.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.SUNDAY) } } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt index e17fa80..4d3f6c6 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/reminders/ReminderTimeTextTest.kt @@ -26,10 +26,24 @@ class ReminderTimeTextTest { isAllDay = false, zone = berlin, locale = Locale.GERMANY, + is24Hour = true, ) 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 fun `timed event crossing midnight includes both dates`() { val text = reminderTimeText( @@ -38,6 +52,7 @@ class ReminderTimeTextTest { isAllDay = false, zone = berlin, locale = Locale.GERMANY, + is24Hour = true, ) assertThat(text).contains("11.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. zone = berlin, locale = Locale.GERMANY, + is24Hour = true, ) assertThat(text).isEqualTo("11.06.2026") } @@ -66,6 +82,7 @@ class ReminderTimeTextTest { isAllDay = true, zone = berlin, locale = Locale.GERMANY, + is24Hour = true, ) assertThat(text).isEqualTo("11.06.2026 – 12.06.2026") } @@ -79,6 +96,7 @@ class ReminderTimeTextTest { isAllDay = true, zone = berlin, locale = Locale.GERMANY, + is24Hour = true, ) assertThat(text).isEqualTo("11.06.2026") } diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt new file mode 100644 index 0000000..be030b1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaRangeTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt new file mode 100644 index 0000000..4aca580 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/TimeFormatTest.kt @@ -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") + } +} diff --git a/fastlane/metadata/android/en-US/changelogs/21100.txt b/fastlane/metadata/android/en-US/changelogs/21100.txt new file mode 100644 index 0000000..a814fd3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21100.txt @@ -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. + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0aee61f..6b13d4f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,10 +26,14 @@ lifecycleCompose = "2.10.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" +work = "2.10.1" +documentfile = "1.0.1" [libraries] # AndroidX core 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-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" }