Compare commits
9 Commits
a6ae4d7e1d
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
| b6a45b7264 | |||
| c7ef52e426 | |||
| b1151b6516 | |||
| 1664625bc5 | |||
| e8b87446f7 | |||
| dc478516cd | |||
| 8945b877b2 | |||
| 1424f0ffc0 | |||
| 548e581554 |
34
CHANGELOG.md
34
CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.11.1] — 2026-06-28
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Calendula can now be set as your default calendar app. It registers the
|
||||||
|
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||||
|
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||||
|
that day. Android has no way for an app to make itself the default, so you pick
|
||||||
|
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||||
|
|
||||||
## [2.11.0] — 2026-06-27
|
## [2.11.0] — 2026-06-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -30,10 +39,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
top naming the exact dates in view, with a button to switch the range just for
|
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
|
the current session (it resets when you reopen the app); the bar can be turned
|
||||||
off. Thanks to @zmaherdev for the suggestion ([#4]).
|
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
|
### Changed
|
||||||
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
||||||
agenda), and the agenda's empty state now reads "You're all caught up".
|
agenda), and the agenda's empty state now reads "You're all caught up".
|
||||||
|
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
|
||||||
|
into collapsible, source-branded cards — each account shows its app's icon —
|
||||||
|
with a per-account menu to enable or disable all of its calendars at once or
|
||||||
|
open the account in its source app.
|
||||||
|
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
|
||||||
|
moved up with the other global reminder options, and the per-calendar reminder
|
||||||
|
overrides now fold into a single expandable section.
|
||||||
|
- Moved **Add Quick Settings tile** out of the event-form section to its own
|
||||||
|
top-level entry in Settings.
|
||||||
|
|
||||||
## [2.10.0] — 2026-06-25
|
## [2.10.0] — 2026-06-25
|
||||||
|
|
||||||
@@ -661,3 +692,6 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#4]: https://codeberg.org/jlmakiola/calendula/issues/4
|
[#4]: https://codeberg.org/jlmakiola/calendula/issues/4
|
||||||
[#5]: https://codeberg.org/jlmakiola/calendula/issues/5
|
[#5]: https://codeberg.org/jlmakiola/calendula/issues/5
|
||||||
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
[#6]: https://codeberg.org/jlmakiola/calendula/issues/6
|
||||||
|
[#7]: https://codeberg.org/jlmakiola/calendula/issues/7
|
||||||
|
[#8]: https://codeberg.org/jlmakiola/calendula/issues/8
|
||||||
|
[#9]: https://codeberg.org/jlmakiola/calendula/issues/9
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -68,7 +68,8 @@ database, no sync stack reinvented.
|
|||||||
|
|
||||||
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
- Real Material 3 Expressive throughout — dynamic color (Android 12+),
|
||||||
expressive motion and shapes, light/dark theme
|
expressive motion and shapes, light/dark theme
|
||||||
- German and English UI, per-app language setting
|
- German and English UI, per-app language setting — and [open to community
|
||||||
|
translations](#-translations)
|
||||||
- **Zero telemetry, zero analytics, no internet permission** — your data
|
- **Zero telemetry, zero analytics, no internet permission** — your data
|
||||||
never leaves the device
|
never leaves the device
|
||||||
|
|
||||||
@@ -108,6 +109,17 @@ without reinstalling. Or build from source — see below.
|
|||||||
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
- **[Architecture](docs/ARCHITECTURE.md)** — the layered design and key pipelines
|
||||||
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
- **[Roadmap](.planning/ROADMAP.md)** — what's shipped and what's next
|
||||||
|
|
||||||
|
## 🌍 Translations
|
||||||
|
|
||||||
|
Calendula ships in German and English, and you're warmly invited to add your
|
||||||
|
language. Translations are managed on a self-hosted **Weblate**:
|
||||||
|
|
||||||
|
**→ [Help translate Calendula](https://weblate.dev.jeanlucmakiola.de/engage/calendula/)**
|
||||||
|
|
||||||
|
No coding needed — register on the Weblate server, pick (or request) a language,
|
||||||
|
and translate the strings in your browser. You can also reach this link in the
|
||||||
|
app from the top of **Settings → App language**.
|
||||||
|
|
||||||
## 📜 License
|
## 📜 License
|
||||||
|
|
||||||
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
[MIT](LICENSE) — Jean-Luc Makiola, 2026
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ android {
|
|||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||||
versionCode = 21100
|
versionCode = 21101
|
||||||
versionName = "2.11.0"
|
versionName = "2.11.1"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -152,6 +152,9 @@ dependencies {
|
|||||||
|
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
implementation(libs.androidx.documentfile)
|
||||||
|
|
||||||
implementation(libs.androidx.glance.appwidget)
|
implementation(libs.androidx.glance.appwidget)
|
||||||
implementation(libs.androidx.glance.material3)
|
implementation(libs.androidx.glance.material3)
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,35 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Be selectable as the system calendar app. Android has no API for
|
||||||
|
an app to make itself the default, so registering the filters
|
||||||
|
launchers and the OS use is what lets the user pick Calendula
|
||||||
|
from the system chooser when a date action fires (issue #9).
|
||||||
|
APP_CALENDAR is the "open the calendar app" action; the VIEW
|
||||||
|
filters below catch a tapped date. -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.APP_CALENDAR" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- A launcher/clock date tap fires ACTION_VIEW on the provider's
|
||||||
|
time Uri (content://com.android.calendar/time/<epochMillis>);
|
||||||
|
some surfaces use the time/epoch mime type. We open the day view
|
||||||
|
on that date (MainActivity.calendarTimeDateOrNull). -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data
|
||||||
|
android:scheme="content"
|
||||||
|
android:host="com.android.calendar"
|
||||||
|
android:pathPrefix="/time" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="time/epoch" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
<!-- Open a .ics file (file manager / email attachment / browser). -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
package de.jeanlucmakiola.calendula
|
package de.jeanlucmakiola.calendula
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Application entry point. Registered as android:name=".CalendulaApp"
|
* Application entry point. Registered as android:name=".CalendulaApp"
|
||||||
@@ -16,5 +24,24 @@ class CalendulaApp : Application() {
|
|||||||
// Install first thing so startup crashes are captured too (privacy-
|
// Install first thing so startup crashes are captured too (privacy-
|
||||||
// respecting, on-device; the user submits the report by hand).
|
// respecting, on-device; the user submits the report by hand).
|
||||||
CrashReporter.install(this)
|
CrashReporter.install(this)
|
||||||
|
reconcileAutoBackup()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring the scheduled auto-backup work back in line with the saved settings
|
||||||
|
* on every launch — re-arms it after a reinstall and, crucially, cancels any
|
||||||
|
* orphaned work once backup has been turned off.
|
||||||
|
*/
|
||||||
|
private fun reconcileAutoBackup() {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(this, BackupWorker.Deps::class.java)
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Default).launch {
|
||||||
|
val prefs = deps.settingsPrefs()
|
||||||
|
BackupScheduler.apply(
|
||||||
|
context = this@CalendulaApp,
|
||||||
|
enabled = prefs.autoBackupEnabled.first(),
|
||||||
|
intervalMinutes = prefs.autoBackupIntervalMinutes.first(),
|
||||||
|
hasFolder = prefs.autoBackupFolderUri.first() != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
|||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
@@ -156,10 +159,33 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
Intent.ACTION_SEND -> IntentCompat.getParcelableExtra(this, Intent.EXTRA_STREAM, Uri::class.java)
|
||||||
else -> null
|
else -> null
|
||||||
} ?: return null
|
} ?: return null
|
||||||
|
// The calendar "view time" Uri (a date tap) is also ACTION_VIEW/content;
|
||||||
|
// it's a navigation, not a file to import, so [navRequestOrNull] owns it.
|
||||||
|
if (uri.host == CALENDAR_PROVIDER_HOST) return null
|
||||||
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
return uri.takeIf { it.scheme == "content" || it.scheme == "file" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date a launcher/clock date tap points at, parsed from the AOSP calendar
|
||||||
|
* "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/
|
||||||
|
* <epochMillis>`. Null for any other intent. The matching manifest filter is
|
||||||
|
* what lets users pick Calendula from the system calendar chooser (issue #9).
|
||||||
|
*/
|
||||||
|
private fun Intent.calendarTimeDateOrNull(): LocalDate? {
|
||||||
|
if (action != Intent.ACTION_VIEW) return null
|
||||||
|
val uri = data ?: return null
|
||||||
|
if (uri.host != CALENDAR_PROVIDER_HOST) return null
|
||||||
|
val segments = uri.pathSegments
|
||||||
|
if (segments.firstOrNull() != "time") return null
|
||||||
|
val millis = segments.getOrNull(1)?.toLongOrNull() ?: return null
|
||||||
|
return Instant.fromEpochMilliseconds(millis)
|
||||||
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
|
}
|
||||||
|
|
||||||
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
private fun Intent.navRequestOrNull(): WidgetNavRequest? {
|
||||||
|
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||||
|
// the day view rooted over the default home view (OpenDate source = null).
|
||||||
|
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||||
val source = sourceViewOrNull()
|
val source = sourceViewOrNull()
|
||||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||||
return when {
|
return when {
|
||||||
@@ -201,6 +227,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
// The calendar provider's authority/host. A date tap arrives as
|
||||||
|
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.
|
||||||
|
private const val CALENDAR_PROVIDER_HOST = "com.android.calendar"
|
||||||
|
|
||||||
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
private const val EXTRA_EVENT_ID = "de.jeanlucmakiola.calendula.extra.EVENT_ID"
|
||||||
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
private const val EXTRA_BEGIN_MILLIS = "de.jeanlucmakiola.calendula.extra.BEGIN"
|
||||||
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
private const val EXTRA_END_MILLIS = "de.jeanlucmakiola.calendula.extra.END"
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.data.backup
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules and runs the automatic periodic `.ics` backup of local calendars
|
||||||
|
* (issue #8). One-way export only — it mirrors the manual "Back up" into a
|
||||||
|
* user-chosen folder on an interval; it is not a sync. Stays INTERNET-free:
|
||||||
|
* everything is local provider reads + a local file write.
|
||||||
|
*/
|
||||||
|
object BackupScheduler {
|
||||||
|
|
||||||
|
private const val WORK_NAME = "auto-backup"
|
||||||
|
private const val WORK_NAME_NOW = "auto-backup-now"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the scheduled work with the current settings: enqueue a unique
|
||||||
|
* periodic request when backup is on and a folder is set, otherwise cancel
|
||||||
|
* it. WorkManager persists the request across reboots on its own.
|
||||||
|
*
|
||||||
|
* The first run is delayed by one interval so it never overlaps an immediate
|
||||||
|
* [runNow] (two simultaneous writes would race and leave a "(1)" duplicate).
|
||||||
|
*/
|
||||||
|
fun apply(context: Context, enabled: Boolean, intervalMinutes: Long, hasFolder: Boolean) {
|
||||||
|
val workManager = WorkManager.getInstance(context)
|
||||||
|
if (!enabled || !hasFolder) {
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME)
|
||||||
|
// Also cancel any pending immediate run — otherwise a failing run-now
|
||||||
|
// keeps retrying after the user has turned backup off.
|
||||||
|
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// WorkManager's own floor is 15 min; the UI floors the user choice at 30.
|
||||||
|
val interval = intervalMinutes.coerceAtLeast(15L)
|
||||||
|
val request = PeriodicWorkRequestBuilder<BackupWorker>(interval, TimeUnit.MINUTES)
|
||||||
|
.setInitialDelay(interval, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one export immediately (e.g. right after enabling or changing the
|
||||||
|
* folder) for instant feedback. Unique + REPLACE so rapid taps coalesce into
|
||||||
|
* a single run rather than racing each other.
|
||||||
|
*/
|
||||||
|
fun runNow(context: Context) {
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
WORK_NAME_NOW,
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
OneTimeWorkRequestBuilder<BackupWorker>().build(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports the local calendars to `calendula-backup.ics` in the configured folder,
|
||||||
|
* overwriting the previous file. Pulls its collaborators through a Hilt
|
||||||
|
* [EntryPoint] so it works under WorkManager's default (no-arg) worker factory —
|
||||||
|
* no custom factory / Application wiring needed. Records the outcome for the
|
||||||
|
* settings status line, and notifies after repeated failures.
|
||||||
|
*/
|
||||||
|
class BackupWorker(
|
||||||
|
appContext: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface Deps {
|
||||||
|
fun settingsPrefs(): SettingsPrefs
|
||||||
|
fun repository(): CalendarRepository
|
||||||
|
fun exporter(): IcsExporter
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||||
|
val prefs = deps.settingsPrefs()
|
||||||
|
// Respect the toggle even for already-queued work: if backup was turned
|
||||||
|
// off, no-op (and don't retry) so a lingering run can't revive itself.
|
||||||
|
if (!prefs.autoBackupEnabled.first()) return Result.success()
|
||||||
|
val folder = prefs.autoBackupFolderUri.first()
|
||||||
|
?: return Result.failure() // nothing to write to — leave scheduling to settings
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
return try {
|
||||||
|
val events = deps.repository().exportEvents()
|
||||||
|
val content = IcsWriter().writeCalendar(events, Clock.System.now())
|
||||||
|
deps.exporter().writeToFolder(folder.toUri(), BACKUP_FILE_NAME, content)
|
||||||
|
prefs.recordAutoBackupRun(success = true, atMillis = now)
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
prefs.recordAutoBackupRun(success = false, atMillis = now)
|
||||||
|
if (prefs.autoBackupStatus.first().consecutiveFailures >= FAILURE_NOTIFY_THRESHOLD) {
|
||||||
|
notifyFailure(applicationContext)
|
||||||
|
}
|
||||||
|
Log.w(TAG, "Automatic backup failed", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyFailure(context: Context) {
|
||||||
|
val canPost = Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
if (!canPost) return
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.backup_channel_name),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { description = context.getString(R.string.backup_channel_description) },
|
||||||
|
)
|
||||||
|
val launch = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||||
|
val tap = launch?.let {
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
context, 0, it,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(context.getString(R.string.backup_failed_title))
|
||||||
|
.setContentText(context.getString(R.string.backup_failed_text))
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_ERROR)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.apply { tap?.let(::setContentIntent) }
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
manager.notify(NOTIFICATION_ID, notification)
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
Log.w(TAG, "Could not post backup-failure notification", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val BACKUP_FILE_NAME = "calendula-backup.ics"
|
||||||
|
private const val FAILURE_NOTIFY_THRESHOLD = 2
|
||||||
|
private const val CHANNEL_ID = "backup"
|
||||||
|
private const val NOTIFICATION_ID = 2
|
||||||
|
private const val TAG = "BackupWorker"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.ics
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -28,6 +29,36 @@ class IcsExporter @Inject constructor(
|
|||||||
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
} ?: throw IOException("Could not open output stream for export (scheme=${uri.scheme})")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write [content] to [fileName] inside the persisted SAF tree [folder],
|
||||||
|
* overwriting it if it already exists (the automatic-backup destination).
|
||||||
|
* Requires a persisted write grant on [folder]. Throws on failure.
|
||||||
|
*/
|
||||||
|
fun writeToFolder(folder: Uri, fileName: String, content: String) {
|
||||||
|
val dir = DocumentFile.fromTreeUri(context, folder)
|
||||||
|
?: throw IOException("Backup folder is not accessible")
|
||||||
|
if (!dir.exists() || !dir.canWrite()) {
|
||||||
|
throw IOException("Backup folder is missing or not writable")
|
||||||
|
}
|
||||||
|
// Reuse the canonical file if present and clean up any "name (1).ics"
|
||||||
|
// duplicates SAF may have created if two runs ever raced — so we always
|
||||||
|
// converge on a single overwritten file.
|
||||||
|
val base = fileName.substringBeforeLast('.')
|
||||||
|
val ext = fileName.substringAfterLast('.', "")
|
||||||
|
var target: DocumentFile? = null
|
||||||
|
for (child in dir.listFiles()) {
|
||||||
|
val name = child.name ?: continue
|
||||||
|
when {
|
||||||
|
name == fileName -> target = child
|
||||||
|
name.startsWith("$base (") && name.endsWith(".$ext") -> child.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val file = target
|
||||||
|
?: dir.createFile(MIME_CALENDAR, fileName)
|
||||||
|
?: throw IOException("Could not create backup file in the chosen folder")
|
||||||
|
writeDocument(file.uri, content)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stage [content] in a private cache file and return a shareable content
|
* Stage [content] in a private cache file and return a shareable content
|
||||||
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
* Uri for an `ACTION_SEND`. [fileName] is the suggested `.ics` name shown to
|
||||||
@@ -42,5 +73,6 @@ class IcsExporter @Inject constructor(
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val SHARE_DIR = "shared_ics"
|
const val SHARE_DIR = "shared_ics"
|
||||||
|
const val MIME_CALENDAR = "text/calendar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
@@ -327,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<Boolean> = store.data.map { it[AUTO_BACKUP_ENABLED_KEY] ?: false }
|
||||||
|
|
||||||
|
suspend fun setAutoBackupEnabled(enabled: Boolean) {
|
||||||
|
store.edit { it[AUTO_BACKUP_ENABLED_KEY] = enabled }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export interval in minutes. Default daily; floored at [MIN_BACKUP_INTERVAL]. */
|
||||||
|
val autoBackupIntervalMinutes: Flow<Long> = store.data.map { prefs ->
|
||||||
|
(prefs[AUTO_BACKUP_INTERVAL_KEY] ?: DEFAULT_BACKUP_INTERVAL).coerceAtLeast(MIN_BACKUP_INTERVAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAutoBackupIntervalMinutes(minutes: Long) {
|
||||||
|
store.edit { it[AUTO_BACKUP_INTERVAL_KEY] = minutes.coerceAtLeast(MIN_BACKUP_INTERVAL) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persisted SAF tree Uri of the destination folder, or null if unset. */
|
||||||
|
val autoBackupFolderUri: Flow<String?> = store.data.map { it[AUTO_BACKUP_FOLDER_KEY] }
|
||||||
|
|
||||||
|
suspend fun setAutoBackupFolderUri(uri: String?) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
if (uri == null) prefs.remove(AUTO_BACKUP_FOLDER_KEY) else prefs[AUTO_BACKUP_FOLDER_KEY] = uri
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outcome of the last automatic run, for the settings status line. */
|
||||||
|
val autoBackupStatus: Flow<BackupStatus> = store.data.map { prefs ->
|
||||||
|
BackupStatus(
|
||||||
|
lastRun = prefs[AUTO_BACKUP_LAST_RUN_KEY] ?: 0L,
|
||||||
|
lastSuccess = prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] ?: true,
|
||||||
|
consecutiveFailures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record an automatic run's outcome; resets the failure streak on success. */
|
||||||
|
suspend fun recordAutoBackupRun(success: Boolean, atMillis: Long) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[AUTO_BACKUP_LAST_RUN_KEY] = atMillis
|
||||||
|
prefs[AUTO_BACKUP_LAST_SUCCESS_KEY] = success
|
||||||
|
val failures = prefs[AUTO_BACKUP_FAILURES_KEY] ?: 0
|
||||||
|
prefs[AUTO_BACKUP_FAILURES_KEY] = if (success) 0 else failures + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
|
||||||
null -> DEFAULT_FORM_FIELDS
|
null -> DEFAULT_FORM_FIELDS
|
||||||
else -> stored.split(',')
|
else -> stored.split(',')
|
||||||
@@ -366,9 +413,27 @@ class SettingsPrefs @Inject constructor(
|
|||||||
stringPreferencesKey("per_calendar_allday_reminder_override")
|
stringPreferencesKey("per_calendar_allday_reminder_override")
|
||||||
internal val DEFAULT_FORM_FIELDS =
|
internal val DEFAULT_FORM_FIELDS =
|
||||||
setOf(EventFormField.Location, EventFormField.Description)
|
setOf(EventFormField.Location, EventFormField.Description)
|
||||||
|
internal val AUTO_BACKUP_ENABLED_KEY = booleanPreferencesKey("auto_backup_enabled")
|
||||||
|
internal val AUTO_BACKUP_INTERVAL_KEY = longPreferencesKey("auto_backup_interval_minutes")
|
||||||
|
internal val AUTO_BACKUP_FOLDER_KEY = stringPreferencesKey("auto_backup_folder_uri")
|
||||||
|
internal val AUTO_BACKUP_LAST_RUN_KEY = longPreferencesKey("auto_backup_last_run")
|
||||||
|
internal val AUTO_BACKUP_LAST_SUCCESS_KEY = booleanPreferencesKey("auto_backup_last_success")
|
||||||
|
internal val AUTO_BACKUP_FAILURES_KEY = intPreferencesKey("auto_backup_failures")
|
||||||
|
/** Default automatic-backup interval: daily. */
|
||||||
|
const val DEFAULT_BACKUP_INTERVAL = 1_440L
|
||||||
|
/** Floor for the automatic-backup interval (also above WorkManager's 15-min limit). */
|
||||||
|
const val MIN_BACKUP_INTERVAL = 30L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot of the automatic backup's last outcome (see [SettingsPrefs.autoBackupStatus]). */
|
||||||
|
data class BackupStatus(
|
||||||
|
/** Epoch millis of the last run, or 0 if it has never run. */
|
||||||
|
val lastRun: Long,
|
||||||
|
val lastSuccess: Boolean,
|
||||||
|
val consecutiveFailures: Int,
|
||||||
|
)
|
||||||
|
|
||||||
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||||
sealed interface CalendarReminderOverride {
|
sealed interface CalendarReminderOverride {
|
||||||
/** No override — the calendar uses the global default. */
|
/** No override — the calendar uses the global default. */
|
||||||
|
|||||||
@@ -196,7 +196,9 @@ fun CalendarHost(
|
|||||||
dismissCoveringOverlays()
|
dismissCoveringOverlays()
|
||||||
createDateIso = null
|
createDateIso = null
|
||||||
pendingDayIso = req.dateIso
|
pendingDayIso = req.dateIso
|
||||||
viewStack = viewBaseStack(defaultView, req.source).drillToDay()
|
// No widget source (an external date tap) roots over the default
|
||||||
|
// home view, so backing out of the day returns home then exits.
|
||||||
|
viewStack = viewBaseStack(defaultView, req.source ?: defaultView).drillToDay()
|
||||||
onWidgetNavConsumed()
|
onWidgetNavConsumed()
|
||||||
}
|
}
|
||||||
is WidgetNavRequest.OpenEvent -> {
|
is WidgetNavRequest.OpenEvent -> {
|
||||||
|
|||||||
@@ -13,8 +13,13 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
* detail-key channel and leave the base view untouched.)
|
* detail-key channel and leave the base view untouched.)
|
||||||
*/
|
*/
|
||||||
sealed interface WidgetNavRequest {
|
sealed interface WidgetNavRequest {
|
||||||
/** Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over [source]. */
|
/**
|
||||||
data class OpenDate(val dateIso: String, val source: CalendarView) : WidgetNavRequest
|
* Open the day view anchored on [dateIso] (an ISO `yyyy-MM-dd` date), over
|
||||||
|
* [source]. A null [source] means the request came from outside the app (a
|
||||||
|
* launcher/clock date tap, issue #9) rather than a widget, so it roots over
|
||||||
|
* the default home view instead of a widget's view.
|
||||||
|
*/
|
||||||
|
data class OpenDate(val dateIso: String, val source: CalendarView?) : WidgetNavRequest
|
||||||
|
|
||||||
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
/** Open one occurrence's detail (an agenda-widget event tap), over [source]. */
|
||||||
data class OpenEvent(
|
data class OpenEvent(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.accounts.AccountManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.text.format.DateUtils
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
@@ -39,6 +40,7 @@ import androidx.compose.material.icons.filled.FileDownload
|
|||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.Schedule
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -76,18 +78,26 @@ import androidx.compose.ui.graphics.asImageBitmap
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.graphics.drawable.toBitmap
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||||
@@ -118,6 +128,7 @@ fun CalendarsScreen(
|
|||||||
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
val disabledIds by viewModel.disabledCalendarIds.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
|
||||||
// [editorSession] bumps on every open so the editor's field state resets for
|
// [editorSession] bumps on every open so the editor's field state resets for
|
||||||
@@ -158,6 +169,10 @@ fun CalendarsScreen(
|
|||||||
backupResult = backupResult,
|
backupResult = backupResult,
|
||||||
onExportBackup = viewModel::exportBackup,
|
onExportBackup = viewModel::exportBackup,
|
||||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||||
|
autoBackup = autoBackup,
|
||||||
|
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||||
|
onSetAutoBackupInterval = viewModel::setAutoBackupIntervalMinutes,
|
||||||
|
onSetAutoBackupFolder = viewModel::setAutoBackupFolder,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
onAdd = { editorSession++; editorId = NEW_CALENDAR_ID },
|
||||||
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
onEdit = { calendar -> editorSession++; editorId = calendar.id },
|
||||||
@@ -177,6 +192,10 @@ private fun CalendarsList(
|
|||||||
backupResult: BackupResult?,
|
backupResult: BackupResult?,
|
||||||
onExportBackup: (android.net.Uri) -> Unit,
|
onExportBackup: (android.net.Uri) -> Unit,
|
||||||
onConsumeBackupResult: () -> Unit,
|
onConsumeBackupResult: () -> Unit,
|
||||||
|
autoBackup: AutoBackupUiState,
|
||||||
|
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
||||||
|
onSetAutoBackupInterval: (Long) -> Unit,
|
||||||
|
onSetAutoBackupFolder: (android.net.Uri) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
onEdit: (CalendarSource) -> Unit,
|
onEdit: (CalendarSource) -> Unit,
|
||||||
@@ -204,6 +223,13 @@ private fun CalendarsList(
|
|||||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||||
) { uri -> uri?.let(onExportBackup) }
|
) { uri -> uri?.let(onExportBackup) }
|
||||||
|
|
||||||
|
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||||
|
// write grant so background runs can keep writing to it.
|
||||||
|
val pickFolder = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree(),
|
||||||
|
) { uri -> uri?.let(onSetAutoBackupFolder) }
|
||||||
|
var showInterval by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
val backupFailedText = stringResource(R.string.calendars_backup_failed)
|
||||||
LaunchedEffect(backupResult) {
|
LaunchedEffect(backupResult) {
|
||||||
when (val r = backupResult) {
|
when (val r = backupResult) {
|
||||||
@@ -280,14 +306,42 @@ private fun CalendarsList(
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||||
HintText(stringResource(R.string.calendars_backup_hint))
|
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(
|
GroupedRow(
|
||||||
title = stringResource(R.string.calendars_backup_action),
|
title = stringResource(R.string.calendars_backup_action),
|
||||||
position = Position.Alone,
|
position = Position.Top,
|
||||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||||
onClick = {
|
onClick = {
|
||||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
@@ -346,6 +400,14 @@ private fun CalendarsList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showInterval) {
|
||||||
|
BackupIntervalDialog(
|
||||||
|
currentMinutes = autoBackup.intervalMinutes,
|
||||||
|
onConfirm = onSetAutoBackupInterval,
|
||||||
|
onDismiss = { showInterval = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -760,6 +822,95 @@ private fun HintText(text: String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Readable name of the persisted backup folder, resolved from its tree Uri. */
|
||||||
|
@Composable
|
||||||
|
private fun rememberFolderName(uriString: String?): String? {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember(uriString) {
|
||||||
|
uriString?.let {
|
||||||
|
runCatching { DocumentFile.fromTreeUri(context, it.toUri())?.name }.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Every 30 minutes" / "Every 2 hours" / "Every day" — the interval in its largest whole unit. */
|
||||||
|
@Composable
|
||||||
|
private fun backupIntervalLabel(minutes: Long): String {
|
||||||
|
val duration = when {
|
||||||
|
minutes % MINUTES_PER_WEEK == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_weeks, (minutes / MINUTES_PER_WEEK).toInt(), (minutes / MINUTES_PER_WEEK).toInt())
|
||||||
|
minutes % MINUTES_PER_DAY == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_days, (minutes / MINUTES_PER_DAY).toInt(), (minutes / MINUTES_PER_DAY).toInt())
|
||||||
|
minutes % 60L == 0L ->
|
||||||
|
pluralStringResource(R.plurals.duration_hours, (minutes / 60L).toInt(), (minutes / 60L).toInt())
|
||||||
|
else ->
|
||||||
|
pluralStringResource(R.plurals.duration_minutes, minutes.toInt(), minutes.toInt())
|
||||||
|
}
|
||||||
|
return stringResource(R.string.calendars_auto_backup_every, duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Last backup: 5 minutes ago" / "… failed" / "No automatic backup yet". */
|
||||||
|
@Composable
|
||||||
|
private fun backupStatusText(status: BackupStatus): String {
|
||||||
|
if (status.lastRun == 0L) return stringResource(R.string.calendars_auto_backup_status_never)
|
||||||
|
val relative = DateUtils.getRelativeTimeSpanString(
|
||||||
|
status.lastRun, System.currentTimeMillis(), DateUtils.MINUTE_IN_MILLIS,
|
||||||
|
).toString()
|
||||||
|
return if (status.lastSuccess) {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_ok, relative)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.calendars_auto_backup_status_failed, relative)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amount + unit picker for the backup interval (floored at 30 minutes). */
|
||||||
|
@Composable
|
||||||
|
private fun BackupIntervalDialog(
|
||||||
|
currentMinutes: Long,
|
||||||
|
onConfirm: (Long) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// minutes-per-unit for each entry; pick the largest unit the current value divides into.
|
||||||
|
val unitMinutes = remember { listOf(1L, 60L, MINUTES_PER_DAY, MINUTES_PER_WEEK) }
|
||||||
|
val units = stringArrayResource(R.array.backup_interval_units).toList()
|
||||||
|
val initialUnit = unitMinutes.indexOfLast { currentMinutes % it == 0L }.coerceAtLeast(0)
|
||||||
|
var amount by rememberSaveable { mutableStateOf((currentMinutes / unitMinutes[initialUnit]).toString()) }
|
||||||
|
var unitIndex by rememberSaveable { mutableStateOf(initialUnit) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.calendars_auto_backup_interval)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DialogAmountField(value = amount, onValueChange = { amount = it }, placeholder = "1")
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
DialogUnitDropdown(label = units[unitIndex], entries = units) { unitIndex = it }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.calendars_auto_backup_interval_min),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val value = amount.toLongOrNull()?.coerceAtLeast(1L) ?: 1L
|
||||||
|
onConfirm((value * unitMinutes[unitIndex]).coerceAtLeast(SettingsPrefs.MIN_BACKUP_INTERVAL))
|
||||||
|
onDismiss()
|
||||||
|
}) { Text(stringResource(R.string.reminder_custom_set)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MINUTES_PER_DAY = 1_440L
|
||||||
|
private const val MINUTES_PER_WEEK = 10_080L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick the app to open for managing a synced calendar's account. The account's
|
* Pick the app to open for managing a synced calendar's account. The account's
|
||||||
* own authenticator package (resolved from [AccountManager], no permission
|
* own authenticator package (resolved from [AccountManager], no permission
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.calendars
|
package de.jeanlucmakiola.calendula.ui.calendars
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -16,6 +22,7 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -33,9 +40,11 @@ import javax.inject.Inject
|
|||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class CalendarsViewModel @Inject constructor(
|
class CalendarsViewModel @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
private val prefs: CalendarPrefs,
|
private val prefs: CalendarPrefs,
|
||||||
|
private val settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -63,6 +72,22 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = emptySet(),
|
initialValue = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Automatic-backup settings + last-run status, for the Backup section UI. */
|
||||||
|
val autoBackup: StateFlow<AutoBackupUiState> = combine(
|
||||||
|
settingsPrefs.autoBackupEnabled,
|
||||||
|
settingsPrefs.autoBackupIntervalMinutes,
|
||||||
|
settingsPrefs.autoBackupFolderUri,
|
||||||
|
settingsPrefs.autoBackupStatus,
|
||||||
|
) { enabled, interval, folder, status ->
|
||||||
|
AutoBackupUiState(enabled, interval, folder, status)
|
||||||
|
}
|
||||||
|
.flowOn(io)
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
|
initialValue = AutoBackupUiState(),
|
||||||
|
)
|
||||||
|
|
||||||
private val _error = MutableStateFlow(false)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||||
|
|
||||||
@@ -136,6 +161,50 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Automatic backup (issue #8) ------------------------------------
|
||||||
|
|
||||||
|
fun setAutoBackupEnabled(enabled: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsPrefs.setAutoBackupEnabled(enabled)
|
||||||
|
reschedule()
|
||||||
|
// Give immediate feedback when turning it on with a folder already set.
|
||||||
|
if (enabled && settingsPrefs.autoBackupFolderUri.first() != null) {
|
||||||
|
BackupScheduler.runNow(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAutoBackupIntervalMinutes(minutes: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsPrefs.setAutoBackupIntervalMinutes(minutes)
|
||||||
|
reschedule()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the chosen destination folder (taking a durable write grant) and run once. */
|
||||||
|
fun setAutoBackupFolder(uri: Uri) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
settingsPrefs.setAutoBackupFolderUri(uri.toString())
|
||||||
|
reschedule()
|
||||||
|
if (settingsPrefs.autoBackupEnabled.first()) BackupScheduler.runNow(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reschedule() {
|
||||||
|
BackupScheduler.apply(
|
||||||
|
context = context,
|
||||||
|
enabled = settingsPrefs.autoBackupEnabled.first(),
|
||||||
|
intervalMinutes = settingsPrefs.autoBackupIntervalMinutes.first(),
|
||||||
|
hasFolder = settingsPrefs.autoBackupFolderUri.first() != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private inline fun write(crossinline block: suspend () -> Unit) {
|
private inline fun write(crossinline block: suspend () -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -149,6 +218,14 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Automatic-backup settings + last-run status for the Backup section. */
|
||||||
|
data class AutoBackupUiState(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val intervalMinutes: Long = SettingsPrefs.DEFAULT_BACKUP_INTERVAL,
|
||||||
|
val folderUri: String? = null,
|
||||||
|
val status: BackupStatus = BackupStatus(lastRun = 0L, lastSuccess = true, consecutiveFailures = 0),
|
||||||
|
)
|
||||||
|
|
||||||
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
/** Outcome of a whole-calendar backup, surfaced once to the screen. */
|
||||||
sealed interface BackupResult {
|
sealed interface BackupResult {
|
||||||
data class Success(val eventCount: Int) : BackupResult
|
data class Success(val eventCount: Int) : BackupResult
|
||||||
|
|||||||
@@ -86,8 +86,10 @@ fun <T> OptionPicker(
|
|||||||
label: @Composable (T) -> String,
|
label: @Composable (T) -> String,
|
||||||
onSelect: (T) -> Unit,
|
onSelect: (T) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
header?.invoke(this)
|
||||||
options.forEachIndexed { index, option ->
|
options.forEachIndexed { index, option ->
|
||||||
val isSelected = option == selected
|
val isSelected = option == selected
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import androidx.compose.material.icons.filled.Gavel
|
|||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.Translate
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -318,6 +319,18 @@ private fun LanguageRow(position: Position) {
|
|||||||
AppLanguage.apply(it)
|
AppLanguage.apply(it)
|
||||||
},
|
},
|
||||||
onDismiss = { showDialog = false },
|
onDismiss = { showDialog = false },
|
||||||
|
// Invite contributions right where users pick their language.
|
||||||
|
header = {
|
||||||
|
val translateUrl = stringResource(R.string.about_translate_url)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_translate),
|
||||||
|
summary = stringResource(R.string.settings_translate_hint),
|
||||||
|
position = Position.Alone,
|
||||||
|
leading = { CategoryIcon(Icons.Default.Translate, ChipAccent.Neutral) },
|
||||||
|
onClick = { openUrl(context, translateUrl) },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,6 +197,14 @@
|
|||||||
<item quantity="one">%d hour</item>
|
<item quantity="one">%d hour</item>
|
||||||
<item quantity="other">%d hours</item>
|
<item quantity="other">%d hours</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="duration_days">
|
||||||
|
<item quantity="one">%d day</item>
|
||||||
|
<item quantity="other">%d days</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="duration_weeks">
|
||||||
|
<item quantity="one">%d week</item>
|
||||||
|
<item quantity="other">%d weeks</item>
|
||||||
|
</plurals>
|
||||||
|
|
||||||
<!-- Shared event strings -->
|
<!-- Shared event strings -->
|
||||||
<string name="event_untitled">(No title)</string>
|
<string name="event_untitled">(No title)</string>
|
||||||
@@ -322,6 +330,8 @@
|
|||||||
<string name="settings_section_language">Language</string>
|
<string name="settings_section_language">Language</string>
|
||||||
<string name="settings_language">App language</string>
|
<string name="settings_language">App language</string>
|
||||||
<string name="settings_language_auto">System default</string>
|
<string name="settings_language_auto">System default</string>
|
||||||
|
<string name="settings_translate">Help translate</string>
|
||||||
|
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||||
<!-- Hub category subtitles -->
|
<!-- Hub category subtitles -->
|
||||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||||
@@ -363,6 +373,26 @@
|
|||||||
<string name="calendars_backup_header">Backup</string>
|
<string name="calendars_backup_header">Backup</string>
|
||||||
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
<string name="calendars_backup_hint">Local calendars aren\'t synced anywhere, so export them to an .ics file to keep a copy.</string>
|
||||||
<string name="calendars_backup_action">Export as .ics file</string>
|
<string name="calendars_backup_action">Export as .ics file</string>
|
||||||
|
<string name="calendars_auto_backup">Automatic backup</string>
|
||||||
|
<string name="calendars_auto_backup_hint">Periodically export your local calendars to a folder as an .ics file.</string>
|
||||||
|
<string name="calendars_auto_backup_folder">Backup folder</string>
|
||||||
|
<string name="calendars_auto_backup_folder_unset">Tap to choose a folder</string>
|
||||||
|
<string name="calendars_auto_backup_interval">Interval</string>
|
||||||
|
<string name="calendars_auto_backup_every">Every %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||||
|
<string name="calendars_auto_backup_status_never">No automatic backup yet</string>
|
||||||
|
<string name="calendars_auto_backup_status_ok">Last backup: %1$s</string>
|
||||||
|
<string name="calendars_auto_backup_status_failed">Last backup failed: %1$s</string>
|
||||||
|
<string name="backup_channel_name">Backup</string>
|
||||||
|
<string name="backup_channel_description">Warns if automatic backups fail repeatedly.</string>
|
||||||
|
<string name="backup_failed_title">Automatic backup failed</string>
|
||||||
|
<string name="backup_failed_text">Calendula couldn\'t write the backup file. Check the backup folder in Settings.</string>
|
||||||
|
<string-array name="backup_interval_units">
|
||||||
|
<item>Minutes</item>
|
||||||
|
<item>Hours</item>
|
||||||
|
<item>Days</item>
|
||||||
|
<item>Weeks</item>
|
||||||
|
</string-array>
|
||||||
<string name="calendars_backup_failed">Couldn\'t export the backup.</string>
|
<string name="calendars_backup_failed">Couldn\'t export the backup.</string>
|
||||||
<plurals name="calendars_backup_done">
|
<plurals name="calendars_backup_done">
|
||||||
<item quantity="one">Exported %d event.</item>
|
<item quantity="one">Exported %d event.</item>
|
||||||
@@ -408,6 +438,7 @@
|
|||||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
||||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||||
|
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>
|
||||||
|
|
||||||
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||||
Gitea issue (the app sends nothing automatically). -->
|
Gitea issue (the app sends nothing automatically). -->
|
||||||
|
|||||||
@@ -21,8 +21,30 @@
|
|||||||
top naming the exact dates in view, with a button to switch the range just for
|
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
|
the current session (it resets when you reopen the app); the bar can be turned
|
||||||
off. Thanks to @zmaherdev for the suggestion ([#4]).
|
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
|
### Changed
|
||||||
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
- Tidied **Settings → Appearance** into clearer groups (theme & colour, calendar,
|
||||||
agenda), and the agenda's empty state now reads "You're all caught up".
|
agenda), and the agenda's empty state now reads "You're all caught up".
|
||||||
|
- Reworked **Settings → Calendars**. Local and synced calendars are now grouped
|
||||||
|
into collapsible, source-branded cards — each account shows its app's icon —
|
||||||
|
with a per-account menu to enable or disable all of its calendars at once or
|
||||||
|
open the account in its source app.
|
||||||
|
- Reorganised **Settings → Notifications**: reliable-delivery and snooze settings
|
||||||
|
moved up with the other global reminder options, and the per-calendar reminder
|
||||||
|
overrides now fold into a single expandable section.
|
||||||
|
- Moved **Add Quick Settings tile** out of the event-form section to its own
|
||||||
|
top-level entry in Settings.
|
||||||
|
|
||||||
|
|||||||
6
fastlane/metadata/android/en-US/changelogs/21101.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/21101.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
### Fixed
|
||||||
|
- Calendula can now be set as your default calendar app. It registers the
|
||||||
|
calendar-app intent filters the system uses, so it appears in the chooser when
|
||||||
|
you tap a date in a launcher or clock — and opening one takes you straight to
|
||||||
|
that day. Android has no way for an app to make itself the default, so you pick
|
||||||
|
it once from the system picker. Thanks to @abrossimow for the report ([#9]).
|
||||||
@@ -26,10 +26,14 @@ lifecycleCompose = "2.10.0"
|
|||||||
androidxTestRules = "1.7.0"
|
androidxTestRules = "1.7.0"
|
||||||
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
|
||||||
glance = "1.1.1"
|
glance = "1.1.1"
|
||||||
|
work = "2.10.1"
|
||||||
|
documentfile = "1.0.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# AndroidX core
|
# AndroidX core
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
|
||||||
|
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
|
||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntime" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
|||||||
Reference in New Issue
Block a user