Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e7ee5b98a | |||
| a82df3f6d0 | |||
| e0e3eb73b9 | |||
| 8a7a0af207 | |||
| 71a652ce3b | |||
| 5fa6eac1ad | |||
| 21ba3fb7cf | |||
| 316c2b0b12 | |||
| ef77eab627 | |||
| 955d47ef12 | |||
| 8536774522 | |||
| a8aeae5f32 | |||
| 988ac009b5 | |||
| 494e486998 | |||
| 3c73028c80 | |||
| 2be1be19fe | |||
| 1f050c2be9 | |||
|
|
5771b603f2 | ||
|
|
5725989aff | ||
| 8b22e1b2af | |||
| a514b8b506 | |||
| 60fcc6b64c | |||
| d6bc660983 | |||
| b449fff77c | |||
| f0cc35f2ce | |||
| 2ce79942c4 | |||
| df426bb8df | |||
| 31f51554f9 | |||
| e967007bdc | |||
| 9f7427e72f | |||
| 79d9e0eaa0 | |||
| a98dd654a6 | |||
| a138e179dd | |||
| 94e3887345 | |||
| f440d385fa | |||
| 8fb4767888 | |||
| 993d74502f | |||
| 4a11c951ae | |||
| f25f308326 | |||
| bff683a403 |
@@ -29,6 +29,7 @@ jobs:
|
||||
with:
|
||||
# Full history so the base..HEAD diff below has a merge-base.
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
# Cheap, always-on guard: the release build must stay reproducible for the
|
||||
# official F-Droid repo (no AGP VCS-info embedding). Runs regardless of
|
||||
|
||||
@@ -33,6 +33,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Resolve version and whether it is a new release
|
||||
id: v
|
||||
@@ -82,6 +84,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
|
||||
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "floret-kit"]
|
||||
path = floret-kit
|
||||
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
||||
36
CHANGELOG.md
@@ -5,6 +5,38 @@ 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.14.0] — 2026-07-06
|
||||
|
||||
### Added
|
||||
- Restore events from a backup file. The backup section of Settings can now read
|
||||
events back **in** from an `.ics` file, not just write one out: pick a file,
|
||||
choose which calendar to import into, and Calendula adds the events — skipping
|
||||
any that are already there and telling you how many it skipped. Export gained a
|
||||
per-calendar selector at the same time, so you can back up just the calendars
|
||||
you pick instead of everything at once ([#32]).
|
||||
- Week numbers in Month view. A new **Week numbers** setting (off by default)
|
||||
adds a slim gutter down the left of the Month grid showing the calendar-week
|
||||
number for each row, sized to match the day cells. Handy if you plan or refer
|
||||
to dates by week number ([#25]).
|
||||
- Tap a date header to open that day. In Week and Agenda view, tapping a date
|
||||
header now opens that date in Day view — the same drill-in that Month view and
|
||||
the agenda widget already offered, so every view behaves the same way. It makes
|
||||
jumping to a specific day quicker: switch to Week, swipe to the week you want,
|
||||
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).
|
||||
- An early Simplified Chinese translation. Calendula has started speaking
|
||||
Simplified Chinese, contributed as a community translation through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
It is still an early effort, so many parts of the app show in English until it
|
||||
fills out — you can already pick it under Settings → Language or in Android's
|
||||
per-app language settings. Thanks to
|
||||
[zh-cn](https://weblate.dev.jeanlucmakiola.de/user/zh-cn/) for getting it
|
||||
started; help finishing it is very welcome.
|
||||
|
||||
### Changed
|
||||
- Long event titles wrap in the edit screen. When editing an event, a long title
|
||||
now wraps onto multiple lines instead of being clipped to a single line, so you
|
||||
can see and edit the whole thing ([#33]).
|
||||
|
||||
## [2.13.1] — 2026-07-06
|
||||
|
||||
### Added
|
||||
@@ -834,7 +866,11 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
||||
[#25]: https://codeberg.org/jlmakiola/calendula/issues/25
|
||||
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
|
||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
|
||||
[#32]: https://codeberg.org/jlmakiola/calendula/issues/32
|
||||
[#33]: https://codeberg.org/jlmakiola/calendula/issues/33
|
||||
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34
|
||||
[#37]: https://codeberg.org/jlmakiola/calendula/issues/37
|
||||
|
||||
@@ -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 = 21301
|
||||
versionName = "2.13.1"
|
||||
versionCode = 21400
|
||||
versionName = "2.14.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -161,6 +161,12 @@ dependencies {
|
||||
implementation(libs.androidx.glance.material3)
|
||||
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation("de.jeanlucmakiola.floret:core-time")
|
||||
implementation("de.jeanlucmakiola.floret:core-locale")
|
||||
implementation("de.jeanlucmakiola.floret:core-crash")
|
||||
implementation("de.jeanlucmakiola.floret:core-reminders")
|
||||
implementation("de.jeanlucmakiola.floret:identity")
|
||||
implementation("de.jeanlucmakiola.floret:components")
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
debugImplementation(libs.androidx.ui.tooling)
|
||||
|
||||
@@ -7,7 +7,8 @@ import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -24,8 +25,18 @@ class CalendulaApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Install first thing so startup crashes are captured too (privacy-
|
||||
// respecting, on-device; the user submits the report by hand).
|
||||
CrashReporter.install(this)
|
||||
// respecting, on-device; the user submits the report by hand). The
|
||||
// capture/loop-detection/report machinery lives in floret-kit's
|
||||
// core-crash; only the app label + issue-tracker URLs are app-specific.
|
||||
CrashReporter.install(
|
||||
this,
|
||||
CrashConfig(
|
||||
appLabel = getString(R.string.app_name),
|
||||
newIssueUrl = getString(R.string.report_issue_url),
|
||||
chooseIssueUrl = getString(R.string.report_issue_choose_url),
|
||||
issueTitle = getString(R.string.crash_report_issue_title),
|
||||
),
|
||||
)
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import androidx.core.net.toUri
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
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.domain.EventForm
|
||||
@@ -33,12 +32,13 @@ 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.common.DebugRibbon
|
||||
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
@@ -71,9 +73,11 @@ interface CalendarDataSource {
|
||||
/**
|
||||
* Every master/one-off event of the writable local calendars, mapped for a
|
||||
* whole-calendar `.ics` backup. Modified-occurrence and cancelled-exception
|
||||
* rows are excluded (see [EventExportProjection]).
|
||||
* rows are excluded (see [EventExportProjection]). When [calendarIds] is
|
||||
* given, only those calendars are exported (still intersected with the
|
||||
* eligible set); `null` exports every eligible calendar.
|
||||
*/
|
||||
fun exportableEvents(): List<IcsEvent>
|
||||
fun exportableEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* The non-empty `Events.UID_2445` values present in [calendarId] — used to
|
||||
@@ -611,12 +615,19 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
override fun exportableEvents(): List<IcsEvent> {
|
||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||
// Only the local calendars the app owns and can write — synced calendars
|
||||
// already have a backup (their server). Map id → display name for the
|
||||
// already have a backup (their server). Exclude the managed special-dates
|
||||
// mirror calendars: their events are derived from contacts, not authored
|
||||
// here, and re-materialise from the contact sync — backing them up would
|
||||
// just duplicate them on restore. A non-null [calendarIds] narrows the
|
||||
// export to the user's chosen subset. Map id → display name for the
|
||||
// X-CALENDULA-CALENDAR tag a restore uses to fan back out.
|
||||
val names = calendars()
|
||||
.filter { it.isLocal && it.canModifyContents }
|
||||
.filter {
|
||||
it.isLocal && it.canModifyContents && !it.isManaged &&
|
||||
(calendarIds == null || it.id in calendarIds)
|
||||
}
|
||||
.associate { it.id to it.displayName }
|
||||
if (names.isEmpty()) return emptyList()
|
||||
|
||||
|
||||
@@ -41,8 +41,9 @@ interface CalendarRepository {
|
||||
/**
|
||||
* Every event of the writable local calendars, ready to serialise into a
|
||||
* whole-calendar `.ics` backup (see [CalendarDataSource.exportableEvents]).
|
||||
* [calendarIds] narrows the export to a chosen subset; `null` exports all.
|
||||
*/
|
||||
suspend fun exportEvents(): List<IcsEvent>
|
||||
suspend fun exportEvents(calendarIds: Set<Long>? = null): List<IcsEvent>
|
||||
|
||||
/**
|
||||
* Bulk-import parsed `.ics` [events] into [targetCalendarId]. Events whose
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toEpochMillis
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -117,7 +118,8 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
override suspend fun deleteCalendar(id: Long) =
|
||||
withContext(io) { dataSource.deleteCalendar(id) }
|
||||
|
||||
override suspend fun exportEvents() = withContext(io) { dataSource.exportableEvents() }
|
||||
override suspend fun exportEvents(calendarIds: Set<Long>?) =
|
||||
withContext(io) { dataSource.exportableEvents(calendarIds) }
|
||||
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.provider.CalendarContract
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import android.util.Log
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import kotlin.time.Instant
|
||||
|
||||
fun Long.toKotlinInstantFromEpochMillis(): Instant = Instant.fromEpochMilliseconds(this)
|
||||
|
||||
fun Instant.toEpochMillis(): Long = toEpochMilliseconds()
|
||||
@@ -4,7 +4,7 @@ import android.provider.CalendarContract
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
@@ -111,13 +111,13 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
* all-day override so new events keep matching. No-op if the calendar for
|
||||
* [type] doesn't exist yet.
|
||||
*/
|
||||
suspend fun applyReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||
suspend fun applyReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||
val calendarId = prefs.specialDatesCalendars.first()[type] ?: return
|
||||
prefs.setCalendarAllDayReminderOverride(calendarId, override)
|
||||
val minutes = when (override) {
|
||||
CalendarReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||
CalendarReminderOverride.None -> emptyList()
|
||||
is CalendarReminderOverride.Minutes -> override.minutes
|
||||
ReminderOverride.Inherit -> prefs.defaultAllDayReminderMinutes.first()
|
||||
ReminderOverride.None -> emptyList()
|
||||
is ReminderOverride.Minutes -> override.minutes
|
||||
}
|
||||
calendars.applyManagedCalendarReminders(
|
||||
calendarId = calendarId,
|
||||
@@ -169,7 +169,7 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
if (!prefs.perCalendarAllDayReminderOverride.first().containsKey(id)) {
|
||||
prefs.setCalendarAllDayReminderOverride(
|
||||
id,
|
||||
CalendarReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||
ReminderOverride.Minutes(DEFAULT_REMINDER_MINUTES),
|
||||
)
|
||||
}
|
||||
return id
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.crash
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.os.Build
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import java.io.File
|
||||
import java.io.PrintWriter
|
||||
import java.io.StringWriter
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Privacy-respecting crash capture (prod-readiness item 10). On an uncaught
|
||||
* exception it writes a self-contained report to the app's private storage and
|
||||
* then chains to the platform's default handler, so the process still dies
|
||||
* normally (and the OS shows its own "stopped" dialog). Nothing is uploaded —
|
||||
* the app holds no `INTERNET` permission. The user submits the report later,
|
||||
* by hand, as a Gitea issue (see the ui/crash surfaces).
|
||||
*
|
||||
* The report is built from a fixed [CrashContext] allowlist — app/Android/device
|
||||
* version, locale, time, and the stack trace — and **nothing else**: no device
|
||||
* identifiers, no account names, no calendar/event content, no logcat. The user
|
||||
* is always shown the full text before it leaves the device.
|
||||
*/
|
||||
object CrashReporter {
|
||||
|
||||
/**
|
||||
* Install the handler. Called first thing in `CalendulaApp.onCreate()` so it
|
||||
* also catches crashes during startup. The handler swallows nothing — it
|
||||
* persists, then delegates to the previously-registered handler.
|
||||
*/
|
||||
fun install(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val previous = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
// Capturing must never mask the original crash, so guard every step.
|
||||
runCatching {
|
||||
val now = System.currentTimeMillis()
|
||||
writeReport(appContext, buildCrashReport(CrashContext.from(appContext), throwable, now))
|
||||
recordCrashTime(appContext, now)
|
||||
}
|
||||
previous?.uncaughtException(thread, throwable)
|
||||
}
|
||||
}
|
||||
|
||||
/** The persisted report from the last crash, or null if there is none. */
|
||||
fun pendingReport(context: Context): String? {
|
||||
val file = reportFile(context)
|
||||
return if (file.exists()) runCatching { file.readText() }.getOrNull()?.takeIf { it.isNotBlank() } else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to surface the report unprompted (on the next launch): a report
|
||||
* exists and the user hasn't already waved this one away. Settings reaches
|
||||
* the report via [pendingReport] regardless, so "Not now" only stops the
|
||||
* auto-prompt — it doesn't discard the report.
|
||||
*/
|
||||
fun shouldPrompt(context: Context): Boolean =
|
||||
reportFile(context).exists() && !dismissedFile(context).exists()
|
||||
|
||||
/** Stop auto-prompting for the current report without discarding it. */
|
||||
fun dismissPrompt(context: Context) {
|
||||
runCatching { dismissedFile(context).apply { parentFile?.mkdirs() }.writeText("") }
|
||||
}
|
||||
|
||||
/** Drop the persisted report once the user has reported it (or from Settings). */
|
||||
fun clearReport(context: Context) {
|
||||
runCatching { reportFile(context).delete() }
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the app appears to be in a startup crash-loop: at least
|
||||
* [LOOP_THRESHOLD] crashes inside [LOOP_WINDOW_MS]. In that case the main UI
|
||||
* can't be trusted to start, so the caller routes straight to the standalone
|
||||
* report screen instead of re-entering the crashing graph.
|
||||
*/
|
||||
fun isCrashLoop(context: Context): Boolean {
|
||||
val times = readCrashTimes(context)
|
||||
if (times.size < LOOP_THRESHOLD) return false
|
||||
val recent = times.sortedDescending()
|
||||
return recent[0] - recent[LOOP_THRESHOLD - 1] <= LOOP_WINDOW_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the app as having started successfully, resetting the loop counter so
|
||||
* an ordinary single crash much later never trips loop detection. The
|
||||
* pending report itself is kept — only the timing trail is cleared.
|
||||
*/
|
||||
fun markHealthy(context: Context) {
|
||||
runCatching { timesFile(context).delete() }
|
||||
}
|
||||
|
||||
// --- persistence -------------------------------------------------------
|
||||
|
||||
private fun writeReport(context: Context, report: String) {
|
||||
val file = reportFile(context).apply { parentFile?.mkdirs() }
|
||||
file.writeText(report.take(MAX_REPORT_CHARS))
|
||||
// A fresh crash should prompt again, even if the previous one was waved away.
|
||||
runCatching { dismissedFile(context).delete() }
|
||||
}
|
||||
|
||||
private fun recordCrashTime(context: Context, nowMillis: Long) {
|
||||
val kept = (readCrashTimes(context) + nowMillis).takeLast(MAX_TIMES)
|
||||
timesFile(context).apply { parentFile?.mkdirs() }
|
||||
.writeText(kept.joinToString("\n"))
|
||||
}
|
||||
|
||||
private fun readCrashTimes(context: Context): List<Long> {
|
||||
val file = timesFile(context)
|
||||
if (!file.exists()) return emptyList()
|
||||
return runCatching { file.readLines().mapNotNull { it.trim().toLongOrNull() } }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun crashDir(context: Context) = File(context.filesDir, CRASH_DIR)
|
||||
private fun reportFile(context: Context) = File(crashDir(context), REPORT_FILE)
|
||||
private fun timesFile(context: Context) = File(crashDir(context), TIMES_FILE)
|
||||
private fun dismissedFile(context: Context) = File(crashDir(context), DISMISSED_FILE)
|
||||
|
||||
private const val CRASH_DIR = "crash"
|
||||
private const val REPORT_FILE = "last_crash.txt"
|
||||
private const val TIMES_FILE = "crash_times.txt"
|
||||
private const val DISMISSED_FILE = "dismissed"
|
||||
private const val MAX_TIMES = 5
|
||||
private const val MAX_REPORT_CHARS = 64 * 1024
|
||||
private const val LOOP_THRESHOLD = 2
|
||||
private const val LOOP_WINDOW_MS = 10_000L
|
||||
}
|
||||
|
||||
/**
|
||||
* The allowlist of non-personal facts that go into a crash report. Built from
|
||||
* [Build] and the app's own [PackageInfo]; deliberately holds no identifiers.
|
||||
*/
|
||||
data class CrashContext(
|
||||
val appVersionName: String,
|
||||
val appVersionCode: Long,
|
||||
val sdkInt: Int,
|
||||
val androidRelease: String,
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val locale: String,
|
||||
) {
|
||||
companion object {
|
||||
fun from(context: Context): CrashContext {
|
||||
val pkg = runCatching {
|
||||
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
}.getOrNull()
|
||||
return CrashContext(
|
||||
appVersionName = pkg?.versionName ?: "?",
|
||||
appVersionCode = pkg?.let { PackageInfoCompat.getLongVersionCode(it) } ?: 0L,
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
androidRelease = Build.VERSION.RELEASE ?: "?",
|
||||
manufacturer = Build.MANUFACTURER ?: "?",
|
||||
model = Build.MODEL ?: "?",
|
||||
locale = Locale.getDefault().toLanguageTag(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a crash report from the [ctx] allowlist, the [throwable]'s full stack
|
||||
* trace, and the crash [nowMillis]. Pure (no Android, no I/O) so it is unit
|
||||
* tested. The leading marker doubles as the file's sanity check in
|
||||
* [CrashReporter.pendingReport].
|
||||
*/
|
||||
fun buildCrashReport(ctx: CrashContext, throwable: Throwable, nowMillis: Long): String {
|
||||
val trace = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }.toString().trim()
|
||||
val time = runCatching {
|
||||
Instant.ofEpochMilli(nowMillis).atZone(ZoneId.systemDefault()).format(TIME_FORMAT)
|
||||
}.getOrDefault(nowMillis.toString())
|
||||
return buildString {
|
||||
appendLine("Calendula crash report")
|
||||
appendLine("App version: ${ctx.appVersionName} (${ctx.appVersionCode})")
|
||||
appendLine("Android: ${ctx.androidRelease} (API ${ctx.sdkInt})")
|
||||
appendLine("Device: ${ctx.manufacturer} ${ctx.model}")
|
||||
appendLine("Locale: ${ctx.locale}")
|
||||
appendLine("Time: $time")
|
||||
appendLine()
|
||||
appendLine("Stack trace:")
|
||||
append(trace)
|
||||
}
|
||||
}
|
||||
|
||||
private val TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
@@ -7,6 +7,11 @@ 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.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverrideCodec
|
||||
import de.jeanlucmakiola.floret.reminders.applyReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.normalizeReminders
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
@@ -200,6 +205,19 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DIM_COMPLETED_EVENTS_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Month grid shows the calendar-week (ISO) number in a left
|
||||
* gutter (#25). Defaults to OFF — users opt in, since it narrows the day
|
||||
* cells slightly. The Week view shows its number unconditionally.
|
||||
*/
|
||||
val showWeekNumbers: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[SHOW_WEEK_NUMBERS_KEY] ?: false
|
||||
}
|
||||
|
||||
suspend fun setShowWeekNumbers(enabled: Boolean) {
|
||||
store.edit { it[SHOW_WEEK_NUMBERS_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
|
||||
@@ -436,14 +454,14 @@ class SettingsPrefs @Inject constructor(
|
||||
* (All-day events ignore this and use [defaultAllDayReminderMinutes].)
|
||||
*/
|
||||
val perCalendarReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||
reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
suspend fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||
suspend fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||
store.edit { prefs ->
|
||||
val current = parseReminderOverrides(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyOverride(calendarId, override)
|
||||
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||
val current = reminderOverrideCodec.parse(prefs[CALENDAR_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyReminderOverride(calendarId, override)
|
||||
prefs[CALENDAR_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,18 +471,18 @@ class SettingsPrefs @Inject constructor(
|
||||
* inherit the global all-day default; present null = no reminder).
|
||||
*/
|
||||
val perCalendarAllDayReminderOverride: Flow<Map<Long, List<Int>>> = store.data.map { prefs ->
|
||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY])
|
||||
}
|
||||
|
||||
suspend fun setCalendarAllDayReminderOverride(
|
||||
calendarId: Long,
|
||||
override: CalendarReminderOverride,
|
||||
override: ReminderOverride,
|
||||
) {
|
||||
store.edit { prefs ->
|
||||
val current =
|
||||
parseReminderOverrides(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyOverride(calendarId, override)
|
||||
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = serializeReminderOverrides(current)
|
||||
reminderOverrideCodec.parse(prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY]).toMutableMap()
|
||||
current.applyReminderOverride(calendarId, override)
|
||||
prefs[CALENDAR_ALLDAY_REMINDER_OVERRIDE_KEY] = reminderOverrideCodec.serialize(current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +677,7 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
@@ -727,28 +746,6 @@ data class BackupStatus(
|
||||
val consecutiveFailures: Int,
|
||||
)
|
||||
|
||||
/** A calendar's reminder-default override (see [SettingsPrefs.perCalendarReminderOverride]). */
|
||||
sealed interface CalendarReminderOverride {
|
||||
/** No override — the calendar uses the global default. */
|
||||
data object Inherit : CalendarReminderOverride
|
||||
/** Explicit "no reminder" for this calendar, regardless of the global default. */
|
||||
data object None : CalendarReminderOverride
|
||||
/** Specific lead times in minutes before the event start (non-empty). */
|
||||
data class Minutes(val minutes: List<Int>) : CalendarReminderOverride
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored override for [calendarId] in an override map, as a
|
||||
* [CalendarReminderOverride] (absent → [CalendarReminderOverride.Inherit],
|
||||
* empty → [CalendarReminderOverride.None]) — the inverse of how
|
||||
* [SettingsPrefs.setCalendarReminderOverride] stores a choice.
|
||||
*/
|
||||
fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||
getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(getValue(calendarId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The lead times to prefill on a new event: the matching per-calendar override
|
||||
* if [calendarId] has one for this event kind, otherwise the global default for
|
||||
@@ -773,21 +770,6 @@ fun resolveDefaultReminder(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a [CalendarReminderOverride] to an override map ([Inherit] removes the
|
||||
* key; [None] and an empty [Minutes] both store the empty list).
|
||||
*/
|
||||
private fun MutableMap<Long, List<Int>>.applyOverride(
|
||||
calendarId: Long,
|
||||
override: CalendarReminderOverride,
|
||||
) {
|
||||
when (override) {
|
||||
CalendarReminderOverride.Inherit -> remove(calendarId)
|
||||
CalendarReminderOverride.None -> put(calendarId, emptyList())
|
||||
is CalendarReminderOverride.Minutes -> put(calendarId, override.minutes.normalizeReminders())
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||
private const val WEEK_START_AUTO = "AUTO"
|
||||
|
||||
@@ -812,8 +794,14 @@ private const val ENTRY_SEP = ";"
|
||||
private const val KEY_VALUE_SEP = "="
|
||||
private const val LIST_SEP = ","
|
||||
|
||||
/** Distinct, ascending lead times — the canonical form stored and resolved. */
|
||||
private fun List<Int>.normalizeReminders(): List<Int> = distinct().sorted()
|
||||
/**
|
||||
* The per-calendar override map codec, in Calendula's stored dialect
|
||||
* (`id=minutes` entries joined by `;`, minutes comma-joined, `none` for an
|
||||
* explicit no-reminder). The model and codec live in floret-kit; the dialect
|
||||
* (fixed at release) stays here.
|
||||
*/
|
||||
private val reminderOverrideCodec =
|
||||
ReminderOverrideCodec(entrySep = ENTRY_SEP, keyValueSep = KEY_VALUE_SEP, listSep = LIST_SEP, noneToken = NONE)
|
||||
|
||||
/**
|
||||
* Parse a stored reminder value into lead times. `null`/empty/"none" → empty
|
||||
@@ -829,25 +817,6 @@ private fun String?.toReminderList(): List<Int> = when {
|
||||
private fun List<Int>.toStoredReminders(): String =
|
||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||
|
||||
private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
||||
if (stored.isNullOrBlank()) return emptyMap()
|
||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
|
||||
// Only the deliberate "none" sentinel means an explicit no-reminder
|
||||
// override (empty list); a non-sentinel value that parses to no valid
|
||||
// minutes is garbage and drops the entry, so the calendar inherits the
|
||||
// global default rather than silently reading as "no reminder".
|
||||
when (val value = parts[1]) {
|
||||
NONE -> id to emptyList()
|
||||
else -> value.toReminderList().takeIf { it.isNotEmpty() }?.let { id to it }
|
||||
?: return@mapNotNull null
|
||||
}
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun serializeReminderOverrides(map: Map<Long, List<Int>>): String =
|
||||
map.entries.joinToString(ENTRY_SEP) { (id, minutes) -> "$id$KEY_VALUE_SEP${minutes.toStoredReminders()}" }
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -24,7 +24,7 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarFadeThrough
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
@@ -172,9 +172,15 @@ fun CalendarHost(
|
||||
// picker (many). A plain conditional overlay (no slide) — it's transient.
|
||||
var importUri by remember { mutableStateOf<android.net.Uri?>(null) }
|
||||
var importForm by remember { mutableStateOf<EventForm?>(null) }
|
||||
// A restore (in-app "Restore from .ics" button) always runs the full import
|
||||
// flow — picker + summary — even for a single-event file, because the intent
|
||||
// is "restore a backup", not "add this one event". An externally opened .ics
|
||||
// keeps routing a single event straight into the prefilled create form.
|
||||
var importForceMany by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(requestedImportUri) {
|
||||
if (requestedImportUri != null) {
|
||||
importUri = requestedImportUri
|
||||
importForceMany = false
|
||||
onImportConsumed()
|
||||
}
|
||||
}
|
||||
@@ -274,7 +280,7 @@ fun CalendarHost(
|
||||
// Switching between the peer views (month/week/day/agenda) is lateral
|
||||
// navigation, so it fades through rather than sliding — paging *within* a
|
||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||
val viewSwitch = calendarFadeThrough()
|
||||
val viewSwitch = fadeThrough()
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
transitionSpec = { viewSwitch },
|
||||
@@ -284,6 +290,7 @@ fun CalendarHost(
|
||||
CalendarView.Week -> WeekScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
@@ -315,6 +322,7 @@ fun CalendarHost(
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
onSelectView = onSelectView,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
@@ -412,7 +420,10 @@ fun CalendarHost(
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
CalendarsScreen(onBack = { showCalendars = false })
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onImport = { importUri = it; importForceMany = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Import flow for an opened/received .ics file. A single event routes
|
||||
@@ -420,6 +431,7 @@ fun CalendarHost(
|
||||
importUri?.let { uri ->
|
||||
ImportScreen(
|
||||
uri = uri,
|
||||
forceMany = importForceMany,
|
||||
onClose = { importUri = null },
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
|
||||
@@ -59,19 +59,19 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
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.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
@@ -93,6 +93,7 @@ private val zone = TimeZone.currentSystemDefault()
|
||||
fun AgendaScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
@@ -191,6 +192,7 @@ fun AgendaScreen(
|
||||
pastDisplay = pastDisplay,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
@@ -289,6 +291,7 @@ private fun AgendaContent(
|
||||
pastDisplay: PastEventDisplay,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (state) {
|
||||
@@ -318,6 +321,7 @@ private fun AgendaContent(
|
||||
dimPast = pastDisplay == PastEventDisplay.DIM,
|
||||
now = now,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -333,6 +337,7 @@ private fun AgendaList(
|
||||
dimPast: Boolean,
|
||||
now: Instant,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -342,7 +347,7 @@ private fun AgendaList(
|
||||
) {
|
||||
days.forEach { day ->
|
||||
stickyHeader(key = "header-${day.date}") {
|
||||
AgendaDayHeader(date = day.date, today = today)
|
||||
AgendaDayHeader(date = day.date, today = today, onOpenDay = onOpenDay)
|
||||
}
|
||||
itemsIndexed(
|
||||
items = day.events,
|
||||
@@ -352,7 +357,7 @@ private fun AgendaList(
|
||||
event = event,
|
||||
position = positionOf(index, day.events.size),
|
||||
dimmed = dimPast && event.hasEnded(now),
|
||||
modifier = calendarAnimateItem(),
|
||||
modifier = animateItemMotion(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
@@ -362,10 +367,16 @@ private fun AgendaList(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaDayHeader(date: LocalDate, today: LocalDate) {
|
||||
private fun AgendaDayHeader(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenDay(date) },
|
||||
) {
|
||||
Text(
|
||||
text = agendaDayLabel(date, today),
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -24,7 +23,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -37,6 +35,7 @@ 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.FileUpload
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
@@ -45,6 +44,7 @@ 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.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -66,6 +66,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -73,10 +74,7 @@ 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
|
||||
@@ -85,7 +83,6 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
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
|
||||
@@ -96,22 +93,37 @@ import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
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.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||
import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
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.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import java.time.LocalDate
|
||||
|
||||
/** Sentinel [editorId] meaning "the editor is composing a new calendar". */
|
||||
private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
|
||||
// SAF mime filter for the restore picker. `.ics` files reach us under several
|
||||
// mimes depending on the source app (our own export uses text/calendar; others
|
||||
// hand them out as octet-stream or text/plain), so accept the common set rather
|
||||
// than hide valid backups behind an over-tight filter.
|
||||
private val RESTORE_MIME_TYPES = arrayOf(
|
||||
"text/calendar",
|
||||
"application/octet-stream",
|
||||
"text/plain",
|
||||
)
|
||||
|
||||
/**
|
||||
* Calendar manager (reached from Settings). Lists the app's own device-only
|
||||
* calendars with create / rename / recolor / delete (via a full-screen editor),
|
||||
@@ -122,6 +134,7 @@ private const val NEW_CALENDAR_ID = Long.MIN_VALUE
|
||||
@Composable
|
||||
fun CalendarsScreen(
|
||||
onBack: () -> Unit,
|
||||
onImport: (android.net.Uri) -> Unit,
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
@@ -168,6 +181,7 @@ fun CalendarsScreen(
|
||||
onConsumeError = viewModel::consumeError,
|
||||
backupResult = backupResult,
|
||||
onExportBackup = viewModel::exportBackup,
|
||||
onImport = onImport,
|
||||
onConsumeBackupResult = viewModel::consumeBackupResult,
|
||||
autoBackup = autoBackup,
|
||||
onSetAutoBackupEnabled = viewModel::setAutoBackupEnabled,
|
||||
@@ -190,7 +204,8 @@ private fun CalendarsList(
|
||||
error: Boolean,
|
||||
onConsumeError: () -> Unit,
|
||||
backupResult: BackupResult?,
|
||||
onExportBackup: (android.net.Uri) -> Unit,
|
||||
onExportBackup: (android.net.Uri, Set<Long>?) -> Unit,
|
||||
onImport: (android.net.Uri) -> Unit,
|
||||
onConsumeBackupResult: () -> Unit,
|
||||
autoBackup: AutoBackupUiState,
|
||||
onSetAutoBackupEnabled: (Boolean) -> Unit,
|
||||
@@ -218,10 +233,19 @@ private fun CalendarsList(
|
||||
}
|
||||
|
||||
// SAF "create document" target for the backup file. The picked Uri is handed
|
||||
// to the VM to stream the .ics into.
|
||||
// to the VM to stream the .ics into. This launcher exports everything
|
||||
// eligible (null); the per-calendar selector owns its own launcher.
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri -> uri?.let(onExportBackup) }
|
||||
) { uri -> uri?.let { onExportBackup(it, null) } }
|
||||
var showExportPicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// SAF "open document" picker for restoring events from a .ics file. The
|
||||
// picked Uri is handed up to the host, which runs it through the same import
|
||||
// flow as an externally opened .ics (parse, dedup by UID, target picker).
|
||||
val openBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> uri?.let(onImport) }
|
||||
|
||||
// SAF folder picker for the automatic-backup destination; the VM persists the
|
||||
// write grant so background runs can keep writing to it.
|
||||
@@ -253,6 +277,7 @@ private fun CalendarsList(
|
||||
title = stringResource(R.string.calendars_title),
|
||||
onBack = onBack,
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
predictiveBack = true,
|
||||
) {
|
||||
// What the per-calendar / per-account switches below actually do.
|
||||
HintText(stringResource(R.string.calendars_disable_hint))
|
||||
@@ -301,8 +326,13 @@ private fun CalendarsList(
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
// safety net. Offered only when there is something exportable: the user's
|
||||
// own local calendars (managed special-dates mirrors don't count).
|
||||
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
||||
// Restore/import can target any writable, non-managed calendar (local or
|
||||
// synced), so its availability is broader than export's.
|
||||
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
|
||||
if (exportable.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
HintText(stringResource(R.string.calendars_backup_hint))
|
||||
@@ -313,7 +343,22 @@ private fun CalendarsList(
|
||||
position = Position.Top,
|
||||
leading = { LeadingAvatar(Icons.Default.FileDownload) },
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
// With more than one exportable calendar, let the user choose
|
||||
// which to include; a single one exports straight away.
|
||||
if (exportable.size == 1) {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
} else {
|
||||
showExportPicker = true
|
||||
}
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
summary = stringResource(R.string.calendars_restore_hint),
|
||||
position = Position.Middle,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = {
|
||||
runCatching { openBackup.launch(RESTORE_MIME_TYPES) }
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
@@ -342,6 +387,19 @@ private fun CalendarsList(
|
||||
)
|
||||
HintText(backupStatusText(autoBackup.status))
|
||||
}
|
||||
} else if (canImport) {
|
||||
// Nothing to back up (no writable local calendar), but events can
|
||||
// still be restored into a writable calendar — offer restore on its
|
||||
// own so it isn't hidden behind export eligibility.
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_restore_header))
|
||||
HintText(stringResource(R.string.calendars_restore_hint))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendars_restore_action),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.FileUpload) },
|
||||
onClick = { runCatching { openBackup.launch(RESTORE_MIME_TYPES) } },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -408,6 +466,86 @@ private fun CalendarsList(
|
||||
onDismiss = { showInterval = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showExportPicker) {
|
||||
ExportCalendarPicker(
|
||||
calendars = local.filter { it.canModifyContents && !it.isManaged },
|
||||
onExport = onExportBackup,
|
||||
onDismiss = { showExportPicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose which local calendars to include in a one-time `.ics` export. Defaults
|
||||
* to all selected; the Export action opens the SAF save dialog and hands back
|
||||
* the picked file with the chosen calendar ids.
|
||||
*/
|
||||
@Composable
|
||||
private fun ExportCalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
onExport: (android.net.Uri, Set<Long>?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Seed once with everything selected and hold it across recomposition and
|
||||
// rotation. NOT keyed on [calendars]: the list is observer-driven, so keying
|
||||
// it would silently reset the user's de-selections whenever the provider
|
||||
// re-emits (a background sync, a recolor). Ids that later vanish are harmless
|
||||
// — the data layer intersects the chosen set with the eligible calendars.
|
||||
var selected by rememberSaveable(
|
||||
stateSaver = listSaver(
|
||||
save = { it.toList() },
|
||||
restore = { it.toSet() },
|
||||
),
|
||||
) {
|
||||
mutableStateOf(calendars.map { it.id }.toSet())
|
||||
}
|
||||
val createBackup = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/calendar"),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
onExport(uri, selected)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.calendars_export_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
HintText(stringResource(R.string.calendars_export_hint))
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id in selected
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
position = positionOf(index, calendars.size),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = {
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { checked ->
|
||||
selected = if (checked) selected + calendar.id else selected - calendar.id
|
||||
},
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selected = if (isSelected) selected - calendar.id else selected + calendar.id
|
||||
},
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
runCatching { createBackup.launch("calendula-backup-${LocalDate.now()}.ics") }
|
||||
},
|
||||
enabled = selected.isNotEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.calendars_export_action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -664,8 +802,8 @@ private fun CalendarGroup(
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column(content = body)
|
||||
}
|
||||
@@ -741,66 +879,6 @@ private fun CalendarGroupMenu(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(text: String) {
|
||||
@@ -935,9 +1013,3 @@ private fun sourceAppIntent(context: Context, accountType: String): Intent {
|
||||
return Intent(Settings.ACTION_SYNC_SETTINGS)
|
||||
}
|
||||
|
||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||
private fun curatedSourcePackage(accountType: String): String? = when {
|
||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||
else -> null
|
||||
}
|
||||
|
||||
|
||||
@@ -107,11 +107,11 @@ class CalendarsViewModel @Inject constructor(
|
||||
* document [uri] as one `VCALENDAR`. Result (event count, or failure) lands
|
||||
* in [backupResult] for a one-shot message.
|
||||
*/
|
||||
fun exportBackup(uri: Uri) {
|
||||
fun exportBackup(uri: Uri, calendarIds: Set<Long>? = null) {
|
||||
viewModelScope.launch {
|
||||
_backupResult.value = try {
|
||||
val count = withContext(io) {
|
||||
val events = repository.exportEvents()
|
||||
val events = repository.exportEvents(calendarIds)
|
||||
icsExporter.writeDocument(
|
||||
uri = uri,
|
||||
content = IcsWriter().writeCalendar(events, Clock.System.now()),
|
||||
|
||||
@@ -13,22 +13,8 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Soften a raw calendar color toward a pastel that fits the active theme.
|
||||
* - Keeps the hue (so users still recognise their calendars)
|
||||
* - Caps saturation so harsh provider colors stop screaming
|
||||
* - Pins value/brightness to a band that reads on both light and dark surfaces
|
||||
*/
|
||||
fun pastelize(rawArgb: Int, dark: Boolean): Color {
|
||||
val hsv = FloatArray(3)
|
||||
android.graphics.Color.colorToHSV(rawArgb, hsv)
|
||||
hsv[1] = (hsv[1] * 0.6f).coerceIn(0.25f, 0.65f)
|
||||
hsv[2] = if (dark) 0.82f else 0.72f
|
||||
return Color(android.graphics.Color.HSVToColor(hsv))
|
||||
}
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
|
||||
/**
|
||||
* Leading avatar for a calendar: a neutral chip holding a calendar glyph tinted
|
||||
|
||||
@@ -37,6 +37,9 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
|
||||
/**
|
||||
* The app's single "which calendar" selection list, shared by the event editor
|
||||
* and the .ics import screen. Renders the same grouped-card system as the
|
||||
* calendar-manager screen: a category header per source — the device chip for
|
||||
* the app's own calendars, the owning app's launcher icon for each synced
|
||||
* account — with the calendars beneath it as a connected card, a colour chip on
|
||||
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||
* (a scrolling column), so the caller owns the surrounding chrome.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.CalendarPickerGroups(
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
) {
|
||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||
val syncedGroups = remember(calendars) {
|
||||
calendars.filterNot { it.isLocal }
|
||||
.groupBy { it.accountName.ifBlank { it.accountType }.ifBlank { it.displayName } }
|
||||
.toList()
|
||||
}
|
||||
|
||||
if (local.isNotEmpty()) {
|
||||
CalendarPickerGroup(
|
||||
title = stringResource(R.string.calendars_local_header),
|
||||
leading = { LeadingAvatar(Icons.Default.PhoneAndroid) },
|
||||
calendars = local,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
syncedGroups.forEachIndexed { index, (account, cals) ->
|
||||
if (local.isNotEmpty() || index > 0) Spacer(Modifier.height(16.dp))
|
||||
CalendarPickerGroup(
|
||||
title = account,
|
||||
leading = { SourceLogo(cals.first().accountType) },
|
||||
calendars = cals,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||
@Composable
|
||||
private fun CalendarPickerGroup(
|
||||
title: String,
|
||||
leading: @Composable () -> Unit,
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
position = Position.Top,
|
||||
leading = leading,
|
||||
)
|
||||
calendars.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id == selectedId
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (index == calendars.lastIndex) Position.Bottom else Position.Middle,
|
||||
selected = isSelected,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = if (isSelected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [android.content.pm.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
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/** Neutral circular chip carrying an arbitrary icon (e.g. the local-device mark). */
|
||||
@Composable
|
||||
fun LeadingAvatar(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Preferred app for account types whose authenticator isn't the app to open. */
|
||||
internal fun curatedSourcePackage(accountType: String): String? = when {
|
||||
accountType.equals("com.google", ignoreCase = true) -> "com.google.android.calendar"
|
||||
else -> null
|
||||
}
|
||||
@@ -1,56 +1,26 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import android.provider.Settings
|
||||
import androidx.activity.BackEventCompat
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Whether the user has asked the system to remove animations (Settings →
|
||||
* Accessibility → "Remove animations", which sets the global animator duration
|
||||
* scale to 0). Compose animations do *not* honour this platform flag on their
|
||||
* own, so the shared motion helpers in this file check it and fall back to a
|
||||
* quick cross-fade — opacity only, no spatial movement — to respect the
|
||||
* vestibular intent of the setting while keeping state changes legible.
|
||||
*
|
||||
* Read once at composition; the scale changes rarely and only takes full effect
|
||||
* after a process restart anyway.
|
||||
* Calendar-specific motion. The family's generic content transitions — section
|
||||
* expand/collapse, item reveal, list relayout, cross-fade, predictive-back, and
|
||||
* the reduce-motion check — now live in floret-kit's identity module
|
||||
* (`expandEnter`, `collapseExit`, `itemEnter`, `animateItemMotion`,
|
||||
* `fadeThrough`, `Modifier.predictiveBack`, `rememberReduceMotion`). What stays
|
||||
* here is only what's specific to paging the calendar grid: the directional
|
||||
* month/week/day slide and the specs that feed it.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberReduceMotion(): Boolean {
|
||||
val resolver = LocalContext.current.contentResolver
|
||||
return remember(resolver) {
|
||||
Settings.Global.getFloat(resolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The M3 Expressive spatial spring used for the month/week slide: the *fast*
|
||||
@@ -98,128 +68,3 @@ fun calendarSlideTransition(
|
||||
return slideInHorizontally(spec) { w -> dir * w }
|
||||
.togetherWith(slideOutHorizontally(spec) { w -> -dir * w })
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-fade [ContentTransform] for swapping whole screens or content blocks
|
||||
* where there is no meaningful spatial direction (e.g. onboarding gates). Pure
|
||||
* opacity, so it doubles as its own reduced-motion form.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarFadeThrough(): ContentTransform {
|
||||
val fade = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
||||
return fadeIn(fade).togetherWith(fadeOut(fade))
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter transition for a vertically-revealed section (expandable rows, inline
|
||||
* fields): height grows from the top while fading in. Under reduced motion the
|
||||
* height growth is dropped, leaving a quick fade.
|
||||
*
|
||||
* Pair with [calendarCollapseExit]. This is the promoted form of the pattern
|
||||
* originally inlined in the event edit form, so every expandable surface in the
|
||||
* app reveals the same way.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarExpandEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||
}
|
||||
}
|
||||
|
||||
/** Exit counterpart to [calendarExpandEnter]: shrink + fade, or fade only under reduced motion. */
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarCollapseExit(reduceMotion: Boolean = rememberReduceMotion()): ExitTransition {
|
||||
val fade = fadeOut(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + fade
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter transition for content revealed by an `AnimatedContent`/`AnimatedVisibility`
|
||||
* (e.g. search results once a query resolves): a gentle rise + fade. Reduced
|
||||
* motion keeps the fade only.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun calendarItemEnter(reduceMotion: Boolean = rememberReduceMotion()): EnterTransition {
|
||||
val fade = fadeIn(MaterialTheme.motionScheme.fastEffectsSpec())
|
||||
return if (reduceMotion) {
|
||||
fade
|
||||
} else {
|
||||
fade + slideInVertically(MaterialTheme.motionScheme.fastSpatialSpec()) { h -> h / 6 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared [LazyItemScope.animateItem] wiring so list rows fade/relocate with the
|
||||
* app's motion scheme instead of Compose's default spring. Returns a bare
|
||||
* [Modifier] under reduced motion so rows snap into place. Requires the list to
|
||||
* supply stable item keys.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun LazyItemScope.calendarAnimateItem(reduceMotion: Boolean = rememberReduceMotion()): Modifier =
|
||||
if (reduceMotion) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.animateItem(
|
||||
fadeInSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
placementSpec = MaterialTheme.motionScheme.fastSpatialSpec(),
|
||||
fadeOutSpec = MaterialTheme.motionScheme.fastEffectsSpec(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard Android predictive-back transform for a full-screen overlay: as the
|
||||
* back gesture is dragged, the surface scales toward ~90%, shifts toward the
|
||||
* swiped edge and rounds its corners, previewing what's behind. Completing the
|
||||
* gesture invokes [onBack]; cancelling springs it back.
|
||||
*
|
||||
* A drop-in replacement for a screen's own `BackHandler(onBack)` — register it
|
||||
* once and apply the returned [Modifier] to that screen's root so the preview
|
||||
* respects the same back semantics. Under reduced motion the visual preview is
|
||||
* skipped (the back still works); on API < 34 the system delivers no progress,
|
||||
* so it degrades to a plain back.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.predictiveBack(
|
||||
onBack: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
reduceMotion: Boolean = rememberReduceMotion(),
|
||||
): Modifier {
|
||||
val progress = remember { Animatable(0f) }
|
||||
var fromLeftEdge by remember { mutableStateOf(true) }
|
||||
|
||||
PredictiveBackHandler(enabled = enabled) { events ->
|
||||
try {
|
||||
events.collect { event ->
|
||||
fromLeftEdge = event.swipeEdge == BackEventCompat.EDGE_LEFT
|
||||
progress.snapTo(FastOutSlowInEasing.transform(event.progress))
|
||||
}
|
||||
onBack()
|
||||
progress.snapTo(0f)
|
||||
} catch (_: CancellationException) {
|
||||
progress.animateTo(0f)
|
||||
}
|
||||
}
|
||||
|
||||
if (reduceMotion) return this
|
||||
return this.graphicsLayer {
|
||||
val p = progress.value
|
||||
val scale = 1f - 0.1f * p
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = (if (fromLeftEdge) 1f else -1f) * 24.dp.toPx() * p
|
||||
shape = RoundedCornerShape(32.dp.toPx() * p)
|
||||
clip = p > 0f
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
|
||||
/**
|
||||
* A wrapping row of round colour swatches; the one matching [selected] is
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
|
||||
/**
|
||||
* A Flutter-style "DEBUG" corner ribbon, drawn across the top-right corner of
|
||||
* the app. Deliberately a stark, un-themed marker (not a product component) so a
|
||||
* debug build is unmistakable at a glance — gate it on `BuildConfig.DEBUG` at the
|
||||
* call site so it never reaches a release build. Non-interactive: it's a plain
|
||||
* label with no pointer handler, so taps fall through to whatever is beneath it.
|
||||
*
|
||||
* Drop it in as the last child of a full-screen [androidx.compose.foundation.layout.Box]
|
||||
* so it overlays the UI.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.DebugRibbon() {
|
||||
Text(
|
||||
text = "DEBUG",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.zIndex(1f)
|
||||
// Push the band out so its midline crosses the very corner, then
|
||||
// rotate it to the classic 45° ribbon.
|
||||
.offset(x = 36.dp, y = 24.dp)
|
||||
.rotate(45f)
|
||||
.background(Color(0xFFB23B00))
|
||||
.width(140.dp)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Tonal 3-digit number input shared by the custom reminder/recurrence steps and
|
||||
* the reminder pickers — the app's [InlineTextField] over a tonal surface, so it
|
||||
* matches the card/grouped-row design language (not Material's outlined field).
|
||||
*/
|
||||
@Composable
|
||||
fun DialogAmountField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
) {
|
||||
// surfaceContainerHighest — the picker/dialog sits on surfaceContainerHigh,
|
||||
// so anything lower vanishes.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
InlineTextField(
|
||||
value = value,
|
||||
onValueChange = { text ->
|
||||
if (text.length <= 3 && text.all(Char::isDigit)) onValueChange(text)
|
||||
},
|
||||
placeholder = placeholder,
|
||||
textStyle = MaterialTheme.typography.titleMedium,
|
||||
keyboardType = KeyboardType.Number,
|
||||
modifier = Modifier
|
||||
.width(72.dp)
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Tonal dropdown trigger + menu shared by the custom reminder/recurrence steps and pickers. */
|
||||
@Composable
|
||||
fun DialogUnitDropdown(
|
||||
label: String,
|
||||
entries: List<String>,
|
||||
onPick: (Int) -> Unit,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
onClick = { open = true },
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 12.dp),
|
||||
) {
|
||||
Text(text = label, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(imageVector = Icons.Default.ArrowDropDown, contentDescription = null)
|
||||
}
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
entries.forEachIndexed { index, entry ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(entry) },
|
||||
onClick = {
|
||||
onPick(index)
|
||||
open = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* Position of a row within a grouped list, after the Android-15 settings
|
||||
* pattern: a run of rows shares one rounded container, with full corners at the
|
||||
* group's outer edges and small corners between, separated by small gaps.
|
||||
*/
|
||||
enum class Position { Top, Middle, Bottom, Alone }
|
||||
|
||||
/** Maps an index within a group of [count] rows to its [Position]. */
|
||||
fun positionOf(index: Int, count: Int): Position = when {
|
||||
count <= 1 -> Position.Alone
|
||||
index == 0 -> Position.Top
|
||||
index == count - 1 -> Position.Bottom
|
||||
else -> Position.Middle
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's standard full-screen list scaffold. By default a collapsing
|
||||
* [LargeTopAppBar] whose title shrinks into the bar (next to the back button) as
|
||||
* the content scrolls — used by Settings and the calendar manager, where the
|
||||
* large header sets the page. Content is a scrollable column that feeds the
|
||||
* toolbar via nested scroll.
|
||||
*
|
||||
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
|
||||
* title sits in the bar from the start, with no expanded header to scroll past.
|
||||
* Preferred for selection pickers, where the tall header is just wasted space
|
||||
* above the options.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CollapsingScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
largeTopBar: Boolean = true,
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val scrollBehavior = if (largeTopBar) {
|
||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
} else {
|
||||
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
}
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.predictiveBack(onBack = onBack)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
val navigationIcon = @Composable {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
val colors = TopAppBarDefaults.topAppBarColors(
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
)
|
||||
if (largeTopBar) {
|
||||
LargeTopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = colors,
|
||||
)
|
||||
} else {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = navigationIcon,
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = colors,
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = snackbarHost,
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
// Mark the scaffold's system-bar insets as consumed so the
|
||||
// imePadding below adds only the keyboard height beyond them
|
||||
// (max, not sum) — otherwise the nav-bar inset double-counts and
|
||||
// leaves an empty strip above the keyboard.
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.fillMaxSize()
|
||||
// Paint the surface across the full area before imePadding carves
|
||||
// into it, so any sliver above the keyboard reads as surface — not
|
||||
// the dialog window's black — during the IME animation.
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// Shrink the scroll viewport by the keyboard inset so a focused
|
||||
// field (e.g. the custom-reminder amount) can scroll into view.
|
||||
.imePadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(top = 8.dp, bottom = 24.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in a grouped list: an M3 [ListItem] over a tonal [Surface] whose
|
||||
* corner radii come from its [position] (so a run of rows reads as a single
|
||||
* rounded card). Corners round further on press. A null [onClick] makes the
|
||||
* row non-interactive (e.g. read-only entries). [dimmed] fades the headline and
|
||||
* summary to the M3 disabled emphasis while leaving the [trailing] control at
|
||||
* full opacity — for rows that are present but switched off.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GroupedRow(
|
||||
title: String,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
summary: String? = null,
|
||||
selected: Boolean = false,
|
||||
dimmed: Boolean = false,
|
||||
container: Color? = null,
|
||||
minHeight: Dp = 72.dp,
|
||||
// The 2.dp separation between rows in a run. Suppressed by the reorderable
|
||||
// list, which owns uniform spacing itself so every slot has the same pitch.
|
||||
gapBelow: Boolean = true,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val interaction = remember { MutableInteractionSource() }
|
||||
val pressed by interaction.collectIsPressedAsState()
|
||||
val full by animateDpAsState(if (pressed) 36.dp else 22.dp, label = "fullCorner")
|
||||
val small by animateDpAsState(if (pressed) 36.dp else 6.dp, label = "smallCorner")
|
||||
val shape = when (position) {
|
||||
Position.Alone -> RoundedCornerShape(full)
|
||||
Position.Top -> RoundedCornerShape(
|
||||
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
||||
)
|
||||
Position.Middle -> RoundedCornerShape(small)
|
||||
Position.Bottom -> RoundedCornerShape(
|
||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||
)
|
||||
}
|
||||
val gap = when {
|
||||
!gapBelow -> Modifier
|
||||
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||
else -> Modifier
|
||||
}
|
||||
val itemColors = if (selected) {
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
leadingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
supportingColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
trailingIconColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
} else if (dimmed) {
|
||||
// M3 disabled emphasis (0.38α) on the text/leading; the trailing control
|
||||
// stays full-opacity so the toggle reads as live even on a faded row.
|
||||
val muted = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = muted,
|
||||
leadingIconColor = muted,
|
||||
supportingColor = muted,
|
||||
)
|
||||
} else {
|
||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||
}
|
||||
val item: @Composable () -> Unit = {
|
||||
ListItem(
|
||||
headlineContent = { Text(title) },
|
||||
supportingContent = summary?.let { text -> { Text(text) } },
|
||||
leadingContent = leading,
|
||||
trailingContent = trailing,
|
||||
colors = itemColors,
|
||||
modifier = Modifier.heightIn(min = minHeight),
|
||||
)
|
||||
}
|
||||
val base = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.then(gap)
|
||||
val containerColor = when {
|
||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||
container != null -> container
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
if (onClick != null) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = containerColor,
|
||||
shape = shape,
|
||||
interactionSource = interaction,
|
||||
modifier = base,
|
||||
) { item() }
|
||||
} else {
|
||||
Surface(color = containerColor, shape = shape, modifier = base) { item() }
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The app's borderless text input: no underline, no outline, just the tonal
|
||||
* card behind it. This is the standard input across the app — we deliberately
|
||||
* don't use Material's outlined/filled text fields, so anything that takes text
|
||||
* (the event form, the calendar manager, dialogs) uses this inside a tonal
|
||||
* [androidx.compose.material3.Surface].
|
||||
*/
|
||||
@Composable
|
||||
fun InlineTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
placeholder: String,
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
|
||||
singleLine: Boolean = true,
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||
imeAction: ImeAction = ImeAction.Default,
|
||||
/** Invoked when the IME action key (e.g. Done) is pressed. */
|
||||
onImeAction: (() -> Unit)? = null,
|
||||
) {
|
||||
val resolvedStyle = textStyle.copy(
|
||||
color = when {
|
||||
// A disabled field reads as dimmed, like a locked value.
|
||||
!enabled -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
textStyle.color.isSpecified -> textStyle.color
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
textStyle = resolvedStyle,
|
||||
singleLine = singleLine,
|
||||
minLines = minLines,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
capitalization = capitalization,
|
||||
imeAction = imeAction,
|
||||
),
|
||||
keyboardActions = onImeAction?.let { action ->
|
||||
KeyboardActions(onAny = { action() })
|
||||
} ?: KeyboardActions.Default,
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
decorationBox = { innerTextField ->
|
||||
Box {
|
||||
if (value.isEmpty()) {
|
||||
// Clearly fainter than typed text, so a hint never reads as
|
||||
// prefilled content.
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = resolvedStyle,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The app's standard pick in a selection dialog: a full-width tonal card,
|
||||
* optionally with a leading icon and a supporting line; the selected option
|
||||
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
|
||||
* only sanctioned selection-modal style (no radio rows, no bare text lists).
|
||||
*/
|
||||
@Composable
|
||||
fun OptionCard(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector? = null,
|
||||
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
|
||||
iconTint: Color = Color.Unspecified,
|
||||
supportingText: String? = null,
|
||||
selected: Boolean = false,
|
||||
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
|
||||
labelColor: Color = Color.Unspecified,
|
||||
) {
|
||||
val contentColor = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
}
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.secondaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = when {
|
||||
iconTint.isSpecified -> iconTint
|
||||
selected -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
Column {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = if (labelColor.isSpecified) labelColor else contentColor,
|
||||
)
|
||||
if (supportingText != null) {
|
||||
Text(
|
||||
text = supportingText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (selected) {
|
||||
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,8 @@ import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -33,92 +31,22 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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
|
||||
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.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideForMinutes
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
|
||||
/**
|
||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||
* reuses the app's [CollapsingScaffold] (back button + full width), but with a
|
||||
* pinned single-line title rather than the large collapsing header — a picker is
|
||||
* a short list, so the tall header would only be empty space to scroll past.
|
||||
* [content] places the connected grouped rows; selecting one calls [onDismiss].
|
||||
*/
|
||||
@Composable
|
||||
fun FullScreenPicker(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
// The dialog window pans by default when the keyboard opens, which —
|
||||
// combined with the content's own imePadding — leaves a fixed black gap
|
||||
// above the keyboard. Switch it to ADJUST_NOTHING so the window stays
|
||||
// full-screen and imePadding alone lifts the focused field.
|
||||
val view = LocalView.current
|
||||
SideEffect {
|
||||
(view.parent as? DialogWindowProvider)?.window
|
||||
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
}
|
||||
CollapsingScaffold(
|
||||
title = title,
|
||||
onBack = onDismiss,
|
||||
largeTopBar = false,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* General single-select picker, full-screen: each option is a connected grouped
|
||||
* row and the current one carries a check. Drop-in for the former dialog
|
||||
* (theme, week start, language, …).
|
||||
*/
|
||||
@Composable
|
||||
fun <T> OptionPicker(
|
||||
title: String,
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
label: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
) {
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
header?.invoke(this)
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = label(option),
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||
@@ -128,7 +56,7 @@ fun <T> OptionPicker(
|
||||
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
||||
* defers to the global default. Clearing the last checked time reverts to "Use
|
||||
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
||||
* wipe the calendar's default) and to explicit [CalendarReminderOverride.None]
|
||||
* wipe the calendar's default) and to explicit [ReminderOverride.None]
|
||||
* on the global default (where empty legitimately means no reminder). A "Custom"
|
||||
* row expands an inline number field plus a unit selector to add an arbitrary
|
||||
* lead time to the set. Changes apply live via [onSelect]; the user leaves via
|
||||
@@ -138,9 +66,9 @@ fun <T> OptionPicker(
|
||||
fun ReminderDefaultPicker(
|
||||
title: String,
|
||||
presets: List<Int>,
|
||||
selected: CalendarReminderOverride,
|
||||
selected: ReminderOverride,
|
||||
allowInherit: Boolean,
|
||||
onSelect: (CalendarReminderOverride) -> Unit,
|
||||
onSelect: (ReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Optimistic local state: once the user edits, the chosen override is
|
||||
@@ -158,9 +86,9 @@ fun ReminderDefaultPicker(
|
||||
LaunchedEffect(selected) {
|
||||
if (!userEdited) current = selected
|
||||
}
|
||||
val inherits = current is CalendarReminderOverride.Inherit
|
||||
val isNone = current is CalendarReminderOverride.None
|
||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
val inherits = current is ReminderOverride.Inherit
|
||||
val isNone = current is ReminderOverride.None
|
||||
val selectedMinutes = (current as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||
// its row (unchecked) until the picker closes instead of vanishing mid-tap,
|
||||
// which would strand a hand-entered value with no way to re-check it.
|
||||
@@ -173,7 +101,7 @@ fun ReminderDefaultPicker(
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
|
||||
fun apply(override: CalendarReminderOverride) {
|
||||
fun apply(override: ReminderOverride) {
|
||||
userEdited = true
|
||||
current = override
|
||||
onSelect(override)
|
||||
@@ -197,7 +125,7 @@ fun ReminderDefaultPicker(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
||||
onClick = { apply(ReminderOverride.Inherit) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
@@ -209,7 +137,7 @@ fun ReminderDefaultPicker(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.None) },
|
||||
onClick = { apply(ReminderOverride.None) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
val rowCount = rows.size + 1 // + the custom row
|
||||
@@ -232,8 +160,8 @@ fun ReminderDefaultPicker(
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
CustomReminderEditor(
|
||||
amountText = amountText,
|
||||
@@ -250,26 +178,6 @@ fun ReminderDefaultPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The override a lead-time set maps to when emitted from [ReminderDefaultPicker].
|
||||
* A non-empty set is [CalendarReminderOverride.Minutes]; an empty set (the last
|
||||
* time was unchecked) reverts to [CalendarReminderOverride.Inherit] on a
|
||||
* per-calendar picker ([allowInherit]) so an accidental toggle-undo can't
|
||||
* silently wipe the calendar's default, and to explicit
|
||||
* [CalendarReminderOverride.None] on the global default (where empty legitimately
|
||||
* means no reminder). Pure so it can be unit-tested.
|
||||
*/
|
||||
internal fun reminderOverrideForMinutes(
|
||||
minutes: List<Int>,
|
||||
allowInherit: Boolean,
|
||||
): CalendarReminderOverride {
|
||||
val norm = minutes.distinct().sorted()
|
||||
return when {
|
||||
norm.isNotEmpty() -> CalendarReminderOverride.Minutes(norm)
|
||||
allowInherit -> CalendarReminderOverride.Inherit
|
||||
else -> CalendarReminderOverride.None
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The expanded "Custom" lead-time editor: a tonal card connected to the Custom
|
||||
@@ -432,8 +340,8 @@ fun AgendaRangePicker(
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = customExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
CustomDaysEditor(
|
||||
amountText = amountText,
|
||||
|
||||
@@ -5,18 +5,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderUnit
|
||||
|
||||
/** Common reminder lead times offered as quick picks in the form and settings. */
|
||||
val REMINDER_PRESETS = listOf(0, 10, 30, 60, 1_440)
|
||||
|
||||
/** The unit of a custom reminder lead time; [minutesFactor] converts to minutes. */
|
||||
enum class ReminderUnit(val minutesFactor: Int) {
|
||||
Minutes(1),
|
||||
Hours(60),
|
||||
Days(1_440),
|
||||
Weeks(10_080),
|
||||
}
|
||||
|
||||
@StringRes
|
||||
fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) {
|
||||
ReminderUnit.Minutes -> R.string.reminder_unit_minutes
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
|
||||
val ReorderableRowHeight: Dp = 64.dp
|
||||
private val RowGap: Dp = 2.dp
|
||||
|
||||
/**
|
||||
* A vertical list whose rows can be dragged into a new order by their handle.
|
||||
*
|
||||
* Built for the short, fixed grouped-card lists in Settings (#24) — no external
|
||||
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
|
||||
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
|
||||
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
||||
* uniform gap, so a row's target slot is simply how many whole pitches it has
|
||||
* been dragged. The held row follows the finger while the others slide out of
|
||||
* its way (animated); on release the order is committed immediately with a
|
||||
* single [onReorder] call, then the held row eases into its new slot as a
|
||||
* purely visual settle — safe to interrupt with another drag, since the
|
||||
* commit itself never waits on it.
|
||||
*
|
||||
* [rowContent] receives the [Position] for the row's place in the order (to reuse
|
||||
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
||||
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> ReorderableColumn(
|
||||
items: List<T>,
|
||||
keyOf: (T) -> Any,
|
||||
onReorder: (List<T>) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
|
||||
) {
|
||||
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Local working copy; re-seeded when the incoming list changes (including the
|
||||
// echo of our own committed order).
|
||||
var order by remember(items) { mutableStateOf(items) }
|
||||
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
||||
// Live translation of the held row from its slot (px); also drives the
|
||||
// release settle — re-based onto the row's new slot once the order commits
|
||||
// (see onDragEnd), then eased down to zero.
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
||||
// Purely visual — the order commit (see onDragEnd) never depends on it.
|
||||
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||
// Whole slots dragged → the slot the held row currently hovers over. Derived
|
||||
// so recomposition only fires when the *target slot* actually changes, not on
|
||||
// every dragged pixel: dragOffset moves every frame during a drag, but the
|
||||
// held row's own translation is already applied in the draw phase via
|
||||
// graphicsLayer below, so only the neighbours' shift (which depends on this)
|
||||
// needs to recompose, and only when a slot boundary is actually crossed.
|
||||
// (Read as a plain val, not `by`, below: a delegated property has a custom
|
||||
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
|
||||
// the loop, so the derived value is captured into a real local instead.)
|
||||
val targetIndex = remember(items, pitchPx) {
|
||||
derivedStateOf {
|
||||
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
|
||||
}
|
||||
}.value
|
||||
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
|
||||
order.forEachIndexed { index, item ->
|
||||
val key = keyOf(item)
|
||||
val isDragged = key == draggedKey
|
||||
|
||||
// Slide neighbours by one pitch to open the gap the held row will drop
|
||||
// into. Snap (not animate) once idle, so committing the new order — which
|
||||
// moves each row's slot — doesn't visibly fight a lingering animation.
|
||||
val shift = when {
|
||||
draggedIndex == null || targetIndex == null || isDragged -> 0f
|
||||
index in (draggedIndex + 1)..targetIndex -> -pitchPx
|
||||
index in targetIndex until draggedIndex -> pitchPx
|
||||
else -> 0f
|
||||
}
|
||||
val animatedShift by animateFloatAsState(
|
||||
targetValue = shift,
|
||||
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
|
||||
label = "reorderShift",
|
||||
)
|
||||
|
||||
val dragHandle = Modifier.pointerInput(key) {
|
||||
detectDragGestures(
|
||||
onDragStart = {
|
||||
settleJob?.cancel()
|
||||
draggedKey = key
|
||||
dragOffset = 0f
|
||||
},
|
||||
onDrag = { change, amount ->
|
||||
change.consume()
|
||||
dragOffset += amount.y
|
||||
},
|
||||
onDragEnd = {
|
||||
val from = order.indexOfFirst { keyOf(it) == key }
|
||||
if (from < 0) return@detectDragGestures
|
||||
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||
if (to != from) {
|
||||
// Commit synchronously and unconditionally, before the settle
|
||||
// animation below runs — the commit must not depend on that
|
||||
// coroutine reaching its end, or a new drag starting within the
|
||||
// ~160ms settle window would cancel it and silently revert an
|
||||
// already-finished reorder.
|
||||
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
||||
onReorder(order)
|
||||
// The row now lays out at slot `to` instead of `from`; re-base
|
||||
// the live offset onto that new slot (same visual position,
|
||||
// expressed relative to the new one) so the settle below eases
|
||||
// it the rest of the way instead of jumping.
|
||||
dragOffset -= (to - from) * pitchPx
|
||||
}
|
||||
settleJob = scope.launch {
|
||||
// Purely visual from here: ease the held row onto its slot, then
|
||||
// release the drag state. Safe to cancel — the order was already
|
||||
// committed above.
|
||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||
draggedKey = null
|
||||
dragOffset = 0f
|
||||
}
|
||||
},
|
||||
onDragCancel = {
|
||||
settleJob = scope.launch {
|
||||
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||
draggedKey = null
|
||||
dragOffset = 0f
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.height(ReorderableRowHeight)
|
||||
.zIndex(if (isDragged) 1f else 0f)
|
||||
.graphicsLayer {
|
||||
translationY = if (isDragged) dragOffset else animatedShift
|
||||
if (isDragged) {
|
||||
scaleX = 1.02f
|
||||
scaleY = 1.02f
|
||||
shadowElevation = 8.dp.toPx()
|
||||
shape = RoundedCornerShape(20.dp)
|
||||
clip = false
|
||||
}
|
||||
},
|
||||
) {
|
||||
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
|
||||
/**
|
||||
* A deliberately minimal, standalone surface for a captured crash report.
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.crash
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BugReport
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* Asks the user to send a captured crash report as an issue. The full report is
|
||||
* shown verbatim in a scrollable panel — the user sees exactly what will leave
|
||||
* the device before choosing to share it (the privacy backstop). [onSend] hands
|
||||
* off to [submitCrashReport]; [onDismiss] declines.
|
||||
*/
|
||||
@Composable
|
||||
fun CrashReportDialog(
|
||||
report: String,
|
||||
onSend: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
icon = { Icon(Icons.Default.BugReport, contentDescription = null) },
|
||||
title = { Text(stringResource(R.string.crash_dialog_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.crash_dialog_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = report,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.heightIn(max = 220.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onSend) { Text(stringResource(R.string.crash_dialog_report)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.crash_dialog_dismiss)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.crash
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.core.net.toUri
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* Hand the captured crash report off to the user's chosen channel: the report
|
||||
* is copied to the clipboard (the reliable path for a full stack trace) and the
|
||||
* project's Gitea "new issue" page is opened with the body prefilled. Nothing is
|
||||
* sent automatically — the app has no network access; the user reviews and
|
||||
* submits the issue themselves.
|
||||
*/
|
||||
fun submitCrashReport(context: Context, report: String) {
|
||||
copyReportToClipboard(context, report)
|
||||
val opened = runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, buildIssueUri(context, report)))
|
||||
}.isSuccess
|
||||
val message = if (opened) R.string.crash_report_copied else R.string.crash_report_open_failed
|
||||
Toast.makeText(context, message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
/** Open the issue tracker's template chooser for a manual (non-crash) report. */
|
||||
fun openIssueTracker(context: Context) {
|
||||
val uri = context.getString(R.string.report_issue_choose_url).toUri()
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, uri)) }
|
||||
}
|
||||
|
||||
private fun copyReportToClipboard(context: Context, report: String) {
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||
val label = context.getString(R.string.crash_report_clip_label)
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText(label, report))
|
||||
}
|
||||
|
||||
/**
|
||||
* The Gitea `issues/new` URL with `title` and `body` prefilled. A full report
|
||||
* can blow past URL-length limits, so an over-long one is left out of the link
|
||||
* (with a "paste from clipboard" placeholder) — the clipboard copy is the
|
||||
* source of truth in that case.
|
||||
*/
|
||||
private fun buildIssueUri(context: Context, report: String) =
|
||||
context.getString(R.string.report_issue_url).toUri().buildUpon()
|
||||
.appendQueryParameter("title", context.getString(R.string.crash_report_issue_title))
|
||||
.appendQueryParameter("body", buildIssueBody(context, report))
|
||||
.build()
|
||||
|
||||
private fun buildIssueBody(context: Context, report: String): String {
|
||||
val block = if (report.length > MAX_URL_REPORT_CHARS) {
|
||||
context.getString(R.string.crash_report_body_paste)
|
||||
} else {
|
||||
"```\n$report\n```"
|
||||
}
|
||||
return context.getString(R.string.crash_report_body_template, block)
|
||||
}
|
||||
|
||||
/** Keep the prefilled body comfortably under common URL-length ceilings. */
|
||||
private const val MAX_URL_REPORT_CHARS = 6_000
|
||||
@@ -77,9 +77,9 @@ import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
|
||||
@@ -91,13 +91,13 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
import de.jeanlucmakiola.floret.components.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.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -37,7 +37,6 @@ import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Contacts
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
@@ -96,6 +95,7 @@ import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -117,33 +117,35 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.floret.identity.animateContentSizeMotion
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||
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.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
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.floret.reminders.ReminderUnit
|
||||
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
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.common.recurrenceText
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -344,29 +346,29 @@ private fun SaveConflictDialog(
|
||||
onDiscard: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_edit_conflict_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(R.string.event_edit_conflict_body))
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_conflict_overwrite),
|
||||
supportingText = stringResource(R.string.event_edit_conflict_overwrite_hint),
|
||||
onClick = onOverwrite,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_conflict_discard),
|
||||
supportingText = stringResource(R.string.event_edit_conflict_discard_hint),
|
||||
onClick = onDiscard,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_edit_conflict_title),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_conflict_body),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_conflict_overwrite),
|
||||
summary = stringResource(R.string.event_edit_conflict_overwrite_hint),
|
||||
position = positionOf(0, 2),
|
||||
onClick = onOverwrite,
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_conflict_discard),
|
||||
summary = stringResource(R.string.event_edit_conflict_discard_hint),
|
||||
position = positionOf(1, 2),
|
||||
onClick = onDiscard,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -422,8 +424,8 @@ private fun OptionalFormSection(
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth(), content = content)
|
||||
}
|
||||
@@ -520,16 +522,23 @@ private fun EventEditContent(
|
||||
.padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp),
|
||||
) {
|
||||
// Title: borderless headline, mirroring the detail screen's title +
|
||||
// accent bar instead of a boxed Material text field.
|
||||
// accent bar instead of a boxed Material text field. Multi-line so long
|
||||
// titles wrap instead of scrolling off one line (#33); newlines are
|
||||
// stripped in setTitle, so this stays a single logical line — the Enter
|
||||
// key just has no effect.
|
||||
InlineField(
|
||||
value = form.title,
|
||||
onValueChange = viewModel::setTitle,
|
||||
placeholder = stringResource(R.string.event_edit_title_hint),
|
||||
textStyle = MaterialTheme.typography.headlineMedium
|
||||
.copy(fontWeight = FontWeight.SemiBold),
|
||||
singleLine = false,
|
||||
enabled = !locked,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Ease the height change as the title wraps to another line
|
||||
// instead of letting the accent bar + cards below jump.
|
||||
.animateContentSizeMotion()
|
||||
.padding(vertical = 4.dp)
|
||||
.focusRequester(titleFocusRequester),
|
||||
)
|
||||
@@ -1056,24 +1065,19 @@ private fun FieldPickerDialog(
|
||||
onSelect: (EventFormField) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_edit_more_fields)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
hiddenFields.forEach { field ->
|
||||
OptionCard(
|
||||
label = stringResource(eventFormFieldLabel(field)),
|
||||
onClick = { onSelect(field) },
|
||||
icon = eventFormFieldIcon(field),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_edit_more_fields),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
hiddenFields.forEachIndexed { index, field ->
|
||||
GroupedRow(
|
||||
title = stringResource(eventFormFieldLabel(field)),
|
||||
position = positionOf(index, hiddenFields.size),
|
||||
leading = { Icon(imageVector = eventFormFieldIcon(field), contentDescription = null) },
|
||||
onClick = { onSelect(field) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Quick-pick lead times offered as chips in the reminder dialog. */
|
||||
@@ -1098,41 +1102,10 @@ private fun ReminderPickerDialog(
|
||||
?.takeIf { it in 1..999 }
|
||||
?.let { it * unit.minutesFactor }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_edit_add_reminder)) },
|
||||
text = {
|
||||
if (!customMode) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen }.forEach { minutes ->
|
||||
OptionCard(
|
||||
label = reminderLabel(minutes),
|
||||
onClick = { onSelect(minutes) },
|
||||
)
|
||||
}
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_reminder_custom),
|
||||
onClick = { customMode = true },
|
||||
labelColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = { amountText = it },
|
||||
placeholder = "10",
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(
|
||||
label = stringResource(reminderUnitLabel(unit)),
|
||||
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||
onPick = { unit = ReminderUnit.entries[it] },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_edit_add_reminder),
|
||||
onDismiss = onDismiss,
|
||||
actions = {
|
||||
// The quick-pick list adds on tap; only the custom step needs Add.
|
||||
if (customMode) {
|
||||
TextButton(
|
||||
@@ -1141,10 +1114,41 @@ private fun ReminderPickerDialog(
|
||||
) { Text(stringResource(R.string.event_edit_add)) }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
) {
|
||||
if (!customMode) {
|
||||
val presets = REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen }
|
||||
val rowCount = presets.size + 1
|
||||
presets.forEachIndexed { index, minutes ->
|
||||
GroupedRow(
|
||||
title = reminderLabel(minutes),
|
||||
position = positionOf(index, rowCount),
|
||||
onClick = { onSelect(minutes) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_reminder_custom),
|
||||
position = positionOf(rowCount - 1, rowCount),
|
||||
onClick = { customMode = true },
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
) {
|
||||
DialogAmountField(
|
||||
value = amountText,
|
||||
onValueChange = { amountText = it },
|
||||
placeholder = "10",
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(
|
||||
label = stringResource(reminderUnitLabel(unit)),
|
||||
entries = ReminderUnit.entries.map { stringResource(reminderUnitLabel(it)) },
|
||||
onPick = { unit = ReminderUnit.entries[it] },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** How a custom recurrence ends; mirrors [RecurrenceEnd] in saveable form. */
|
||||
@@ -1214,109 +1218,10 @@ private fun RecurrencePickerDialog(
|
||||
null
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_detail_recurrence)) },
|
||||
text = {
|
||||
if (!customMode) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_recurrence_none),
|
||||
onClick = { onSelect(null) },
|
||||
selected = current == null,
|
||||
)
|
||||
RecurrenceFreq.entries.forEach { entry ->
|
||||
OptionCard(
|
||||
label = stringResource(recurrencePresetLabel(entry)),
|
||||
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
||||
selected = isPlainPreset && parsed?.freq == entry,
|
||||
)
|
||||
}
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_recurrence_custom),
|
||||
onClick = { customMode = true },
|
||||
selected = current != null && !isPlainPreset,
|
||||
labelColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_recurrence_every),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogAmountField(
|
||||
value = intervalText,
|
||||
onValueChange = { intervalText = it },
|
||||
placeholder = "1",
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(
|
||||
label = stringResource(recurrenceUnitLabel(freq)),
|
||||
entries = RecurrenceFreq.entries.map {
|
||||
stringResource(recurrenceUnitLabel(it))
|
||||
},
|
||||
onPick = { freq = RecurrenceFreq.entries[it] },
|
||||
)
|
||||
}
|
||||
if (freq == RecurrenceFreq.Weekly) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
WeekdayToggleRow(
|
||||
selected = daysMask.toDaySet(),
|
||||
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
||||
locale = locale,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_recurrence_ends),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_recurrence_end_never),
|
||||
onClick = { endMode = RecurrenceEndMode.Never },
|
||||
selected = endMode == RecurrenceEndMode.Never,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_recurrence_end_until),
|
||||
onClick = {
|
||||
endMode = RecurrenceEndMode.Until
|
||||
showUntilPicker = true
|
||||
},
|
||||
supportingText = untilDate?.let {
|
||||
remember(it, locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(locale).format(it.toJavaLocalDate())
|
||||
}
|
||||
},
|
||||
selected = endMode == RecurrenceEndMode.Until,
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_edit_recurrence_end_count),
|
||||
onClick = { endMode = RecurrenceEndMode.Count },
|
||||
selected = endMode == RecurrenceEndMode.Count,
|
||||
)
|
||||
if (endMode == RecurrenceEndMode.Count) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
DialogAmountField(
|
||||
value = countText,
|
||||
onValueChange = { countText = it },
|
||||
placeholder = "10",
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_recurrence_times),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_recurrence),
|
||||
onDismiss = onDismiss,
|
||||
actions = {
|
||||
// The preset list applies on tap; only the custom step needs OK.
|
||||
if (customMode) {
|
||||
TextButton(
|
||||
@@ -1325,10 +1230,113 @@ private fun RecurrencePickerDialog(
|
||||
) { Text(stringResource(R.string.dialog_ok)) }
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
) {
|
||||
if (!customMode) {
|
||||
val rowCount = RecurrenceFreq.entries.size + 2
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_none),
|
||||
position = positionOf(0, rowCount),
|
||||
selected = current == null,
|
||||
onClick = { onSelect(null) },
|
||||
)
|
||||
RecurrenceFreq.entries.forEachIndexed { index, entry ->
|
||||
GroupedRow(
|
||||
title = stringResource(recurrencePresetLabel(entry)),
|
||||
position = positionOf(index + 1, rowCount),
|
||||
selected = isPlainPreset && parsed?.freq == entry,
|
||||
onClick = { onSelect(SimpleRecurrence(entry).toRRule()) },
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_custom),
|
||||
position = positionOf(rowCount - 1, rowCount),
|
||||
selected = current != null && !isPlainPreset,
|
||||
onClick = { customMode = true },
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_recurrence_every),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogAmountField(
|
||||
value = intervalText,
|
||||
onValueChange = { intervalText = it },
|
||||
placeholder = "1",
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
DialogUnitDropdown(
|
||||
label = stringResource(recurrenceUnitLabel(freq)),
|
||||
entries = RecurrenceFreq.entries.map {
|
||||
stringResource(recurrenceUnitLabel(it))
|
||||
},
|
||||
onPick = { freq = RecurrenceFreq.entries[it] },
|
||||
)
|
||||
}
|
||||
if (freq == RecurrenceFreq.Weekly) {
|
||||
WeekdayToggleRow(
|
||||
selected = daysMask.toDaySet(),
|
||||
onToggle = { day -> daysMask = daysMask xor day.toMaskBit() },
|
||||
locale = locale,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_recurrence_ends),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_end_never),
|
||||
position = positionOf(0, 3),
|
||||
selected = endMode == RecurrenceEndMode.Never,
|
||||
onClick = { endMode = RecurrenceEndMode.Never },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_end_until),
|
||||
summary = untilDate?.let {
|
||||
remember(it, locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(locale).format(it.toJavaLocalDate())
|
||||
}
|
||||
},
|
||||
position = positionOf(1, 3),
|
||||
selected = endMode == RecurrenceEndMode.Until,
|
||||
onClick = {
|
||||
endMode = RecurrenceEndMode.Until
|
||||
showUntilPicker = true
|
||||
},
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.event_edit_recurrence_end_count),
|
||||
position = positionOf(2, 3),
|
||||
selected = endMode == RecurrenceEndMode.Count,
|
||||
onClick = { endMode = RecurrenceEndMode.Count },
|
||||
)
|
||||
if (endMode == RecurrenceEndMode.Count) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 4.dp),
|
||||
) {
|
||||
DialogAmountField(
|
||||
value = countText,
|
||||
onValueChange = { countText = it },
|
||||
placeholder = "10",
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_recurrence_times),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showUntilPicker) {
|
||||
CalendarDatePickerDialog(
|
||||
@@ -1612,6 +1620,7 @@ private fun AddGuestInlineCard(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
placeholder = stringResource(R.string.event_edit_add_guest_hint),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done,
|
||||
onImeAction = { commit() },
|
||||
@@ -1692,24 +1701,14 @@ private fun VisibilityPickerDialog(
|
||||
onSelect: (AccessLevel) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_edit_visibility)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
AccessLevel.entries.forEach { level ->
|
||||
OptionCard(
|
||||
label = stringResource(accessLevelLabel(level)),
|
||||
onClick = { onSelect(level) },
|
||||
icon = accessLevelIcon(level),
|
||||
selected = level == selected,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.event_edit_visibility),
|
||||
options = AccessLevel.entries.toList(),
|
||||
selected = selected,
|
||||
label = { stringResource(accessLevelLabel(it)) },
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss,
|
||||
leading = { Icon(imageVector = accessLevelIcon(it), contentDescription = null) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1732,51 +1731,48 @@ private fun ColorPickerDialog(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_edit_color)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (palette.isNotEmpty()) {
|
||||
ColorSwatchRow(
|
||||
colors = palette.map { it.argb },
|
||||
selected = selected,
|
||||
onSelect = { argb ->
|
||||
palette.firstOrNull { it.argb == argb }
|
||||
?.let { onPickKey(it.key, it.argb) }
|
||||
},
|
||||
dark = dark,
|
||||
)
|
||||
} else {
|
||||
ColorSwatchRow(
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = selected,
|
||||
onSelect = onPickRaw,
|
||||
dark = dark,
|
||||
)
|
||||
if (syncWarning) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_color_sync_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
dismissButton = if (hasExplicitColor) {
|
||||
{
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_edit_color),
|
||||
onDismiss = onDismiss,
|
||||
actions = {
|
||||
if (hasExplicitColor) {
|
||||
TextButton(onClick = onClear) {
|
||||
Text(stringResource(R.string.event_edit_color_reset))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (palette.isNotEmpty()) {
|
||||
ColorSwatchRow(
|
||||
colors = palette.map { it.argb },
|
||||
selected = selected,
|
||||
onSelect = { argb ->
|
||||
palette.firstOrNull { it.argb == argb }
|
||||
?.let { onPickKey(it.key, it.argb) }
|
||||
},
|
||||
dark = dark,
|
||||
)
|
||||
} else {
|
||||
ColorSwatchRow(
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = selected,
|
||||
onSelect = onPickRaw,
|
||||
dark = dark,
|
||||
)
|
||||
if (syncWarning) {
|
||||
Text(
|
||||
text = stringResource(R.string.event_edit_color_sync_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) {
|
||||
@@ -1900,6 +1896,7 @@ private fun InlineField(
|
||||
minLines = minLines,
|
||||
enabled = enabled,
|
||||
keyboardType = keyboardType,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1975,50 +1972,14 @@ private fun CalendarPicker(
|
||||
onSelect: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Group by owning account (name, else type, else the calendar's own name),
|
||||
// preserving the provider's order within and across groups — the same
|
||||
// grouping [groupByAccount] applies for the drawer filter.
|
||||
val groups = remember(calendars) {
|
||||
calendars
|
||||
.groupBy {
|
||||
it.accountName.takeIf(String::isNotBlank)
|
||||
?: it.accountType.takeIf(String::isNotBlank)
|
||||
?: it.displayName
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_calendar),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
groups.forEach { (account, cals) ->
|
||||
Text(
|
||||
text = account,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
|
||||
)
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
val isSelected = calendar.id == selectedId
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = positionOf(index, cals.size),
|
||||
selected = isSelected,
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
trailing = if (isSelected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { onSelect(calendar.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
CalendarPickerGroups(
|
||||
calendars = calendars,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +336,11 @@ class EventEditViewModel @Inject constructor(
|
||||
_revealed.value = _revealed.value + field
|
||||
}
|
||||
|
||||
fun setTitle(value: String) = update { it.copy(title = value) }
|
||||
// The title field wraps (multi-line) so long titles stay visible (#33), but
|
||||
// a title is one logical line: drop any newline the IME's Enter key or a
|
||||
// paste would introduce, so it never reaches the provider's TITLE column.
|
||||
fun setTitle(value: String) =
|
||||
update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
|
||||
fun setLocation(value: String) = update { it.copy(location = value) }
|
||||
fun setDescription(value: String) = update { it.copy(description = value) }
|
||||
fun setAllDay(value: Boolean) {
|
||||
|
||||
@@ -21,8 +21,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
/**
|
||||
* Calendar-visibility filter (M3), rendered inline in the navigation drawer.
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
package de.jeanlucmakiola.calendula.ui.imports
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -18,6 +30,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
@@ -30,6 +43,12 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -38,14 +57,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
|
||||
/**
|
||||
* Handles an opened/received `.ics` file. A single event is handed straight to
|
||||
* the prefilled create form via [onOpenSingle]; several events show a target-
|
||||
* calendar picker and import in bulk (dedup by UID), then a result summary.
|
||||
* Empty/failed files show a short message and close.
|
||||
* Empty/failed files show a short message and close. [forceMany] keeps a
|
||||
* single-event file on the bulk path — used by the in-app restore, whose intent
|
||||
* is "restore a backup" rather than "add this one event".
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -53,9 +74,16 @@ fun ImportScreen(
|
||||
uri: Uri,
|
||||
onClose: () -> Unit,
|
||||
onOpenSingle: (EventForm) -> Unit,
|
||||
viewModel: ImportViewModel = hiltViewModel(),
|
||||
forceMany: Boolean = false,
|
||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
||||
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||
// across imports — its one-shot `load` guard would then show the *previous*
|
||||
// file's parsed state on the next import (a second restore, export→restore,
|
||||
// etc.). Keying per uri hands each distinct file a fresh VM (fresh Loading
|
||||
// state), while the same uri (rotation) reuses it and holds the result.
|
||||
viewModel: ImportViewModel = hiltViewModel(key = uri.toString()),
|
||||
) {
|
||||
LaunchedEffect(uri) { viewModel.load(uri) }
|
||||
LaunchedEffect(uri) { viewModel.load(uri, forceMany) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// A single event isn't shown here — it opens the create form for review.
|
||||
@@ -63,18 +91,55 @@ fun ImportScreen(
|
||||
(state as? ImportUiState.Single)?.let { onOpenSingle(it.form); onClose() }
|
||||
}
|
||||
|
||||
// Hoisted target calendar so the always-visible top-bar Import action can
|
||||
// read it without the user scrolling to a bottom button. Defaults to the
|
||||
// first *local* calendar — the first row the picker shows ("Your calendars"
|
||||
// group leads) — so the pre-selection lines up with the top of the list;
|
||||
// falls back to the first calendar if there are no local ones. Re-defaults
|
||||
// when the "many" list first arrives (keyed on it), then holds the pick.
|
||||
val many = state as? ImportUiState.Many
|
||||
val defaultTarget = many?.calendars?.let { cals ->
|
||||
(cals.firstOrNull { it.isLocal } ?: cals.firstOrNull())?.id
|
||||
}
|
||||
var selected by rememberSaveable(defaultTarget) { mutableStateOf(defaultTarget) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.predictiveBack(onBack = onClose)
|
||||
.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.import_title)) },
|
||||
title = {
|
||||
Text(
|
||||
if (many != null) {
|
||||
pluralStringResource(
|
||||
R.plurals.import_title_count,
|
||||
many.events.size,
|
||||
many.events.size,
|
||||
)
|
||||
} else {
|
||||
stringResource(R.string.import_title)
|
||||
},
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.event_edit_close))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Only meaningful in the multi-event picker with a writable
|
||||
// target; every other state has nothing to confirm here.
|
||||
if (many != null && many.calendars.isNotEmpty()) {
|
||||
Button(
|
||||
onClick = { selected?.let(viewModel::import) },
|
||||
enabled = selected != null,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.import_button))
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
@@ -90,7 +155,7 @@ fun ImportScreen(
|
||||
|
||||
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||
is ImportUiState.Many -> ManyContent(s, onImport = viewModel::import)
|
||||
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
|
||||
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||
}
|
||||
}
|
||||
@@ -98,84 +163,160 @@ fun ImportScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManyContent(state: ImportUiState.Many, onImport: (Long) -> Unit) {
|
||||
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
|
||||
// No writable calendar to import into — tell the user honestly.
|
||||
if (state.calendars.isEmpty()) {
|
||||
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||
return
|
||||
}
|
||||
var selected by rememberSaveable { mutableStateOf(state.calendars.first().id) }
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
.padding(top = 8.dp, bottom = 24.dp),
|
||||
) {
|
||||
Text(
|
||||
pluralStringResource(R.plurals.import_event_count, state.events.size, state.events.size),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
CalendarPickerGroups(
|
||||
calendars = state.calendars,
|
||||
selectedId = selected,
|
||||
onSelect = onSelect,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.import_target_header),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
state.calendars.forEach { calendar ->
|
||||
OptionCard(
|
||||
label = calendar.displayName,
|
||||
onClick = { selected = calendar.id },
|
||||
selected = calendar.id == selected,
|
||||
icon = null,
|
||||
)
|
||||
}
|
||||
state.warnings.forEach { WarningText(it) }
|
||||
Button(
|
||||
onClick = { onImport(selected) },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) {
|
||||
Text(pluralStringResource(R.plurals.import_action, state.events.size, state.events.size))
|
||||
if (state.warnings.isNotEmpty()) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
state.warnings.forEach { WarningText(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DoneContent(state: ImportUiState.Done, onClose: () -> Unit) {
|
||||
// A little expressive pop on the success badge — springs in on first show.
|
||||
val badgeScale = remember { Animatable(0.7f) }
|
||||
LaunchedEffect(Unit) {
|
||||
badgeScale.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Box(
|
||||
Modifier
|
||||
.size(112.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = badgeScale.value
|
||||
scaleY = badgeScale.value
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(56.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
stringResource(R.string.import_done_title),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.import_done_imported,
|
||||
state.summary.imported,
|
||||
state.summary.imported,
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
if (state.summary.skippedDuplicate > 0) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
pluralStringResource(
|
||||
R.plurals.import_done_skipped,
|
||||
state.summary.skippedDuplicate,
|
||||
state.summary.skippedDuplicate,
|
||||
),
|
||||
stringResource(R.string.import_done_dedup_note),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
Button(onClick = onClose, modifier = Modifier.padding(top = 12.dp)) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
ImportStatCard(
|
||||
count = state.summary.imported,
|
||||
label = stringResource(R.string.import_done_added_label),
|
||||
contentDescription = pluralStringResource(
|
||||
R.plurals.import_done_imported,
|
||||
state.summary.imported,
|
||||
state.summary.imported,
|
||||
),
|
||||
container = MaterialTheme.colorScheme.secondaryContainer,
|
||||
onContainer = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
if (state.summary.skippedDuplicate > 0) {
|
||||
ImportStatCard(
|
||||
count = state.summary.skippedDuplicate,
|
||||
label = stringResource(R.string.import_done_skipped_label),
|
||||
contentDescription = pluralStringResource(
|
||||
R.plurals.import_done_skipped,
|
||||
state.summary.skippedDuplicate,
|
||||
state.summary.skippedDuplicate,
|
||||
),
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
onContainer = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = onClose,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.import_close))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A big-number tonal tile summarising one import outcome (added / skipped). */
|
||||
@Composable
|
||||
private fun RowScope.ImportStatCard(
|
||||
count: Int,
|
||||
label: String,
|
||||
contentDescription: String,
|
||||
container: Color,
|
||||
onContainer: Color,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clearAndSetSemantics { this.contentDescription = contentDescription },
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = container,
|
||||
) {
|
||||
Column(
|
||||
Modifier.padding(vertical = 20.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
count.toString(),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = onContainer,
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = onContainer.copy(alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WarningText(warning: IcsParseWarning) {
|
||||
val text = when (warning) {
|
||||
|
||||
@@ -66,8 +66,13 @@ class ImportViewModel @Inject constructor(
|
||||
val state: StateFlow<ImportUiState> = _state.asStateFlow()
|
||||
private var started = false
|
||||
|
||||
/** Read + parse [uri] once; subsequent calls (recomposition) are ignored. */
|
||||
fun load(uri: Uri) {
|
||||
/**
|
||||
* Read + parse [uri] once; subsequent calls (recomposition) are ignored.
|
||||
* When [forceMany] is set (an in-app restore), a single-event file still goes
|
||||
* through the bulk picker + summary rather than the prefilled create form —
|
||||
* a restore is "bring back a backup", not "add this one event".
|
||||
*/
|
||||
fun load(uri: Uri, forceMany: Boolean = false) {
|
||||
if (started) return
|
||||
started = true
|
||||
viewModelScope.launch {
|
||||
@@ -77,19 +82,21 @@ class ImportViewModel @Inject constructor(
|
||||
_state.value = when {
|
||||
parsed == null -> ImportUiState.Failed
|
||||
parsed.events.isEmpty() -> ImportUiState.Empty
|
||||
parsed.events.size == 1 -> ImportUiState.Single(
|
||||
parsed.events.size == 1 && !forceMany -> ImportUiState.Single(
|
||||
form = parsed.events.single().toEventForm(TimeZone.currentSystemDefault()),
|
||||
warnings = parsed.warnings,
|
||||
)
|
||||
else -> {
|
||||
// A disabled calendar is removed from the app, so it can't be
|
||||
// an import target — exclude it alongside the read-only ones.
|
||||
// Managed special-dates calendars are contact-derived and
|
||||
// editor-locked, so they're not a valid destination either.
|
||||
val disabled = prefs.disabledCalendarIds.first()
|
||||
ImportUiState.Many(
|
||||
events = parsed.events,
|
||||
warnings = parsed.warnings,
|
||||
calendars = repository.calendars().first()
|
||||
.filter { it.canModifyContents && it.id !in disabled },
|
||||
.filter { it.canModifyContents && !it.isManaged && it.id !in disabled },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,16 +75,18 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
@@ -108,6 +110,7 @@ fun MonthScreen(
|
||||
val month by viewModel.month.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
val dimCompleted by viewModel.dimCompletedEvents.collectAsStateWithLifecycle()
|
||||
val showWeekNumbers by viewModel.showWeekNumbers.collectAsStateWithLifecycle()
|
||||
// The instant before which an event counts as completed, or null when dimming
|
||||
// is off. derivedStateOf keeps the per-minute "now" from recomposing the
|
||||
// screen while the setting is off (it stays null regardless of the tick).
|
||||
@@ -211,11 +214,12 @@ fun MonthScreen(
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
WeekdayHeader(weekStart = weekStart)
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
MonthContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
@@ -231,6 +235,7 @@ fun MonthScreen(
|
||||
private fun MonthContent(
|
||||
state: MonthUiState,
|
||||
slideDir: Int,
|
||||
showWeekNumbers: Boolean,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
@@ -276,6 +281,7 @@ private fun MonthContent(
|
||||
is MonthUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is MonthUiState.Success -> MonthGrid(
|
||||
state = s,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
)
|
||||
}
|
||||
@@ -325,7 +331,7 @@ private fun MonthTopBar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek) {
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||
val locale = currentLocale()
|
||||
val days = remember(weekStart, locale) {
|
||||
(0 until 7).map { offset ->
|
||||
@@ -337,6 +343,8 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
// Reserve the gutter so the weekday labels stay over their day columns.
|
||||
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
||||
days.forEach { dow ->
|
||||
val isWeekend = dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY
|
||||
val javaDow = java.time.DayOfWeek.of(dow.ordinal + 1)
|
||||
@@ -354,6 +362,9 @@ private fun WeekdayHeader(weekStart: DayOfWeek) {
|
||||
|
||||
private val EVENT_ROW_HEIGHT = 20.dp
|
||||
private val DAY_NUMBER_HEIGHT = 22.dp
|
||||
/** Width of the optional left calendar-week gutter (#25); narrow, since it only
|
||||
* seats a one- or two-digit week number in a full-height tonal pill. */
|
||||
private val WEEK_NUMBER_GUTTER = 40.dp
|
||||
private val DAY_NUMBER_GAP = 4.dp
|
||||
private val CELL_TOP_PADDING = 6.dp
|
||||
private val CELL_GAP = 2.dp
|
||||
@@ -363,6 +374,7 @@ private const val MAX_EVENT_ROWS = 3
|
||||
@Composable
|
||||
private fun MonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
@@ -376,6 +388,7 @@ private fun MonthGrid(
|
||||
week = week,
|
||||
today = state.today,
|
||||
month = state.month,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -397,6 +410,7 @@ private fun MonthWeekRow(
|
||||
week: MonthWeek,
|
||||
today: LocalDate,
|
||||
month: YearMonth,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -404,135 +418,179 @@ private fun MonthWeekRow(
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||
|
||||
BoxWithConstraints(modifier) {
|
||||
val colW = maxWidth / 7
|
||||
|
||||
// Per-day background pills — same surfaceContainer rounded surface the
|
||||
// week/day views use, so the three views share one visual language.
|
||||
// Spanning bars draw on top of these, bridging cells, so they still read
|
||||
// as one continuous event.
|
||||
Row(Modifier.matchParentSize()) {
|
||||
week.days.forEach { d ->
|
||||
val inMonth = d.month == month.month && d.year == month.year
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(
|
||||
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = CELL_SHAPE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
week.days.forEach { d ->
|
||||
DayNumberCell(
|
||||
date = d,
|
||||
isToday = d == today,
|
||||
inMonth = d.month == month.month && d.year == month.year,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Breathing room between the day number (and today's circle) and the
|
||||
// first event row.
|
||||
Spacer(Modifier.height(DAY_NUMBER_GAP))
|
||||
Box(
|
||||
Row(modifier) {
|
||||
// Optional calendar-week gutter, sized so the seven day columns below
|
||||
// divide the remaining width — the absolute bar offsets stay correct
|
||||
// because they're measured inside the grid box, not the whole row.
|
||||
if (showWeekNumbers) {
|
||||
WeekNumberGutter(
|
||||
weekStart = week.days.first(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
// Spanning bars on their shared lanes.
|
||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||
val cols = span.endCol - span.startCol + 1
|
||||
MonthBar(
|
||||
event = span.event,
|
||||
dark = dark,
|
||||
continuesLeft = span.continuesLeft,
|
||||
continuesRight = span.continuesRight,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * span.startCol,
|
||||
y = EVENT_ROW_HEIGHT * span.lane,
|
||||
)
|
||||
.width(colW * cols)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||
.width(WEEK_NUMBER_GUTTER)
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
val colW = maxWidth / 7
|
||||
|
||||
// Per-day background pills — same surfaceContainer rounded surface the
|
||||
// week/day views use, so the three views share one visual language.
|
||||
// Spanning bars draw on top of these, bridging cells, so they still read
|
||||
// as one continuous event.
|
||||
Row(Modifier.matchParentSize()) {
|
||||
week.days.forEach { d ->
|
||||
val inMonth = d.month == month.month && d.year == month.year
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(
|
||||
color = if (inMonth) MaterialTheme.colorScheme.surfaceContainer
|
||||
else MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = CELL_SHAPE,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Single-day timed pills + overflow, per column. Pills fill the
|
||||
// lane slots no bar occupies on THIS day (top-most first), so a
|
||||
// bar-free day isn't pushed down by a multi-day event that only
|
||||
// sits on other days of the week.
|
||||
week.days.forEachIndexed { col, d ->
|
||||
val timed = week.timedByDay[d].orEmpty()
|
||||
val occupied = week.spans
|
||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
|
||||
val pillsShown = timed.take(freeSlots.size)
|
||||
pillsShown.forEachIndexed { i, ev ->
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(top = CELL_TOP_PADDING)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
week.days.forEach { d ->
|
||||
DayNumberCell(
|
||||
date = d,
|
||||
isToday = d == today,
|
||||
inMonth = d.month == month.month && d.year == month.year,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Breathing room between the day number (and today's circle) and the
|
||||
// first event row.
|
||||
Spacer(Modifier.height(DAY_NUMBER_GAP))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
// Spanning bars on their shared lanes.
|
||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||
val cols = span.endCol - span.startCol + 1
|
||||
MonthBar(
|
||||
event = ev,
|
||||
event = span.event,
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
continuesLeft = span.continuesLeft,
|
||||
continuesRight = span.continuesRight,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
||||
x = colW * span.startCol,
|
||||
y = EVENT_ROW_HEIGHT * span.lane,
|
||||
)
|
||||
.width(colW)
|
||||
.width(colW * cols)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||
if (hidden > 0) {
|
||||
val hiddenColors = buildList {
|
||||
week.spans
|
||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||
.forEach { add(it.event.color) }
|
||||
timed.drop(pillsShown.size).forEach { add(it.color) }
|
||||
}.distinct().take(3)
|
||||
OverflowDots(
|
||||
colors = hiddenColors,
|
||||
extra = hidden - hiddenColors.size,
|
||||
dark = dark,
|
||||
modifier = Modifier
|
||||
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||
.width(colW)
|
||||
.padding(horizontal = 3.dp),
|
||||
)
|
||||
// Single-day timed pills + overflow, per column. Pills fill the
|
||||
// lane slots no bar occupies on THIS day (top-most first), so a
|
||||
// bar-free day isn't pushed down by a multi-day event that only
|
||||
// sits on other days of the week.
|
||||
week.days.forEachIndexed { col, d ->
|
||||
val timed = week.timedByDay[d].orEmpty()
|
||||
val occupied = week.spans
|
||||
.filter { it.lane < shownLanes && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val freeSlots = (0 until MAX_EVENT_ROWS).filter { it !in occupied }
|
||||
val pillsShown = timed.take(freeSlots.size)
|
||||
pillsShown.forEachIndexed { i, ev ->
|
||||
MonthBar(
|
||||
event = ev,
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
y = EVENT_ROW_HEIGHT * freeSlots[i],
|
||||
)
|
||||
.width(colW)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||
if (hidden > 0) {
|
||||
val hiddenColors = buildList {
|
||||
week.spans
|
||||
.filter { it.lane >= shownLanes && col in it.startCol..it.endCol }
|
||||
.forEach { add(it.event.color) }
|
||||
timed.drop(pillsShown.size).forEach { add(it.color) }
|
||||
}.distinct().take(3)
|
||||
OverflowDots(
|
||||
colors = hiddenColors,
|
||||
extra = hidden - hiddenColors.size,
|
||||
dark = dark,
|
||||
modifier = Modifier
|
||||
.offset(x = colW * col, y = EVENT_ROW_HEIGHT * MAX_EVENT_ROWS)
|
||||
.width(colW)
|
||||
.padding(horizontal = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tap layer: in month view a tap on any day opens that day. Padded and
|
||||
// clipped to the background pill so the ripple matches it.
|
||||
Row(Modifier.matchParentSize()) {
|
||||
week.days.forEach { d ->
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.clip(CELL_SHAPE)
|
||||
.clickable { onOpenDay(d) },
|
||||
)
|
||||
// Tap layer: in month view a tap on any day opens that day. Padded and
|
||||
// clipped to the background pill so the ripple matches it.
|
||||
Row(Modifier.matchParentSize()) {
|
||||
week.days.forEach { d ->
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.clip(CELL_SHAPE)
|
||||
.clickable { onOpenDay(d) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||
* Week view's badge), with the ISO week number centred like a day number. The
|
||||
* week is computed on the row's first day — the same basis as the Week view — so
|
||||
* the two agree.
|
||||
*/
|
||||
@Composable
|
||||
private fun WeekNumberGutter(weekStart: LocalDate, modifier: Modifier = Modifier) {
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
val label = stringResource(R.string.week_number_label)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer, CELL_SHAPE)
|
||||
.semantics { contentDescription = "$label $weekNumber" },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = weekNumber.toString(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayNumberCell(
|
||||
date: LocalDate,
|
||||
|
||||
@@ -67,6 +67,14 @@ class MonthViewModel @Inject constructor(
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
/** Whether to show the calendar-week number gutter (#25; display only). */
|
||||
val showWeekNumbers: StateFlow<Boolean> = settingsPrefs.showWeekNumbers
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = false,
|
||||
)
|
||||
|
||||
private val todayDate: LocalDate
|
||||
get() = Clock.System.now().toLocalDateTime(zone).date
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* The app's adaptive launcher mark, reconstructed as a large branded squircle —
|
||||
* the hero of the onboarding screens (floret-kit's OnboardingScaffold supplies
|
||||
* the shell; the mark stays app-local so each sibling keeps its own identity).
|
||||
* A lock badge overlays the corner when permission has been [denied].
|
||||
*/
|
||||
@Composable
|
||||
internal fun BrandHero(denied: Boolean) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.clip(RoundedCornerShape(34.dp))
|
||||
.background(colorResource(R.color.ic_launcher_background)),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = stringResource(R.string.app_name),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
if (denied) {
|
||||
// A small lock badge sits over the corner to signal "blocked".
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = 10.dp, y = 10.dp)
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.errorContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/** MD3 8dp spacing scale shared by the onboarding screens. */
|
||||
internal object OnboardingSpace {
|
||||
val xs = 8.dp
|
||||
val sm = 16.dp
|
||||
val md = 24.dp
|
||||
val lg = 32.dp
|
||||
val xl = 48.dp
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared onboarding shell (calendar grant, reminder step): a scrollable,
|
||||
* centred hero + body with the call(s) to action pinned to the bottom (clear
|
||||
* of the navigation bar). The content slot is centred horizontally; benefit
|
||||
* rows fill the width so their own content left-aligns.
|
||||
*/
|
||||
@Composable
|
||||
internal fun OnboardingScaffold(
|
||||
hero: @Composable () -> Unit,
|
||||
actions: @Composable ColumnScope.() -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
body: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
bottomBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
content = actions,
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = OnboardingSpace.md),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Spacer(Modifier.height(OnboardingSpace.xl))
|
||||
hero()
|
||||
Spacer(Modifier.height(OnboardingSpace.lg))
|
||||
body()
|
||||
Spacer(Modifier.height(OnboardingSpace.md))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The app's adaptive launcher mark, reconstructed as a large branded squircle. */
|
||||
@Composable
|
||||
internal fun BrandHero(denied: Boolean) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(128.dp)
|
||||
.clip(RoundedCornerShape(34.dp))
|
||||
.background(colorResource(R.color.ic_launcher_background)),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.ic_launcher_foreground),
|
||||
contentDescription = stringResource(R.string.app_name),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
if (denied) {
|
||||
// A small lock badge sits over the corner to signal "blocked".
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.offset(x = 10.dp, y = 10.dp)
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.errorContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One trust point: a tonal icon chip on the left, title + supporting text right. */
|
||||
@Composable
|
||||
internal fun BenefitRow(icon: ImageVector, title: String, body: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.secondaryContainer),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(OnboardingSpace.sm))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = body,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.permission
|
||||
|
||||
import de.jeanlucmakiola.floret.components.BenefitRow
|
||||
import de.jeanlucmakiola.floret.components.OnboardingScaffold
|
||||
import de.jeanlucmakiola.floret.components.OnboardingSpace
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
|
||||
@@ -42,22 +42,23 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.calendarAnimateItem
|
||||
import de.jeanlucmakiola.calendula.ui.common.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.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 de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import java.time.Instant as JavaInstant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -101,6 +102,7 @@ fun SearchScreen(
|
||||
value = query,
|
||||
onValueChange = viewModel::setQuery,
|
||||
placeholder = stringResource(R.string.search_hint),
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Search,
|
||||
onImeAction = { keyboard?.hide() },
|
||||
modifier = Modifier
|
||||
@@ -175,7 +177,7 @@ private fun SearchResults(
|
||||
SearchResultRow(
|
||||
event = event,
|
||||
position = positionOf(index, events.size),
|
||||
modifier = calendarAnimateItem(),
|
||||
modifier = animateItemMotion(),
|
||||
onClick = { onEventClick(event) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import java.util.Locale
|
||||
|
||||
private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
|
||||
|
||||
/**
|
||||
* Per-app language via AppCompatDelegate, driven by res/xml/locales_config.xml.
|
||||
*
|
||||
* That file is the single source of truth for which languages we ship: dropping
|
||||
* in a values-<tag> translation and adding a matching `<locale>` entry makes the
|
||||
* language show up here and in the system per-app-language settings, with no
|
||||
* other code change. The system-default choice is represented as `null`.
|
||||
*
|
||||
* On API 33+ this delegates to the platform per-app-languages API; below that
|
||||
* the appcompat backport persists the choice itself (manifest `autoStoreLocales`
|
||||
* service), so we don't mirror it in DataStore. Setting a locale recreates the
|
||||
* activity, which re-reads the current value for the picker.
|
||||
*/
|
||||
object AppLanguage {
|
||||
|
||||
/**
|
||||
* The BCP-47 tags the app ships translations for, in declaration order, as
|
||||
* listed in locales_config.xml. Returns whatever could be parsed; a missing
|
||||
* or malformed config yields an empty list (the picker then offers only the
|
||||
* system-default entry rather than crashing).
|
||||
*/
|
||||
fun supportedTags(context: Context): List<String> {
|
||||
val tags = mutableListOf<String>()
|
||||
val parser = context.resources.getXml(R.xml.locales_config)
|
||||
try {
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
if (event == XmlPullParser.START_TAG && parser.name == "locale") {
|
||||
parser.getAttributeValue(ANDROID_NS, "name")?.let(tags::add)
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Fall back to whatever was parsed before the failure.
|
||||
} finally {
|
||||
parser.close()
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
/** The applied app language as a BCP-47 tag, or `null` when following the system. */
|
||||
fun currentTag(): String? {
|
||||
val locales = AppCompatDelegate.getApplicationLocales()
|
||||
return if (locales.isEmpty) null else locales[0]?.toLanguageTag()
|
||||
}
|
||||
|
||||
/** Apply a BCP-47 tag, or `null` to follow the system languages. */
|
||||
fun apply(tag: String?) {
|
||||
val locales = if (tag == null) {
|
||||
LocaleListCompat.getEmptyLocaleList()
|
||||
} else {
|
||||
LocaleListCompat.forLanguageTags(tag)
|
||||
}
|
||||
AppCompatDelegate.setApplicationLocales(locales)
|
||||
}
|
||||
|
||||
/**
|
||||
* The autonym for a tag — the language's own name in its own script, e.g.
|
||||
* "Deutsch", "English", "Français" — so users find their language regardless
|
||||
* of the current UI language. Capitalised per the language's own rules.
|
||||
*/
|
||||
fun displayName(tag: String): String {
|
||||
val locale = Locale.forLanguageTag(tag)
|
||||
return locale.getDisplayName(locale)
|
||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() }
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -59,9 +58,9 @@ import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import de.jeanlucmakiola.floret.locale.AppLanguage
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
@@ -80,6 +79,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -94,42 +94,44 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
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.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.floret.crash.CrashReportDialog
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import de.jeanlucmakiola.floret.crash.openIssueTracker
|
||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.AboutCard
|
||||
import de.jeanlucmakiola.floret.components.AboutLink
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableRowHeight
|
||||
import de.jeanlucmakiola.floret.components.ReorderableColumn
|
||||
import de.jeanlucmakiola.floret.components.ReorderableRowHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||
import de.jeanlucmakiola.floret.components.OptionPicker
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.calendula.ui.common.reminderLeadTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
@@ -158,7 +160,7 @@ private enum class ChipAccent { Neutral, Primary, Tertiary }
|
||||
* Settings (M4), restructured in v2.3 into a category hub with sub-screens.
|
||||
* Both the hub and the sub-screens use a collapsing [LargeTopAppBar] and the
|
||||
* grouped-row card system. Calendars opens the separate manager hoisted in
|
||||
* [CalendarHost]; Language opens an inline OptionCard dialog; About is a card
|
||||
* [CalendarHost]; Language opens a full-screen picker; About is a card
|
||||
* at the top. A full-screen destination; [onBack] pops it.
|
||||
*/
|
||||
@Composable
|
||||
@@ -236,7 +238,7 @@ private fun SettingsHub(
|
||||
onOpenSection: (SettingsSection) -> Unit,
|
||||
onManageCalendars: () -> Unit,
|
||||
) {
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack) {
|
||||
CollapsingScaffold(title = stringResource(R.string.settings_title), onBack = onBack, predictiveBack = true) {
|
||||
Box(Modifier.padding(horizontal = 16.dp)) { AboutCard() }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
@@ -357,7 +359,7 @@ private fun LanguageRow(position: Position) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
|
||||
// null = follow the system; the rest are BCP-47 tags from locales_config.xml.
|
||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context) }
|
||||
val options = remember { listOf<String?>(null) + AppLanguage.supportedTags(context, R.xml.locales_config) }
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_language),
|
||||
@@ -370,6 +372,7 @@ private fun LanguageRow(position: Position) {
|
||||
if (showDialog) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_language),
|
||||
predictiveBack = true,
|
||||
options = options,
|
||||
selected = current,
|
||||
label = { languageLabel(it) },
|
||||
@@ -396,83 +399,30 @@ private fun LanguageRow(position: Position) {
|
||||
|
||||
@Composable
|
||||
private fun AboutCard() {
|
||||
val context = LocalContext.current
|
||||
val sourceUrl = stringResource(R.string.about_source_url)
|
||||
val licenseUrl = stringResource(R.string.about_license_url)
|
||||
val supportUrl = stringResource(R.string.about_support_url)
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AppLogo()
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.settings_about_author),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { openUrl(context, sourceUrl) },
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_gitea),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.settings_about_source))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { openUrl(context, licenseUrl) },
|
||||
contentPadding = PaddingValues(horizontal = 12.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Gavel,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.settings_license))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = { openUrl(context, supportUrl) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Favorite,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.settings_about_support))
|
||||
}
|
||||
}
|
||||
}
|
||||
// The card layout lives in floret-kit (components.AboutCard); Calendula
|
||||
// supplies its own logo, author and the source / licence / support links.
|
||||
AboutCard(
|
||||
logo = { AppLogo() },
|
||||
appName = stringResource(R.string.app_name),
|
||||
author = stringResource(R.string.settings_about_author),
|
||||
primaryLinks = listOf(
|
||||
AboutLink(
|
||||
icon = ImageVector.vectorResource(R.drawable.ic_gitea),
|
||||
label = stringResource(R.string.settings_about_source),
|
||||
url = stringResource(R.string.about_source_url),
|
||||
),
|
||||
AboutLink(
|
||||
icon = Icons.Default.Gavel,
|
||||
label = stringResource(R.string.settings_license),
|
||||
url = stringResource(R.string.about_license_url),
|
||||
),
|
||||
),
|
||||
highlightLink = AboutLink(
|
||||
icon = Icons.Default.Favorite,
|
||||
label = stringResource(R.string.settings_about_support),
|
||||
url = stringResource(R.string.about_support_url),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Plain centred version mark at the foot of the settings list (no card). */
|
||||
@@ -550,6 +500,7 @@ private fun AppearanceScreen(
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
// Theme & colour
|
||||
GroupedRow(
|
||||
@@ -613,6 +564,18 @@ private fun AppearanceScreen(
|
||||
position = Position.Middle,
|
||||
onClick = { showWeekStart = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_week_numbers),
|
||||
summary = stringResource(R.string.settings_week_numbers_summary),
|
||||
position = Position.Middle,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showWeekNumbers,
|
||||
onCheckedChange = viewModel::setShowWeekNumbers,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setShowWeekNumbers(!state.showWeekNumbers) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_time_format),
|
||||
summary = timeFormatLabel(state.timeFormat),
|
||||
@@ -684,6 +647,7 @@ private fun AppearanceScreen(
|
||||
if (showTheme) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
predictiveBack = true,
|
||||
options = ThemeMode.entries,
|
||||
selected = state.themeMode,
|
||||
label = { themeLabel(it) },
|
||||
@@ -716,6 +680,7 @@ private fun AppearanceScreen(
|
||||
if (showWeekStart) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
predictiveBack = true,
|
||||
options = WEEK_START_OPTIONS,
|
||||
selected = state.weekStart,
|
||||
label = { weekStartLabel(it) },
|
||||
@@ -744,6 +709,7 @@ private fun AppearanceScreen(
|
||||
if (showTimeFormat) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_time_format),
|
||||
predictiveBack = true,
|
||||
options = TimeFormatPref.entries,
|
||||
selected = state.timeFormat,
|
||||
label = { timeFormatLabel(it) },
|
||||
@@ -764,6 +730,7 @@ private fun AppearanceScreen(
|
||||
if (showDefaultView) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
predictiveBack = true,
|
||||
options = IMPLEMENTED_VIEWS,
|
||||
selected = state.defaultView,
|
||||
label = { stringResource(it.labelRes) },
|
||||
@@ -904,6 +871,7 @@ private fun EventFormScreen(
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_event_form),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_form_fields_hint),
|
||||
@@ -1039,6 +1007,7 @@ private fun NotificationsScreen(
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_notifications),
|
||||
onBack = onBack,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_reminders),
|
||||
@@ -1129,8 +1098,8 @@ private fun NotificationsScreen(
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = calendarSectionExpanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
state.writableCalendars.forEach { calendar ->
|
||||
@@ -1178,18 +1147,18 @@ private fun NotificationsScreen(
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = calendarExpandEnter(),
|
||||
exit = calendarCollapseExit(),
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
val timed = state.perCalendarReminderOverride.choiceFor(calendar.id)
|
||||
val timed = state.perCalendarReminderOverride.reminderOverrideFor(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)
|
||||
val allDay = state.perCalendarAllDayReminderOverride.reminderOverrideFor(calendar.id)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_reminder_allday),
|
||||
summary = calendarOverrideSummary(allDay, state.defaultAllDayReminderMinutes),
|
||||
@@ -1207,6 +1176,7 @@ private fun NotificationsScreen(
|
||||
if (showSnooze) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
predictiveBack = true,
|
||||
options = SNOOZE_PRESETS,
|
||||
selected = state.snoozeMinutes,
|
||||
label = { snoozeDurationLabel(it) },
|
||||
@@ -1262,7 +1232,7 @@ private fun NotificationsScreen(
|
||||
},
|
||||
),
|
||||
presets = if (target.isAllDay) ALLDAY_REMINDER_PRESETS else REMINDER_PRESETS,
|
||||
selected = map.choiceFor(target.calendarId),
|
||||
selected = map.reminderOverrideFor(target.calendarId),
|
||||
allowInherit = true,
|
||||
onSelect = {
|
||||
if (target.isAllDay) {
|
||||
@@ -1451,7 +1421,7 @@ private fun SpecialDatesScreen(
|
||||
ReminderDefaultPicker(
|
||||
title = stringResource(R.string.settings_special_dates_reminders),
|
||||
presets = ALLDAY_REMINDER_PRESETS,
|
||||
selected = state.reminderChoices[type] ?: CalendarReminderOverride.None,
|
||||
selected = state.reminderChoices[type] ?: ReminderOverride.None,
|
||||
// Managed calendars own their reminders outright — no "inherit global".
|
||||
allowInherit = false,
|
||||
onSelect = { viewModel.setSpecialDatesReminders(type, it) },
|
||||
@@ -1461,8 +1431,8 @@ private fun SpecialDatesScreen(
|
||||
}
|
||||
|
||||
/** The lead-time list backing a managed calendar's reminder choice (for the summary label). */
|
||||
private fun specialDatesReminderMinutes(choice: CalendarReminderOverride?): List<Int> =
|
||||
(choice as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
private fun specialDatesReminderMinutes(choice: ReminderOverride?): List<Int> =
|
||||
(choice as? ReminderOverride.Minutes)?.minutes.orEmpty()
|
||||
|
||||
@Composable
|
||||
private fun SpecialDatesDisableDialog(
|
||||
@@ -1561,12 +1531,12 @@ private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
|
||||
private data class OverrideTarget(val calendarId: Long, val isAllDay: Boolean)
|
||||
|
||||
/** A global default (empty = none) as a picker choice for selection highlighting. */
|
||||
private fun List<Int>.toReminderChoice(): CalendarReminderOverride =
|
||||
if (isEmpty()) CalendarReminderOverride.None else CalendarReminderOverride.Minutes(this)
|
||||
private fun List<Int>.toReminderChoice(): ReminderOverride =
|
||||
if (isEmpty()) ReminderOverride.None else ReminderOverride.Minutes(this)
|
||||
|
||||
/** A picked choice as global-default minutes (Inherit isn't offered for globals). */
|
||||
private fun CalendarReminderOverride.toMinutesList(): List<Int> =
|
||||
(this as? CalendarReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||
private fun ReminderOverride.toMinutesList(): List<Int> =
|
||||
(this as? ReminderOverride.Minutes)?.minutes ?: emptyList()
|
||||
|
||||
/**
|
||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||
@@ -1650,13 +1620,13 @@ private fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||
@Composable
|
||||
private fun calendarOverrideSummary(
|
||||
choice: CalendarReminderOverride,
|
||||
choice: ReminderOverride,
|
||||
globalDefault: List<Int>,
|
||||
): String = when (choice) {
|
||||
CalendarReminderOverride.Inherit ->
|
||||
ReminderOverride.Inherit ->
|
||||
stringResource(R.string.settings_calendar_reminder_inherits, reminderChoiceLabel(globalDefault))
|
||||
CalendarReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is CalendarReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||
ReminderOverride.None -> stringResource(R.string.reminder_none)
|
||||
is ReminderOverride.Minutes -> reminderChoiceLabel(choice.minutes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
@@ -33,6 +33,8 @@ data class SettingsUiState(
|
||||
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
|
||||
/** Whether the month/week grids fade events that have already finished. */
|
||||
val dimCompletedEvents: Boolean = false,
|
||||
/** Whether the Month grid shows calendar-week numbers in a left gutter (#25). */
|
||||
val showWeekNumbers: 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). */
|
||||
@@ -96,7 +98,7 @@ data class SpecialDatesUiState(
|
||||
val types: Set<SpecialDateType> = SpecialDateType.entries.toSet(),
|
||||
val titleTemplates: Map<SpecialDateType, String> = emptyMap(),
|
||||
/** The reminder choice per type's managed calendar (applied to all its events). */
|
||||
val reminderChoices: Map<SpecialDateType, CalendarReminderOverride> = emptyMap(),
|
||||
val reminderChoices: Map<SpecialDateType, ReminderOverride> = emptyMap(),
|
||||
/** Whether {year} resolves in titles (the source year is static and always correct). */
|
||||
val showYear: Boolean = true,
|
||||
/** True when READ_CONTACTS was revoked after enabling — the mirror is paused. */
|
||||
|
||||
@@ -17,8 +17,8 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
|
||||
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
@@ -116,9 +116,15 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.agendaScreenRange,
|
||||
prefs.agendaWidgetRange,
|
||||
prefs.timeFormat,
|
||||
prefs.showHourLines,
|
||||
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
||||
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
||||
// Two grid-display toggles folded into one flow so they fit this
|
||||
// group — the outer combine is already at its five-arg limit.
|
||||
combine(prefs.showHourLines, prefs.showWeekNumbers, ::Pair),
|
||||
) { view, screenRange, widgetRange, timeFormat, gridToggles ->
|
||||
ViewSettings(
|
||||
view, screenRange, widgetRange, timeFormat,
|
||||
showHourLines = gridToggles.first,
|
||||
showWeekNumbers = gridToggles.second,
|
||||
)
|
||||
},
|
||||
combine(
|
||||
prefs.agendaShowRangeBar,
|
||||
@@ -140,6 +146,7 @@ class SettingsViewModel @Inject constructor(
|
||||
agendaWidgetRange = views.agendaWidgetRange,
|
||||
timeFormat = views.timeFormat,
|
||||
showHourLines = views.showHourLines,
|
||||
showWeekNumbers = views.showWeekNumbers,
|
||||
agendaShowRangeBar = misc.showRangeBar,
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
@@ -212,6 +219,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val agendaWidgetRange: AgendaRange,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val showHourLines: Boolean,
|
||||
val showWeekNumbers: Boolean,
|
||||
)
|
||||
|
||||
private data class MiscSettings(
|
||||
@@ -255,7 +263,7 @@ class SettingsViewModel @Inject constructor(
|
||||
base.copy(
|
||||
// An override is always seeded on creation; present-empty = None.
|
||||
reminderChoices = calendars.mapValues { (_, calendarId) ->
|
||||
allDayOverrides.choiceFor(calendarId)
|
||||
allDayOverrides.reminderOverrideFor(calendarId)
|
||||
},
|
||||
)
|
||||
}.stateIn(
|
||||
@@ -309,7 +317,7 @@ class SettingsViewModel @Inject constructor(
|
||||
* Set a type's managed-calendar reminder default and apply it to all its
|
||||
* existing events (managed calendars own their reminders calendar-wide).
|
||||
*/
|
||||
fun setSpecialDatesReminders(type: SpecialDateType, override: CalendarReminderOverride) {
|
||||
fun setSpecialDatesReminders(type: SpecialDateType, override: ReminderOverride) {
|
||||
viewModelScope.launch { withContext(io) { specialDatesEngine.applyReminders(type, override) } }
|
||||
}
|
||||
|
||||
@@ -398,6 +406,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setShowHourLines(enabled) }
|
||||
}
|
||||
|
||||
fun setShowWeekNumbers(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setShowWeekNumbers(enabled) }
|
||||
}
|
||||
|
||||
fun setPastEventDisplay(mode: PastEventDisplay) {
|
||||
viewModelScope.launch {
|
||||
prefs.setPastEventDisplay(mode)
|
||||
@@ -476,11 +488,11 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setSnoozeMinutes(minutes) }
|
||||
}
|
||||
|
||||
fun setCalendarReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||
fun setCalendarReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||
viewModelScope.launch { prefs.setCalendarReminderOverride(calendarId, override) }
|
||||
}
|
||||
|
||||
fun setCalendarAllDayReminderOverride(calendarId: Long, override: CalendarReminderOverride) {
|
||||
fun setCalendarAllDayReminderOverride(calendarId: Long, override: ReminderOverride) {
|
||||
viewModelScope.launch { prefs.setCalendarAllDayReminderOverride(calendarId, override) }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
package de.jeanlucmakiola.calendula.ui.theme
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialExpressiveTheme
|
||||
import androidx.compose.material3.MotionScheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import de.jeanlucmakiola.floret.identity.FloretExpressiveTheme
|
||||
|
||||
/**
|
||||
* App theme. Honors:
|
||||
* - System light/dark.
|
||||
* - Dynamic Color on API 31+, else falls back to the hand-tuned scheme
|
||||
* derived from [CalendulaSeed].
|
||||
* - A user-chosen [typography] (custom fonts, issue #19), defaulting to the
|
||||
* Material scale on the system typeface.
|
||||
* Calendula's theme: the family's [FloretExpressiveTheme] machinery — dynamic
|
||||
* colour on API 31+, system light/dark, and the **standard** motion scheme
|
||||
* (spring choreography without the overshoot) — fed with Calendula's own
|
||||
* identity: its seed-derived fallback schemes ([CalendulaLightFallback] /
|
||||
* [CalendulaDarkFallback]) and a [typography] that defaults to
|
||||
* [CalendulaTypography] but is overridable for the user's custom-font choice
|
||||
* (issue #19).
|
||||
*
|
||||
* The Settings screen overrides darkTheme, dynamicColor, and typography; the
|
||||
* bare defaults (used by the crash screen) just follow the system.
|
||||
* The mechanics live in floret-kit's identity module; the look stays here, so a
|
||||
* sibling app reuses the same theming without inheriting Calendula's palette.
|
||||
* The Settings screen overrides darkTheme, dynamicColor and typography; the bare
|
||||
* defaults (used by the crash screen) just follow the system.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun CalendulaTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
@@ -30,24 +26,12 @@ fun CalendulaTheme(
|
||||
typography: Typography = CalendulaTypography,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val ctx = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
|
||||
}
|
||||
darkTheme -> CalendulaDarkFallback
|
||||
else -> CalendulaLightFallback
|
||||
}
|
||||
|
||||
// MaterialExpressiveTheme routes all component + custom motion through
|
||||
// MaterialTheme.motionScheme (switches, chips, pickers, calendar slide,
|
||||
// FAB, field reveal). The STANDARD scheme is a deliberate choice over
|
||||
// expressive(): same spring choreography, but without the overshoot —
|
||||
// the bouncy variant felt overdone in review (2026-06-11).
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
FloretExpressiveTheme(
|
||||
lightScheme = CalendulaLightFallback,
|
||||
darkScheme = CalendulaDarkFallback,
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = dynamicColor,
|
||||
typography = typography,
|
||||
motionScheme = MotionScheme.standard(),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
@@ -98,12 +98,14 @@ 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
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
@@ -128,6 +130,7 @@ private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
fun WeekScreen(
|
||||
selectedView: CalendarView,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
@@ -249,6 +252,7 @@ fun WeekScreen(
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = { d, minutes -> onCreateEvent(d, minutes) },
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
@@ -268,6 +272,7 @@ private fun WeekContent(
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -342,6 +347,7 @@ private fun WeekContent(
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
)
|
||||
}
|
||||
@@ -355,6 +361,7 @@ private fun WeekSuccess(
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
@@ -363,7 +370,7 @@ private fun WeekSuccess(
|
||||
.fillMaxWidth()
|
||||
.background(topSectionColor),
|
||||
) {
|
||||
WeekDayHeader(days = state.days, today = state.today)
|
||||
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||
}
|
||||
// Breathing room between the (colour-shifting) top section and the
|
||||
@@ -427,13 +434,14 @@ private fun WeekTopBar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
||||
private fun WeekDayHeader(
|
||||
days: List<LocalDate>,
|
||||
today: LocalDate,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val weekStart = days.first()
|
||||
val weekNumber = remember(weekStart) {
|
||||
java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, weekStart.day)
|
||||
.get(java.time.temporal.IsoFields.WEEK_OF_WEEK_BASED_YEAR)
|
||||
}
|
||||
val weekNumber = remember(weekStart) { weekStart.toJavaLocalDate().isoWeekNumber() }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -453,7 +461,10 @@ private fun WeekDayHeader(days: List<LocalDate>, today: LocalDate) {
|
||||
val javaDow = java.time.DayOfWeek.of(date.dayOfWeek.ordinal + 1)
|
||||
val isToday = date == today
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clickable { onOpenDay(date) },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
|
||||
@@ -53,7 +53,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
import de.jeanlucmakiola.calendula.widget.loadAgendaWidgetData
|
||||
|
||||
@@ -50,7 +50,7 @@ import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthWeek
|
||||
import de.jeanlucmakiola.calendula.ui.month.layoutMonthWeeks
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
|
||||
@@ -220,7 +220,7 @@
|
||||
<string name="search_action">Suchen</string>
|
||||
<string name="search_hint">Termine suchen</string>
|
||||
<string name="search_back">Zurück</string>
|
||||
<string name="search_clear">Löschen</string>
|
||||
<string name="search_clear">Leeren</string>
|
||||
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
|
||||
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
|
||||
<!-- Startbildschirm-Widgets -->
|
||||
@@ -245,6 +245,7 @@
|
||||
<!-- Einstellungen (M4) -->
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
<string name="settings_back">Zurück</string>
|
||||
<string name="back">Zurück</string>
|
||||
<string name="settings_section_appearance">Darstellung</string>
|
||||
<string name="settings_theme">Design</string>
|
||||
<string name="settings_theme_system">System</string>
|
||||
@@ -329,7 +330,7 @@
|
||||
<string name="calendars_add">Kalender hinzufügen</string>
|
||||
<string name="calendars_synced_header">Synchronisierte Kalender</string>
|
||||
<string name="calendars_synced_hint">Diese stammen von Konten auf deinem Gerät. Erstelle und bearbeite sie in der jeweiligen App.</string>
|
||||
<string name="calendars_manage_in_app">Verwalten</string>
|
||||
<string name="calendars_manage_in_app">In der App verwalten</string>
|
||||
<string name="calendars_add_account">Konto hinzufügen</string>
|
||||
<string name="calendars_new_title">Neuer Kalender</string>
|
||||
<string name="calendars_edit_title">Kalender bearbeiten</string>
|
||||
@@ -377,15 +378,15 @@
|
||||
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
|
||||
</plurals>
|
||||
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
|
||||
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
|
||||
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
||||
<string name="crash_dialog_title">%1$s ist abgestürzt</string>
|
||||
<string name="crash_dialog_message">%1$s wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>
|
||||
<string name="crash_dialog_report">Melden</string>
|
||||
<string name="crash_dialog_dismiss">Nicht jetzt</string>
|
||||
<string name="crash_report_issue_title">Absturzbericht</string>
|
||||
<string name="crash_report_clip_label">Calendula-Absturzbericht</string>
|
||||
<string name="crash_report_clip_label">%1$s-Absturzbericht</string>
|
||||
<string name="crash_report_copied">Bericht in die Zwischenablage kopiert</string>
|
||||
<string name="crash_report_open_failed">Der Issue-Tracker konnte nicht geöffnet werden. Der Bericht ist in deiner Zwischenablage.</string>
|
||||
<string name="crash_report_body_template">Danke, dass du einen Absturz in Calendula meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%1$s\n</string>
|
||||
<string name="crash_report_body_template">Danke, dass du einen Absturz in %1$s meldest. Bitte ergänze, was du gerade getan hast, und sende dann ab.\n\n### Was ist passiert\n\n\n### Absturzbericht\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||
<string name="event_edit_managed_hint">Verwaltet durch “%1$s” — der Titel, das Datum und Wiederholungen werden synchron mit deinen Kontakten gehalten. Erinnerungen, Ort und Beschreibung kannst du frei bearbeiten.</string>
|
||||
<plurals name="duration_days">
|
||||
@@ -393,10 +394,10 @@
|
||||
<item quantity="other">%d Tage</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">Woche</item>
|
||||
<item quantity="other">Wochen</item>
|
||||
<item quantity="one">%d Woche</item>
|
||||
<item quantity="other">%d Wochen</item>
|
||||
</plurals>
|
||||
<string name="settings_default_view">Standartansicht</string>
|
||||
<string name="settings_default_view">Standardansicht</string>
|
||||
<string name="settings_font_headings">Überschriften Schriftart</string>
|
||||
<string name="settings_font_system">Systemstandard</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
@@ -410,4 +411,67 @@
|
||||
<string name="settings_past_events_hide">Ausblenden</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_special_dates_reminders">Erinnerungen</string>
|
||||
<string name="settings_font_body">Inhaltsschriftart</string>
|
||||
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||
<string name="font_lora">Lora</string>
|
||||
<string name="settings_font_custom_selected">Benutzerdefinierte Schriftart</string>
|
||||
<string name="settings_section_views">Ansichten</string>
|
||||
<string name="settings_quick_switch_header">Schnellwechselknopf</string>
|
||||
<string name="settings_quick_switch_hint">Wähle aus, durch welche Ansichten du mit dem Knopf oben rechts wechseln möchtest, und ziehe, um sie neu anzuordnen. Deaktivierte Ansichten bleiben über das Navigationsmenü erreichbar.</string>
|
||||
<string name="settings_drawer_order_header">Navigationsmenü</string>
|
||||
<string name="settings_drawer_order_hint">Ziehen, um die im Navigationsmenü aufgelisteten Ansichten neu anzuordnen.</string>
|
||||
<string name="reorder_drag_handle">Zum Neuanordnen ziehen</string>
|
||||
<string name="settings_calendar_reminders_title">Erinnerungen pro Kalender</string>
|
||||
<string name="settings_translate">Hilf beim Übersetzen</string>
|
||||
<string name="settings_translate_hint">Füge eine Sprache auf Weblate hinzu oder verbessere sie</string>
|
||||
<string name="settings_views_subtitle">Schnellwechsellnopf und Menüreihenfolge</string>
|
||||
<string name="settings_special_dates_subtitle">Kontakte-Geburtstage und Jubiläen</string>
|
||||
<string name="settings_section_special_dates">Besondere Termine von Kontakten</string>
|
||||
<string name="settings_special_dates_enable">Kontakttermine anzeigen</string>
|
||||
<string name="settings_special_dates_enable_hint">Spiegle die Geburtstage und andere Termine deiner Kontakte in lokale Kalender. Liest nur Kontakte auf diesem Gerät – es wird nichts hochgeladen und Ihre Kontakte werden nie geändert.</string>
|
||||
<string name="settings_special_dates_type_birthday">Geburtstage</string>
|
||||
<string name="settings_special_dates_type_anniversary">Jahrestage</string>
|
||||
<string name="settings_special_dates_type_custom">Andere Ereignisse</string>
|
||||
<string name="settings_special_dates_template">Titelformat</string>
|
||||
<string name="settings_special_dates_template_hint">Verwende {Name} für den Kontakt und {Jahr} für das Jahr (das Geburtsjahr oder das Anfangsjahr eines Jahrestages; versteckt, wenn unbekannt).</string>
|
||||
<string name="settings_special_dates_show_year">Jahr anzeigen</string>
|
||||
<string name="settings_special_dates_show_year_hint">{year} in Titel einfügen, wenn bekannt</string>
|
||||
<string name="settings_special_dates_sync_now">Jetzt synchronisieren</string>
|
||||
<string name="settings_special_dates_never_synced">Noch nicht synchronisiert</string>
|
||||
<string name="settings_special_dates_last_synced">Zuletzt synchronisiert %1$s</string>
|
||||
<string name="settings_special_dates_calendar_hint">Lege die Farbe und Sichtbarkeit jedes Kalenders in den Kalendereinstellungen fest.</string>
|
||||
<string name="settings_special_dates_paused_title">Pausiert</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">In den besonderen Terminen der Kontakte setzen</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula kann deine Kontakte nicht mehr lesen, daher werden diese Kalender nicht aktualisiert.</string>
|
||||
<string name="settings_special_dates_disable_title">Kontakttermine deaktivieren?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Dadurch werden die Kontaktterminkalender und ihre Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_grant">Zugriff gewähren</string>
|
||||
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Ereignisse gelöscht. Alle von dir hinzugefügten Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
|
||||
<string name="dialog_save">Speichern</string>
|
||||
<string name="calendars_disable_hint">Deaktiviere einen Kalender, um ihn aus der App zu entfernen – seine Ereignisse, Filter und Auswahlmöglichkeiten. Es wird nichts gelöscht und du kannst ihn hier jederzeit wieder aktivieren.</string>
|
||||
<string name="calendars_show_in_app_a11y">„%1$s“ in der App anzeigen</string>
|
||||
<string name="calendars_account_menu_a11y">Weitere Optionen für %1$s</string>
|
||||
<string name="calendars_enable_all">Alle aktivieren</string>
|
||||
<string name="calendars_disable_all">Alle deaktivieren</string>
|
||||
<string name="calendars_auto_backup">Automatische Sicherung</string>
|
||||
<string name="calendars_auto_backup_hint">Exportiere deine lokalen Kalender regelmäßig als .ics-Datei in einen Ordner.</string>
|
||||
<string name="calendars_auto_backup_folder">Sicherungsordner</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Tippe, um einen Ordner auszuwählen</string>
|
||||
<string name="calendars_auto_backup_interval">Intervall</string>
|
||||
<string name="calendars_auto_backup_every">Alle %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Mindestens 30 Minuten.</string>
|
||||
<string name="calendars_auto_backup_status_never">Noch keine automatische Sicherung</string>
|
||||
<string name="calendars_auto_backup_status_ok">Letzte Sicherung: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Letzte Sicherung fehlgeschlagen: %1$s</string>
|
||||
<string name="backup_channel_name">Sicherung</string>
|
||||
<string name="backup_channel_description">Warnt, wenn automatische Sicherungen wiederholt fehlschlagen.</string>
|
||||
<string name="backup_failed_title">Automatische Sicherung fehlgeschlagen</string>
|
||||
<string name="backup_failed_text">Calendula konnte die Sicherungsdatei nicht schreiben. Überprüfe den Sicherungsordner in den Einstellungen.</string>
|
||||
<string name="special_dates_calendar_birthday">Geburtstage</string>
|
||||
<string name="special_dates_calendar_anniversary">Jahrestage</string>
|
||||
<string name="special_dates_calendar_custom">Besondere Termine</string>
|
||||
<string name="special_dates_default_title_birthday">Geburtstag von {name} ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">Jahrestag von {name} ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
</resources>
|
||||
|
||||
@@ -456,4 +456,11 @@
|
||||
<string name="settings_special_dates_disable_type_message">Questo cancella il calendario “%1$s” ed i suoi eventi. Ogni promemoria o note che erano stati aggiunti saranno persi.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Spegni</string>
|
||||
<string name="dialog_save">Salva</string>
|
||||
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">Impostato nelle date speciali dei contatti</string>
|
||||
<string name="special_dates_calendar_birthday">Compleanni</string>
|
||||
<string name="special_dates_calendar_anniversary">Anniversari</string>
|
||||
<string name="special_dates_calendar_custom">Date speciali</string>
|
||||
<string name="special_dates_default_title_birthday">Compleanno di {name} ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">Anniversario di {name} ({year})</string>
|
||||
</resources>
|
||||
|
||||
@@ -268,6 +268,8 @@
|
||||
<!-- Settings (M4) -->
|
||||
<string name="settings_title">Settings</string>
|
||||
<string name="settings_back">Back</string>
|
||||
<!-- Overrides floret-kit components' CollapsingScaffold back-button label. -->
|
||||
<string name="back">Back</string>
|
||||
<string name="settings_section_appearance">Appearance</string>
|
||||
<string name="settings_theme">Theme</string>
|
||||
<string name="settings_theme_system">System</string>
|
||||
@@ -287,6 +289,8 @@
|
||||
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
|
||||
<string name="settings_week_start">Week starts on</string>
|
||||
<string name="settings_week_start_auto">Automatic</string>
|
||||
<string name="settings_week_numbers">Week numbers</string>
|
||||
<string name="settings_week_numbers_summary">Show calendar-week numbers in month view</string>
|
||||
<string name="settings_time_format">Time format</string>
|
||||
<string name="settings_time_format_auto">Automatic</string>
|
||||
<string name="settings_time_format_12h">12-hour (2:00 PM)</string>
|
||||
@@ -428,6 +432,12 @@
|
||||
<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_action">Export as .ics file</string>
|
||||
<string name="calendars_export_title">Export calendars</string>
|
||||
<string name="calendars_export_hint">Choose which calendars to include in the .ics file.</string>
|
||||
<string name="calendars_export_action">Export</string>
|
||||
<string name="calendars_restore_header">Restore</string>
|
||||
<string name="calendars_restore_action">Restore from .ics file</string>
|
||||
<string name="calendars_restore_hint">Import events from a backup or another calendar app.</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>
|
||||
@@ -460,11 +470,19 @@
|
||||
<string name="import_failed">Couldn\'t read this file.</string>
|
||||
<string name="import_no_calendar">No writable calendar to import into. Create a local calendar first.</string>
|
||||
<string name="import_done_title">Import complete</string>
|
||||
<string name="import_done_dedup_note">Events already in the calendar were skipped.</string>
|
||||
<string name="import_done_added_label">Added</string>
|
||||
<string name="import_done_skipped_label">Duplicates</string>
|
||||
<string name="import_close">Close</string>
|
||||
<string name="import_warning_recurrence">Some changed occurrences of recurring events were skipped.</string>
|
||||
<string name="import_warning_no_start">An event without a start time was skipped.</string>
|
||||
<string name="import_warning_attendees">Guest lists weren\'t imported.</string>
|
||||
<string name="import_warning_timezone">An unknown time zone fell back to your device\'s.</string>
|
||||
<string name="import_button">Import</string>
|
||||
<plurals name="import_title_count">
|
||||
<item quantity="one">Importing %d event</item>
|
||||
<item quantity="other">Importing %d events</item>
|
||||
</plurals>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="one">%d event in this file.</item>
|
||||
<item quantity="other">%d events in this file.</item>
|
||||
@@ -497,15 +515,15 @@
|
||||
|
||||
<!-- Crash reporting: a captured report the user can submit, by hand, as a
|
||||
Gitea issue (the app sends nothing automatically). -->
|
||||
<string name="crash_dialog_title">Calendula crashed</string>
|
||||
<string name="crash_dialog_message">Calendula closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
|
||||
<string name="crash_dialog_title">%1$s crashed</string>
|
||||
<string name="crash_dialog_message">%1$s closed unexpectedly last time. You can help fix it by sending this report as an issue. It stays on your device until you choose to share it, and includes no personal data or calendar content — only the technical details below.</string>
|
||||
<string name="crash_dialog_report">Report</string>
|
||||
<string name="crash_dialog_dismiss">Not now</string>
|
||||
<string name="crash_report_issue_title">Crash report</string>
|
||||
<string name="crash_report_clip_label">Calendula crash report</string>
|
||||
<string name="crash_report_clip_label">%1$s crash report</string>
|
||||
<string name="crash_report_copied">Report copied to your clipboard</string>
|
||||
<string name="crash_report_open_failed">Couldn\'t open the issue tracker. The report is on your clipboard.</string>
|
||||
<string name="crash_report_body_template">Thanks for reporting a crash in Calendula. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%1$s\n</string>
|
||||
<string name="crash_report_body_template">Thanks for reporting a crash in %1$s. Please add anything you remember about what you were doing, then submit.\n\n### What happened\n\n\n### Crash report\n%2$s\n</string>
|
||||
<string name="crash_report_body_paste">_(The report was too long for this link — paste it from your clipboard here.)_</string>
|
||||
<string name="report_issue_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new</string>
|
||||
<string name="report_issue_choose_url" translatable="false">https://codeberg.org/jlmakiola/calendula/issues/new/choose</string>
|
||||
|
||||
@@ -12,4 +12,5 @@
|
||||
<locale android:name="de" />
|
||||
<locale android:name="es" />
|
||||
<locale android:name="it" />
|
||||
<locale android:name="zh-CN" />
|
||||
</locale-config>
|
||||
|
||||
@@ -536,6 +536,30 @@ class CalendarRepositoryImplTest {
|
||||
assertThat(fake.importedEvents.map { it.second }).containsExactly(3L, 3L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exportEvents forwards the chosen calendar-id subset to the data source`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.exportEvents(calendarIds = setOf(1L, 4L))
|
||||
|
||||
assertThat(fake.lastExportableEventsCalendarIds).containsExactly(1L, 4L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exportEvents defaults to null so every eligible calendar is exported`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), newSettings(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.exportEvents()
|
||||
|
||||
assertThat(fake.lastExportableEventsCalendarIds).isNull()
|
||||
}
|
||||
|
||||
private fun parsedEvent(uid: String?) = de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent(
|
||||
uid = uid,
|
||||
summary = "E",
|
||||
|
||||
@@ -23,6 +23,8 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||
var eventColorPaletteResult: (Long) -> List<EventColorOption> = { emptyList() }
|
||||
var exportableEventsResult: List<IcsEvent> = emptyList()
|
||||
/** The [calendarIds] the last [exportableEvents] call received (null = all). */
|
||||
var lastExportableEventsCalendarIds: Set<Long>? = null
|
||||
/** UIDs the target calendar already holds, for import dedup. */
|
||||
var existingUidsResult: Set<String> = emptySet()
|
||||
/** Set to make the next write call throw. */
|
||||
@@ -59,7 +61,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||
override fun eventColorPalette(calendarId: Long): List<EventColorOption> =
|
||||
eventColorPaletteResult(calendarId)
|
||||
override fun exportableEvents(): List<IcsEvent> = exportableEventsResult
|
||||
override fun exportableEvents(calendarIds: Set<Long>?): List<IcsEvent> {
|
||||
lastExportableEventsCalendarIds = calendarIds
|
||||
return exportableEventsResult
|
||||
}
|
||||
|
||||
override fun existingUids(calendarId: Long): Set<String> = existingUidsResult
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlin.time.Instant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class TimeBridgeTest {
|
||||
|
||||
@Test
|
||||
fun `epoch millis round-trips through Instant`() {
|
||||
val original = 1_717_840_800_000L // 2024-06-08T10:00:00Z
|
||||
val instant = original.toKotlinInstantFromEpochMillis()
|
||||
assertThat(instant.toEpochMillis()).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zero millis maps to Instant epoch`() {
|
||||
assertThat(0L.toKotlinInstantFromEpochMillis()).isEqualTo(Instant.fromEpochMilliseconds(0L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `negative epoch millis is supported`() {
|
||||
val original = -1_000_000L
|
||||
assertThat(original.toKotlinInstantFromEpochMillis().toEpochMillis()).isEqualTo(original)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
@@ -169,7 +169,7 @@ class SpecialDatesSyncEngineTest {
|
||||
engine.sync()
|
||||
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
||||
|
||||
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.Minutes(listOf(0, 1440)))
|
||||
engine.applyReminders(SpecialDateType.Birthday, ReminderOverride.Minutes(listOf(0, 1440)))
|
||||
|
||||
// Persisted as the per-calendar all-day override (so new events match)...
|
||||
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal])
|
||||
@@ -187,7 +187,7 @@ class SpecialDatesSyncEngineTest {
|
||||
engine.sync()
|
||||
val birthdayCal = settings.specialDatesCalendars.first().getValue(SpecialDateType.Birthday)
|
||||
|
||||
engine.applyReminders(SpecialDateType.Birthday, CalendarReminderOverride.None)
|
||||
engine.applyReminders(SpecialDateType.Birthday, ReminderOverride.None)
|
||||
|
||||
assertThat(settings.perCalendarAllDayReminderOverride.first()[birthdayCal]).isEmpty()
|
||||
assertThat(calendars.appliedManagedReminders.last().third).isEmpty()
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
package de.jeanlucmakiola.calendula.data.crash
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.floret.crash.CrashContext
|
||||
import de.jeanlucmakiola.floret.crash.buildCrashReport
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Guards the privacy contract of the report Calendula ships: the report builder
|
||||
* itself now lives in floret-kit's core-crash, but the allowlist — exactly the
|
||||
* app/Android/device/locale/time header lines plus the stack trace, and nothing
|
||||
* else — is what Calendula relies on, so it stays pinned here with Calendula's
|
||||
* own app label.
|
||||
*/
|
||||
class CrashReportBuilderTest {
|
||||
|
||||
private val context = CrashContext(
|
||||
@@ -17,7 +26,7 @@ class CrashReportBuilderTest {
|
||||
|
||||
@Test
|
||||
fun `report carries the allowlisted facts and the stack trace`() {
|
||||
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L)
|
||||
val report = buildCrashReport(context, IllegalStateException("boom"), nowMillis = 0L, appLabel = "Calendula")
|
||||
|
||||
assertThat(report).startsWith("Calendula crash report")
|
||||
assertThat(report).contains("App version: 2.7.0 (20700)")
|
||||
@@ -33,7 +42,7 @@ class CrashReportBuilderTest {
|
||||
@Test
|
||||
fun `nested causes are included`() {
|
||||
val cause = NullPointerException("inner")
|
||||
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L)
|
||||
val report = buildCrashReport(context, RuntimeException("outer", cause), nowMillis = 0L, appLabel = "Calendula")
|
||||
|
||||
assertThat(report).contains("outer")
|
||||
assertThat(report).contains("Caused by")
|
||||
@@ -42,7 +51,7 @@ class CrashReportBuilderTest {
|
||||
|
||||
@Test
|
||||
fun `report holds only the allowlisted lines before the stack trace`() {
|
||||
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L)
|
||||
val report = buildCrashReport(context, Exception("x"), nowMillis = 0L, appLabel = "Calendula")
|
||||
val header = report.substringBefore("Stack trace:").trim().lines()
|
||||
|
||||
// No identifiers, accounts, or extra fields ever creep into the header:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.data.prefs
|
||||
|
||||
import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
@@ -95,6 +97,14 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.showHourLines.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.showWeekNumbers.first()).isFalse()
|
||||
prefs.setShowWeekNumbers(true)
|
||||
assertThat(prefs.showWeekNumbers.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `autofocus event title defaults on and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
@@ -323,14 +333,14 @@ class SettingsPrefsTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).isEmpty()
|
||||
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15, 10_080)))
|
||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.None)
|
||||
prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(listOf(15, 10_080)))
|
||||
prefs.setCalendarReminderOverride(9L, ReminderOverride.None)
|
||||
prefs.perCalendarReminderOverride.first().let { map ->
|
||||
assertThat(map).containsExactly(7L, listOf(15, 10_080), 9L, emptyList<Int>())
|
||||
}
|
||||
|
||||
// Inherit drops the override entirely (absent != an empty-list value).
|
||||
prefs.setCalendarReminderOverride(9L, CalendarReminderOverride.Inherit)
|
||||
prefs.setCalendarReminderOverride(9L, ReminderOverride.Inherit)
|
||||
prefs.perCalendarReminderOverride.first().let { map ->
|
||||
assertThat(map).containsExactly(7L, listOf(15, 10_080))
|
||||
assertThat(map.containsKey(9L)).isFalse()
|
||||
@@ -370,12 +380,12 @@ class SettingsPrefsTest {
|
||||
@Test
|
||||
fun `per-calendar all-day override round-trips independently of the timed one`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
prefs.setCalendarReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(15)))
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Minutes(listOf(1_440)))
|
||||
prefs.setCalendarReminderOverride(7L, ReminderOverride.Minutes(listOf(15)))
|
||||
prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Minutes(listOf(1_440)))
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).containsExactly(7L, listOf(1_440))
|
||||
// Clearing the all-day override leaves the timed one untouched.
|
||||
prefs.setCalendarAllDayReminderOverride(7L, CalendarReminderOverride.Inherit)
|
||||
prefs.setCalendarAllDayReminderOverride(7L, ReminderOverride.Inherit)
|
||||
assertThat(prefs.perCalendarAllDayReminderOverride.first()).isEmpty()
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(7L, listOf(15))
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The pure emit rule behind [ReminderDefaultPicker]: a non-empty selection is
|
||||
* [CalendarReminderOverride.Minutes] (normalised), while clearing the last time
|
||||
* reverts to [CalendarReminderOverride.Inherit] on a per-calendar picker (so an
|
||||
* accidental toggle-undo can't silently wipe the calendar's default) and to
|
||||
* explicit [CalendarReminderOverride.None] on the global default.
|
||||
*/
|
||||
class ReminderOverrideForMinutesTest {
|
||||
|
||||
@Test
|
||||
fun `empty selection on a per-calendar picker reverts to inherit`() {
|
||||
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = true))
|
||||
.isEqualTo(CalendarReminderOverride.Inherit)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty selection on the global default is explicit none`() {
|
||||
assertThat(reminderOverrideForMinutes(emptyList(), allowInherit = false))
|
||||
.isEqualTo(CalendarReminderOverride.None)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a non-empty selection is normalised minutes regardless of scope`() {
|
||||
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = true))
|
||||
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
|
||||
assertThat(reminderOverrideForMinutes(listOf(30, 10, 10, 60), allowInherit = false))
|
||||
.isEqualTo(CalendarReminderOverride.Minutes(listOf(10, 30, 60)))
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,21 @@ Builds:
|
||||
versionCode: 20705
|
||||
commit: v2.7.5
|
||||
subdir: app
|
||||
submodules: true
|
||||
gradle:
|
||||
- yes
|
||||
# No NDK / no flavors. The release buildType applies a signingConfig only
|
||||
# when key.properties exists; on the buildserver it does not, so this
|
||||
# produces the unsigned APK F-Droid compares against our binary.
|
||||
#
|
||||
# submodules: true — REQUIRED from v2.11.0 onward, where the build pulls
|
||||
# shared code from the `floret-kit` git submodule via a Gradle composite
|
||||
# build (`includeBuild("floret-kit")`). Without it F-Droid checks out an
|
||||
# empty floret-kit/ and the from-source build fails, stalling publishing.
|
||||
# Inert for v2.7.5 (no submodule yet); kept here so AutoUpdateMode copies it
|
||||
# onto every auto-generated future build entry. The kit is plain-Kotlin and
|
||||
# carries no foojay toolchain resolver, so it clears the same reproducibility
|
||||
# bar (enforced by scripts/check_reproducible_release.sh).
|
||||
|
||||
# SHA-256 of our app signing certificate (public; embedded in every published
|
||||
# APK). Locks F-Droid to publish only binaries signed with our key.
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 198 KiB |
30
fastlane/metadata/android/en-US/changelogs/21400.txt
Normal file
@@ -0,0 +1,30 @@
|
||||
### Added
|
||||
- Restore events from a backup file. The backup section of Settings can now read
|
||||
events back **in** from an `.ics` file, not just write one out: pick a file,
|
||||
choose which calendar to import into, and Calendula adds the events — skipping
|
||||
any that are already there and telling you how many it skipped. Export gained a
|
||||
per-calendar selector at the same time, so you can back up just the calendars
|
||||
you pick instead of everything at once ([#32]).
|
||||
- Week numbers in Month view. A new **Week numbers** setting (off by default)
|
||||
adds a slim gutter down the left of the Month grid showing the calendar-week
|
||||
number for each row, sized to match the day cells. Handy if you plan or refer
|
||||
to dates by week number ([#25]).
|
||||
- Tap a date header to open that day. In Week and Agenda view, tapping a date
|
||||
header now opens that date in Day view — the same drill-in that Month view and
|
||||
the agenda widget already offered, so every view behaves the same way. It makes
|
||||
jumping to a specific day quicker: switch to Week, swipe to the week you want,
|
||||
then tap the date to open it. Thanks to @ptab for the suggestion ([#37]).
|
||||
- An early Simplified Chinese translation. Calendula has started speaking
|
||||
Simplified Chinese, contributed as a community translation through
|
||||
[Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/).
|
||||
It is still an early effort, so many parts of the app show in English until it
|
||||
fills out — you can already pick it under Settings → Language or in Android's
|
||||
per-app language settings. Thanks to
|
||||
[zh-cn](https://weblate.dev.jeanlucmakiola.de/user/zh-cn/) for getting it
|
||||
started; help finishing it is very welcome.
|
||||
|
||||
### Changed
|
||||
- Long event titles wrap in the edit screen. When editing an event, a long title
|
||||
now wraps onto multiple lines instead of being clipped to a single line, so you
|
||||
can see and edit the whole thing ([#33]).
|
||||
|
||||
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 192 KiB |
25
fastlane/metadata/android/es-ES/full_description.txt
Normal file
@@ -0,0 +1,25 @@
|
||||
Calendula es una aplicación de calendario moderna y de código abierto
|
||||
para Android. Funciona directamente sobre el proveedor de calendario del
|
||||
sistema: cualquier origen sincronizado en tu dispositivo — Nextcloud
|
||||
mediante DAVx5, Google, calendarios locales, suscripciones WebCal —
|
||||
aparece automáticamente, y los cambios que hagas se sincronizan de vuelta
|
||||
del mismo modo.
|
||||
|
||||
Crea, edita y elimina eventos, incluidos los recurrentes con cambios
|
||||
acotados (solo este evento / este y todos los siguientes / toda la serie)
|
||||
y un sencillo selector de repetición. Calendula también entrega los
|
||||
recordatorios de tus eventos como notificaciones: toca una y estarás en
|
||||
el evento.
|
||||
|
||||
Si quieres, muestra en tu calendario los cumpleaños, aniversarios y otras
|
||||
fechas especiales de tus contactos. Tú mismo lo activas; los contactos se
|
||||
leen solo en tu dispositivo, nunca se suben y nunca se modifican —
|
||||
Calendula solo refleja las fechas en calendarios locales que controlas
|
||||
por completo.
|
||||
|
||||
El factor diferenciador es el diseño: auténtico Material 3 Expressive en
|
||||
toda la aplicación, con Dynamic Color, movimiento expresivo y formas
|
||||
expresivas.
|
||||
|
||||
Privacidad: cero telemetría, sin analíticas, sin acceso a la red — tus
|
||||
datos nunca salen del dispositivo.
|
||||
BIN
fastlane/metadata/android/es-ES/images/icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 207 KiB |
1
fastlane/metadata/android/es-ES/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Un calendario moderno con Material 3 Expressive para Android.
|
||||
1
fastlane/metadata/android/es-ES/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Calendula
|
||||
24
fastlane/metadata/android/it-IT/full_description.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
Calendula è un'app di calendario moderna e open source per Android.
|
||||
Funziona direttamente sul provider di calendario di sistema: qualsiasi
|
||||
origine sincronizzata sul tuo dispositivo — Nextcloud tramite DAVx5,
|
||||
Google, calendari locali, iscrizioni WebCal — compare automaticamente, e
|
||||
le modifiche che apporti vengono sincronizzate a ritroso allo stesso
|
||||
modo.
|
||||
|
||||
Crea, modifica ed elimina eventi, compresi quelli ricorrenti con
|
||||
modifiche mirate (solo questo evento / questo e tutti i successivi /
|
||||
l'intera serie) e un semplice selettore di ripetizione. Calendula
|
||||
recapita anche i promemoria dei tuoi eventi come notifiche: ne tocchi una
|
||||
e sei subito sull'evento.
|
||||
|
||||
Se vuoi, mostra nel calendario i compleanni, gli anniversari e le altre
|
||||
date speciali dei tuoi contatti. Sei tu ad attivarlo; i contatti vengono
|
||||
letti solo sul tuo dispositivo, non vengono mai caricati e non vengono
|
||||
mai modificati — Calendula si limita a rispecchiare le date in calendari
|
||||
locali che controlli completamente.
|
||||
|
||||
La differenza sta nel design: vero Material 3 Expressive ovunque, con
|
||||
Dynamic Color, animazioni espressive e forme espressive.
|
||||
|
||||
Privacy: zero telemetria, nessuna analisi, nessun accesso alla rete — i
|
||||
tuoi dati non lasciano mai il dispositivo.
|
||||
BIN
fastlane/metadata/android/it-IT/images/icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 154 KiB |