Compare commits
66 Commits
4e125e58d5
...
renovate/m
| Author | SHA1 | Date | |
|---|---|---|---|
| 1770c4ae42 | |||
| 2239c38ecc | |||
| 79ad70e0c0 | |||
| f168021f51 | |||
| a2b1e7792b | |||
| c6ce0830b1 | |||
| be13f97802 | |||
| f6d40de832 | |||
| fdee311658 | |||
| 1ff41153e9 | |||
| 49d04bcee0 | |||
| 5e470baf4e | |||
| c8a4f90404 | |||
| 0a89ff9d0e | |||
| 0718afd5a6 | |||
| 84e6402031 | |||
| 7fe59b36c3 | |||
| 8881559e67 | |||
| 9f7e93d5f8 | |||
| 3863e74857 | |||
| 44d056f31f | |||
| 5c513a1b19 | |||
| 037d05b171 | |||
| 86d4e11584 | |||
| 67a07a9346 | |||
| b8a420bdab | |||
| 442697d57c | |||
| 8e3a17fdc3 | |||
| e6dd627c85 | |||
|
|
5844539db2 | ||
|
|
2cc83d7714 | ||
|
|
dada1bbe72 | ||
|
|
e061cbe127 | ||
| 262b2b8273 | |||
| efca0c86c4 | |||
| 08cd95cfba | |||
| bfc9a0db39 | |||
| 08905990cc | |||
| b5895b190e | |||
| 37f0e22911 | |||
| 4c26450ad4 | |||
| e5aa90bdd6 | |||
| 580dc5a669 | |||
| b9e800e9fc | |||
| 40d70d9f2b | |||
| 14b19cd902 | |||
|
|
67511ce9be | ||
|
|
2e90682874 | ||
|
|
2a4cb6a896 | ||
| 8d530c5681 | |||
| 11578a5588 | |||
| 405ff16233 | |||
| 69460bc35a | |||
|
|
bf10deaf5c | ||
|
|
b9e861af62 | ||
|
|
2eadda937b | ||
|
|
71b90fa184 | ||
|
|
4e9cf88bd4 | ||
|
|
b999202629 | ||
|
|
6f0e13a4a2 | ||
|
|
9ef7b33800 | ||
|
|
d04b431f2c | ||
|
|
dbeeadff80 | ||
|
|
f4a169ef5f | ||
|
|
604099b3f2 | ||
|
|
a3e6541512 |
46
CHANGELOG.md
46
CHANGELOG.md
@@ -5,9 +5,19 @@ 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).
|
||||
|
||||
## [Unreleased]
|
||||
## [2.13.0] — 2026-07-03
|
||||
|
||||
### Added
|
||||
- Calendula now speaks Spanish and Italian. Both arrived as community
|
||||
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
|
||||
— a **huge thank you** to
|
||||
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
|
||||
translation and to
|
||||
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
|
||||
one! Pick your language under Settings → Language or in Android's per-app
|
||||
language settings. Strings added in this very release may still show in
|
||||
English until the translations catch up. Want Calendula in your language?
|
||||
Translating happens entirely in the browser — every contribution is welcome.
|
||||
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||
birthdays, anniversaries and other dates into local "Birthdays",
|
||||
@@ -25,8 +35,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||
multi-select; per-calendar overrides can still inherit the global default or
|
||||
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||
- Widget headers now open the app. Tapping the month/year title on the month
|
||||
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||
the agenda widget opens it on your default view — so there's a one-tap way
|
||||
back into the app that lands where you'd expect, instead of only through a day
|
||||
or event. On the month widget, tapping anywhere on a day — not just the small
|
||||
date number — now opens that day, and the "today" button snaps the grid back
|
||||
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||
suggestions ([#18], [#20]).
|
||||
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
|
||||
and **Body font** pickers: keep the system default, choose a bundled face
|
||||
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
|
||||
face), or load any `.ttf`/`.otf` file from your device, independently per
|
||||
role. Font size and colour stay with Android's accessibility scaling and the
|
||||
app theme, as discussed on the issue. Thanks to @abrossimow for the
|
||||
suggestion ([#19]).
|
||||
- Choose what the view-switch button cycles through. Settings → Appearance now
|
||||
lets you pick which views the top-right quick-switch button rotates between
|
||||
and drag them into your preferred order; views you switch off stay reachable
|
||||
from the navigation menu, which can be reordered the same way. Thanks to
|
||||
@abrossimow for the suggestion ([#24]).
|
||||
|
||||
### Fixed
|
||||
- Month widget arrows and "today" button work again. On release builds the
|
||||
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||
popping up — matching how a disabled calendar's events already stay hidden
|
||||
@@ -37,6 +71,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||
lands on just that one event and leaves the rest of the series untouched
|
||||
([#16]).
|
||||
- Every calendar can be picked when creating an event. The event editor's
|
||||
calendar picker was a fixed-height dialog, so with many calendars anything
|
||||
past the first nine or so was simply unreachable. It is now a full-screen,
|
||||
scrollable list grouped by account, with each calendar's colour shown.
|
||||
Thanks to @dschuermann for the report ([#29]).
|
||||
|
||||
## [2.12.0] — 2026-06-28
|
||||
|
||||
@@ -766,3 +805,8 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#15]: https://codeberg.org/jlmakiola/calendula/issues/15
|
||||
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
|
||||
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17
|
||||
[#18]: https://codeberg.org/jlmakiola/calendula/issues/18
|
||||
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19
|
||||
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20
|
||||
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24
|
||||
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29
|
||||
|
||||
@@ -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 = 21200
|
||||
versionName = "2.12.0"
|
||||
versionCode = 21300
|
||||
versionName = "2.13.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
12
app/proguard-rules.pro
vendored
12
app/proguard-rules.pro
vendored
@@ -16,6 +16,18 @@
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
-dontwarn androidx.room.paging.**
|
||||
|
||||
# Glance runs an @Composable's `actionRunCallback<T>()` by persisting the
|
||||
# callback's fully-qualified class name into the click PendingIntent, then
|
||||
# reflectively instantiating it (Class.forName(name).newInstance()) when the tap
|
||||
# fires. Under R8 full mode (AGP 9 default) these ActionCallback classes — only
|
||||
# ever referenced reflectively — get renamed or have their no-arg constructor
|
||||
# stripped, so the lookup fails silently and the tap does nothing. In the month
|
||||
# and agenda widgets that broke every run-callback control (the prev/next/today
|
||||
# month arrows and the agenda refresh) in release builds while actionStartActivity
|
||||
# taps, which ride a PendingIntent and need no reflection, kept working. Keep
|
||||
# every ActionCallback's name and constructor intact.
|
||||
-keep class * implements androidx.glance.appwidget.action.ActionCallback { <init>(...); }
|
||||
|
||||
# WorkManager instantiates an InputMerger reflectively (Class.newInstance) from
|
||||
# the fully-qualified class name persisted in the WorkSpec, so the class must
|
||||
# keep both its name and a no-arg constructor. Glance renders every widget
|
||||
|
||||
@@ -34,8 +34,11 @@ import de.jeanlucmakiola.calendula.ui.common.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.calendula.ui.theme.CalendulaTheme
|
||||
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
@@ -99,9 +102,22 @@ class MainActivity : AppCompatActivity() {
|
||||
val use24Hour = remember(settings.timeFormat, context) {
|
||||
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||
}
|
||||
// The user's custom-font choice, resolved to a Material typography
|
||||
// (issue #19). Recomputed only when a token — or the custom-font
|
||||
// re-import stamp AppFontSettings carries, so replacing the file
|
||||
// behind an active "custom" token still refreshes — changes;
|
||||
// "system for both" returns the default scale untouched.
|
||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||
val typography = remember(fonts, context) {
|
||||
calendulaTypography(
|
||||
brand = resolveFontFamily(fonts.brand, FontRole.BRAND, context),
|
||||
plain = resolveFontFamily(fonts.plain, FontRole.PLAIN, context),
|
||||
)
|
||||
}
|
||||
CalendulaTheme(
|
||||
darkTheme = darkTheme,
|
||||
dynamicColor = settings.dynamicColor,
|
||||
typography = typography,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
@@ -193,6 +209,12 @@ class MainActivity : AppCompatActivity() {
|
||||
// An external date tap (launcher/clock) has no widget source, so it opens
|
||||
// the day view rooted over the default home view (OpenDate source = null).
|
||||
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
|
||||
// A widget header tap: open a top-level view with no date drill-in. The
|
||||
// empty string carried by [openViewIntent] means "the default home view".
|
||||
if (hasExtra(EXTRA_OPEN_VIEW)) {
|
||||
val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty()
|
||||
return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name })
|
||||
}
|
||||
val source = sourceViewOrNull()
|
||||
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
|
||||
return when {
|
||||
@@ -244,6 +266,10 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
|
||||
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
|
||||
|
||||
// A widget header tap asking to open a top-level view (no date drill-in).
|
||||
// Its value is the target [CalendarView] name, or "" for the default view.
|
||||
private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW"
|
||||
|
||||
// The [CalendarView] (by name) of the widget a launch came from. Roots the
|
||||
// in-app back stack in that view; absent for non-widget launches (reminders).
|
||||
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
|
||||
@@ -307,5 +333,19 @@ class MainActivity : AppCompatActivity() {
|
||||
putExtra(EXTRA_DATE_ISO, date.toString())
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the app on a top-level [view] with no date drill-in — a widget
|
||||
* header tap. A null [view] opens the user's default home view (the agenda
|
||||
* widget's "Upcoming" title); a concrete view roots there over the default
|
||||
* home (the month widget's month/year title → [CalendarView.Month]). The
|
||||
* per-view data URI keeps distinct headers' PendingIntents from collapsing.
|
||||
*/
|
||||
fun openViewIntent(context: Context, view: CalendarView?): Intent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
data = "calendula://view/${view?.name ?: "default"}".toUri()
|
||||
putExtra(EXTRA_OPEN_VIEW, view?.name ?: "")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,27 @@ internal fun toProviderAllDayMinutes(
|
||||
return ((utcMidnight - fire) / MILLIS_PER_MINUTE).toInt()
|
||||
}
|
||||
|
||||
/**
|
||||
* The next date on or after [today] carrying [month]/[day] — the date to sample
|
||||
* a yearly all-day reminder's UTC offset at. A managed birthday's DTSTART is an
|
||||
* ancient anchor (1972 for year-less dates), a year whose timezone rules
|
||||
* (pre-DST offsets) differ from today's; sampling the offset there skews every
|
||||
* modern occurrence by hours. Sampling at the upcoming occurrence makes the
|
||||
* stored offset correct for it and its neighbours (only ±1h DST drift remains,
|
||||
* the inherent limit). Feb-29 skips forward to the next leap year.
|
||||
*/
|
||||
internal fun nextYearlyOccurrence(month: Int, day: Int, today: LocalDate): LocalDate {
|
||||
var year = today.year
|
||||
// A Feb-29 date is valid only every ~4 years; 8 tries always reaches one.
|
||||
repeat(8) {
|
||||
val candidate = runCatching { LocalDate.of(year, month, day) }.getOrNull()
|
||||
if (candidate != null && !candidate.isBefore(today)) return candidate
|
||||
year++
|
||||
}
|
||||
// Unreachable for real month/day inputs; fall back to the raw anchor.
|
||||
return LocalDate.of(today.year, month, day)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the semantic whole-day lead time from a raw all-day reminder
|
||||
* [rawMinutes]. Keys off the **local date** of the encoded fire instant, so it
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
/**
|
||||
* Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` /
|
||||
* `EVENT_COLOR`. The named entries exist for callers that need one specific
|
||||
* hue (the managed special-dates calendars), so they can't drift from the
|
||||
* swatches offered in the colour picker.
|
||||
*/
|
||||
object CalendarColorPalette {
|
||||
val Red = 0xFFD50000.toInt()
|
||||
val Orange = 0xFFE67C00.toInt()
|
||||
val Amber = 0xFFF6BF26.toInt()
|
||||
val Green = 0xFF33B679.toInt()
|
||||
val DarkGreen = 0xFF0B8043.toInt()
|
||||
val Blue = 0xFF039BE5.toInt()
|
||||
val Indigo = 0xFF3F51B5.toInt()
|
||||
val Purple = 0xFF8E24AA.toInt()
|
||||
val Graphite = 0xFF616161.toInt()
|
||||
|
||||
/** The full palette, in swatch-row order. */
|
||||
val all: List<Int> = listOf(Red, Orange, Amber, Green, DarkGreen, Blue, Indigo, Purple, Graphite)
|
||||
}
|
||||
@@ -24,12 +24,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.EventStatus
|
||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.MANAGED_UID_PREFIX
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.rruleTruncatedAt
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
@@ -135,11 +137,12 @@ interface CalendarDataSource {
|
||||
fun updateManagedFields(eventId: Long, values: Map<String, Any?>)
|
||||
|
||||
/**
|
||||
* Replace the reminders on **every** event in [calendarId] with [minutes]
|
||||
* (all-day lead times), each encoded from its own date so it fires at
|
||||
* [allDayReminderTimeMinutes]. Used by the special-dates section so a reminder
|
||||
* change applies to existing events too, not just future ones — the managed
|
||||
* calendars treat reminders as a calendar-level setting.
|
||||
* Replace the reminders on **every managed** event in [calendarId] with
|
||||
* [minutes] (all-day lead times), each encoded so it fires at
|
||||
* [allDayReminderTimeMinutes] on its upcoming occurrence. Used by the
|
||||
* special-dates section so a reminder change applies to existing events too,
|
||||
* not just future ones — the managed calendars treat reminders as a
|
||||
* calendar-level setting. Skips any non-managed (user) event in the calendar.
|
||||
*/
|
||||
fun applyManagedCalendarReminders(
|
||||
calendarId: Long,
|
||||
@@ -393,10 +396,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
CalendarContract.Events.DTSTART,
|
||||
CalendarContract.Events.RRULE,
|
||||
),
|
||||
// Only our own mirror events (UID prefix), so a user event that somehow
|
||||
// landed in this calendar (e.g. an .ics import) is never seen as
|
||||
// "existing but not desired" and deleted by the diff.
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.UID_2445} IS NOT NULL",
|
||||
arrayOf(calendarId.toString()),
|
||||
"${CalendarContract.Events.UID_2445} LIKE ?",
|
||||
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
||||
null,
|
||||
)?.use { c ->
|
||||
buildList {
|
||||
@@ -416,47 +422,21 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
} ?: emptyList()
|
||||
|
||||
override fun insertManagedEvent(form: EventForm, uid: String, allDayReminderTimeMinutes: Int): Long {
|
||||
// Deterministic UID = the reconciliation key. A re-sync reads it back
|
||||
// (queryManagedEvents) and diffs on it, so a contact never doubles.
|
||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||
val values = ContentValues().apply {
|
||||
put(
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||
)
|
||||
// Deterministic UID = the reconciliation key. A re-sync reads it back
|
||||
// (queryManagedEvents) and diffs on it, so a contact never doubles.
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
if (form.rrule == null) {
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, form.rrule)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
}
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
form.location.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
form.description.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
}
|
||||
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
val values = buildEventInsertValues(form, uid, times)
|
||||
val eventId = resolver.insert(CalendarContract.Events.CONTENT_URI, values.toContentValues())
|
||||
?.let(ContentUris::parseId)
|
||||
?: throw WriteFailedException("insert managed event into calendar id=${form.calendarId}")
|
||||
// Seeded once, then never re-touched by sync — so a user who moves a
|
||||
// birthday reminder keeps their change.
|
||||
encodedReminders(form, allDayReminderTimeMinutes).forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to managed event $eventId")
|
||||
}
|
||||
}
|
||||
// birthday reminder keeps their change. Encoded against the upcoming
|
||||
// occurrence, not the ancient DTSTART anchor (wrong timezone offset).
|
||||
val anchor = Instant.ofEpochMilli(times.dtStartMillis).atZone(ZoneOffset.UTC).toLocalDate()
|
||||
insertReminderRows(
|
||||
eventId,
|
||||
managedAllDayReminderMinutes(anchor, form.reminders, allDayReminderTimeMinutes),
|
||||
)
|
||||
return eventId
|
||||
}
|
||||
|
||||
@@ -475,23 +455,26 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
allDayReminderTimeMinutes: Int,
|
||||
minutes: List<Int>,
|
||||
) {
|
||||
val zone = ZoneId.systemDefault()
|
||||
resolver.query(
|
||||
CalendarContract.Events.CONTENT_URI,
|
||||
arrayOf(CalendarContract.Events._ID, CalendarContract.Events.DTSTART),
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND ${CalendarContract.Events.DELETED} = 0",
|
||||
arrayOf(calendarId.toString()),
|
||||
// Only our own mirror events, so a stray user event in this calendar
|
||||
// isn't all-day-re-encoded or stripped of its own reminders.
|
||||
"${CalendarContract.Events.CALENDAR_ID} = ? AND " +
|
||||
"${CalendarContract.Events.DELETED} = 0 AND " +
|
||||
"${CalendarContract.Events.UID_2445} LIKE ?",
|
||||
arrayOf(calendarId.toString(), "$MANAGED_UID_PREFIX%"),
|
||||
null,
|
||||
)?.use { c ->
|
||||
while (c.moveToNext()) {
|
||||
val eventId = c.getLong(0)
|
||||
// Managed events are all-day: DTSTART is a UTC midnight.
|
||||
val startDate = Instant.ofEpochMilli(c.getLong(1))
|
||||
// Managed events are all-day: DTSTART is a UTC midnight anchor.
|
||||
val anchor = Instant.ofEpochMilli(c.getLong(1))
|
||||
.atZone(ZoneOffset.UTC).toLocalDate()
|
||||
val encoded = minutes
|
||||
.map { toProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes) }
|
||||
.distinct()
|
||||
reconcileReminders(eventId, encoded)
|
||||
reconcileReminders(
|
||||
eventId,
|
||||
managedAllDayReminderMinutes(anchor, minutes, allDayReminderTimeMinutes),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -779,35 +762,15 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
.distinct()
|
||||
|
||||
override fun insertEvent(form: EventForm, allDayReminderTimeMinutes: Int): Long {
|
||||
val times = form.toWriteTimes(ZoneId.systemDefault())
|
||||
val values = ContentValues().apply {
|
||||
put(
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||
)
|
||||
// A globally-unique UID so a later .ics backup/restore can identify
|
||||
// the event and not duplicate it on re-import (the provider leaves
|
||||
// this null for events it didn't sync). Older rows without one fall
|
||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||
put(CalendarContract.Events.UID_2445, "${UUID.randomUUID()}@calendula")
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
// The provider's invariant: recurring rows carry RRULE+DURATION
|
||||
// (and no DTEND), one-off rows carry DTEND.
|
||||
if (form.rrule == null) {
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, form.rrule)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
}
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
form.location.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
form.description.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
// A globally-unique UID so a later .ics backup/restore can identify
|
||||
// the event and not duplicate it on re-import (the provider leaves
|
||||
// this null for events it didn't sync). Older rows without one fall
|
||||
// back to a stable synthesised UID at export time (deriveIcsUid).
|
||||
val values = buildEventInsertValues(
|
||||
form,
|
||||
uid = "${UUID.randomUUID()}@calendula",
|
||||
times = form.toWriteTimes(ZoneId.systemDefault()),
|
||||
).toContentValues().apply {
|
||||
// A null colour just leaves both columns unset (the event inherits
|
||||
// its calendar's colour), so only the key/raw cases are written.
|
||||
when {
|
||||
@@ -819,19 +782,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values)
|
||||
?: throw WriteFailedException("insert event into calendar id=${form.calendarId}")
|
||||
val eventId = ContentUris.parseId(uri)
|
||||
// Best effort (spec §8): the event exists at this point — a reminder
|
||||
// that fails to attach is logged, not surfaced as a failed create.
|
||||
encodedReminders(form, allDayReminderTimeMinutes)
|
||||
.forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
}
|
||||
seedReminders(eventId, form, allDayReminderTimeMinutes)
|
||||
// Guests are best-effort like reminders: a row that fails to attach is
|
||||
// logged, not surfaced as a failed create. Calendula never sends an
|
||||
// invitation — it only writes the rows; the backend decides delivery.
|
||||
@@ -839,6 +790,47 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
return eventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach [form]'s reminders to a freshly inserted event. Best effort
|
||||
* (spec §8): the event exists at this point — a reminder that fails to
|
||||
* attach is logged, not surfaced as a failed create.
|
||||
*/
|
||||
private fun seedReminders(eventId: Long, form: EventForm, allDayReminderTimeMinutes: Int) =
|
||||
insertReminderRows(eventId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||
|
||||
/** Insert one `METHOD_ALERT` reminder row per raw provider offset. */
|
||||
private fun insertReminderRows(eventId: Long, encodedMinutes: List<Int>) {
|
||||
encodedMinutes.forEach { minutes ->
|
||||
val reminder = ContentValues().apply {
|
||||
put(CalendarContract.Reminders.EVENT_ID, eventId)
|
||||
put(CalendarContract.Reminders.MINUTES, minutes)
|
||||
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
|
||||
}
|
||||
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
|
||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A managed all-day event's reminders, encoded so they fire at
|
||||
* [allDayReminderTimeMinutes] on its **upcoming** occurrence. The event's
|
||||
* DTSTART is an ancient recurrence anchor (see [nextYearlyOccurrence]); using
|
||||
* it directly would sample a stale timezone offset, so the offset is sampled
|
||||
* at the next occurrence of the anchor's month/day instead.
|
||||
*/
|
||||
private fun managedAllDayReminderMinutes(
|
||||
anchor: LocalDate,
|
||||
reminders: List<Int>,
|
||||
allDayReminderTimeMinutes: Int,
|
||||
): List<Int> {
|
||||
val zone = ZoneId.systemDefault()
|
||||
val onDate = nextYearlyOccurrence(anchor.monthValue, anchor.dayOfMonth, LocalDate.now(zone))
|
||||
return reminders
|
||||
.map { toProviderAllDayMinutes(it, onDate, zone, allDayReminderTimeMinutes) }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
override fun updateEvent(
|
||||
eventId: Long,
|
||||
original: EventForm,
|
||||
@@ -1263,11 +1255,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
const val LOCAL_ACCOUNT_NAME = "Calendula"
|
||||
|
||||
/**
|
||||
* Column and namespace for the special-dates marker. CAL_SYNC1 already
|
||||
* holds the local-calendar description, so the marker uses CAL_SYNC2.
|
||||
* Column and namespace for the special-dates marker (shared with the
|
||||
* calendar projection, which reads it into [CalendarSource.isManaged]).
|
||||
* CAL_SYNC1 already holds the local-calendar description, so it uses CAL_SYNC2.
|
||||
*/
|
||||
val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
|
||||
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
|
||||
val MANAGED_MARKER_COLUMN: String = CalendarProjection.MANAGED_MARKER_COLUMN
|
||||
const val MANAGED_MARKER_PREFIX = CalendarProjection.MANAGED_MARKER_PREFIX
|
||||
|
||||
/**
|
||||
* How far ahead/behind a search looks for a recurring event's nearest
|
||||
|
||||
@@ -24,5 +24,12 @@ internal fun ColumnReader.toCalendarSource(): CalendarSource {
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// A special-dates mirror calendar, recognised by its durable CAL_SYNC2
|
||||
// marker (only meaningful on the local calendars the app owns). This is
|
||||
// the source of truth for the editor lock — independent of any stored
|
||||
// preference that a backup restore could have wiped.
|
||||
isManaged = isLocal &&
|
||||
getString(CalendarProjection.IDX_MANAGED_MARKER)
|
||||
?.startsWith(CalendarProjection.MANAGED_MARKER_PREFIX) == true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,42 @@ internal fun EventWriteTimes.toRfc2445Duration(isAllDay: Boolean): String = if (
|
||||
"P${(dtEndMillis - dtStartMillis) / 1_000L}S"
|
||||
}
|
||||
|
||||
/**
|
||||
* Column values for a brand-new Events row: identity, times (the provider's
|
||||
* invariant — recurring rows carry RRULE+DURATION and no DTEND, one-off rows
|
||||
* carry DTEND), availability/access level and trimmed optional text. Shared by
|
||||
* the user-event and managed-event insert paths, which differ only in the
|
||||
* [uid] they stamp; colour and attendees are user-event concerns the caller
|
||||
* layers on top.
|
||||
*/
|
||||
internal fun buildEventInsertValues(
|
||||
form: EventForm,
|
||||
uid: String,
|
||||
times: EventWriteTimes,
|
||||
): Map<String, Any?> = buildMap {
|
||||
put(
|
||||
CalendarContract.Events.CALENDAR_ID,
|
||||
requireNotNull(form.calendarId) { "EventForm.calendarId is required" },
|
||||
)
|
||||
put(CalendarContract.Events.UID_2445, uid)
|
||||
put(CalendarContract.Events.TITLE, form.title.trim())
|
||||
put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0)
|
||||
put(CalendarContract.Events.DTSTART, times.dtStartMillis)
|
||||
if (form.rrule == null) {
|
||||
put(CalendarContract.Events.DTEND, times.dtEndMillis)
|
||||
} else {
|
||||
put(CalendarContract.Events.RRULE, form.rrule)
|
||||
put(CalendarContract.Events.DURATION, times.toRfc2445Duration(form.isAllDay))
|
||||
}
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone)
|
||||
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
|
||||
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
|
||||
form.location.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.EVENT_LOCATION, it) }
|
||||
form.description.trim().takeIf { it.isNotEmpty() }
|
||||
?.let { put(CalendarContract.Events.DESCRIPTION, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Dirty-checked column values for updating an existing Events row: only what
|
||||
* the user actually changed is written, so untouched fields can't stomp
|
||||
|
||||
@@ -15,9 +15,17 @@ internal object CalendarProjection {
|
||||
// own we stash one in CAL_SYNC1 (synced rows put their sync token here,
|
||||
// so the mapper only reads it for local calendars).
|
||||
DESCRIPTION_COLUMN,
|
||||
// The special-dates marker (CAL_SYNC2) — the durable identity the app
|
||||
// uses to recognise its own managed calendars, independent of any
|
||||
// stored preference id (which a backup restore / data wipe can lose).
|
||||
MANAGED_MARKER_COLUMN,
|
||||
)
|
||||
|
||||
const val DESCRIPTION_COLUMN: String = CalendarContract.Calendars.CAL_SYNC1
|
||||
const val MANAGED_MARKER_COLUMN: String = CalendarContract.Calendars.CAL_SYNC2
|
||||
|
||||
/** Namespace prefix of every managed-calendar [MANAGED_MARKER_COLUMN] value. */
|
||||
const val MANAGED_MARKER_PREFIX = "calendula.specialdates"
|
||||
|
||||
const val IDX_ID = 0
|
||||
const val IDX_DISPLAY_NAME = 1
|
||||
@@ -27,6 +35,7 @@ internal object CalendarProjection {
|
||||
const val IDX_VISIBLE = 5
|
||||
const val IDX_ACCESS_LEVEL = 6
|
||||
const val IDX_DESCRIPTION = 7
|
||||
const val IDX_MANAGED_MARKER = 8
|
||||
}
|
||||
|
||||
internal object InstanceProjection {
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.contacts
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -34,8 +35,8 @@ class AndroidSpecialDatesCalendarSpec @Inject constructor(
|
||||
)
|
||||
|
||||
override fun color(type: SpecialDateType): Int = when (type) {
|
||||
SpecialDateType.Birthday -> 0xFF8E24AA.toInt() // purple
|
||||
SpecialDateType.Anniversary -> 0xFFD50000.toInt() // red
|
||||
SpecialDateType.Custom -> 0xFF039BE5.toInt() // blue
|
||||
SpecialDateType.Birthday -> CalendarColorPalette.Purple
|
||||
SpecialDateType.Anniversary -> CalendarColorPalette.Red
|
||||
SpecialDateType.Custom -> CalendarColorPalette.Blue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.parseContactEventDate
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -28,6 +29,11 @@ interface ContactSpecialDatesDataSource {
|
||||
fun readSpecialDates(): List<ContactSpecialDate>
|
||||
}
|
||||
|
||||
/** Whether `READ_CONTACTS` is granted — the gate for every contacts read. */
|
||||
fun Context.hasContactsPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Map a `ContactsContract` event `TYPE` to our calendar bucket. */
|
||||
internal fun specialDateTypeForRawEventType(type: Int): SpecialDateType = when (type) {
|
||||
Event.TYPE_BIRTHDAY -> SpecialDateType.Birthday
|
||||
@@ -40,16 +46,17 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : ContactSpecialDatesDataSource {
|
||||
|
||||
override fun hasPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
override fun hasPermission(): Boolean = context.hasContactsPermission()
|
||||
|
||||
override fun readSpecialDates(): List<ContactSpecialDate> {
|
||||
if (!hasPermission()) return emptyList()
|
||||
val resolver = context.contentResolver
|
||||
// A contact can carry the same date more than once (multiple raw
|
||||
// contacts under one aggregate); keep the first per (contact, type).
|
||||
val seen = HashSet<Pair<String, SpecialDateType>>()
|
||||
// A contact can carry the same date more than once (multiple raw contacts
|
||||
// under one aggregate); dedup on the mirror's reconciliation key so exact
|
||||
// duplicates collapse while genuinely distinct dates (two custom events on
|
||||
// one contact) are all kept. Ordered by Data._ID so which of two truly
|
||||
// conflicting rows wins is stable across syncs (no event ping-pong).
|
||||
val seen = HashSet<String>()
|
||||
val result = ArrayList<ContactSpecialDate>()
|
||||
runCatching {
|
||||
resolver.query(
|
||||
@@ -57,7 +64,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||
PROJECTION,
|
||||
"${ContactsContract.Data.MIMETYPE} = ?",
|
||||
arrayOf(Event.CONTENT_ITEM_TYPE),
|
||||
null,
|
||||
"${ContactsContract.Data._ID} ASC",
|
||||
)?.use { c ->
|
||||
val idxDate = c.getColumnIndexOrThrow(Event.START_DATE)
|
||||
val idxType = c.getColumnIndexOrThrow(Event.TYPE)
|
||||
@@ -68,8 +75,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||
val lookup = c.getString(idxLookup)?.takeIf { it.isNotEmpty() } ?: continue
|
||||
val parts = parseContactEventDate(c.getString(idxDate)) ?: continue
|
||||
val type = specialDateTypeForRawEventType(c.getInt(idxType))
|
||||
if (!seen.add(lookup to type)) continue
|
||||
result += ContactSpecialDate(
|
||||
val date = ContactSpecialDate(
|
||||
lookupKey = lookup,
|
||||
displayName = c.getString(idxName)?.trim().orEmpty(),
|
||||
type = type,
|
||||
@@ -78,6 +84,7 @@ class AndroidContactSpecialDatesDataSource @Inject constructor(
|
||||
year = parts.year,
|
||||
label = c.getString(idxLabel)?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
if (seen.add(date.managedUid())) result += date
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "Reading contact special-dates failed", it) }
|
||||
|
||||
@@ -30,6 +30,7 @@ object SpecialDatesScheduler {
|
||||
|
||||
private const val WORK_NAME = "special-dates-sync"
|
||||
private const val WORK_NAME_NOW = "special-dates-sync-now"
|
||||
private const val WORK_NAME_FOREGROUND = "special-dates-sync-foreground"
|
||||
private const val KEY_FOREGROUND = "foreground"
|
||||
|
||||
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
|
||||
@@ -38,6 +39,7 @@ object SpecialDatesScheduler {
|
||||
if (!enabled) {
|
||||
workManager.cancelUniqueWork(WORK_NAME)
|
||||
workManager.cancelUniqueWork(WORK_NAME_NOW)
|
||||
workManager.cancelUniqueWork(WORK_NAME_FOREGROUND)
|
||||
return
|
||||
}
|
||||
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
|
||||
@@ -48,15 +50,19 @@ object SpecialDatesScheduler {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one reconcile immediately. [foreground] runs are debounced (the app can
|
||||
* resume often); enable/"Sync now" runs ([foreground] false) always sync.
|
||||
* Run one reconcile immediately. [foreground] runs (the app resuming) are
|
||||
* debounced inside the worker and use their own work name, so a frequent
|
||||
* foreground resync can never REPLACE — and swallow — a pending enable /
|
||||
* "Sync now" run, which always syncs. The engine serializes the two if they
|
||||
* overlap.
|
||||
*/
|
||||
fun runNow(context: Context, foreground: Boolean = false) {
|
||||
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
|
||||
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
|
||||
.build()
|
||||
val name = if (foreground) WORK_NAME_FOREGROUND else WORK_NAME_NOW
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(WORK_NAME_NOW, ExistingWorkPolicy.REPLACE, request)
|
||||
.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
|
||||
@@ -83,9 +89,6 @@ class SpecialDatesSyncWorker(
|
||||
override suspend fun doWork(): Result {
|
||||
val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
|
||||
val prefs = deps.settingsPrefs()
|
||||
// Respect the toggle even for already-queued work; never revive a
|
||||
// disabled feature.
|
||||
if (!prefs.specialDatesEnabled.first()) return Result.success()
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
// A foreground resume can fire often — skip if we synced recently.
|
||||
@@ -95,15 +98,28 @@ class SpecialDatesSyncWorker(
|
||||
}
|
||||
|
||||
return try {
|
||||
val result = deps.syncEngine().sync()
|
||||
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
|
||||
SpecialDatesStalledReason.PermissionRevoked
|
||||
} else {
|
||||
null
|
||||
when (val result = deps.syncEngine().sync()) {
|
||||
// The engine re-checks the toggle, so already-queued work never
|
||||
// revives a disabled feature; don't record a run either.
|
||||
SpecialDatesSyncResult.Disabled -> return Result.success()
|
||||
else -> {
|
||||
val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) {
|
||||
SpecialDatesStalledReason.PermissionRevoked
|
||||
} else {
|
||||
null
|
||||
}
|
||||
prefs.recordSpecialDatesRun(now, stalled)
|
||||
}
|
||||
}
|
||||
prefs.recordSpecialDatesRun(now, stalled)
|
||||
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
|
||||
Result.success()
|
||||
} catch (e: SecurityException) {
|
||||
// A revoked calendar/contacts permission won't fix itself on retry —
|
||||
// park the feature in a stalled state (surfaced in settings) instead
|
||||
// of retrying with backoff forever.
|
||||
Log.w(TAG, "Special-dates sync lacks a required permission", e)
|
||||
prefs.recordSpecialDatesRun(now, SpecialDatesStalledReason.PermissionRevoked)
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Special-dates sync failed", e)
|
||||
Result.retry()
|
||||
|
||||
@@ -6,7 +6,6 @@ import de.jeanlucmakiola.calendula.data.calendar.ManagedEventRow
|
||||
import de.jeanlucmakiola.calendula.data.calendar.toWriteTimes
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceFreq
|
||||
@@ -14,10 +13,12 @@ import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.managedEventUid
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
|
||||
import de.jeanlucmakiola.calendula.domain.toRRule
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import java.time.ZoneId
|
||||
@@ -39,6 +40,16 @@ interface SpecialDatesCalendarSpec {
|
||||
fun defaultTitleTemplate(type: SpecialDateType): String
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored title template for [type], falling back to the localized default
|
||||
* when blank/absent — the one resolution rule shared by the sync (what gets
|
||||
* written) and the settings editor (what gets shown).
|
||||
*/
|
||||
fun SpecialDatesCalendarSpec.resolveTitleTemplate(
|
||||
type: SpecialDateType,
|
||||
stored: Map<SpecialDateType, String>,
|
||||
): String = stored[type]?.takeIf { it.isNotBlank() } ?: defaultTitleTemplate(type)
|
||||
|
||||
/**
|
||||
* Mirrors contact birthdays/anniversaries/custom dates into local calendars,
|
||||
* one per type. Every reconcile is an idempotent diff keyed on the deterministic
|
||||
@@ -55,11 +66,19 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
private val spec: SpecialDatesCalendarSpec,
|
||||
) {
|
||||
|
||||
// Serializes calendar-lifecycle work: two overlapping syncs (the daily job
|
||||
// racing a "Sync now"/foreground run) would each see no managed calendar and
|
||||
// both create one; a teardown racing an in-flight sync would delete calendars
|
||||
// the sync then recreates. Holding this for the whole of sync()/teardown()
|
||||
// makes those check-then-act sequences atomic, and — because sync() re-reads
|
||||
// the enabled flag inside the lock — a teardown always wins the race.
|
||||
private val lifecycleMutex = Mutex()
|
||||
|
||||
/**
|
||||
* Reconcile every enabled type against the device's contacts. Returns why it
|
||||
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
|
||||
*/
|
||||
suspend fun sync(): SpecialDatesSyncResult {
|
||||
suspend fun sync(): SpecialDatesSyncResult = lifecycleMutex.withLock {
|
||||
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
|
||||
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
|
||||
|
||||
@@ -72,8 +91,7 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
val showYear = prefs.specialDatesShowYear.first()
|
||||
|
||||
calendarByType.forEach { (type, calendarId) ->
|
||||
val template = templates[type]?.takeIf { it.isNotBlank() }
|
||||
?: spec.defaultTitleTemplate(type)
|
||||
val template = spec.resolveTitleTemplate(type, templates)
|
||||
syncType(
|
||||
calendarId = calendarId,
|
||||
type = type,
|
||||
@@ -83,7 +101,7 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
reminderCtx = reminderCtx,
|
||||
)
|
||||
}
|
||||
return SpecialDatesSyncResult.Success
|
||||
SpecialDatesSyncResult.Success
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +127,7 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
}
|
||||
|
||||
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
|
||||
suspend fun teardown() {
|
||||
suspend fun teardown() = lifecycleMutex.withLock {
|
||||
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
|
||||
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
|
||||
}
|
||||
@@ -143,8 +161,8 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun createCalendar(type: SpecialDateType): Long {
|
||||
// reconcileCalendars persists the id — the single place ids are recorded.
|
||||
val id = calendars.createManagedCalendar(spec.displayName(type), spec.color(type), type)
|
||||
prefs.setSpecialDatesCalendarId(type, id)
|
||||
// Seed a useful all-day reminder default (on the day + a week before), so
|
||||
// birthdays get lead time out of the box. Per-calendar, user-adjustable —
|
||||
// only set when the user hasn't already configured this calendar.
|
||||
@@ -203,7 +221,7 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
|
||||
return BuiltManagedEvent(
|
||||
desired = ManagedEventDesired(
|
||||
uid = managedEventUid(type, sd.lookupKey),
|
||||
uid = sd.managedUid(),
|
||||
title = title,
|
||||
dtStartMillis = dtStartMillis,
|
||||
rrule = YEARLY_RRULE,
|
||||
@@ -212,29 +230,21 @@ class SpecialDatesSyncEngine @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
// Managed events are always all-day, so only the all-day defaults apply.
|
||||
private suspend fun readReminderContext(): ReminderContext = ReminderContext(
|
||||
timedGlobal = prefs.defaultReminderMinutes.first(),
|
||||
allDayGlobal = prefs.defaultAllDayReminderMinutes.first(),
|
||||
timedOverrides = prefs.perCalendarReminderOverride.first(),
|
||||
allDayOverrides = prefs.perCalendarAllDayReminderOverride.first(),
|
||||
allDayTimeMinutes = prefs.allDayReminderTimeMinutes.first(),
|
||||
)
|
||||
|
||||
private data class ReminderContext(
|
||||
val timedGlobal: List<Int>,
|
||||
val allDayGlobal: List<Int>,
|
||||
val timedOverrides: Map<Long, List<Int>>,
|
||||
val allDayOverrides: Map<Long, List<Int>>,
|
||||
val allDayTimeMinutes: Int,
|
||||
) {
|
||||
fun resolveAllDay(calendarId: Long): List<Int> = resolveDefaultReminder(
|
||||
timedGlobal = timedGlobal,
|
||||
allDayGlobal = allDayGlobal,
|
||||
timedOverrides = timedOverrides,
|
||||
allDayOverrides = allDayOverrides,
|
||||
calendarId = calendarId,
|
||||
isAllDay = true,
|
||||
)
|
||||
/** Same semantics as `resolveDefaultReminder`: present-empty = explicit none. */
|
||||
fun resolveAllDay(calendarId: Long): List<Int> =
|
||||
allDayOverrides[calendarId] ?: allDayGlobal
|
||||
}
|
||||
|
||||
private data class BuiltManagedEvent(val desired: ManagedEventDesired, val form: EventForm)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.jeanlucmakiola.calendula.data.fonts
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
import android.graphics.fonts.Font as PlatformFont
|
||||
|
||||
/**
|
||||
* Storage for user-loaded custom fonts (issue #19). A font a user picks via the
|
||||
* system file picker is copied into the app's private storage — one file per
|
||||
* [FontRole] — so the selection survives even if the original is later moved or
|
||||
* deleted, and never leaves the app. The chosen file is validated as a real font
|
||||
* before it replaces the previous one, so a bad pick can't wedge the app-wide
|
||||
* typography.
|
||||
*/
|
||||
object CustomFontStore {
|
||||
|
||||
private fun dir(context: Context): File = File(context.filesDir, "fonts")
|
||||
|
||||
/** The stored font file for [role] (may not exist yet). */
|
||||
fun file(context: Context, role: FontRole): File =
|
||||
File(dir(context), "${role.name.lowercase(Locale.ROOT)}.ttf")
|
||||
|
||||
fun exists(context: Context, role: FontRole): Boolean =
|
||||
file(context, role).let { it.exists() && it.length() > 0 }
|
||||
|
||||
/**
|
||||
* Copy [uri] into per-role storage, first validating that it parses as a
|
||||
* font. Returns true on success; on any failure the previous file is left
|
||||
* untouched and false is returned (the caller keeps the old selection).
|
||||
*/
|
||||
fun import(context: Context, role: FontRole, uri: Uri): Boolean {
|
||||
val target = file(context, role)
|
||||
target.parentFile?.mkdirs()
|
||||
val tmp = File(target.parentFile, "${role.name.lowercase(Locale.ROOT)}.tmp")
|
||||
return try {
|
||||
context.contentResolver.openInputStream(uri)?.use { input ->
|
||||
tmp.outputStream().use { output -> input.copyTo(output) }
|
||||
} ?: return false
|
||||
// Font.Builder throws IOException for anything that isn't a valid
|
||||
// font file (API 29+, which is our minSdk) — a cheap, reliable check.
|
||||
PlatformFont.Builder(tmp).build()
|
||||
if (target.exists() && !target.delete()) return false
|
||||
tmp.renameTo(target)
|
||||
} catch (_: Exception) {
|
||||
tmp.delete()
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget the custom font for [role] (used when switching away from it). */
|
||||
fun clear(context: Context, role: FontRole) {
|
||||
file(context, role).delete()
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,15 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -105,6 +109,47 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
|
||||
* strings — "system", "custom", or a bundled font's token — resolved to a
|
||||
* FontFamily at the theme layer; an unknown token degrades to the default.
|
||||
*/
|
||||
val brandFont: Flow<String> = store.data.map { prefs ->
|
||||
prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||
}
|
||||
|
||||
val plainFont: Flow<String> = store.data.map { prefs ->
|
||||
prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN
|
||||
}
|
||||
|
||||
suspend fun setBrandFont(token: String) {
|
||||
store.edit { it[BRAND_FONT_KEY] = token }
|
||||
}
|
||||
|
||||
suspend fun setPlainFont(token: String) {
|
||||
store.edit { it[PLAIN_FONT_KEY] = token }
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-role bump counter for the user-loaded custom font. Re-importing
|
||||
* overwrites the same file under the same "custom" token, so the token alone
|
||||
* can't signal the change; this stamp — carried into AppFontSettings — breaks
|
||||
* value equality so the resolved FontFamily (and its previews) refresh. A
|
||||
* missing key is 0 (existing installs); the first import bumps it to 1.
|
||||
*/
|
||||
val brandFontStamp: Flow<Int> = store.data.map { prefs -> prefs[BRAND_FONT_STAMP_KEY] ?: 0 }
|
||||
|
||||
val plainFontStamp: Flow<Int> = store.data.map { prefs -> prefs[PLAIN_FONT_STAMP_KEY] ?: 0 }
|
||||
|
||||
/** Bump [role]'s custom-font stamp after a re-import so equality-keyed caches refresh. */
|
||||
suspend fun bumpCustomFontStamp(role: FontRole) {
|
||||
val key = when (role) {
|
||||
FontRole.BRAND -> BRAND_FONT_STAMP_KEY
|
||||
FontRole.PLAIN -> PLAIN_FONT_STAMP_KEY
|
||||
}
|
||||
store.edit { it[key] = (it[key] ?: 0) + 1 }
|
||||
}
|
||||
|
||||
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
||||
}
|
||||
@@ -203,6 +248,47 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick-switch button customisation (#24): the ordered full view list plus
|
||||
* which views are enabled in the cycle. Stored comma-joined by enum name, a
|
||||
* "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing
|
||||
* views are appended enabled and unknown names dropped, so a future view
|
||||
* defaults into the cycle. An absent key means [QuickSwitchConfig.Default].
|
||||
*/
|
||||
val quickSwitchConfig: Flow<QuickSwitchConfig> = store.data.map { prefs ->
|
||||
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||
}
|
||||
|
||||
suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
||||
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-modify-write of the quick-switch config: [transform] sees the
|
||||
* value currently stored, parsed inside the edit block, not the async-echoed
|
||||
* UI snapshot. Two rapid intents (a toggle then a drag) therefore compose
|
||||
* instead of each re-serialising a stale copy of the other field over it.
|
||||
*/
|
||||
suspend fun updateQuickSwitch(transform: (QuickSwitchConfig) -> QuickSwitchConfig) {
|
||||
store.edit { prefs ->
|
||||
val current = parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
|
||||
prefs[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(transform(current))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
|
||||
* are appended in default order and unknown names dropped. Absent key means
|
||||
* [IMPLEMENTED_VIEWS] (the historical fixed order).
|
||||
*/
|
||||
val drawerViewOrder: Flow<List<CalendarView>> = store.data.map { prefs ->
|
||||
parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY])
|
||||
}
|
||||
|
||||
suspend fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||
store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional event-form fields shown by default (the rest hide behind
|
||||
* "more fields"). Stored comma-joined by enum name: an absent key means
|
||||
@@ -524,9 +610,47 @@ class SettingsPrefs @Inject constructor(
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/** Parse a plain comma-joined view order, completed to every implemented view. */
|
||||
private fun parseViewOrder(stored: String?): List<CalendarView> =
|
||||
completeViewOrder(
|
||||
stored?.split(',').orEmpty()
|
||||
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
|
||||
)
|
||||
|
||||
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
|
||||
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
|
||||
if (stored == null) return QuickSwitchConfig.Default
|
||||
val parsed = stored.split(',').mapNotNull { raw ->
|
||||
val token = raw.trim()
|
||||
val disabled = token.startsWith("!")
|
||||
val name = if (disabled) token.drop(1) else token
|
||||
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
|
||||
}
|
||||
val order = completeViewOrder(parsed.map { it.first })
|
||||
// Only views explicitly stored disabled are excluded; anything appended
|
||||
// (a view added in a later release) defaults into the cycle.
|
||||
val disabled = parsed.filter { it.second }.map { it.first }.toSet()
|
||||
return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet())
|
||||
}
|
||||
|
||||
private fun serializeQuickSwitch(config: QuickSwitchConfig): String =
|
||||
completeViewOrder(config.order).joinToString(",") { view ->
|
||||
if (view in config.enabled) view.name else "!${view.name}"
|
||||
}
|
||||
|
||||
/** Keep the given order (de-duplicated), then append any views it omits. */
|
||||
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
|
||||
val ordered = seen.distinct()
|
||||
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
||||
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
|
||||
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
|
||||
internal val PLAIN_FONT_STAMP_KEY = intPreferencesKey("plain_font_stamp")
|
||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||
@@ -536,6 +660,8 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
@@ -611,6 +737,18 @@ sealed interface CalendarReminderOverride {
|
||||
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
|
||||
@@ -696,7 +834,15 @@ private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
||||
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
|
||||
id to parts[1].toReminderList()
|
||||
// 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -17,16 +17,28 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* True when [this] alert belongs to a calendar the user disabled in-app, so its
|
||||
* reminder must be suppressed (mirroring the event filtering in
|
||||
* CalendarRepositoryImpl). Alerts whose calendar is unknown (id 0L — e.g. a
|
||||
* pre-upgrade snooze PendingIntent minted before EXTRA_CALENDAR_ID existed) are
|
||||
* never treated as disabled. This is the one predicate the disabled-calendar
|
||||
* gate is built from: [postableAlerts] here and the choke point in
|
||||
* [ReminderNotifier.post] both use it.
|
||||
*/
|
||||
internal fun ReminderAlert.isForDisabledCalendar(disabledCalendarIds: Set<Long>): Boolean =
|
||||
calendarId != 0L && calendarId in disabledCalendarIds
|
||||
|
||||
/**
|
||||
* The due alerts that should actually surface as notifications: everything
|
||||
* except alerts whose calendar the user has disabled in-app (mirroring the
|
||||
* event filtering in CalendarRepositoryImpl). The caller still marks the full
|
||||
* due set fired, so suppressed alerts are not re-broadcast by the provider.
|
||||
* except alerts whose calendar the user has disabled in-app. The caller still
|
||||
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
|
||||
* provider.
|
||||
*/
|
||||
internal fun postableAlerts(
|
||||
due: List<ReminderAlert>,
|
||||
disabledCalendarIds: Set<Long>,
|
||||
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
|
||||
): List<ReminderAlert> = due.filterNot { it.isForDisabledCalendar(disabledCalendarIds) }
|
||||
|
||||
/**
|
||||
* Becomes the app that turns the calendar provider's reminder alarms into
|
||||
@@ -45,6 +57,7 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
@Inject lateinit var notifier: ReminderNotifier
|
||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||
@@ -60,11 +73,16 @@ class EventReminderReceiver : BroadcastReceiver() {
|
||||
val now = System.currentTimeMillis()
|
||||
val due = alertStore.dueAlerts(now)
|
||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||
val postable = postableAlerts(due, disabled)
|
||||
// Suppress reminders for disabled calendars, but still mark
|
||||
// every due alert fired so the provider stops re-broadcasting
|
||||
// the suppressed ones.
|
||||
postableAlerts(due, disabled).forEach { notifier.post(it) }
|
||||
// the suppressed ones. Stash those suppressed alerts so
|
||||
// re-enabling their calendar can recover them (they would
|
||||
// otherwise stay STATE_FIRED forever with no re-scan).
|
||||
postable.forEach { notifier.post(it) }
|
||||
alertStore.markFired(due.map { it.alertId }, now)
|
||||
suppressedStore.stash(due - postable.toSet(), now)
|
||||
suppressedStore.purgeExpired(now)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
|
||||
@@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -33,6 +34,7 @@ import javax.inject.Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val calendarPrefs: CalendarPrefs,
|
||||
) {
|
||||
|
||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||
@@ -44,6 +46,11 @@ class ReminderNotifier @Inject constructor(
|
||||
}
|
||||
|
||||
suspend fun post(alert: ReminderAlert) {
|
||||
// The single choke point for the disabled-calendar gate: it covers both
|
||||
// the provider broadcast (EventReminderReceiver) and a snoozed re-show
|
||||
// (ReminderActionReceiver), so a calendar disabled after a snooze no
|
||||
// longer notifies — without either receiver duplicating the check.
|
||||
if (alert.isForDisabledCalendar(calendarPrefs.disabledCalendarIds.first())) return
|
||||
ensureChannel()
|
||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
val is24Hour = settingsPrefs.timeFormat.first()
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import java.util.Base64
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Still relevant while the event has not ended: a reminder for an event that is
|
||||
* already over is pointless to re-surface. Falls back to the begin time when the
|
||||
* end is unknown (0L). Used both to decide what to re-post and to purge the stash.
|
||||
*/
|
||||
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
|
||||
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
|
||||
|
||||
/**
|
||||
* Local stash of reminder alerts that fired while their calendar was disabled
|
||||
* in-app. [EventReminderReceiver] marks every due alert `STATE_FIRED` regardless
|
||||
* (so the provider stops re-broadcasting the suppressed ones), which would
|
||||
* otherwise lose those reminders forever — there is no re-scan. Stashing lets
|
||||
* [de.jeanlucmakiola.calendula.ui.calendars.CalendarsViewModel] re-post them if
|
||||
* the user re-enables the calendar before the event is over.
|
||||
*
|
||||
* Persisted in the shared preferences DataStore as a set of self-describing
|
||||
* strings (one per alert); the stash never reaches a screen, so there is no
|
||||
* domain model. Entries whose event has already ended are dropped on the next
|
||||
* stash/recover/purge, so the stash only ever holds a handful of pending alerts.
|
||||
*/
|
||||
@Singleton
|
||||
class SuppressedReminderStore @Inject constructor(
|
||||
private val store: DataStore<Preferences>,
|
||||
) {
|
||||
|
||||
/** Add [alerts] to the stash, replacing any existing entry with the same id. */
|
||||
suspend fun stash(alerts: List<ReminderAlert>, nowMillis: Long) {
|
||||
if (alerts.isEmpty()) return
|
||||
store.edit { prefs ->
|
||||
val byId = decodeAll(prefs).associateByTo(mutableMapOf()) { it.alertId }
|
||||
alerts.forEach { byId[it.alertId] = it }
|
||||
prefs.putStash(byId.values.filter { it.isRelevantAt(nowMillis) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove and return the still-relevant stashed alerts belonging to any of
|
||||
* [calendarIds]; drops expired entries for every calendar in passing.
|
||||
*/
|
||||
suspend fun recoverFor(calendarIds: Set<Long>, nowMillis: Long): List<ReminderAlert> {
|
||||
val recovered = mutableListOf<ReminderAlert>()
|
||||
store.edit { prefs ->
|
||||
val kept = decodeAll(prefs).filter { alert ->
|
||||
when {
|
||||
!alert.isRelevantAt(nowMillis) -> false // expired: drop
|
||||
alert.calendarId in calendarIds -> { recovered += alert; false }
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
prefs.putStash(kept)
|
||||
}
|
||||
return recovered
|
||||
}
|
||||
|
||||
/** Drop entries whose event has already ended — cheap opportunistic cleanup. */
|
||||
suspend fun purgeExpired(nowMillis: Long) {
|
||||
store.edit { prefs ->
|
||||
prefs.putStash(decodeAll(prefs).filter { it.isRelevantAt(nowMillis) })
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeAll(prefs: Preferences): List<ReminderAlert> =
|
||||
prefs[KEY].orEmpty().mapNotNull { decodeStashEntry(it) }
|
||||
|
||||
private fun MutablePreferences.putStash(alerts: List<ReminderAlert>) {
|
||||
val encoded = alerts.map { encodeStashEntry(it) }.toSet()
|
||||
if (encoded.isEmpty()) remove(KEY) else set(KEY, encoded)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val KEY = stringSetPreferencesKey("suppressed_reminders")
|
||||
}
|
||||
}
|
||||
|
||||
// One stash entry as a delimited string. The '|' separator is safe because every
|
||||
// free-text field is Base64-encoded first (that alphabet never contains '|'), and
|
||||
// a null location is stored as a distinct sentinel that Base64 also never yields.
|
||||
private const val FIELD_SEP = "|"
|
||||
private const val NULL_LOCATION = "-"
|
||||
|
||||
internal fun encodeStashEntry(alert: ReminderAlert): String = listOf(
|
||||
alert.alertId.toString(),
|
||||
alert.eventId.toString(),
|
||||
alert.calendarId.toString(),
|
||||
alert.beginMillis.toString(),
|
||||
alert.endMillis.toString(),
|
||||
if (alert.isAllDay) "1" else "0",
|
||||
alert.title.toBase64(),
|
||||
alert.location?.toBase64() ?: NULL_LOCATION,
|
||||
).joinToString(FIELD_SEP)
|
||||
|
||||
/** Reverse of [encodeStashEntry]; returns null for a malformed entry (dropped). */
|
||||
internal fun decodeStashEntry(raw: String): ReminderAlert? {
|
||||
val parts = raw.split(FIELD_SEP)
|
||||
if (parts.size != 8) return null
|
||||
return try {
|
||||
ReminderAlert(
|
||||
alertId = parts[0].toLong(),
|
||||
eventId = parts[1].toLong(),
|
||||
calendarId = parts[2].toLong(),
|
||||
beginMillis = parts[3].toLong(),
|
||||
endMillis = parts[4].toLong(),
|
||||
title = parts[6].fromBase64(),
|
||||
location = parts[7].takeIf { it != NULL_LOCATION }?.fromBase64(),
|
||||
isAllDay = parts[5] == "1",
|
||||
)
|
||||
} catch (e: NumberFormatException) {
|
||||
null
|
||||
} catch (e: IllegalArgumentException) { // bad Base64
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toBase64(): String =
|
||||
Base64.getEncoder().encodeToString(toByteArray(Charsets.UTF_8))
|
||||
|
||||
private fun String.fromBase64(): String =
|
||||
String(Base64.getDecoder().decode(this), Charsets.UTF_8)
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
/**
|
||||
* The two Material 3 typeface roles a user can set independently (issue #19):
|
||||
* [BRAND] drives the display/headline styles (expression), [PLAIN] the
|
||||
* title/body/label styles (readability). Each defaults to the system typeface.
|
||||
*/
|
||||
enum class FontRole { BRAND, PLAIN }
|
||||
@@ -26,6 +26,14 @@ data class CalendarSource(
|
||||
* owns for its own calendars). Always null for synced calendars.
|
||||
*/
|
||||
val description: String? = null,
|
||||
/**
|
||||
* A special-dates mirror calendar the app manages (birthdays/anniversaries
|
||||
* from contacts). Its events' title/date/recurrence are owned by the sync,
|
||||
* so it's hidden from the new-event calendar picker and its events lock those
|
||||
* fields in the editor. Recognised by a durable provider marker, so it holds
|
||||
* even after a backup restore clears the app's stored ids.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
)
|
||||
|
||||
data class EventInstance(
|
||||
|
||||
@@ -3,12 +3,40 @@ package de.jeanlucmakiola.calendula.domain.contacts
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||
* contact date. Stable across syncs (the reconciliation key), and namespaced by
|
||||
* type so the same contact's birthday and anniversary never collide.
|
||||
* Prefix of every managed-event `UID_2445`. Distinguishes mirror events from
|
||||
* user-created ones (which carry a random `<uuid>@calendula` UID), so the sync
|
||||
* only ever reconciles — and never deletes — events it actually owns.
|
||||
*/
|
||||
fun managedEventUid(type: SpecialDateType, lookupKey: String): String =
|
||||
"contact-${type.name.lowercase()}:$lookupKey@calendula"
|
||||
const val MANAGED_UID_PREFIX = "contact-"
|
||||
|
||||
/**
|
||||
* The deterministic `Events.UID_2445` that ties a mirrored event to its source
|
||||
* contact date. Stable across syncs (the reconciliation key), namespaced by
|
||||
* type so the same contact's birthday and anniversary never collide, and — when
|
||||
* a [discriminator] is given — by it too, so two Custom dates on one contact
|
||||
* (e.g. "Wedding" and "Graduation") get distinct events instead of clobbering
|
||||
* each other.
|
||||
*/
|
||||
fun managedEventUid(type: SpecialDateType, lookupKey: String, discriminator: String? = null): String {
|
||||
val disc = discriminator?.takeIf { it.isNotBlank() }?.let { ":$it" }.orEmpty()
|
||||
return "$MANAGED_UID_PREFIX${type.name.lowercase()}:$lookupKey$disc@calendula"
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconciliation key for this date's mirrored event. Birthdays/anniversaries
|
||||
* are one-per-contact, so they key on the contact alone; a [SpecialDateType.Custom]
|
||||
* date adds a discriminator (its label, else its month-day) so distinct custom
|
||||
* dates on one contact don't collapse into a single event.
|
||||
*/
|
||||
fun ContactSpecialDate.managedUid(): String =
|
||||
managedEventUid(type, lookupKey, customDiscriminator())
|
||||
|
||||
private fun ContactSpecialDate.customDiscriminator(): String? =
|
||||
if (type == SpecialDateType.Custom) {
|
||||
label?.trim()?.lowercase()?.ifBlank { null } ?: "%02d-%02d".format(month, day)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* The all-day date the recurring `FREQ=YEARLY` series is anchored at: the real
|
||||
|
||||
@@ -79,6 +79,11 @@ fun CalendarHost(
|
||||
// correcting it. Brief blank first frame, matching the onboarding gate above.
|
||||
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
|
||||
|
||||
// View customisation (#24): the quick-switch cycle and drawer order. Both have
|
||||
// sensible non-empty initial values, so they're ready before the first frame.
|
||||
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
|
||||
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
|
||||
|
||||
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
|
||||
mutableStateOf(listOf(defaultView))
|
||||
}
|
||||
@@ -212,6 +217,17 @@ fun CalendarHost(
|
||||
detailKey = key
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.OpenView -> {
|
||||
// A widget header tap: land on a top-level view with no date
|
||||
// drill-in. Reveal it by dropping any covering overlay, then root
|
||||
// the stack on the target (null → the default home) over the
|
||||
// default home — so backing out returns to the default, then exits.
|
||||
dismissCoveringOverlays()
|
||||
createDateIso = null
|
||||
pendingDayIso = null
|
||||
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
|
||||
onWidgetNavConsumed()
|
||||
}
|
||||
is WidgetNavRequest.Create -> {
|
||||
// External "new event" entries (QS tile / launcher shortcut /
|
||||
// widget) must land on top of whatever is open — the form overlay
|
||||
@@ -260,6 +276,8 @@ fun CalendarHost(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
CalendarView.Day -> DayScreen(
|
||||
selectedView = currentView,
|
||||
@@ -269,6 +287,8 @@ fun CalendarHost(
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
initialDateIso = pendingDayIso,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
CalendarView.Month -> MonthScreen(
|
||||
selectedView = currentView,
|
||||
@@ -277,6 +297,8 @@ fun CalendarHost(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
CalendarView.Agenda -> AgendaScreen(
|
||||
selectedView = currentView,
|
||||
@@ -285,6 +307,8 @@ fun CalendarHost(
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenSearch = onOpenSearch,
|
||||
onCreateEvent = onCreateEvent,
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
drawerViewOrder = drawerViewOrder,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -26,4 +28,21 @@ class CalendarHostViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = null,
|
||||
)
|
||||
|
||||
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
|
||||
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
|
||||
.map { it.cycle }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = IMPLEMENTED_VIEWS,
|
||||
)
|
||||
|
||||
/** Order of the views in the navigation drawer (#24); every view always shown. */
|
||||
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = IMPLEMENTED_VIEWS,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.hasContactsPermission
|
||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||
@@ -53,10 +54,7 @@ fun RootScreen(
|
||||
// Refresh the contact special-dates mirror on foreground (the
|
||||
// worker is debounced and no-ops when the feature is off). Gated
|
||||
// on the permission so users without the opt-in never enqueue it.
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
context, Manifest.permission.READ_CONTACTS
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
if (context.hasContactsPermission()) {
|
||||
SpecialDatesScheduler.runNow(context, foreground = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,4 +31,13 @@ sealed interface WidgetNavRequest {
|
||||
|
||||
/** Open the create-event form prefilled for [dateIso] (today when null). */
|
||||
data class Create(val dateIso: String?) : WidgetNavRequest
|
||||
|
||||
/**
|
||||
* Open the app rooted on a top-level [view] with no date drill-in — a widget
|
||||
* header tap. A null [view] means "the user's default home view" (the agenda
|
||||
* widget's "Upcoming" title, issue #20); a concrete view roots there over the
|
||||
* default home (the month widget's month/year title → [CalendarView.Month],
|
||||
* issue #18), so backing out returns to the default view, then exits.
|
||||
*/
|
||||
data class OpenView(val view: CalendarView?) : WidgetNavRequest
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ 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
|
||||
@@ -96,6 +97,8 @@ fun AgendaScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AgendaViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -120,6 +123,7 @@ fun AgendaScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -140,7 +144,7 @@ fun AgendaScreen(
|
||||
topBar = {
|
||||
AgendaTopBar(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
|
||||
@@ -94,7 +94,7 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
@@ -142,7 +142,7 @@ fun CalendarsScreen(
|
||||
sessionKey = editorSession,
|
||||
isNew = editorId == NEW_CALENDAR_ID,
|
||||
initialName = editing?.displayName.orEmpty(),
|
||||
initialColor = editing?.color ?: CALENDAR_COLOR_PALETTE.first(),
|
||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||
initialDescription = editing?.description.orEmpty(),
|
||||
onSave = { name, color, description ->
|
||||
val id = editorId
|
||||
@@ -507,7 +507,7 @@ private fun CalendarEditor(
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
ColorSwatchRow(
|
||||
colors = CALENDAR_COLOR_PALETTE,
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = color,
|
||||
onSelect = { color = it },
|
||||
dark = dark,
|
||||
|
||||
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderNotifier
|
||||
import de.jeanlucmakiola.calendula.data.reminders.SuppressedReminderStore
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -45,6 +47,8 @@ class CalendarsViewModel @Inject constructor(
|
||||
private val icsExporter: IcsExporter,
|
||||
private val prefs: CalendarPrefs,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val suppressedStore: SuppressedReminderStore,
|
||||
private val notifier: ReminderNotifier,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -144,7 +148,10 @@ class CalendarsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + id else current - id
|
||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
||||
if (next != current) {
|
||||
prefs.setDisabledCalendarIds(next)
|
||||
if (!disabled) recoverReminders(setOf(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +164,29 @@ class CalendarsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
val current = prefs.disabledCalendarIds.first()
|
||||
val next = if (disabled) current + ids else current - ids.toSet()
|
||||
if (next != current) prefs.setDisabledCalendarIds(next)
|
||||
if (next != current) {
|
||||
prefs.setDisabledCalendarIds(next)
|
||||
if (!disabled) recoverReminders(current intersect ids.toSet())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-post the reminders that fired while [reEnabledIds] were disabled and are
|
||||
* still relevant (event not yet over), then drop them from the stash. Runs
|
||||
* after the disabled set is written, so the notifier's own disabled gate lets
|
||||
* them through. Best-effort at re-enable time: it mirrors the receiver gates
|
||||
* (reminders on + postable), and there is no later re-scan, so alerts left
|
||||
* unposted because those gates are closed are simply released.
|
||||
*/
|
||||
private suspend fun recoverReminders(reEnabledIds: Set<Long>) {
|
||||
if (reEnabledIds.isEmpty()) return
|
||||
val recovered = suppressedStore.recoverFor(reEnabledIds, System.currentTimeMillis())
|
||||
if (recovered.isNotEmpty() &&
|
||||
settingsPrefs.remindersEnabled.first() &&
|
||||
notifier.canPost()
|
||||
) {
|
||||
recovered.forEach { notifier.post(it) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ fun CalendarDrawer(
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -73,10 +74,10 @@ fun CalendarDrawer(
|
||||
DrawerHeader()
|
||||
|
||||
DrawerSectionHeader(stringResource(R.string.view_section))
|
||||
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
|
||||
viewOrder.forEachIndexed { index, view ->
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
position = positionOf(index, IMPLEMENTED_VIEWS.size),
|
||||
position = positionOf(index, viewOrder.size),
|
||||
selected = view == currentView,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(view.icon, contentDescription = null) },
|
||||
|
||||
@@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List<CalendarView> =
|
||||
|
||||
/** Next view in [available], wrapping around. Falls back to Month if absent. */
|
||||
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
|
||||
if (available.isEmpty()) return this
|
||||
val i = available.indexOf(this)
|
||||
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
|
||||
if (i < 0) return available.first()
|
||||
return available[(i + 1) % available.size]
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's customisation of the top-bar quick-switch button (#24): which views
|
||||
* it cycles through ([enabled]) and in what [order]. [order] always lists every
|
||||
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||
* is the subset the pill actually steps through, in [order]. The navigation
|
||||
* drawer keeps its own separate order and always lists every view, so a view
|
||||
* disabled here stays reachable there.
|
||||
*/
|
||||
data class QuickSwitchConfig(
|
||||
val order: List<CalendarView>,
|
||||
val enabled: Set<CalendarView>,
|
||||
) {
|
||||
/** Views the pill steps through, in [order]. */
|
||||
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||
|
||||
companion object {
|
||||
/** All views, in default order, all enabled. */
|
||||
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||
|
||||
/**
|
||||
* Fewest views that keep the switch meaningful — a "switch" needs at
|
||||
* least two targets, so the settings screen blocks disabling below this.
|
||||
*/
|
||||
const val MIN_ENABLED = 2
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level view back stack (bottom → top): the [default] home view always
|
||||
* sits at the bottom. Pressing back pops one level until only the home view
|
||||
|
||||
@@ -67,16 +67,3 @@ fun ColorSwatchRow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Google-Calendar-style palette; ARGB ints for a raw `CALENDAR_COLOR` / `EVENT_COLOR`. */
|
||||
val CALENDAR_COLOR_PALETTE: List<Int> = listOf(
|
||||
0xFFD50000, // red
|
||||
0xFFE67C00, // orange
|
||||
0xFFF6BF26, // amber
|
||||
0xFF33B679, // green
|
||||
0xFF0B8043, // dark green
|
||||
0xFF039BE5, // blue
|
||||
0xFF3F51B5, // indigo
|
||||
0xFF8E24AA, // purple
|
||||
0xFF616161, // graphite
|
||||
).map { it.toInt() }
|
||||
|
||||
@@ -28,6 +28,7 @@ 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
|
||||
@@ -57,10 +58,16 @@ fun positionOf(index: Int, count: Int): Position = when {
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
|
||||
* whose title shrinks into the bar (next to the back button) as the content
|
||||
* scrolls. Content is a scrollable column that feeds the toolbar via nested
|
||||
* scroll. Used by Settings and the calendar manager so they share one shell.
|
||||
* 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
|
||||
@@ -68,12 +75,16 @@ 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 =
|
||||
val scrollBehavior = if (largeTopBar) {
|
||||
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||
} else {
|
||||
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
|
||||
}
|
||||
Scaffold(
|
||||
modifier = modifier
|
||||
.predictiveBack(onBack = onBack)
|
||||
@@ -81,22 +92,34 @@ fun CollapsingScaffold(
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
LargeTopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = actions,
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
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 ->
|
||||
@@ -142,6 +165,9 @@ fun GroupedRow(
|
||||
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,
|
||||
@@ -160,9 +186,10 @@ fun GroupedRow(
|
||||
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||
)
|
||||
}
|
||||
val gap = when (position) {
|
||||
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||
Position.Bottom, Position.Alone -> Modifier
|
||||
val gap = when {
|
||||
!gapBelow -> Modifier
|
||||
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||
else -> Modifier
|
||||
}
|
||||
val itemColors = if (selected) {
|
||||
ListItemDefaults.colors(
|
||||
|
||||
@@ -24,6 +24,7 @@ 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
|
||||
@@ -45,8 +46,9 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
|
||||
/**
|
||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||
* reuses the app's [CollapsingScaffold] (collapsing title + back button), so a
|
||||
* picker is visually identical to a Settings sub-page and uses the full width.
|
||||
* 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
|
||||
@@ -71,7 +73,12 @@ fun FullScreenPicker(
|
||||
(view.parent as? DialogWindowProvider)?.window
|
||||
?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
}
|
||||
CollapsingScaffold(title = title, onBack = onDismiss, content = content)
|
||||
CollapsingScaffold(
|
||||
title = title,
|
||||
onBack = onDismiss,
|
||||
largeTopBar = false,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,12 +123,16 @@ fun <T> OptionPicker(
|
||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
||||
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||
* "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use
|
||||
* default reminder" row above the list; choosing it defers to the global default
|
||||
* and clears the checked times. With nothing checked and inherit off, the choice
|
||||
* is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row
|
||||
* expands an inline number field plus a unit selector to add an arbitrary lead
|
||||
* time to the set. Changes apply live via [onSelect]; the user leaves via back.
|
||||
* "on the day"). Two exclusive checkmark rows sit above the list: an explicit
|
||||
* "No reminder" (both pickers, so the empty state stays visible and reachable)
|
||||
* 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]
|
||||
* 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
|
||||
* back.
|
||||
*/
|
||||
@Composable
|
||||
fun ReminderDefaultPicker(
|
||||
@@ -132,48 +143,54 @@ fun ReminderDefaultPicker(
|
||||
onSelect: (CalendarReminderOverride) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// Optimistic local state: the chosen override is authoritative while the
|
||||
// picker is open, so quick successive toggles compose on each other instead
|
||||
// of racing the round-trip through the settings flow (which would drop a
|
||||
// toggle made before the previous write echoes back). Seeded once on open;
|
||||
// the picker leaves composition on dismiss, so reopening re-reads [selected].
|
||||
// Optimistic local state: once the user edits, the chosen override is
|
||||
// authoritative while the picker is open, so quick successive toggles compose
|
||||
// on each other instead of racing the round-trip through the settings flow
|
||||
// (which would drop a toggle made before the previous write echoes back).
|
||||
var current by remember { mutableStateOf(selected) }
|
||||
// Whether the user has toggled anything yet. Until then, [current] keeps
|
||||
// mirroring [selected]: a picker that first composed against the settings
|
||||
// flow's initialValue (empty — reachable after process-death restore straight
|
||||
// onto the Notifications page, before the CalendarProvider-gated combine
|
||||
// resolves) then adopts the real stored default instead of persisting empty
|
||||
// on the first tap. After the first edit, local state is authoritative.
|
||||
var userEdited by remember { mutableStateOf(false) }
|
||||
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()
|
||||
// Presets plus any selected custom (non-preset) values, each as its own row.
|
||||
val rows = presets + selectedMinutes.filter { it !in presets }.sorted()
|
||||
// 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.
|
||||
val seenCustom = remember { mutableSetOf<Int>() }
|
||||
seenCustom += selectedMinutes.filter { it !in presets }
|
||||
// Presets plus every custom value seen this session, each as its own row.
|
||||
val rows = presets + seenCustom.sorted()
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||
|
||||
fun apply(override: CalendarReminderOverride) {
|
||||
userEdited = true
|
||||
current = override
|
||||
onSelect(override)
|
||||
}
|
||||
// Emit the override for a new set of minutes: empty collapses to None, which
|
||||
// (with inherit unchosen) reads as an explicit "no reminder".
|
||||
fun emit(minutes: List<Int>) {
|
||||
val norm = minutes.distinct().sorted()
|
||||
apply(
|
||||
if (norm.isEmpty()) {
|
||||
CalendarReminderOverride.None
|
||||
} else {
|
||||
CalendarReminderOverride.Minutes(norm)
|
||||
},
|
||||
)
|
||||
}
|
||||
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
|
||||
fun toggle(minute: Int) =
|
||||
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
// Exclusive "use default" choice (per-calendar only): its own group above
|
||||
// the multi-select list, since inheriting is mutually exclusive with
|
||||
// picking specific times.
|
||||
// Exclusive choices in their own group above the multi-select list, since
|
||||
// each is mutually exclusive with picking specific times: "use default"
|
||||
// (per-calendar only) and an explicit "no reminder" (both pickers, so the
|
||||
// empty state stays visible and deliberately reachable).
|
||||
if (allowInherit) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_use_default),
|
||||
position = Position.Alone,
|
||||
position = Position.Top,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
@@ -182,8 +199,19 @@ fun ReminderDefaultPicker(
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.reminder_none),
|
||||
position = if (allowInherit) Position.Bottom else Position.Alone,
|
||||
selected = isNone,
|
||||
trailing = if (isNone) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = { apply(CalendarReminderOverride.None) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
val rowCount = rows.size + 1 // + the custom row
|
||||
rows.forEachIndexed { index, minute ->
|
||||
val checked = minute in selectedMinutes
|
||||
@@ -222,6 +250,27 @@ 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
|
||||
* row above it (matching the grouped-row system, so the two read as one
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ 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.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
@@ -116,6 +117,8 @@ fun DayScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
initialDateIso: String? = null,
|
||||
viewModel: DayViewModel = hiltViewModel(),
|
||||
@@ -175,6 +178,7 @@ fun DayScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = date,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -196,7 +200,7 @@ fun DayScreen(
|
||||
DayTopBar(
|
||||
date = date,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
|
||||
@@ -250,14 +250,18 @@ fun EventDetailScreen(
|
||||
contentDescription = stringResource(R.string.event_detail_edit),
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = onDeleteClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
)
|
||||
// Managed special-dates events have no delete — the sync
|
||||
// would resurrect them; the date is removed at the contact.
|
||||
if (!s.isManaged) {
|
||||
IconButton(
|
||||
onClick = onDeleteClick,
|
||||
enabled = deleteState != DeleteUiState.Deleting,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15,6 +15,12 @@ sealed interface EventDetailUiState {
|
||||
val calendarName: String?,
|
||||
/** Whether the owning calendar allows modifying events (shows edit/delete). */
|
||||
val canModify: Boolean = false,
|
||||
/**
|
||||
* The event belongs to a managed special-dates calendar: its editable
|
||||
* fields (reminders, notes) can still be edited, but delete is hidden —
|
||||
* the sync would just resurrect it. Contact dates are removed at the source.
|
||||
*/
|
||||
val isManaged: Boolean = false,
|
||||
) : EventDetailUiState
|
||||
}
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ class EventDetailViewModel @Inject constructor(
|
||||
detail = corrected,
|
||||
calendarName = calendar?.displayName,
|
||||
canModify = calendar?.canModifyContents == true,
|
||||
isManaged = calendar?.isManaged == true,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
|
||||
@@ -37,6 +37,7 @@ 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
|
||||
@@ -119,13 +120,16 @@ 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.calendula.ui.common.CALENDAR_COLOR_PALETTE
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
|
||||
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||
@@ -965,7 +969,7 @@ private fun EventEditContent(
|
||||
}
|
||||
|
||||
if (showCalendarPicker) {
|
||||
CalendarPickerDialog(
|
||||
CalendarPicker(
|
||||
calendars = state.calendars,
|
||||
selectedId = form.calendarId,
|
||||
onSelect = {
|
||||
@@ -1745,7 +1749,7 @@ private fun ColorPickerDialog(
|
||||
)
|
||||
} else {
|
||||
ColorSwatchRow(
|
||||
colors = CALENDAR_COLOR_PALETTE,
|
||||
colors = CalendarColorPalette.all,
|
||||
selected = selected,
|
||||
onSelect = onPickRaw,
|
||||
dark = dark,
|
||||
@@ -1957,34 +1961,64 @@ private fun ScheduleRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen calendar picker: a scrollable list of every writable calendar,
|
||||
* grouped under its owning account like the visibility filter and calendar
|
||||
* manager. Replaces the former fixed-height [AlertDialog], which clipped the
|
||||
* list past ~9 rows so accounts with many calendars had unreachable entries
|
||||
* (#29). Selecting a row calls [onSelect]; the caller closes the picker.
|
||||
*/
|
||||
@Composable
|
||||
private fun CalendarPickerDialog(
|
||||
private fun CalendarPicker(
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_detail_calendar)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
calendars.forEach { calendar ->
|
||||
OptionCard(
|
||||
label = calendar.displayName,
|
||||
onClick = { onSelect(calendar.id) },
|
||||
icon = Icons.Default.CalendarMonth,
|
||||
// The calendar's own colour carries its identity.
|
||||
iconTint = pastelize(calendar.color, dark),
|
||||
supportingText = calendar.accountName.takeIf { it.isNotBlank() },
|
||||
selected = calendar.id == selectedId,
|
||||
)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
.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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,18 +122,24 @@ class EventEditViewModel @Inject constructor(
|
||||
val autofocusTitle: Boolean,
|
||||
)
|
||||
|
||||
/** Every calendar the provider exposes; the source for both lists below. */
|
||||
private val allCalendars: Flow<List<CalendarSource>> =
|
||||
repository.calendars().catch { emit(emptyList()) }
|
||||
|
||||
/**
|
||||
* Writable calendars — the only valid event targets. Disabled calendars are
|
||||
* excluded, so you can't create into a calendar you've removed from the app;
|
||||
* a last-used preselect landing on a now-disabled calendar falls back to the
|
||||
* first remaining writable one (handled by [resolvedCalendarId] and [state]).
|
||||
* Managed special-dates calendars are excluded too: their events are owned by
|
||||
* the contact sync, which would delete any user event created there.
|
||||
*/
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = combine(
|
||||
repository.calendars(),
|
||||
allCalendars,
|
||||
prefs.disabledCalendarIds,
|
||||
) { calendars, disabled ->
|
||||
calendars.filter { it.canModifyContents && it.id !in disabled }
|
||||
}.catch { emit(emptyList()) }
|
||||
calendars.filter { it.canModifyContents && it.id !in disabled && !it.isManaged }
|
||||
}
|
||||
|
||||
/** The target calendar id, resolved exactly as the form shows it. */
|
||||
private val resolvedCalendarId: Flow<Long?> = combine(
|
||||
@@ -165,24 +171,33 @@ class EventEditViewModel @Inject constructor(
|
||||
::ExternalInputs,
|
||||
).flowOn(io),
|
||||
colorPalette,
|
||||
settingsPrefs.managedCalendarIds,
|
||||
) { local, external, palette, managedIds ->
|
||||
allCalendars,
|
||||
) { local, external, palette, allCalendars ->
|
||||
val form = local.form ?: return@combine null
|
||||
val resolvedId = form.calendarId
|
||||
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
|
||||
?: external.writable.firstOrNull()?.id
|
||||
val resolved = form.copy(calendarId = resolvedId)
|
||||
val resolvedCalendar = allCalendars.firstOrNull { it.id == resolvedId }
|
||||
// Editing an event in a managed calendar locks its synced fields. Keyed
|
||||
// off the calendar's durable marker, not a stored id, so it holds after a
|
||||
// backup restore too.
|
||||
val isManaged = local.editTarget != null && resolvedCalendar?.isManaged == true
|
||||
// The picker offers writable calendars only; when editing a managed event
|
||||
// its own (excluded) calendar is added back so the row still names it.
|
||||
val pickerCalendars =
|
||||
if (isManaged && resolvedCalendar != null) external.writable + resolvedCalendar
|
||||
else external.writable
|
||||
val visibleFields = external.defaultFields + local.revealed
|
||||
EventEditUiState(
|
||||
form = resolved,
|
||||
calendars = external.writable,
|
||||
calendars = pickerCalendars,
|
||||
problems = if (local.showProblems) resolved.problems() else emptySet(),
|
||||
saveState = local.saveState,
|
||||
visibleFields = visibleFields,
|
||||
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
|
||||
isEditing = local.editTarget != null,
|
||||
// Editing an event in a managed calendar locks its synced fields.
|
||||
isManaged = local.editTarget != null && resolvedId != null && resolvedId in managedIds,
|
||||
isManaged = isManaged,
|
||||
autofocusTitle = external.autofocusTitle,
|
||||
// A modified-occurrence exception can't carry its own rule, so
|
||||
// the scope dialog drops "only this event" after a rule change.
|
||||
@@ -420,7 +435,10 @@ class EventEditViewModel @Inject constructor(
|
||||
_saveState.value = SaveUiState.Saved
|
||||
return
|
||||
}
|
||||
if (target != null && target.original.rrule != null) {
|
||||
// Managed events are a yearly series whose editable fields (reminders,
|
||||
// notes, location) live on the series row — never offer the scope dialog,
|
||||
// which would split the series into an exception the sync then reverts.
|
||||
if (target != null && target.original.rrule != null && !current.isManaged) {
|
||||
_saveState.value = SaveUiState.AwaitingScope
|
||||
return
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ 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.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
@@ -98,6 +99,8 @@ fun MonthScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MonthViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -157,6 +160,7 @@ fun MonthScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = LocalDate(month.year, month.month, 1),
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -178,7 +182,7 @@ fun MonthScreen(
|
||||
MonthTopBar(
|
||||
month = month,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
|
||||
@@ -7,9 +7,11 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Icon
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import android.text.format.DateFormat
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -43,6 +45,8 @@ import androidx.compose.material.icons.filled.Cake
|
||||
import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.DragHandle
|
||||
import androidx.compose.material.icons.filled.SwapVert
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
@@ -53,6 +57,7 @@ import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -62,6 +67,7 @@ import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -76,6 +82,7 @@ import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -86,8 +93,10 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
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.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
@@ -98,13 +107,21 @@ 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.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.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.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReorderableRowHeight
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
|
||||
@@ -118,13 +135,17 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalTime
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Calendar
|
||||
|
||||
/** The settings sub-screens reached from the hub's category rows. */
|
||||
private enum class SettingsSection { Appearance, EventForm, Notifications, SpecialDates }
|
||||
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates }
|
||||
|
||||
/**
|
||||
* Token-based accent for a leading icon chip (container / on-container pair).
|
||||
@@ -169,6 +190,13 @@ fun SettingsScreen(
|
||||
) {
|
||||
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.Views,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = section == SettingsSection.EventForm,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
@@ -219,6 +247,13 @@ private fun SettingsHub(
|
||||
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
|
||||
onClick = { onOpenSection(SettingsSection.Appearance) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
summary = stringResource(R.string.settings_views_subtitle),
|
||||
position = Position.Middle,
|
||||
leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Neutral) },
|
||||
onClick = { onOpenSection(SettingsSection.Views) },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_section_event_form),
|
||||
summary = stringResource(R.string.settings_event_form_subtitle),
|
||||
@@ -499,6 +534,18 @@ private fun AppearanceScreen(
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
var showBrandFont by remember { mutableStateOf(false) }
|
||||
var showPlainFont by remember { mutableStateOf(false) }
|
||||
|
||||
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
|
||||
// A picked file that didn't parse as a font: tell the user and keep the old choice.
|
||||
val context = LocalContext.current
|
||||
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.fontImportFailed.collect {
|
||||
Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
@@ -535,6 +582,24 @@ private fun AppearanceScreen(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Fonts — the two Material typeface roles, each independently choosable
|
||||
// (issue #19). Headings = brand (display/headline); body = plain
|
||||
// (title/body/label). Both default to the system typeface.
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_font_headings),
|
||||
summary = fontLabel(fonts.brand),
|
||||
position = Position.Top,
|
||||
onClick = { showBrandFont = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_font_body),
|
||||
summary = fontLabel(fonts.plain),
|
||||
position = Position.Bottom,
|
||||
onClick = { showPlainFont = true },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Calendar — view, week, and timeline formatting
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
@@ -626,6 +691,28 @@ private fun AppearanceScreen(
|
||||
onDismiss = { showTheme = false },
|
||||
)
|
||||
}
|
||||
if (showBrandFont) {
|
||||
FontPicker(
|
||||
title = stringResource(R.string.settings_font_headings),
|
||||
role = FontRole.BRAND,
|
||||
selected = fonts.brand,
|
||||
stamp = fonts.brandStamp,
|
||||
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
|
||||
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
|
||||
onDismiss = { showBrandFont = false },
|
||||
)
|
||||
}
|
||||
if (showPlainFont) {
|
||||
FontPicker(
|
||||
title = stringResource(R.string.settings_font_body),
|
||||
role = FontRole.PLAIN,
|
||||
selected = fonts.plain,
|
||||
stamp = fonts.plainStamp,
|
||||
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
|
||||
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
|
||||
onDismiss = { showPlainFont = false },
|
||||
)
|
||||
}
|
||||
if (showWeekStart) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
@@ -686,6 +773,128 @@ private fun AppearanceScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Views (#24): reorder the top-bar quick-switch cycle and choose which views it
|
||||
* steps through, plus reorder the navigation-drawer list. Two independent lists —
|
||||
* a view disabled in the quick-switch cycle is still reachable from the drawer,
|
||||
* which always lists every view. The switch needs at least two targets, so the
|
||||
* last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off.
|
||||
*/
|
||||
@Composable
|
||||
private fun ViewsScreen(
|
||||
state: SettingsUiState,
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
onBack = onBack,
|
||||
) {
|
||||
val config = state.quickSwitchConfig
|
||||
|
||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
// Turning a view off is blocked once only the minimum remain enabled.
|
||||
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
|
||||
ReorderableColumn(
|
||||
items = config.order,
|
||||
keyOf = { it },
|
||||
onReorder = { viewModel.setQuickSwitchOrder(it) },
|
||||
) { view, position, dragHandle, isDragging ->
|
||||
val checked = view in config.enabled
|
||||
ViewRow(
|
||||
view = view,
|
||||
position = position,
|
||||
isDragging = isDragging,
|
||||
dragHandle = dragHandle,
|
||||
dimmed = !checked,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
// Keep the last two on: with fewer, the pill can't switch.
|
||||
enabled = !checked || canDisable,
|
||||
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SectionHeader(stringResource(R.string.settings_drawer_order_header))
|
||||
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
ReorderableColumn(
|
||||
items = state.drawerViewOrder,
|
||||
keyOf = { it },
|
||||
onReorder = { viewModel.setDrawerViewOrder(it) },
|
||||
) { view, position, dragHandle, isDragging ->
|
||||
ViewRow(
|
||||
view = view,
|
||||
position = position,
|
||||
isDragging = isDragging,
|
||||
dragHandle = dragHandle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One reorderable view row: the view's icon and name, an optional [trailing]
|
||||
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
|
||||
@Composable
|
||||
private fun ViewRow(
|
||||
view: CalendarView,
|
||||
position: Position,
|
||||
isDragging: Boolean,
|
||||
dragHandle: Modifier,
|
||||
dimmed: Boolean = false,
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = stringResource(view.labelRes),
|
||||
position = position,
|
||||
dimmed = dimmed,
|
||||
minHeight = ReorderableRowHeight,
|
||||
// The reorderable column owns the inter-row spacing (uniform pitch).
|
||||
gapBelow = false,
|
||||
container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null,
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = view.icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
trailing = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
trailing?.invoke()
|
||||
if (trailing != null) Spacer(Modifier.width(8.dp))
|
||||
Box(
|
||||
modifier = dragHandle.size(48.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.DragHandle,
|
||||
contentDescription = stringResource(R.string.reorder_drag_handle),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
|
||||
@Composable
|
||||
private fun SettingsHint(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventFormScreen(
|
||||
state: SettingsUiState,
|
||||
@@ -1090,7 +1299,7 @@ private fun SpecialDatesScreen(
|
||||
}
|
||||
}
|
||||
val requestOrEnable: () -> Unit = {
|
||||
if (hasContactsPermission(context)) {
|
||||
if (context.hasContactsPermission()) {
|
||||
viewModel.setSpecialDatesEnabled(true)
|
||||
} else {
|
||||
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
|
||||
@@ -1290,12 +1499,21 @@ private fun SpecialDatesTemplateDialog(
|
||||
title = { Text(stringResource(R.string.settings_special_dates_template)) },
|
||||
text = {
|
||||
Column {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
// The app's borderless input over a tonal surface (the dialog
|
||||
// convention — see DialogControls), not Material's outlined field.
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
InlineTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
placeholder = stringResource(R.string.settings_special_dates_template),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.settings_special_dates_template_hint),
|
||||
@@ -1324,10 +1542,6 @@ private fun specialDateTypeLabel(type: SpecialDateType): Int = when (type) {
|
||||
SpecialDateType.Custom -> R.string.settings_special_dates_type_custom
|
||||
}
|
||||
|
||||
private fun hasContactsPermission(context: Context): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
@Composable
|
||||
private fun specialDatesLastRunLabel(context: Context, lastRun: Long): String =
|
||||
if (lastRun <= 0L) {
|
||||
@@ -1426,22 +1640,11 @@ private fun formatTimeOfDay(context: Context, minutesOfDay: Int): String {
|
||||
return DateFormat.getTimeFormat(context).format(time)
|
||||
}
|
||||
|
||||
/** The stored override for [calendarId], as a picker choice (absent → inherit). */
|
||||
private fun Map<Long, List<Int>>.choiceFor(calendarId: Long): CalendarReminderOverride = when {
|
||||
!containsKey(calendarId) -> CalendarReminderOverride.Inherit
|
||||
this.getValue(calendarId).isEmpty() -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(this.getValue(calendarId))
|
||||
}
|
||||
|
||||
/** Label for a global-default choice: empty → "None", else the lead times joined. */
|
||||
@Composable
|
||||
private fun reminderChoiceLabel(minutes: List<Int>): String {
|
||||
if (minutes.isEmpty()) return stringResource(R.string.reminder_none)
|
||||
// reminderLeadTimeLabel is @Composable, so resolve each part in a loop rather
|
||||
// than a (non-composable) joinToString transform.
|
||||
val parts = ArrayList<String>(minutes.size)
|
||||
for (m in minutes) parts.add(reminderLeadTimeLabel(m))
|
||||
return parts.joinToString(", ")
|
||||
return minutes.map { reminderLeadTimeLabel(it) }.joinToString(", ")
|
||||
}
|
||||
|
||||
/** Row summary for a calendar: its override, or the inherited global default. */
|
||||
@@ -1504,6 +1707,152 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
|
||||
},
|
||||
)
|
||||
|
||||
/** The summary label for a stored font token (issue #19). */
|
||||
@Composable
|
||||
private fun fontLabel(token: String): String = when (token) {
|
||||
FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system)
|
||||
FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected)
|
||||
else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) }
|
||||
?: stringResource(R.string.settings_font_system)
|
||||
}
|
||||
|
||||
/**
|
||||
* MIME types offered to the document picker so it lists only font files. Covers
|
||||
* the modern `font/` types plus the legacy `application/` font aliases some
|
||||
* providers still report. Anything that slips through is still validated by
|
||||
* [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use.
|
||||
*/
|
||||
private val FONT_PICKER_MIME_TYPES = arrayOf(
|
||||
"font/ttf",
|
||||
"font/otf",
|
||||
"font/sfnt",
|
||||
"font/collection",
|
||||
"application/x-font-ttf",
|
||||
"application/x-font-otf",
|
||||
"application/font-sfnt",
|
||||
"application/vnd.ms-opentype",
|
||||
)
|
||||
|
||||
/**
|
||||
* Full-screen font chooser for one [FontRole]: the system default, each bundled
|
||||
* font (previewed in its own face), and "Choose file…" which opens the system
|
||||
* picker to load a .ttf/.otf. Selecting a system/bundled option applies at once;
|
||||
* a file is validated and imported by the caller, switching to the custom font
|
||||
* on success.
|
||||
*/
|
||||
@Composable
|
||||
private fun FontPicker(
|
||||
title: String,
|
||||
role: FontRole,
|
||||
selected: String,
|
||||
stamp: Int,
|
||||
onSelect: (String) -> Unit,
|
||||
onImport: (Uri) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
onImport(uri)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// System default + the bundled fonts + the "Choose file…" row.
|
||||
val rowCount = BundledFont.entries.size + 2
|
||||
val isCustom = selected == FONT_CUSTOM_TOKEN
|
||||
// Resolving the custom face stats the disk and builds a fresh FontFamily, so
|
||||
// memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file
|
||||
// refreshes the preview while plain recompositions reuse the cached family.
|
||||
val customPreview = remember(role, isCustom, stamp) {
|
||||
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
FontOptionRow(
|
||||
label = stringResource(R.string.settings_font_system),
|
||||
preview = FontFamily.Default,
|
||||
selected = selected == FONT_SYSTEM_TOKEN,
|
||||
position = positionOf(0, rowCount),
|
||||
onClick = {
|
||||
onSelect(FONT_SYSTEM_TOKEN)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
BundledFont.entries.forEachIndexed { index, font ->
|
||||
FontOptionRow(
|
||||
label = stringResource(font.labelRes),
|
||||
preview = font.family,
|
||||
selected = selected == font.token,
|
||||
position = positionOf(index + 1, rowCount),
|
||||
onClick = {
|
||||
onSelect(font.token)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
FontOptionRow(
|
||||
label = if (isCustom) {
|
||||
stringResource(R.string.settings_font_custom_selected)
|
||||
} else {
|
||||
stringResource(R.string.settings_font_choose_file)
|
||||
},
|
||||
// A loaded font previews in its own face; otherwise show an upload cue.
|
||||
preview = customPreview,
|
||||
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
|
||||
selected = isCustom,
|
||||
position = positionOf(rowCount - 1, rowCount),
|
||||
onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in
|
||||
* the option's own [preview] face (or an [leadingIcon] cue when there's nothing
|
||||
* to preview), and a check when it's the current selection.
|
||||
*/
|
||||
@Composable
|
||||
private fun FontOptionRow(
|
||||
label: String,
|
||||
preview: FontFamily?,
|
||||
selected: Boolean,
|
||||
position: Position,
|
||||
onClick: () -> Unit,
|
||||
leadingIcon: ImageVector? = null,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = label,
|
||||
position = position,
|
||||
selected = selected,
|
||||
leading = {
|
||||
if (preview != null) {
|
||||
Text(
|
||||
text = "Ag",
|
||||
fontFamily = preview,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
} else if (leadingIcon != null) {
|
||||
Icon(imageVector = leadingIcon, contentDescription = null)
|
||||
}
|
||||
},
|
||||
trailing = if (selected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = onClick,
|
||||
)
|
||||
}
|
||||
|
||||
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
|
||||
private val WEEK_START_OPTIONS: List<WeekStartPref> =
|
||||
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }
|
||||
|
||||
@@ -11,6 +11,8 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
|
||||
/**
|
||||
* Settings screen state (M4). Persisted preferences are instant to read, so
|
||||
@@ -39,6 +41,10 @@ data class SettingsUiState(
|
||||
val agendaShowRangeBar: Boolean = true,
|
||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||
val defaultView: CalendarView = CalendarView.Week,
|
||||
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
|
||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
@@ -13,8 +14,11 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
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.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason
|
||||
@@ -23,17 +27,22 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.withContext
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
|
||||
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
|
||||
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
@@ -43,6 +52,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
@@ -115,8 +125,14 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.autofocusEventTitle,
|
||||
prefs.pastEventDisplay,
|
||||
prefs.dimCompletedEvents,
|
||||
::MiscSettings,
|
||||
),
|
||||
// View customisation (#24) folded into one flow so it fits this
|
||||
// group — the outer combine is already at its five-arg limit.
|
||||
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
|
||||
ViewCustomization(quickSwitch, drawer)
|
||||
},
|
||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||
},
|
||||
) { base, defaults, overrides, views, misc ->
|
||||
base.copy(
|
||||
defaultView = views.defaultView,
|
||||
@@ -128,6 +144,8 @@ class SettingsViewModel @Inject constructor(
|
||||
autofocusEventTitle = misc.autofocusEventTitle,
|
||||
pastEventDisplay = misc.pastEventDisplay,
|
||||
dimCompletedEvents = misc.dimCompletedEvents,
|
||||
quickSwitchConfig = misc.viewCustomization.quickSwitch,
|
||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -144,6 +162,29 @@ class SettingsViewModel @Inject constructor(
|
||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||
)
|
||||
|
||||
/**
|
||||
* The app-wide custom-font choice (issue #19). Its own flow — both this
|
||||
* screen and [MainActivity] (which builds the typography) read it
|
||||
* independently of the main settings combine.
|
||||
*/
|
||||
val fontState: StateFlow<AppFontSettings> = combine(
|
||||
prefs.brandFont,
|
||||
prefs.plainFont,
|
||||
prefs.brandFontStamp,
|
||||
prefs.plainFontStamp,
|
||||
) { brand, plain, brandStamp, plainStamp ->
|
||||
AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = AppFontSettings(),
|
||||
)
|
||||
|
||||
// Emitted when a picked font file couldn't be read as a font; the screen
|
||||
// surfaces it and the previous selection stays put.
|
||||
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
|
||||
|
||||
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
|
||||
// updateAll calls at once — concurrent calls coalesce around a stale read
|
||||
// and can leave the widget on the intermediate value. Held sequentially,
|
||||
@@ -178,6 +219,12 @@ class SettingsViewModel @Inject constructor(
|
||||
val autofocusEventTitle: Boolean,
|
||||
val pastEventDisplay: PastEventDisplay,
|
||||
val dimCompletedEvents: Boolean,
|
||||
val viewCustomization: ViewCustomization,
|
||||
)
|
||||
|
||||
private data class ViewCustomization(
|
||||
val quickSwitch: QuickSwitchConfig,
|
||||
val drawerOrder: List<CalendarView>,
|
||||
)
|
||||
|
||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||
@@ -195,8 +242,7 @@ class SettingsViewModel @Inject constructor(
|
||||
// A blank stored template resolves to the localized default so the
|
||||
// editor always shows something sensible.
|
||||
titleTemplates = SpecialDateType.entries.associateWith { type ->
|
||||
templates[type]?.takeIf { it.isNotBlank() }
|
||||
?: specialDatesSpec.defaultTitleTemplate(type)
|
||||
specialDatesSpec.resolveTitleTemplate(type, templates)
|
||||
},
|
||||
showYear = showYear,
|
||||
stalledPermission = status.stalled == SpecialDatesStalledReason.PermissionRevoked,
|
||||
@@ -207,13 +253,9 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.perCalendarAllDayReminderOverride,
|
||||
) { base, calendars, allDayOverrides ->
|
||||
base.copy(
|
||||
// An override is always seeded on creation; present-empty = None.
|
||||
reminderChoices = calendars.mapValues { (_, calendarId) ->
|
||||
// An override is always seeded on creation; present-empty = None.
|
||||
when (val minutes = allDayOverrides[calendarId]) {
|
||||
null -> CalendarReminderOverride.Inherit
|
||||
emptyList<Int>() -> CalendarReminderOverride.None
|
||||
else -> CalendarReminderOverride.Minutes(minutes)
|
||||
}
|
||||
allDayOverrides.choiceFor(calendarId)
|
||||
},
|
||||
)
|
||||
}.stateIn(
|
||||
@@ -282,6 +324,35 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setDynamicColor(enabled) }
|
||||
}
|
||||
|
||||
fun setFont(role: FontRole, token: String) {
|
||||
viewModelScope.launch {
|
||||
when (role) {
|
||||
FontRole.BRAND -> prefs.setBrandFont(token)
|
||||
FontRole.PLAIN -> prefs.setPlainFont(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a user-picked font file for [role]. Copies and validates it off the
|
||||
* main thread; on success the role switches to the custom font, otherwise the
|
||||
* previous choice is kept and [fontImportFailed] fires.
|
||||
*/
|
||||
fun importCustomFont(role: FontRole, uri: Uri) {
|
||||
viewModelScope.launch {
|
||||
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
|
||||
if (ok) {
|
||||
// Bump the stamp so replacing an already-active custom font (same
|
||||
// file, same "custom" token) still breaks font-settings equality
|
||||
// and refreshes the resolved typeface.
|
||||
prefs.bumpCustomFontStamp(role)
|
||||
setFont(role, FONT_CUSTOM_TOKEN)
|
||||
} else {
|
||||
_fontImportFailed.emit(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setWeekStart(pref: WeekStartPref) {
|
||||
viewModelScope.launch {
|
||||
prefs.setWeekStart(pref)
|
||||
@@ -355,6 +426,32 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable [view] in the quick-switch cycle. Goes through an atomic
|
||||
* read-modify-write so a concurrent reorder can't clobber this toggle (and
|
||||
* vice versa) by re-serialising a stale snapshot. The MIN_ENABLED floor is
|
||||
* re-checked inside the transform: the screen's own guard reads the
|
||||
* async-echoed snapshot, so two quick disable-taps could both look allowed
|
||||
* yet compose to a below-minimum cycle.
|
||||
*/
|
||||
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.updateQuickSwitch { config ->
|
||||
val next = if (enabled) config.enabled + view else config.enabled - view
|
||||
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Reorder the quick-switch views (atomic read-modify-write; keeps the enabled set). */
|
||||
fun setQuickSwitchOrder(order: List<CalendarView>) {
|
||||
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
|
||||
}
|
||||
|
||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||
}
|
||||
|
||||
fun setRemindersEnabled(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
|
||||
}
|
||||
|
||||
129
app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt
Normal file
129
app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Fonts.kt
Normal file
@@ -0,0 +1,129 @@
|
||||
package de.jeanlucmakiola.calendula.ui.theme
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import java.io.File
|
||||
|
||||
/** Stored token for "use the device default typeface" — the default for both roles. */
|
||||
const val FONT_SYSTEM_TOKEN = "system"
|
||||
|
||||
/** Stored token for "use the font the user loaded from a file" (per role). */
|
||||
const val FONT_CUSTOM_TOKEN = "custom"
|
||||
|
||||
/**
|
||||
* A font checked into the app that a user can pick without providing a file
|
||||
* (issue #19). [token] is the stable value persisted in preferences; [family] is
|
||||
* the resolved Compose family. Kept deliberately small and readability-focused.
|
||||
*/
|
||||
enum class BundledFont(
|
||||
val token: String,
|
||||
@StringRes val labelRes: Int,
|
||||
val family: FontFamily,
|
||||
) {
|
||||
/** Purpose-built for low-vision legibility (Braille Institute). */
|
||||
AtkinsonHyperlegible(
|
||||
token = "atkinson_hyperlegible",
|
||||
labelRes = R.string.font_atkinson_hyperlegible,
|
||||
family = FontFamily(
|
||||
Font(R.font.atkinson_hyperlegible_regular, FontWeight.Normal),
|
||||
Font(R.font.atkinson_hyperlegible_bold, FontWeight.Bold),
|
||||
),
|
||||
),
|
||||
|
||||
/** A serif alternative; shipped as a variable font, weights via its wght axis. */
|
||||
Lora(
|
||||
token = "lora",
|
||||
labelRes = R.string.font_lora,
|
||||
family = FontFamily(
|
||||
Font(R.font.lora, FontWeight.Normal, variationSettings = weightAxis(400)),
|
||||
Font(R.font.lora, FontWeight.Medium, variationSettings = weightAxis(500)),
|
||||
Font(R.font.lora, FontWeight.Bold, variationSettings = weightAxis(700)),
|
||||
),
|
||||
),
|
||||
|
||||
/** A monospace option. */
|
||||
JetBrainsMono(
|
||||
token = "jetbrains_mono",
|
||||
labelRes = R.string.font_jetbrains_mono,
|
||||
family = FontFamily(
|
||||
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
|
||||
Font(R.font.jetbrains_mono_bold, FontWeight.Bold),
|
||||
),
|
||||
);
|
||||
|
||||
companion object {
|
||||
fun fromToken(token: String?): BundledFont? = entries.firstOrNull { it.token == token }
|
||||
}
|
||||
}
|
||||
|
||||
private fun weightAxis(weight: Int) = FontVariation.Settings(FontVariation.weight(weight))
|
||||
|
||||
/** The two roles' current font tokens (see [FontRole]); the app-wide typeface choice. */
|
||||
data class AppFontSettings(
|
||||
val brand: String = FONT_SYSTEM_TOKEN,
|
||||
val plain: String = FONT_SYSTEM_TOKEN,
|
||||
// Per-role custom-font re-import stamp: bumped when the "custom" file is
|
||||
// replaced, so a same-token re-import still breaks equality and refreshes the
|
||||
// resolved family. 0 for existing installs / no import yet (issue #19).
|
||||
val brandStamp: Int = 0,
|
||||
val plainStamp: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve a stored [token] for [role] to a Compose [FontFamily], or null to mean
|
||||
* "leave the Material default" (the system typeface). A custom token whose file
|
||||
* is missing or unreadable also resolves to null, so a deleted file degrades to
|
||||
* the system font rather than crashing text layout.
|
||||
*/
|
||||
fun resolveFontFamily(token: String, role: FontRole, context: Context): FontFamily? = when (token) {
|
||||
FONT_SYSTEM_TOKEN -> null
|
||||
FONT_CUSTOM_TOKEN -> customFontFamily(CustomFontStore.file(context, role))
|
||||
else -> BundledFont.fromToken(token)?.family
|
||||
}
|
||||
|
||||
private fun customFontFamily(file: File): FontFamily? =
|
||||
if (file.exists() && file.length() > 0) {
|
||||
runCatching { FontFamily(Font(file)) }.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the app typography from the chosen [brand] and [plain] families. A null
|
||||
* family leaves that role's Material default (system typeface) in place, so the
|
||||
* common "system for both" case returns [CalendulaTypography] untouched. Brand
|
||||
* drives display + headline; plain drives title + body + label (M3's two
|
||||
* typeface roles).
|
||||
*/
|
||||
fun calendulaTypography(brand: FontFamily?, plain: FontFamily?): Typography {
|
||||
if (brand == null && plain == null) return CalendulaTypography
|
||||
val base = CalendulaTypography
|
||||
fun TextStyle.withFamily(family: FontFamily?): TextStyle =
|
||||
if (family != null) copy(fontFamily = family) else this
|
||||
return base.copy(
|
||||
displayLarge = base.displayLarge.withFamily(brand),
|
||||
displayMedium = base.displayMedium.withFamily(brand),
|
||||
displaySmall = base.displaySmall.withFamily(brand),
|
||||
headlineLarge = base.headlineLarge.withFamily(brand),
|
||||
headlineMedium = base.headlineMedium.withFamily(brand),
|
||||
headlineSmall = base.headlineSmall.withFamily(brand),
|
||||
titleLarge = base.titleLarge.withFamily(plain),
|
||||
titleMedium = base.titleMedium.withFamily(plain),
|
||||
titleSmall = base.titleSmall.withFamily(plain),
|
||||
bodyLarge = base.bodyLarge.withFamily(plain),
|
||||
bodyMedium = base.bodyMedium.withFamily(plain),
|
||||
bodySmall = base.bodySmall.withFamily(plain),
|
||||
labelLarge = base.labelLarge.withFamily(plain),
|
||||
labelMedium = base.labelMedium.withFamily(plain),
|
||||
labelSmall = base.labelSmall.withFamily(plain),
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -15,15 +16,18 @@ import androidx.compose.ui.platform.LocalContext
|
||||
* - 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.
|
||||
*
|
||||
* The Settings screen (later) can override useDynamicColor and themePreference,
|
||||
* but the V1 foundation just follows the system.
|
||||
* 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(),
|
||||
dynamicColor: Boolean = true,
|
||||
typography: Typography = CalendulaTypography,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = when {
|
||||
@@ -42,7 +46,7 @@ fun CalendulaTheme(
|
||||
// the bouncy variant felt overdone in review (2026-06-11).
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = CalendulaTypography,
|
||||
typography = typography,
|
||||
motionScheme = MotionScheme.standard(),
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -81,6 +81,7 @@ 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.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
@@ -131,6 +132,8 @@ fun WeekScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onCreateEvent: (LocalDate, Int?) -> Unit,
|
||||
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: WeekViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -195,6 +198,7 @@ fun WeekScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = weekStart,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -216,7 +220,7 @@ fun WeekScreen(
|
||||
WeekTopBar(
|
||||
weekStart = weekStart,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next()) },
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
scrollBehavior = scrollBehavior,
|
||||
|
||||
@@ -193,6 +193,8 @@ private fun AgendaHeader() {
|
||||
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Tap the "Upcoming" title to open the app on the user's default view
|
||||
// (issue #20) — a null target resolves to the default home in the host.
|
||||
Text(
|
||||
text = context.getString(R.string.widget_agenda_title),
|
||||
style = TextStyle(
|
||||
@@ -200,7 +202,11 @@ private fun AgendaHeader() {
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
modifier = GlanceModifier.defaultWeight(),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(
|
||||
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
|
||||
),
|
||||
)
|
||||
IconButton(
|
||||
resId = R.drawable.ic_widget_refresh,
|
||||
|
||||
@@ -194,6 +194,8 @@ private fun MonthHeader(label: String) {
|
||||
),
|
||||
),
|
||||
)
|
||||
// Tap the month/year title to open the app on the month view (issue #18),
|
||||
// rooted over the default home so backing out returns there.
|
||||
Text(
|
||||
text = label,
|
||||
style = TextStyle(
|
||||
@@ -204,16 +206,17 @@ private fun MonthHeader(label: String) {
|
||||
),
|
||||
modifier = GlanceModifier
|
||||
.defaultWeight()
|
||||
.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openViewIntent(context, CalendarView.Month),
|
||||
),
|
||||
),
|
||||
)
|
||||
// The "today" button snaps the grid back to the current month in place.
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_today,
|
||||
contentDescription = context.getString(R.string.widget_today),
|
||||
onClick = GlanceModifier.clickable(
|
||||
actionStartActivity(
|
||||
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
|
||||
),
|
||||
),
|
||||
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
|
||||
)
|
||||
HeaderIcon(
|
||||
resId = R.drawable.ic_widget_chevron_right,
|
||||
@@ -301,6 +304,15 @@ private fun WeekRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open [date]'s day view rooted in the month view (so back returns to the month
|
||||
* grid) — the same target the in-app month grid uses when a day cell is tapped.
|
||||
* Shared by every tappable part of a day column so, as in the app, a tap anywhere
|
||||
* on a day opens it; only an event bar on top opts out to open its own detail.
|
||||
*/
|
||||
private fun openDayAction(context: Context, date: LocalDate) =
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
|
||||
|
||||
@Composable
|
||||
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
@@ -308,11 +320,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(DAY_NUMBER_HEIGHT)
|
||||
// Tap a day number to open that day, rooted in the month view so back
|
||||
// returns to the month grid.
|
||||
.clickable(
|
||||
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
|
||||
),
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
@@ -339,6 +347,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
|
||||
|
||||
@Composable
|
||||
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
var col = 0
|
||||
while (col < 7) {
|
||||
@@ -352,7 +361,14 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
|
||||
if (timed != null) {
|
||||
SpanBar(event = timed, dark = dark, width = colW)
|
||||
} else {
|
||||
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
|
||||
// Empty lane cell: a tap opens that day, so blank space in a
|
||||
// day column is a day-open target just like the number is.
|
||||
Box(
|
||||
GlanceModifier
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.clickable(openDayAction(context, week.days[col])),
|
||||
) {}
|
||||
}
|
||||
col += 1
|
||||
}
|
||||
@@ -401,14 +417,20 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
|
||||
|
||||
@Composable
|
||||
private fun OverflowRow(week: MonthWeek, colW: Dp) {
|
||||
val context = LocalContext.current
|
||||
Row(modifier = GlanceModifier.fillMaxWidth()) {
|
||||
week.days.forEachIndexed { col, date ->
|
||||
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
|
||||
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
|
||||
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
|
||||
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
|
||||
// The overflow row is part of the day column too: tapping it (whether
|
||||
// it shows "+N" or is blank) opens that day, same as the app.
|
||||
Box(
|
||||
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
|
||||
modifier = GlanceModifier
|
||||
.width(colW)
|
||||
.height(LANE_HEIGHT)
|
||||
.clickable(openDayAction(context, date)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (hidden > 0) {
|
||||
|
||||
BIN
app/src/main/res/font/atkinson_hyperlegible_bold.ttf
Normal file
BIN
app/src/main/res/font/atkinson_hyperlegible_bold.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/atkinson_hyperlegible_regular.ttf
Normal file
BIN
app/src/main/res/font/atkinson_hyperlegible_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/jetbrains_mono_bold.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_bold.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/jetbrains_mono_regular.ttf
Normal file
BIN
app/src/main/res/font/jetbrains_mono_regular.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/lora.ttf
Normal file
BIN
app/src/main/res/font/lora.ttf
Normal file
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="app_tagline">Ein moderner Kalender.</string>
|
||||
|
||||
<string name="state_loading">Lädt…</string>
|
||||
<string name="state_retry">Erneut versuchen</string>
|
||||
<string name="state_failure_unknown">Etwas ist schiefgelaufen.</string>
|
||||
@@ -10,10 +10,9 @@
|
||||
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
|
||||
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
|
||||
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
|
||||
|
||||
<!-- Permission-Flow (F1) -->
|
||||
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App zu Beginn nicht — und nichts verlässt je dein Gerät.</string>
|
||||
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App nie.</string>
|
||||
<string name="permission_request_button">Kalender-Zugriff erlauben</string>
|
||||
<string name="permission_denied_title">Kalender-Zugriff abgelehnt</string>
|
||||
<string name="permission_denied_body">Ohne Kalender-Zugriff kann Calendula keine Termine anzeigen. Du kannst den Zugriff in den System-Einstellungen wieder erlauben.</string>
|
||||
@@ -26,7 +25,6 @@
|
||||
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
|
||||
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
|
||||
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
|
||||
|
||||
<!-- Monatsansicht (S1) -->
|
||||
<string name="month_prev">Vorheriger Monat</string>
|
||||
<string name="month_next">Nächster Monat</string>
|
||||
@@ -35,14 +33,11 @@
|
||||
<string name="month_open_menu">Menü öffnen</string>
|
||||
<string name="month_action_settings">Einstellungen</string>
|
||||
<string name="month_a11y_today_prefix">Heute</string>
|
||||
|
||||
<!-- Wochenansicht (S2) -->
|
||||
<string name="week_today_action">Diese Woche</string>
|
||||
<string name="week_number_label">KW</string>
|
||||
|
||||
<!-- Tagesansicht (S3) -->
|
||||
<string name="day_today_action">Heute</string>
|
||||
|
||||
<!-- Event-Detail-Screen (S4) -->
|
||||
<string name="event_detail_back">Zurück</string>
|
||||
<string name="event_detail_edit">Bearbeiten</string>
|
||||
@@ -61,7 +56,6 @@
|
||||
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
||||
<string name="dialog_cancel">Abbrechen</string>
|
||||
<string name="dialog_ok">OK</string>
|
||||
|
||||
<!-- Termin-Formular (v1.2 Erstellen) -->
|
||||
<string name="event_edit_new_title">Neuer Termin</string>
|
||||
<string name="event_edit_close">Schließen</string>
|
||||
@@ -94,7 +88,6 @@
|
||||
<string name="reminder_unit_weeks">Wochen</string>
|
||||
<string name="event_edit_availability">Verfügbarkeit</string>
|
||||
<string name="event_edit_visibility">Sichtbarkeit</string>
|
||||
|
||||
<!-- Termin-Formular — eigene Terminfarbe -->
|
||||
<string name="event_edit_color">Farbe</string>
|
||||
<string name="event_edit_color_default">Kalenderfarbe</string>
|
||||
@@ -103,7 +96,6 @@
|
||||
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
|
||||
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
|
||||
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
|
||||
|
||||
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
|
||||
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
|
||||
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
|
||||
@@ -113,7 +105,6 @@
|
||||
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
|
||||
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
|
||||
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
|
||||
|
||||
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
|
||||
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
|
||||
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
|
||||
@@ -156,7 +147,6 @@
|
||||
<string name="event_attendee_tentative">Vorläufig</string>
|
||||
<string name="event_attendee_needs_action">Keine Antwort</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
|
||||
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
|
||||
<string name="event_detail_reminders">Erinnerungen</string>
|
||||
<string name="event_detail_timezone">Zeitzone</string>
|
||||
@@ -196,10 +186,8 @@
|
||||
<item quantity="one">%d Stunde</item>
|
||||
<item quantity="other">%d Stunden</item>
|
||||
</plurals>
|
||||
|
||||
<!-- Geteilte Event-Strings -->
|
||||
<string name="event_untitled">(Ohne Titel)</string>
|
||||
|
||||
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
|
||||
<string name="reminder_channel_name">Termin-Erinnerungen</string>
|
||||
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
|
||||
@@ -215,23 +203,19 @@
|
||||
<string name="reminder_onboarding_skip_button">Später</string>
|
||||
<string name="reminder_action_snooze">Schlummern</string>
|
||||
<string name="reminder_action_dismiss">Verwerfen</string>
|
||||
|
||||
<!-- View-Switcher (M1) -->
|
||||
<string name="view_month">Monat</string>
|
||||
<string name="view_week">Woche</string>
|
||||
<string name="view_day">Tag</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">Ansicht</string>
|
||||
|
||||
<!-- Zu Datum springen (Navigationsleiste) -->
|
||||
<string name="drawer_jump_to_date">Zu Datum springen</string>
|
||||
|
||||
<!-- Agenda-Ansicht -->
|
||||
<string name="agenda_today_action">Heute</string>
|
||||
<string name="agenda_header_today">Heute</string>
|
||||
<string name="agenda_header_tomorrow">Morgen</string>
|
||||
<string name="agenda_empty_title">Alles erledigt</string>
|
||||
|
||||
<!-- Terminsuche -->
|
||||
<string name="search_action">Suchen</string>
|
||||
<string name="search_hint">Termine suchen</string>
|
||||
@@ -239,7 +223,6 @@
|
||||
<string name="search_clear">Löschen</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 -->
|
||||
<string name="widget_agenda_title">Anstehend</string>
|
||||
<string name="widget_agenda_label">Calendula Agenda</string>
|
||||
@@ -250,19 +233,15 @@
|
||||
<string name="widget_prev_month">Vorheriger Monat</string>
|
||||
<string name="widget_next_month">Nächster Monat</string>
|
||||
<string name="widget_today">Heute</string>
|
||||
|
||||
<!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
|
||||
<string name="shortcut_new_event_short">Neuer Termin</string>
|
||||
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
|
||||
|
||||
<!-- Schnelleinstellungen-Kachel -->
|
||||
<string name="qs_tile_new_event_label">Neuer Termin</string>
|
||||
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
|
||||
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
|
||||
|
||||
<!-- Kalender-Filter (M3) -->
|
||||
<string name="filter_title">Kalender</string>
|
||||
|
||||
<!-- Einstellungen (M4) -->
|
||||
<string name="settings_title">Einstellungen</string>
|
||||
<string name="settings_back">Zurück</string>
|
||||
@@ -343,7 +322,6 @@
|
||||
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
|
||||
<string name="settings_report_problem">Problem melden</string>
|
||||
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
|
||||
|
||||
<!-- Calendar manager -->
|
||||
<string name="calendars_title">Kalender</string>
|
||||
<string name="calendars_local_header">Deine Kalender</string>
|
||||
@@ -398,7 +376,6 @@
|
||||
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
|
||||
<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>
|
||||
@@ -410,38 +387,4 @@
|
||||
<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_paste">_(Der Bericht war zu lang für diesen Link — füge ihn aus deiner Zwischenablage hier ein.)_</string>
|
||||
|
||||
<!-- Contact special dates (issue #15) -->
|
||||
<string name="special_dates_calendar_birthday">Geburtstage</string>
|
||||
<string name="special_dates_calendar_anniversary">Jahrestage</string>
|
||||
<string name="special_dates_calendar_custom">Besondere Tage</string>
|
||||
<string name="special_dates_default_title_birthday">{name}s Geburtstag ({year})</string>
|
||||
<string name="special_dates_default_title_anniversary">{name}s Jahrestag ({year})</string>
|
||||
<string name="special_dates_default_title_custom">{name}</string>
|
||||
<string name="event_edit_managed_hint">Verwaltet von „%1$s“ — Titel, Datum und Wiederholung werden aus deinen Kontakten synchron gehalten. Erinnerungen, Ort und Notizen kannst du selbst bearbeiten.</string>
|
||||
<string name="settings_special_dates_subtitle">Geburtstage & Jahrestage aus Kontakten</string>
|
||||
<string name="settings_section_special_dates">Besondere Kontakttage</string>
|
||||
<string name="settings_special_dates_enable">Kontakttage anzeigen</string>
|
||||
<string name="settings_special_dates_enable_hint">Spiegelt Geburtstage und weitere Daten deiner Kontakte in lokale Kalender. Liest Kontakte nur auf diesem Gerät — nichts wird hochgeladen, und deine Kontakte werden nie verä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">Weitere Tage</string>
|
||||
<string name="settings_special_dates_template">Titelformat</string>
|
||||
<string name="settings_special_dates_template_hint">Verwende {name} für den Kontakt und {year} für das Jahr (Geburtsjahr bzw. Startjahr eines Jahrestags; ausgeblendet, wenn unbekannt).</string>
|
||||
<string name="settings_special_dates_reminders">Erinnerungen</string>
|
||||
<string name="settings_special_dates_show_year">Jahr anzeigen</string>
|
||||
<string name="settings_special_dates_show_year_hint">{year} in Titeln anzeigen, wenn es bekannt ist</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">Farbe und Sichtbarkeit jedes Kalenders legst du in den Kalender-Einstellungen fest.</string>
|
||||
<string name="settings_calendar_reminders_managed_hint">In Besondere Kontakttage festgelegt</string>
|
||||
<string name="settings_special_dates_paused_title">Pausiert</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_grant">Zugriff erlauben</string>
|
||||
<string name="settings_special_dates_disable_title">Kontakttage deaktivieren?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Dadurch werden die Kontakttage-Kalender und ihre Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Dadurch werden der Kalender „%1$s“ und seine Termine gelöscht. Hinzugefügte Erinnerungen oder Notizen gehen verloren.</string>
|
||||
<string name="settings_special_dates_disable_confirm">Deaktivieren</string>
|
||||
<string name="dialog_save">Speichern</string>
|
||||
</resources>
|
||||
|
||||
421
app/src/main/res/values-es/strings.xml
Normal file
421
app/src/main/res/values-es/strings.xml
Normal file
@@ -0,0 +1,421 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="app_tagline">Un calendario moderno.</string>
|
||||
<string name="state_loading">Cargando…</string>
|
||||
<string name="state_retry">Vuelve a intentarlo</string>
|
||||
<string name="state_failure_unknown">Algo salio mal.</string>
|
||||
<string name="state_failure_permission">Se requiere acceso al calendario.</string>
|
||||
<string name="state_failure_permission_action">Permite el acceso</string>
|
||||
<string name="state_failure_no_calendars">No se han configurado calendarios.</string>
|
||||
<string name="state_failure_no_calendars_action">Abrir los ajustes del calendario del sistema</string>
|
||||
<string name="state_failure_provider">No se pudo leer el calendario.</string>
|
||||
<string name="permission_rationale_title">Mira todos sus eventos, a la perfección</string>
|
||||
<string name="permission_rationale_body">Calendula necesita acceso a tu calendario para mostrarte y administrar eventos. Ese es el único permiso que pedirá.</string>
|
||||
<string name="permission_request_button">Conceder el acceso al calendario</string>
|
||||
<string name="permission_denied_title">Acceso a calendario denegado</string>
|
||||
<string name="permission_denied_body">Calendula no puede mostrar eventos sin acceso al calendario. Puedes concederlo en ajustes del sistema.</string>
|
||||
<string name="permission_open_settings_button">Abrir ajustes del sistema</string>
|
||||
<string name="permission_retry_button">Intentarlo de nuevo</string>
|
||||
<string name="permission_benefit_private_title">Permanece en tu dispositivo</string>
|
||||
<string name="permission_benefit_private_body">Tus calendarios se leen localmente y nunca abandonan tu celular.</string>
|
||||
<string name="permission_benefit_sync_title">Todos tus calendarios, juntos</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, local — todo lo sincronizado en el dispositivo aparecerá.</string>
|
||||
<string name="permission_benefit_privacy_title">Sin rastreo, jamás</string>
|
||||
<string name="permission_benefit_privacy_body">Sin telemetría, sin analíticas, sin anuncios.</string>
|
||||
<string name="permission_privacy_footnote">Permanece en tu dispositivo, no necesita acceso a internet</string>
|
||||
<string name="month_prev">Mes anterior</string>
|
||||
<string name="month_next">Siguiente mes</string>
|
||||
<string name="month_today_action">Hoy</string>
|
||||
<string name="month_more_actions">Mas acciones</string>
|
||||
<string name="month_open_menu">Abrir menu</string>
|
||||
<string name="month_action_settings">Ajustes</string>
|
||||
<string name="month_a11y_today_prefix">Hoy</string>
|
||||
<string name="week_today_action">Esta semana</string>
|
||||
<string name="week_number_label">Sem</string>
|
||||
<string name="day_today_action">Hoy</string>
|
||||
<string name="event_detail_back">Atras</string>
|
||||
<string name="event_detail_edit">Editar</string>
|
||||
<string name="event_detail_delete">Eliminar</string>
|
||||
<string name="event_detail_share">Compartir</string>
|
||||
<string name="event_share_chooser_title">Compartir evento</string>
|
||||
<string name="event_share_failed">No se pudo compartir el evento.</string>
|
||||
<string name="event_delete_title">¿Eliminar evento?</string>
|
||||
<string name="event_delete_body">El evento se elimino de tu calendario y de todos los dispositivos con los cuales se sincroniza.</string>
|
||||
<string name="event_delete_recurring_title">Eliminar evento recurrente</string>
|
||||
<string name="event_delete_option_occurrence">Solo este evento</string>
|
||||
<string name="event_delete_option_following">Este y todos los eventos siguientes</string>
|
||||
<string name="event_delete_option_series">Todos los eventos en la serie</string>
|
||||
<string name="event_edit_recurring_title">Editar evento recurrente</string>
|
||||
<string name="event_delete_failed">No se pudo eliminar el evento</string>
|
||||
<string name="event_delete_write_denied">Calendula necesita acceso de escritura para eliminar eventos</string>
|
||||
<string name="dialog_cancel">Cancelar</string>
|
||||
<string name="dialog_ok">Okey</string>
|
||||
<string name="event_edit_new_title">Nuevo evento</string>
|
||||
<string name="event_edit_close">Cerrar</string>
|
||||
<string name="event_edit_save">Guardar</string>
|
||||
<string name="event_edit_title_hint">Añadir título</string>
|
||||
<string name="event_edit_starts">Inicio</string>
|
||||
<string name="event_edit_ends">Final</string>
|
||||
<string name="event_edit_error_end_before_start">Termina antes de comenzar</string>
|
||||
<string name="event_edit_error_no_calendar">No hay ningún calendario en donde se pueda escribir disponible</string>
|
||||
<string name="event_edit_save_failed">No se pudo guardar el evento</string>
|
||||
<string name="event_edit_write_denied">Calendula necesita acceso de escritura para crear eventos</string>
|
||||
<string name="event_edit_more_fields">Mas campos</string>
|
||||
<string name="event_edit_add">Añadir</string>
|
||||
<string name="event_edit_add_reminder">Añadir recordatorio</string>
|
||||
<string name="event_edit_remove_reminder">Eliminar recordatorio</string>
|
||||
<string name="event_edit_attendees">Invitados</string>
|
||||
<string name="event_edit_add_guest">Añadir invitados</string>
|
||||
<string name="event_edit_add_guest_hint">Añadir invitados por correo…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Añadir desde contactos</string>
|
||||
<string name="event_edit_location_from_contacts">Seleccionar dirección desde contactos</string>
|
||||
<string name="event_edit_remove_guest">Eliminar invitado</string>
|
||||
<string name="event_edit_attendee_required">Obligatorio</string>
|
||||
<string name="event_edit_attendee_optional">Opcional</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula no envia invitaciones. Tu cuenta de calendario puede enviar correos a los invitados cuando se sincronice.</string>
|
||||
<string name="event_edit_attendees_note_local">Guardado en el dispositivo. No se avisa a nadie.</string>
|
||||
<string name="event_edit_reminder_custom">Personalizado</string>
|
||||
<string name="reminder_unit_minutes">minutos</string>
|
||||
<string name="reminder_unit_hours">horas</string>
|
||||
<string name="reminder_unit_days">días</string>
|
||||
<string name="reminder_unit_weeks">semanas</string>
|
||||
<string name="event_edit_availability">Disponibilidad</string>
|
||||
<string name="event_edit_visibility">Visibilidad</string>
|
||||
<string name="event_edit_color">Color</string>
|
||||
<string name="event_edit_color_default">Color del calendario</string>
|
||||
<string name="event_edit_color_custom">Color personalizado</string>
|
||||
<string name="event_edit_color_reset">Reiniciar</string>
|
||||
<string name="event_edit_color_unsupported">No disponible para este calendario</string>
|
||||
<string name="event_edit_color_unsupported_hint">Este calendario no admite un ser de colores. Puedes permitir colores personalizados para este tipo de calendarios en ajustes.</string>
|
||||
<string name="event_edit_color_sync_warning">Este calendario puede eliminar o cambiar el color la próxima vez que se sincronice.</string>
|
||||
<string name="event_edit_conflict_title">El evento fue cambiado por otra fuente</string>
|
||||
<string name="event_edit_conflict_body">Mientras lo editabas, este evento fue cambiado — debido a la sincronización u otra aplicación ¿Que deberia le ocurrir a tus cambios?</string>
|
||||
<string name="event_edit_conflict_overwrite">Guardar mis cambios</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Sobreescribir el cambio externo en solo los aspectos que he cambiado</string>
|
||||
<string name="event_edit_conflict_discard">Descartar todos mis cambios</string>
|
||||
<string name="event_edit_conflict_discard_hint">El evento permanece tal y como esta ahora</string>
|
||||
<string name="event_edit_gone_title">Evento eliminado</string>
|
||||
<string name="event_edit_gone_body">Este evento fue eliminado durante el proceso, en otro dispositivo o aplicación por ejemplo. Tus cambios no pudieron ser guardados.</string>
|
||||
<string name="event_edit_recurrence_none">No se repite</string>
|
||||
<string name="event_edit_recurrence_custom">Personalizado</string>
|
||||
<string name="event_edit_recurrence_every">Cada</string>
|
||||
<string name="recurrence_unit_days">días</string>
|
||||
<string name="recurrence_unit_weeks">semanas</string>
|
||||
<string name="recurrence_unit_months">meses</string>
|
||||
<string name="recurrence_unit_years">años</string>
|
||||
<string name="event_edit_recurrence_ends">Finaliza</string>
|
||||
<string name="event_edit_recurrence_end_never">Nunca</string>
|
||||
<string name="event_edit_recurrence_end_until">En una fecha</string>
|
||||
<string name="event_edit_recurrence_end_count">Después de cierto numero de veces</string>
|
||||
<string name="event_edit_recurrence_times">veces</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">El evento se deja de repetir antes de que este comience</string>
|
||||
<string name="event_availability_busy">Ocupado</string>
|
||||
<string name="event_access_default">Por defecto</string>
|
||||
<string name="event_access_public">Público</string>
|
||||
<string name="event_detail_all_day">Todo el día</string>
|
||||
<string name="event_detail_calendar">Calendario</string>
|
||||
<string name="event_detail_calendar_unknown">Calendario desconocido</string>
|
||||
<string name="event_detail_location">Ubicación</string>
|
||||
<string name="event_detail_description">Descripción</string>
|
||||
<string name="event_detail_attendees">Participantes</string>
|
||||
<string name="event_detail_recurrence">Recurrencia</string>
|
||||
<string name="agenda_today_action">Hoy</string>
|
||||
<string name="agenda_header_today">Hoy</string>
|
||||
<string name="search_back">Atras</string>
|
||||
<string name="widget_new_event">Nuevo evento</string>
|
||||
<string name="widget_prev_month">Mes anterior</string>
|
||||
<string name="widget_next_month">Siguiente mes</string>
|
||||
<string name="widget_today">Hoy</string>
|
||||
<string name="filter_title">Calendarios</string>
|
||||
<string name="settings_title">Ajustes</string>
|
||||
<string name="settings_back">Atras</string>
|
||||
<string name="agenda_range_day">Hoy</string>
|
||||
<string name="agenda_range_this_week">Esta semana</string>
|
||||
<string name="settings_section_calendars">Calendarios</string>
|
||||
<string name="calendars_title">Calendarios</string>
|
||||
<string name="calendars_color_label">Color</string>
|
||||
<string name="import_close">Cerrar</string>
|
||||
<string name="shortcut_new_event_short">Nuevo evento</string>
|
||||
<string name="qs_tile_new_event_label">Nuevo evento</string>
|
||||
<string name="event_detail_recurring">Repetir el evento</string>
|
||||
<string name="recurrence_daily">Todos los días</string>
|
||||
<string name="recurrence_weekly">Todas las semanas</string>
|
||||
<string name="recurrence_monthly">Todos los meses</string>
|
||||
<string name="recurrence_yearly">Todos los años</string>
|
||||
<string name="recurrence_every_n_days">Cada %1$d días</string>
|
||||
<string name="recurrence_every_n_weeks">Cada %1$d semanas</string>
|
||||
<string name="recurrence_every_n_months">Cada %1$d meses</string>
|
||||
<string name="recurrence_every_n_years">Cada %1$d años</string>
|
||||
<string name="event_detail_not_found">Este evento ya no existe.</string>
|
||||
<string name="event_attendee_accepted">Aceptado</string>
|
||||
<string name="event_attendee_declined">Rechazado</string>
|
||||
<string name="event_attendee_tentative">Tentativo</string>
|
||||
<string name="event_attendee_needs_action">Sin respuesta</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_detail_reminders">Recordatorios</string>
|
||||
<string name="event_detail_timezone">Zona horaria</string>
|
||||
<string name="event_status_tentative">Tentativo</string>
|
||||
<string name="event_status_cancelled">Cancelado</string>
|
||||
<string name="event_availability_free">Disponible</string>
|
||||
<string name="event_access_private">Privado</string>
|
||||
<string name="event_access_confidential">Confidencial</string>
|
||||
<string name="event_attendee_organizer">Organizador</string>
|
||||
<string name="event_attendee_optional">Opcional</string>
|
||||
<string name="agenda_range_custom">Personalizado…</string>
|
||||
<string name="calendars_backup_header">Respaldo</string>
|
||||
<string name="backup_channel_name">Respaldo</string>
|
||||
<string name="event_detail_self_response">Tu respuesta: %1$s</string>
|
||||
<string name="reminder_at_time">Al la hora del evento</string>
|
||||
<string name="reminder_default">Recordatorio por defecto</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minuto antes</item>
|
||||
<item quantity="many">%d minutos antes</item>
|
||||
<item quantity="other">%d minutos antes</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="one">%d hora antes</item>
|
||||
<item quantity="many">%d horas antes</item>
|
||||
<item quantity="other">%d horas antes</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="one">%d día antes</item>
|
||||
<item quantity="many">%d días antes</item>
|
||||
<item quantity="other">%d días antes</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="one">%d semana antes</item>
|
||||
<item quantity="many">%d semanas antes</item>
|
||||
<item quantity="other">%d semanas antes</item>
|
||||
</plurals>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d minuto</item>
|
||||
<item quantity="many">%d minutos</item>
|
||||
<item quantity="other">%d minutos</item>
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d hora</item>
|
||||
<item quantity="many">%d horas</item>
|
||||
<item quantity="other">%d horas</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d dia</item>
|
||||
<item quantity="many">%d días</item>
|
||||
<item quantity="other">%d días</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d semana</item>
|
||||
<item quantity="many">%d semanas</item>
|
||||
<item quantity="other">%d semanas</item>
|
||||
</plurals>
|
||||
<string name="event_untitled">(Sin título)</string>
|
||||
<string name="reminder_channel_name">Recordatorios del evento</string>
|
||||
<string name="reminder_channel_description">Notificaciones a la hora de los recordatorios de tus eventos</string>
|
||||
<string name="reminder_onboarding_title">Nunca te pierdas un evento</string>
|
||||
<string name="reminder_onboarding_body">Android no muestra recordatorios de eventos por su cuenta — una app de calendario tiene que hacerlo. Deja que calendula cumpla ese trabajo.</string>
|
||||
<string name="reminder_benefit_delivery_title">Recordatorios, enviados</string>
|
||||
<string name="reminder_benefit_delivery_body">Cada recordatorio de tus eventos aparece como notificación, justo a tiempo.</string>
|
||||
<string name="reminder_benefit_duplicates_title">¿Utilizas otra aplicación de calendario?</string>
|
||||
<string name="reminder_benefit_duplicates_body">Si otra aplicación tambien muestra recordatorios, los veras dos veces — desactivalos en esta o la otra aplicación.</string>
|
||||
<string name="reminder_benefit_reversible_title">Cambialo en cualquier momento</string>
|
||||
<string name="reminder_benefit_reversible_body">El interruptor se encuentra en Ajustes, en la sección de Notificaciones.</string>
|
||||
<string name="reminder_onboarding_enable_button">Activar recordatorios</string>
|
||||
<string name="reminder_onboarding_skip_button">Mas tarde</string>
|
||||
<string name="reminder_action_snooze">Posponer</string>
|
||||
<string name="reminder_action_dismiss">Descartar</string>
|
||||
<string name="view_month">Mes</string>
|
||||
<string name="view_week">Semana</string>
|
||||
<string name="view_day">Día</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">Vista</string>
|
||||
<string name="drawer_jump_to_date">Ir a la fecha</string>
|
||||
<string name="agenda_header_tomorrow">Mañana</string>
|
||||
<string name="agenda_empty_title">Ya estas al día</string>
|
||||
<string name="search_action">Buscar</string>
|
||||
<string name="search_hint">Buscar eventos</string>
|
||||
<string name="search_clear">Limpiar</string>
|
||||
<string name="search_idle_hint">Buscar eventos por titulo, ubicación o notas.</string>
|
||||
<string name="search_empty">No evento coincide con “%1$s”.</string>
|
||||
<string name="widget_agenda_title">Próximamente</string>
|
||||
<string name="widget_agenda_label">Calendula agenda</string>
|
||||
<string name="widget_month_label">Calendula mes</string>
|
||||
<string name="widget_refresh">Actualizar</string>
|
||||
<string name="widget_needs_permission">Abre Calendula para conceder acceso al calendario</string>
|
||||
<string name="settings_section_appearance">Apariencia</string>
|
||||
<string name="settings_theme">Tema</string>
|
||||
<string name="settings_theme_system">Igual que el sistema</string>
|
||||
<string name="settings_theme_light">Claro</string>
|
||||
<string name="settings_theme_dark">Oscuro</string>
|
||||
<string name="settings_default_view">Vista por defecto</string>
|
||||
<string name="settings_dynamic_color">Color dinámico</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requiere android 12 o una versión posterior</string>
|
||||
<string name="settings_week_start">La semana empieza en</string>
|
||||
<string name="settings_week_start_auto">Automático</string>
|
||||
<string name="settings_time_format">Formato de la hora</string>
|
||||
<string name="settings_time_format_auto">Automático</string>
|
||||
<string name="settings_time_format_12h">12-horas (2:00 PM)</string>
|
||||
<string name="settings_time_format_24h">24-horas (14:00)</string>
|
||||
<string name="settings_hour_lines">Lineas entre horas</string>
|
||||
<string name="settings_hour_lines_summary">Muestra una linea que separa cada hora en la vista diaria y semanal</string>
|
||||
<string name="settings_dim_completed">Oscurecer eventos completados</string>
|
||||
<string name="settings_dim_completed_summary">Oscurece eventos que ya han terminado en vista semanal y mensual</string>
|
||||
<string name="settings_past_events">Eventos pasados</string>
|
||||
<string name="settings_past_events_show">Mostrar</string>
|
||||
<string name="settings_past_events_dim">Oscurecer</string>
|
||||
<string name="settings_past_events_hide">Ocultar</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Rango de agenda</string>
|
||||
<string name="settings_agenda_range_hint">Cuanto tiempo abarca la agenda al mostrar eventos.</string>
|
||||
<string name="settings_agenda_widget_range">Rango del widget de agenda</string>
|
||||
<string name="settings_agenda_widget_range_hint">Cuanto tiempo abarca el widget de agenda en la pantalla principal al mostrar eventos.</string>
|
||||
<string name="settings_agenda_range_bar">Barra de tiempo</string>
|
||||
<string name="settings_agenda_range_bar_hint">Muestra una barra en la parte superior de la agenda con las fechas mostradas, con un botón para cambiar el rango durante la sesión</string>
|
||||
<string name="agenda_range_this_month">Este mes</string>
|
||||
<string name="agenda_range_week">Próximos 7 días</string>
|
||||
<string name="agenda_range_month">Próximos 30 días</string>
|
||||
<string name="agenda_range_custom_hint">Días</string>
|
||||
<string name="agenda_range_override_hint">Solo por ahora — reinicia tu rango guardado cuando reabras calendula.</string>
|
||||
<string name="agenda_range_showing_label">Mostrando todos los próximos eventos por</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d dia</item>
|
||||
<item quantity="many">%d dias</item>
|
||||
<item quantity="other">%d dias</item>
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">Nueva formulario de evento</string>
|
||||
<string name="settings_form_fields_hint">Campos mostrados por defecto — todo lo demás se encuentra bajo \"mas campos\"</string>
|
||||
<string name="settings_autofocus_title">Enfocar titulo en nuevos eventos</string>
|
||||
<string name="settings_autofocus_title_hint">Cuando comienzas un nuevo evento, coloca el cursor en el titulo y abre el teclado inmediatamente.</string>
|
||||
<string name="settings_color_unsupported">Permitir colores en calendarios no admitidos</string>
|
||||
<string name="settings_color_unsupported_hint">Algunas calendarios (como algunos CalDAV) no publican un set de colores; un color customizado puede ser eliminado o sobreescrito por su servicio de sincronización. Eso es una limitacion de estos calendarios, no algo que calendula pueda solucionar.</string>
|
||||
<string name="settings_section_notifications">Notificaciones</string>
|
||||
<string name="settings_reminders">Recordatorios de eventos</string>
|
||||
<string name="settings_reminders_hint">Los recordatorios aparecen dos veces? Otra aplicación de calendario tambien los esta generando — desactivarlo en una de las dos apps.</string>
|
||||
<string name="settings_default_reminder">Recordatorio por defecto</string>
|
||||
<string name="settings_default_reminder_allday">Eventos que duran todo el dia</string>
|
||||
<string name="settings_allday_reminder_time">Hora del recordatorio de todo el día</string>
|
||||
<string name="settings_allday_reminder_time_hint">Los recordatorios para eventos de todo el día se generaran a las %1$s</string>
|
||||
<string name="reminder_none">Ninguno</string>
|
||||
<string name="reminder_use_default">Usar recordatorio por defecto</string>
|
||||
<string name="reminder_custom_amount">Cantidad</string>
|
||||
<string name="reminder_custom_set">Guardar</string>
|
||||
<string name="settings_calendar_reminders_title">Recordatorios por calendario</string>
|
||||
<string name="settings_calendar_reminders_hint">Sobreescribe el por recordatorio defecto por calendario — De forma separada para eventos con duración fija o que duran todo el dia. Un calendario puede mantener el por defecto o tener su propia configuracion.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Por defecto (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Notificaciones confiables</string>
|
||||
<string name="settings_reliable_delivery_hint">Android puede atrasar los recordatorios para guardar batería. Excluye calendula de la optimización de batería para que se puedan generar a tiempo.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Excluido de la optimización de batería — los recordatorios llegaran a tiempo.</string>
|
||||
<string name="settings_snooze_duration">Duración al posponer</string>
|
||||
<string name="settings_manage_calendars">Administrar calendarios</string>
|
||||
<string name="settings_manage_calendars_hint">Crear calendarios locales; administrar calendarios sincronizados</string>
|
||||
<string name="settings_section_language">Idioma</string>
|
||||
<string name="settings_language">Idioma de la aplicación</string>
|
||||
<string name="settings_language_auto">Igual que el sistema</string>
|
||||
<string name="settings_translate">Ayuda a traducir</string>
|
||||
<string name="settings_translate_hint">Añade o mejora un idioma en Weblate</string>
|
||||
<string name="settings_appearance_subtitle">Tema, vista por defecto, inicio de semana</string>
|
||||
<string name="settings_event_form_subtitle">Campos por defecto en nuevos eventos</string>
|
||||
<string name="settings_notifications_subtitle">Recordatorios de eventos</string>
|
||||
<string name="settings_license">Licencia</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">Por Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Fuente</string>
|
||||
<string name="settings_about_support">Ayuda al desarrollo</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_about_logo_desc">Icono de la aplicación calendula</string>
|
||||
<string name="settings_report_problem">Reportar un problema</string>
|
||||
<string name="settings_report_problem_hint">Enviar un reporte de error o abre el sistema de seguimiento de errores</string>
|
||||
<string name="calendars_local_header">Tus calendarios</string>
|
||||
<string name="calendars_local_empty">Sin calendarios locales todavia. Crea uno para mantener eventos solo en este dispositivo.</string>
|
||||
<string name="calendars_add">Añadir calendario</string>
|
||||
<string name="calendars_disable_hint">Desactiva un calendario para removerlo de la aplicación — sus eventos, filtros y selectores. Nada se eliminara, y puedes reactivarlo en cualquier momento.</string>
|
||||
<string name="calendars_show_in_app_a11y">Mostrar \"%1$s\" en la aplicación</string>
|
||||
<string name="calendars_synced_header">Calendarios sincronizados</string>
|
||||
<string name="calendars_synced_hint">Estos provienen de cuentas en tu dispositivo. Crea o editalos en su propia aplicación.</string>
|
||||
<string name="calendars_manage_in_app">Gestionar en aplicación</string>
|
||||
<string name="calendars_account_menu_a11y">Mas opciones para %1$s</string>
|
||||
<string name="calendars_enable_all">Habilitar todos</string>
|
||||
<string name="calendars_disable_all">Desactivar todos</string>
|
||||
<string name="calendars_add_account">Añadir cuenta</string>
|
||||
<string name="calendars_new_title">Nuevo calendario</string>
|
||||
<string name="calendars_edit_title">Editar calendario</string>
|
||||
<string name="calendars_name_label">Nombre</string>
|
||||
<string name="calendars_description_hint">Añadir descripción</string>
|
||||
<string name="calendars_delete_confirm_title">¿Eliminar calendario?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" y todos sus eventos serán eliminados permanentemente de este dispositivo.</string>
|
||||
<string name="calendars_write_error">No se pudieron guardar los cambios.</string>
|
||||
<string name="calendars_backup_hint">Los calendarios locales no se sincronizan, exportalos como un archivo .ics para mantener una copia.</string>
|
||||
<string name="calendars_backup_action">Exportar como archivo .ics</string>
|
||||
<string name="calendars_auto_backup">Respaldo automático</string>
|
||||
<string name="calendars_auto_backup_hint">Exporta tus calendarios a una carpeta como un archivo .ics de forma periodica.</string>
|
||||
<string name="calendars_auto_backup_folder">Carpeta de respaldo</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Toca para seleccionar una carpeta</string>
|
||||
<string name="calendars_auto_backup_interval">Intervalo</string>
|
||||
<string name="calendars_auto_backup_every">Cada %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Mínimo 30 minutos.</string>
|
||||
<string name="calendars_auto_backup_status_never">Sin respaldos automáticos todavía</string>
|
||||
<string name="calendars_auto_backup_status_ok">Ultimo respaldo: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Ultimo respaldo fallido: %1$s</string>
|
||||
<string name="backup_channel_description">Advierte si los respaldos automáticos fallan repetidamente.</string>
|
||||
<string name="backup_failed_title">El respaldo automático fallo</string>
|
||||
<string name="backup_failed_text">Calendula no pudo generar el archivo de respaldo, comprueba la carpeta de respaldo en ajustes.</string>
|
||||
<string name="calendars_backup_failed">No se pudo exportar el respaldo.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">Se exporto %d evento.</item>
|
||||
<item quantity="many">Se exportaron %d eventos.</item>
|
||||
<item quantity="other">Se exportaron %d eventos.</item>
|
||||
</plurals>
|
||||
<string name="import_title">Importar eventos</string>
|
||||
<string name="import_target_header">Añadir a calendario</string>
|
||||
<string name="import_empty">No se encontraron eventos en este archivo.</string>
|
||||
<string name="import_failed">No se pudo leer el archivo.</string>
|
||||
<string name="import_no_calendar">No hay ningún calendario editable en el cual importar los eventos. Crea un calendario local primero.</string>
|
||||
<string name="import_done_title">Importación completa</string>
|
||||
<string name="import_warning_recurrence">Se omitieron algunas ocurrencias editadas de eventos recurrentes.</string>
|
||||
<string name="import_warning_no_start">Se omitió un evento sin hora de inicio.</string>
|
||||
<string name="import_warning_attendees">No se importaron las listas de invitados.</string>
|
||||
<string name="import_warning_timezone">Se reemplazo una zona horaria desconocida con la de tu dispositivo.</string>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="one">%d evento en este archivo.</item>
|
||||
<item quantity="many">%d eventos en este archivo.</item>
|
||||
<item quantity="other">%d eventos en este archivo.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="one">Importar %d evento</item>
|
||||
<item quantity="many">Importar %d eventos</item>
|
||||
<item quantity="other">Importar %d eventos</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_imported">
|
||||
<item quantity="one">%d evento importado.</item>
|
||||
<item quantity="many">%d eventos importados.</item>
|
||||
<item quantity="other">%d eventos importados.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="one">Se omitió %d archivo ya presente en este calendario.</item>
|
||||
<item quantity="many">Se omitieron %d archivos ya presentes en este calendario.</item>
|
||||
<item quantity="other">Se omitieron %d archivos ya presentes en este calendario.</item>
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_long">Crear un nuevo evento</string>
|
||||
<string name="settings_qs_tile">Añadir mosaico de ajustes rápidos</string>
|
||||
<string name="settings_qs_tile_hint">Añadir un recuadro de \"nuevo evento\" al panel de ajustes rápidos.</string>
|
||||
<string name="crash_dialog_title">Calendula ha fallado</string>
|
||||
<string name="crash_dialog_message">Calendula se cerro de forma inesperada la ultima vez. Puedes ayudar a solucionarlo enviando un reporte del problema. Este permanece en tu dispositivo hasta que decidas enviarlo, y no contiene información personal o contenido de tu calendario — solo los detalles técnicos de mas abajo.</string>
|
||||
<string name="crash_dialog_report">Reportar</string>
|
||||
<string name="crash_dialog_dismiss">No por ahora</string>
|
||||
<string name="crash_report_issue_title">Reporte de error</string>
|
||||
<string name="crash_report_clip_label">Reporte de error de Calendula</string>
|
||||
<string name="crash_report_copied">Reporte copiado a tu porta papeles</string>
|
||||
<string name="crash_report_open_failed">No se pudo abrir el sistema de seguimiento de errores. El reporte es en tu portapapeles.</string>
|
||||
<string name="crash_report_body_template">Gracias por reportar un error en Calendula. Por favor añade todo lo que recuerdas sobre lo que estabas haciendo y luego publicalo.\n\n### que sucedió\n\n\n### reporte de error\n%1$s\n</string>
|
||||
<string name="crash_report_body_paste">_(el reporte es demasiado largo para este link — pegalo aquí desde su portapeles.)_</string>
|
||||
<string name="event_attendee_resource">Recurso</string>
|
||||
<string name="settings_section_about">Acerca de</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d veces</string>
|
||||
<string name="recurrence_with_until">%1$s hasta %2$s</string>
|
||||
<string name="recurrence_on_days">%1$s los %2$s</string>
|
||||
</resources>
|
||||
9
app/src/main/res/values-fr/strings.xml
Normal file
9
app/src/main/res/values-fr/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
421
app/src/main/res/values-it/strings.xml
Normal file
421
app/src/main/res/values-it/strings.xml
Normal file
@@ -0,0 +1,421 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
<string name="app_name">Calendula</string>
|
||||
<string name="app_tagline">Un calendario moderno.</string>
|
||||
<string name="state_loading">Caricamento…</string>
|
||||
<string name="state_retry">Riprova</string>
|
||||
<string name="state_failure_unknown">Qualcosa è andato storto.</string>
|
||||
<string name="state_failure_permission">È richiesto l\'accesso al calendario.</string>
|
||||
<string name="state_failure_permission_action">Concedi l\'accesso</string>
|
||||
<string name="state_failure_no_calendars">Nessun calendario configurato.</string>
|
||||
<string name="state_failure_no_calendars_action">Apri le impostazioni calendario di sistema</string>
|
||||
<string name="state_failure_provider">Errore nella lettura del calendario.</string>
|
||||
<string name="permission_rationale_title">Guarda tutti i tuoi eventi, magnificamente</string>
|
||||
<string name="permission_rationale_body">Calendula ha bisogno di accedere al tuo calendario per mostrare e gestire gli eventi. È l\'unica cosa che ti chiederà.</string>
|
||||
<string name="permission_request_button">Concedi l\'accesso al calendario</string>
|
||||
<string name="permission_denied_title">Accesso al calendario negato</string>
|
||||
<string name="permission_denied_body">Calendula non può mostrare gli eventi senza accesso al calendario. Puoi concederlo dalle impostazioni di sistema.</string>
|
||||
<string name="permission_open_settings_button">Apri le impostazioni di sistema</string>
|
||||
<string name="permission_retry_button">Riprova</string>
|
||||
<string name="permission_benefit_private_title">Rimane sul tuo dispositivo</string>
|
||||
<string name="permission_benefit_private_body">I tuoi calendari sono letti localmente e non lasciano mai il tuo telefono.</string>
|
||||
<string name="permission_benefit_sync_title">Tutti i tuoi calendari, insieme</string>
|
||||
<string name="permission_benefit_sync_body">Google, CalDAV, locali - qualsiasi calendario sincronizzato sul dispositivo semplicemente compare.</string>
|
||||
<string name="permission_benefit_privacy_title">Nessun tracking, per sempre</string>
|
||||
<string name="permission_benefit_privacy_body">Zero telemetria, zero analytics, zero pubblicità.</string>
|
||||
<string name="permission_privacy_footnote">Rimane sul tuo dispositivo - nessun accesso a internet</string>
|
||||
<string name="month_prev">Mese precedente</string>
|
||||
<string name="month_next">Mese prossimo</string>
|
||||
<string name="month_today_action">Oggi</string>
|
||||
<string name="month_more_actions">Altre azioni</string>
|
||||
<string name="month_open_menu">Apri il menù</string>
|
||||
<string name="month_action_settings">Impostazioni</string>
|
||||
<string name="month_a11y_today_prefix">Oggi</string>
|
||||
<string name="week_today_action">Questa settimana</string>
|
||||
<string name="week_number_label">Wk</string>
|
||||
<string name="day_today_action">Oggi</string>
|
||||
<string name="event_detail_back">Indietro</string>
|
||||
<string name="event_detail_edit">Modifica</string>
|
||||
<string name="event_detail_delete">Elimina</string>
|
||||
<string name="event_detail_share">Condividi</string>
|
||||
<string name="event_share_chooser_title">Condividi evento</string>
|
||||
<string name="event_share_failed">Impossibile condividere questo evento.</string>
|
||||
<string name="event_delete_title">Eliminare l\'evento?</string>
|
||||
<string name="event_delete_body">Questo evento viene rimosso dal tuo calendario e da tutti i dispositivi sincronizzati.</string>
|
||||
<string name="event_delete_recurring_title">Elimina ricorrenza</string>
|
||||
<string name="event_delete_option_occurrence">Solo questo evento</string>
|
||||
<string name="event_delete_option_following">Questo e gli eventi successivi</string>
|
||||
<string name="event_delete_option_series">Tutti gli eventi della serie</string>
|
||||
<string name="event_edit_recurring_title">Modifica ricorrenza</string>
|
||||
<string name="event_delete_failed">Impossibile eliminare l\'evento</string>
|
||||
<string name="event_delete_write_denied">Calendula ha bisogno dei permessi di scrittura per eliminare gli eventi</string>
|
||||
<string name="dialog_cancel">Annulla</string>
|
||||
<string name="dialog_ok">Ok</string>
|
||||
<string name="event_edit_new_title">Nuovo evento</string>
|
||||
<string name="event_edit_close">Chiudi</string>
|
||||
<string name="event_edit_save">Salva</string>
|
||||
<string name="event_edit_title_hint">Aggiungi titolo</string>
|
||||
<string name="event_edit_starts">Inizio</string>
|
||||
<string name="event_edit_ends">Fine</string>
|
||||
<string name="event_edit_error_end_before_start">Fine antecedente l\'inizio</string>
|
||||
<string name="event_edit_error_no_calendar">Non sono disponibili calendari</string>
|
||||
<string name="event_edit_save_failed">Impossibile salvare l\'evento</string>
|
||||
<string name="event_edit_write_denied">Calendula ha bisogno dei permessi di scrittura per creare gli eventi</string>
|
||||
<string name="event_edit_more_fields">Altri campi</string>
|
||||
<string name="event_edit_add">Aggiungi</string>
|
||||
<string name="event_edit_add_reminder">Aggiungi promemoria</string>
|
||||
<string name="event_edit_remove_reminder">Rimuovi promemoria</string>
|
||||
<string name="event_edit_attendees">Partecipanti</string>
|
||||
<string name="event_edit_add_guest">Aggiungi partecipante</string>
|
||||
<string name="event_edit_add_guest_hint">Aggiungi un partecipante via email…</string>
|
||||
<string name="event_edit_add_guest_from_contacts">Aggiungi dai contatti</string>
|
||||
<string name="event_edit_location_from_contacts">Scegli un indirizzo dai contatti</string>
|
||||
<string name="event_edit_remove_guest">Rimuovi partecipante</string>
|
||||
<string name="event_edit_attendee_required">Obbligatorio</string>
|
||||
<string name="event_edit_attendee_optional">Facoltativo</string>
|
||||
<string name="event_edit_attendees_note_synced">Calendula non manda gli inviti agli eventi. Il tuo account calendario potrà inviare le email ai partecipanti al momento della sincronizzazione.</string>
|
||||
<string name="event_edit_attendees_note_local">Salvato sul dispositivo. Nessuno ha ricevuto notifiche.</string>
|
||||
<string name="event_edit_reminder_custom">Personalizza</string>
|
||||
<string name="reminder_unit_minutes">minuti</string>
|
||||
<string name="reminder_unit_hours">ore</string>
|
||||
<string name="reminder_unit_days">giorni</string>
|
||||
<string name="reminder_unit_weeks">settimane</string>
|
||||
<string name="event_edit_availability">Disponibilità</string>
|
||||
<string name="event_edit_visibility">Visibilità</string>
|
||||
<string name="event_edit_color">Colore</string>
|
||||
<string name="event_edit_color_default">Colore calendario</string>
|
||||
<string name="event_edit_color_custom">Colore personalizzato</string>
|
||||
<string name="event_edit_color_reset">Reset</string>
|
||||
<string name="event_edit_color_unsupported">Non disponibile per questo calendario</string>
|
||||
<string name="event_edit_color_unsupported_hint">I colori non sono ammessi per questo calendario. Puoi abilitare i colori personalizzati per questo tipo di calendario dalle Impostazioni.</string>
|
||||
<string name="event_edit_color_sync_warning">Il colore per questo calendario potrà andare perso o essere sovrascritto alla prossima sincronizzazione.</string>
|
||||
<string name="event_edit_conflict_title">Evento modificato altrove</string>
|
||||
<string name="event_edit_conflict_body">Durante la modifica l\'evento è stato cambiato, tramite sincronizzazione o tramite altra app. Cosa fare con le modifiche in corso?</string>
|
||||
<string name="event_edit_conflict_overwrite">Salva le modifiche</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Sovrascrivi solo i campi modificati</string>
|
||||
<string name="event_edit_conflict_discard">Annulla le mie modifiche</string>
|
||||
<string name="event_edit_conflict_discard_hint">Mantieni l\'evento com\'è ora</string>
|
||||
<string name="event_edit_gone_title">Evento eliminato</string>
|
||||
<string name="event_edit_gone_body">Nel frattempo, questo evento è stato eliminato, ad esempio su un altro dispositivo. Le modifiche non possono più essere salvate.</string>
|
||||
<string name="event_edit_recurrence_none">Non si ripete</string>
|
||||
<string name="event_edit_recurrence_custom">Personalizza</string>
|
||||
<string name="event_edit_recurrence_every">La serie</string>
|
||||
<string name="recurrence_unit_days">giorni</string>
|
||||
<string name="recurrence_unit_weeks">settimane</string>
|
||||
<string name="recurrence_unit_months">mesi</string>
|
||||
<string name="recurrence_unit_years">anni</string>
|
||||
<string name="event_edit_recurrence_ends">Fine</string>
|
||||
<string name="event_edit_recurrence_end_never">Mai</string>
|
||||
<string name="event_edit_recurrence_end_until">Fino alla data</string>
|
||||
<string name="event_edit_recurrence_end_count">Dopo un certo numero di occorrenze</string>
|
||||
<string name="event_edit_recurrence_times">occorrenze</string>
|
||||
<string name="event_edit_error_recurrence_ends_before_start">Fine antecedente l\'inizio</string>
|
||||
<string name="event_availability_busy">Occupato</string>
|
||||
<string name="event_access_default">Default</string>
|
||||
<string name="event_access_public">Pubblico</string>
|
||||
<string name="event_detail_all_day">Tutto il giorno</string>
|
||||
<string name="event_detail_calendar">Calendario</string>
|
||||
<string name="event_detail_calendar_unknown">Calendario sconosciuto</string>
|
||||
<string name="event_detail_location">Luogo</string>
|
||||
<string name="event_detail_description">Descrizione</string>
|
||||
<string name="event_detail_attendees">Partecipanti</string>
|
||||
<string name="recurrence_daily">Ogni giorni</string>
|
||||
<string name="recurrence_weekly">Ogni settimana</string>
|
||||
<string name="recurrence_monthly">Ogni mese</string>
|
||||
<string name="recurrence_yearly">Ogni anno</string>
|
||||
<string name="recurrence_every_n_days">Ogni %1$d giorni</string>
|
||||
<string name="recurrence_every_n_weeks">Ogni %1$d settimane</string>
|
||||
<string name="recurrence_every_n_months">Ogni %1$d mesi</string>
|
||||
<string name="recurrence_every_n_years">Ogni %1$d anni</string>
|
||||
<string name="event_detail_not_found">Questo evento è stato eliminato.</string>
|
||||
<string name="event_attendee_accepted">Accettato</string>
|
||||
<string name="event_attendee_declined">Rifiutato</string>
|
||||
<string name="event_attendee_tentative">Provvisorio</string>
|
||||
<string name="event_attendee_needs_action">Nessuna risposta</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_detail_reminders">Promemoria</string>
|
||||
<string name="event_detail_timezone">Fuso orario</string>
|
||||
<string name="event_status_tentative">Provvisorio</string>
|
||||
<string name="event_status_cancelled">Annullato</string>
|
||||
<string name="event_availability_free">Disponibile</string>
|
||||
<string name="event_access_private">Privato</string>
|
||||
<string name="event_access_confidential">Confidenziale</string>
|
||||
<string name="event_attendee_organizer">Organizzatore</string>
|
||||
<string name="event_attendee_optional">Facoltativo</string>
|
||||
<string name="event_detail_self_response">La tua risposta: %1$s</string>
|
||||
<string name="reminder_at_time">All\'ora dell\'evento</string>
|
||||
<string name="reminder_default">Promemoria standard</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minuto prima</item>
|
||||
<item quantity="many">%d minuti prima</item>
|
||||
<item quantity="other">%d minuti prima</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_hours">
|
||||
<item quantity="one">%d ora prima</item>
|
||||
<item quantity="many">%d ore prima</item>
|
||||
<item quantity="other">%d ore prima</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_days">
|
||||
<item quantity="one">%d giorno prima</item>
|
||||
<item quantity="many">%d giorni prima</item>
|
||||
<item quantity="other">%d giorni prima</item>
|
||||
</plurals>
|
||||
<plurals name="reminder_weeks">
|
||||
<item quantity="one">%d settimana prima</item>
|
||||
<item quantity="many">%d settimanw prima</item>
|
||||
<item quantity="other">%d settimane prima</item>
|
||||
</plurals>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="one">%d minuto</item>
|
||||
<item quantity="many">%d minuti</item>
|
||||
<item quantity="other">%d minuti</item>
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="one">%d ora</item>
|
||||
<item quantity="many">%d ore</item>
|
||||
<item quantity="other">%d ore</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="one">%d giorno</item>
|
||||
<item quantity="many">%d giorni</item>
|
||||
<item quantity="other">%d giorni</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d settimana</item>
|
||||
<item quantity="many">%d settimane</item>
|
||||
<item quantity="other">%d settimane</item>
|
||||
</plurals>
|
||||
<string name="event_untitled">(nessun titolo)</string>
|
||||
<string name="reminder_channel_name">Promemoria evento</string>
|
||||
<string name="event_attendee_resource">Risorsa</string>
|
||||
<string name="reminder_onboarding_title">Non perderti nessun evento</string>
|
||||
<string name="reminder_onboarding_body">Android non mostra i promemoria per gli eventi autonomamente, serve un\'app. Lascia che sia Calendula a farlo.</string>
|
||||
<string name="reminder_benefit_delivery_title">Promemoria, eccoli</string>
|
||||
<string name="reminder_benefit_delivery_body">Tutti i promemoria dei tuoi eventi arrivano tramite notifica, proprio al momento giusto.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Stai usando un\'altra app calendario?</string>
|
||||
<string name="reminder_benefit_duplicates_body">Se un\'altra app manda promemoria li vedrai due volte. Disattivali qui o nell\'altra app.</string>
|
||||
<string name="reminder_benefit_reversible_title">Cambialo in ogni momento</string>
|
||||
<string name="reminder_benefit_reversible_body">Lo switch è nelle Impostazioni, sezione Notifiche.</string>
|
||||
<string name="reminder_onboarding_enable_button">Attiva i promemoria</string>
|
||||
<string name="reminder_onboarding_skip_button">Non ora</string>
|
||||
<string name="reminder_action_snooze">Posponi</string>
|
||||
<string name="reminder_action_dismiss">Ignora</string>
|
||||
<string name="view_month">Mese</string>
|
||||
<string name="view_week">Settimana</string>
|
||||
<string name="view_day">Giorno</string>
|
||||
<string name="view_agenda">Agenda</string>
|
||||
<string name="view_section">Vista</string>
|
||||
<string name="drawer_jump_to_date">Vai alla data</string>
|
||||
<string name="agenda_today_action">Oggi</string>
|
||||
<string name="agenda_header_tomorrow">Domani</string>
|
||||
<string name="agenda_empty_title">Hai fatto tutto</string>
|
||||
<string name="search_action">Cerca</string>
|
||||
<string name="search_hint">Cerca eventi</string>
|
||||
<string name="search_back">Indietro</string>
|
||||
<string name="search_clear">Pulisci</string>
|
||||
<string name="search_idle_hint">Cerca gli eventi per titolo, luogo o note.</string>
|
||||
<string name="search_empty">Nessun risultato per \"%1$s\".</string>
|
||||
<string name="widget_agenda_title">Prossimamente</string>
|
||||
<string name="widget_agenda_label">Agenda Calendula</string>
|
||||
<string name="widget_month_label">Mese Calendula</string>
|
||||
<string name="widget_refresh">Aggiorna</string>
|
||||
<string name="widget_new_event">Nuovo evento</string>
|
||||
<string name="widget_needs_permission">Apri Calendula per concedere l\'accesso al calendario</string>
|
||||
<string name="widget_prev_month">Mese precedente</string>
|
||||
<string name="filter_title">Calendari</string>
|
||||
<string name="settings_section_appearance">Aspetto</string>
|
||||
<string name="settings_theme">Tema</string>
|
||||
<string name="settings_theme_system">Sistema</string>
|
||||
<string name="settings_theme_light">Chiaro</string>
|
||||
<string name="settings_theme_dark">Scuro</string>
|
||||
<string name="settings_default_view">Vista di default</string>
|
||||
<string name="settings_dynamic_color">Colori dinamici</string>
|
||||
<string name="settings_dynamic_color_unavailable">Richiede Android 12 o superiore</string>
|
||||
<string name="settings_week_start">La settimana comincia di</string>
|
||||
<string name="settings_week_start_auto">Automatico</string>
|
||||
<string name="settings_time_format">Formato ora</string>
|
||||
<string name="settings_time_format_auto">Automatico</string>
|
||||
<string name="settings_time_format_12h">12 ore</string>
|
||||
<string name="settings_time_format_24h">24 ore</string>
|
||||
<string name="settings_hour_lines">Righe per le ore</string>
|
||||
<string name="settings_hour_lines_summary">Mostra una riga separatrice per ogni ora nella vista Settimana e Giorno</string>
|
||||
<string name="settings_dim_completed">Attenua gli eventi completati</string>
|
||||
<string name="settings_dim_completed_summary">Attenua gli eventi conclusi nella vista Mese e Settimana</string>
|
||||
<string name="settings_past_events">Eventi passati</string>
|
||||
<string name="settings_past_events_show">Mostra</string>
|
||||
<string name="settings_past_events_dim">Attenua</string>
|
||||
<string name="settings_past_events_hide">Nascondi</string>
|
||||
<string name="settings_agenda_header">Agenda</string>
|
||||
<string name="settings_agenda_range">Intervallo dell\'Agenda</string>
|
||||
<string name="settings_agenda_range_hint">Anticipo con cui vengono visualizzati gli eventi nell\'Agenda.</string>
|
||||
<string name="settings_agenda_widget_range">Intervallo del widget Agenda</string>
|
||||
<string name="settings_agenda_widget_range_hint">Anticipo con cui vengono visualizzati gli eventi nel widget Agenda.</string>
|
||||
<string name="settings_agenda_range_bar">Barra dell\'intervallo</string>
|
||||
<string name="settings_agenda_range_bar_hint">Mostra una barra sopra l\'agenda che indica le date mostrate, con un pulsante per cambiare l\'intervallo scelto per la sessione</string>
|
||||
<string name="agenda_range_day">Oggi</string>
|
||||
<string name="agenda_range_this_month">Questo mese</string>
|
||||
<string name="agenda_range_week">Prossimi 7 giorni</string>
|
||||
<string name="agenda_range_month">Prossimi 30 giorni</string>
|
||||
<string name="agenda_range_custom">Personalizza…</string>
|
||||
<string name="agenda_range_custom_hint">Giorni</string>
|
||||
<string name="agenda_range_override_hint">Solo questa volta, ripristina l\'intervallo salvato al riavvio di Calendula.</string>
|
||||
<string name="agenda_range_showing_label">Mostra tutti gli eventi in arrivo per</string>
|
||||
<plurals name="agenda_range_days">
|
||||
<item quantity="one">%d giorno</item>
|
||||
<item quantity="many">%d giorni</item>
|
||||
<item quantity="other">%d giorni</item>
|
||||
</plurals>
|
||||
<string name="settings_section_event_form">Scheda Nuovo Evento</string>
|
||||
<string name="settings_form_fields_hint">Campi mostrati di default, tutti gli altri saranno in \"Altri campi\"</string>
|
||||
<string name="settings_autofocus_title">Subito al titolo su un nuovo evento</string>
|
||||
<string name="settings_autofocus_title_hint">Quando crei un nuovo evento, posiziona subito il cursore sul campo Titolo e mostra la tastiera.</string>
|
||||
<string name="settings_color_unsupported">Abilita i colori sui calendari non supportati</string>
|
||||
<string name="settings_color_unsupported_hint">Alcuni calendari (e.g. alcuni CalDAV) non pubblicano il set di colori di cui sono dotati; i colori personalizzati assegnati a un evento possono essere persi o sovrascritti alla prossima sincronizzazione. È una limitazione di questo tipo di calendari, Calendula non può farci nulla.</string>
|
||||
<string name="settings_section_notifications">Notifiche</string>
|
||||
<string name="settings_reminders_hint">Vedi promemoria duplicati? Vuol dire che li sta mostrando anche un\'altra app. Disattivali qui o nell\'altra app.</string>
|
||||
<string name="settings_default_reminder_allday">Eventi giornalieri</string>
|
||||
<string name="settings_allday_reminder_time">Ora per i promemoria degli eventi giornalieri</string>
|
||||
<string name="settings_allday_reminder_time_hint">I promemoria per gli eventi giornalieri vengono mandati alle %1$s</string>
|
||||
<string name="reminder_none">Nessuno</string>
|
||||
<string name="reminder_use_default">Usa il promemoria standard</string>
|
||||
<string name="reminder_custom_amount">Valore</string>
|
||||
<string name="reminder_custom_set">Imposta</string>
|
||||
<string name="settings_calendar_reminders_title">Promemoria per calendario</string>
|
||||
<string name="settings_calendar_reminders_hint">Sovrascrivi i reminder standard con reminder specifici per calendario, separati per eventi giornalieri ed eventi ad ore. Ogni calendario può usare i promemoria di default, ignorarli o avere i suoi personalizzati.</string>
|
||||
<string name="settings_calendar_reminder_inherits">Default (%1$s)</string>
|
||||
<string name="settings_reliable_delivery">Notifiche affidabili</string>
|
||||
<string name="settings_reliable_delivery_hint">Android potrebbe posticipare le notifiche per risparmiare batteria. Escludi Calendula così che le notifiche possano arrivare puntuali.</string>
|
||||
<string name="settings_reliable_delivery_exempt">Escludi dall\'ottimizzazione batteria. I promemoria arriveranno puntuali.</string>
|
||||
<string name="settings_snooze_duration">Durata del posticipo</string>
|
||||
<string name="settings_section_calendars">Calendari</string>
|
||||
<string name="settings_manage_calendars">Gestisci calendari</string>
|
||||
<string name="settings_manage_calendars_hint">Crea calendari locali; gestisci quelli sincronizzati</string>
|
||||
<string name="settings_section_language">Lingua</string>
|
||||
<string name="settings_language">Lingua dell\'app</string>
|
||||
<string name="settings_language_auto">Lingua di sistema</string>
|
||||
<string name="settings_translate">Aiuta a tradurre</string>
|
||||
<string name="settings_translate_hint">Aggiungi o migliora una traduzione su Weblate</string>
|
||||
<string name="settings_appearance_subtitle">Tema, vista di default, inizio settimana</string>
|
||||
<string name="settings_event_form_subtitle">Campi di default per nuovi eventi</string>
|
||||
<string name="settings_section_about">A proposito</string>
|
||||
<string name="settings_license">Licenza</string>
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">da Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Sorgente</string>
|
||||
<string name="settings_about_support">Sostieni lo sviluppo</string>
|
||||
<string name="settings_about_version">Versione %1$s</string>
|
||||
<string name="settings_about_logo_desc">Icona dell\'app</string>
|
||||
<string name="settings_report_problem">Segnala un problema</string>
|
||||
<string name="settings_report_problem_hint">Invia un crash report o apri il gestore dei problemi</string>
|
||||
<string name="calendars_title">Calendari</string>
|
||||
<string name="calendars_local_header">I tuoi calendari</string>
|
||||
<string name="calendars_local_empty">Non ci sono ancora calendari locali. Creane uno per avere gli eventi solo su questo dispositivo.</string>
|
||||
<string name="calendars_add">Aggiungi calendario</string>
|
||||
<string name="calendars_disable_hint">.Disattiva un calendario per non vederlo più sull\'app. Non viene cancellato nulla, puoi riattivarlo in qualsiasi momento.</string>
|
||||
<string name="calendars_show_in_app_a11y">Mostra \"%1$s\" nell\'app</string>
|
||||
<string name="calendars_synced_header">Calendari sincronizzati</string>
|
||||
<string name="calendars_synced_hint">Questi vengono dagli account presenti sul tuo dispositivo. Creali e modificali nella loro app.</string>
|
||||
<string name="calendars_manage_in_app">Gestisci in app</string>
|
||||
<string name="calendars_account_menu_a11y">Altre opzioni per %1$s</string>
|
||||
<string name="calendars_enable_all">Attiva tutti</string>
|
||||
<string name="calendars_disable_all">Disattiva tutti</string>
|
||||
<string name="calendars_add_account">Aggiungi account</string>
|
||||
<string name="calendars_new_title">Nuovo calendario</string>
|
||||
<string name="calendars_edit_title">Modifica calendario</string>
|
||||
<string name="calendars_name_label">Nome</string>
|
||||
<string name="calendars_color_label">Colore</string>
|
||||
<string name="calendars_description_hint">Aggiungi descrizione</string>
|
||||
<string name="calendars_delete_confirm_title">Eliminare calendario?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" e tutti i suoi eventi saranno definitivamente rimossi da questo dispositivo.</string>
|
||||
<string name="calendars_write_error">Impossibile salvare le modifiche.</string>
|
||||
<string name="calendars_backup_header">Backup</string>
|
||||
<string name="calendars_backup_hint">I calendari locali non vengono sincronizzati da nessuna parte, esportali in un file .ics per conservarne una copia.</string>
|
||||
<string name="calendars_backup_action">Esporta come file .ics</string>
|
||||
<string name="calendars_auto_backup">Backup automatico</string>
|
||||
<string name="calendars_auto_backup_hint">Esporta periodicamente in una cartella i tuoi calendari in formato .ics.</string>
|
||||
<string name="calendars_auto_backup_folder">Cartella di backup</string>
|
||||
<string name="calendars_auto_backup_folder_unset">Scegli una cartella</string>
|
||||
<string name="calendars_auto_backup_interval">Intervallo</string>
|
||||
<string name="calendars_auto_backup_every">Ogni %1$s</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimo 30 minuti.</string>
|
||||
<string name="calendars_auto_backup_status_never">Backup automatico non attivo</string>
|
||||
<string name="calendars_auto_backup_status_ok">Ultimo backup: %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">Ultimo backup fallito: %1$s</string>
|
||||
<string name="backup_channel_name">Backup</string>
|
||||
<string name="backup_channel_description">Avvisa se i backup automatici falliscono ripetutamente.</string>
|
||||
<string name="backup_failed_title">Backup automatico fallito</string>
|
||||
<string name="backup_failed_text">Calendula non è riuscito a esportare il file di backup. Controlla la cartella di backup nelle Impostazioni.</string>
|
||||
<string name="calendars_backup_failed">Impossibile esportare il backup.</string>
|
||||
<plurals name="calendars_backup_done">
|
||||
<item quantity="one">Esportato %d evento.</item>
|
||||
<item quantity="many">Esportati %d eventi.</item>
|
||||
<item quantity="other">Esportati %d eventi.</item>
|
||||
</plurals>
|
||||
<string name="import_title">Importa eventi</string>
|
||||
<string name="import_target_header">Aggiungi al calendario</string>
|
||||
<string name="import_empty">Non ci sono eventi in questo file.</string>
|
||||
<string name="import_failed">Impossibile leggere il file.</string>
|
||||
<string name="import_no_calendar">Non ci sono calendari su cui importare. Crea prima un calendario locale.</string>
|
||||
<string name="import_done_title">Import completato</string>
|
||||
<string name="import_close">Chiudi</string>
|
||||
<string name="import_warning_recurrence">Alcune occorrenze degli eventi ricorrenti, che erano state modificate, sono state saltate.</string>
|
||||
<string name="import_warning_no_start">Un evento senza orario di inizio è stato saltato.</string>
|
||||
<string name="import_warning_attendees">Gli elenchi dei partecipanti non sono stati importati.</string>
|
||||
<string name="import_warning_timezone">Un fuso orario sconosciuto è tornato sul tuo dispositivo.</string>
|
||||
<plurals name="import_event_count">
|
||||
<item quantity="one">%d evento in questo file.</item>
|
||||
<item quantity="many">%d eventi in questo file.</item>
|
||||
<item quantity="other">%d eventi in questo file.</item>
|
||||
</plurals>
|
||||
<plurals name="import_action">
|
||||
<item quantity="one">Importa %d evento</item>
|
||||
<item quantity="many">Importa %d eventi</item>
|
||||
<item quantity="other">Importa %d eventi</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_imported">
|
||||
<item quantity="one">Importato %d evento.</item>
|
||||
<item quantity="many">Importati %d eventi.</item>
|
||||
<item quantity="other">Importati %d eventi.</item>
|
||||
</plurals>
|
||||
<plurals name="import_done_skipped">
|
||||
<item quantity="one">Saltato %d evento già presente nel calendario.</item>
|
||||
<item quantity="many">SaltatI %d eventi già presentI nel calendario.</item>
|
||||
<item quantity="other">SaltatI %d eventi già presentI nel calendario.</item>
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_long">Crea un nuovo evento</string>
|
||||
<string name="qs_tile_new_event_label">Nuovo evento</string>
|
||||
<string name="settings_qs_tile">Aggiungi un toggle nelle Impostazioni Rapide</string>
|
||||
<string name="settings_qs_tile_hint">Aggiungi un toggle \"Nuovo evento\" nel pannello delle Impostazioni Rapide.</string>
|
||||
<string name="crash_dialog_title">Calendula è crashato</string>
|
||||
<string name="crash_dialog_message">Calendula si è chiuso inaspettatamente l\'ultima volta. Puoi contribuire allo sviluppo inviando questo report, che rimane sul tuo dispositivo finchè non scegli di inviarlo e non include informazioni personali o sui tuoi calendari:solo le informazioni tecniche qui sotto.</string>
|
||||
<string name="crash_dialog_report">Report</string>
|
||||
<string name="crash_dialog_dismiss">Non ora</string>
|
||||
<string name="crash_report_issue_title">Crash report</string>
|
||||
<string name="crash_report_clip_label">Calendula crash report</string>
|
||||
<string name="crash_report_copied">Report copiato negli Appunti</string>
|
||||
<string name="crash_report_open_failed">Impossibile aprire il gestore dei problemi. Il report è sugli Appunti.</string>
|
||||
<string name="crash_report_body_template">Grazie per riportare un crash di Calendula. Per favore, aggiungi ogni informazione che ricordi in merito a quello che stavi facendo, quindi invia\n\n### Cosa è successo\n\n\n###Crash report\n%1$s\n</string>
|
||||
<string name="crash_report_body_paste">_(Il report era troppo lungo per questo link - copialo qui dai tuoi Appunti.)_</string>
|
||||
<string name="event_detail_recurrence">Ricorrenza</string>
|
||||
<string name="event_detail_recurring">Evento ricorrente</string>
|
||||
<string name="recurrence_on_days">%1$s da %2$s</string>
|
||||
<string name="recurrence_with_until">%1$s fino a %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s, %2$d volte</string>
|
||||
<string name="reminder_channel_description">Notifiche al momento del promemoria dei tuoi eventi</string>
|
||||
<string name="agenda_header_today">Oggi</string>
|
||||
<string name="widget_next_month">Mese prossimo</string>
|
||||
<string name="widget_today">Oggi</string>
|
||||
<string name="settings_title">Impostazioni</string>
|
||||
<string name="settings_back">Indietro</string>
|
||||
<string name="agenda_range_this_week">Questa settimana</string>
|
||||
<string name="settings_reminders">Promemoria evento</string>
|
||||
<string name="settings_default_reminder">Promemoria standard</string>
|
||||
<string name="settings_notifications_subtitle">Promemoria evento</string>
|
||||
<string name="shortcut_new_event_short">Nuovo evento</string>
|
||||
</resources>
|
||||
9
app/src/main/res/values-pl/strings.xml
Normal file
9
app/src/main/res/values-pl/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
9
app/src/main/res/values-pt/strings.xml
Normal file
9
app/src/main/res/values-pt/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
9
app/src/main/res/values-ru/strings.xml
Normal file
9
app/src/main/res/values-ru/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string-array name="backup_interval_units">
|
||||
<item>Minutes</item>
|
||||
<item>Hours</item>
|
||||
<item>Days</item>
|
||||
<item>Weeks</item>
|
||||
</string-array>
|
||||
</resources>
|
||||
@@ -276,6 +276,15 @@
|
||||
<string name="settings_default_view">Default view</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||
<string name="settings_font_headings">Headings font</string>
|
||||
<string name="settings_font_body">Body font</string>
|
||||
<string name="settings_font_system">System default</string>
|
||||
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
|
||||
<string name="font_lora">Lora</string>
|
||||
<string name="font_jetbrains_mono">JetBrains Mono</string>
|
||||
<string name="settings_font_choose_file">Choose file…</string>
|
||||
<string name="settings_font_custom_selected">Custom font</string>
|
||||
<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_time_format">Time format</string>
|
||||
@@ -310,6 +319,12 @@
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<string name="settings_section_views">Views</string>
|
||||
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
||||
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
|
||||
<string name="reorder_drag_handle">Drag to reorder</string>
|
||||
<string name="settings_section_event_form">New event form</string>
|
||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||
<string name="settings_autofocus_title">Focus title on new event</string>
|
||||
@@ -344,6 +359,7 @@
|
||||
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
|
||||
<!-- Hub category subtitles -->
|
||||
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
|
||||
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
|
||||
<string name="settings_event_form_subtitle">Default fields for new events</string>
|
||||
<string name="settings_notifications_subtitle">Event reminders</string>
|
||||
<string name="settings_special_dates_subtitle">Contact birthdays & anniversaries</string>
|
||||
|
||||
@@ -10,4 +10,6 @@
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en" />
|
||||
<locale android:name="de" />
|
||||
<locale android:name="es" />
|
||||
<locale android:name="it" />
|
||||
</locale-config>
|
||||
|
||||
@@ -83,6 +83,43 @@ class AllDayReminderEncodingTest {
|
||||
assertThat(toProviderAllDayMinutes(-1, summer, berlin, nineAm)).isEqualTo(-1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next yearly occurrence wraps to next year once this year's date has passed`() {
|
||||
val today = LocalDate.of(2026, 8, 1)
|
||||
assertThat(nextYearlyOccurrence(5, 14, today)).isEqualTo(LocalDate.of(2027, 5, 14))
|
||||
assertThat(nextYearlyOccurrence(12, 25, today)).isEqualTo(LocalDate.of(2026, 12, 25))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next yearly occurrence returns today when the date is today`() {
|
||||
val today = LocalDate.of(2026, 3, 10)
|
||||
assertThat(nextYearlyOccurrence(3, 10, today)).isEqualTo(today)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next yearly occurrence skips non-leap years for a Feb-29 date`() {
|
||||
assertThat(nextYearlyOccurrence(2, 29, LocalDate.of(2026, 6, 1)))
|
||||
.isEqualTo(LocalDate.of(2028, 2, 29))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sampling the offset at the next occurrence, not a 1972 anchor, fixes the fire hour`() {
|
||||
// A year-less birthday's DTSTART anchor is 1972 (Berlin had no DST then,
|
||||
// UTC+1). Sampling the offset there skews a modern CEST occurrence by an
|
||||
// hour; sampling at the upcoming occurrence fires at the intended time.
|
||||
val anchor1972 = LocalDate.of(1972, 7, 15)
|
||||
val nextOccurrence = nextYearlyOccurrence(7, 15, LocalDate.of(2026, 6, 1)) // 2026-07-15, CEST
|
||||
val rawFromAnchor = toProviderAllDayMinutes(0, anchor1972, berlin, nineAm)
|
||||
val rawFromNext = toProviderAllDayMinutes(0, nextOccurrence, berlin, nineAm)
|
||||
|
||||
val fireFromAnchor = actualFire(rawFromAnchor, nextOccurrence)
|
||||
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
|
||||
val fireFromNext = actualFire(rawFromNext, nextOccurrence)
|
||||
.let(java.time.Instant::ofEpochMilli).atZone(berlin).toLocalTime()
|
||||
assertThat(fireFromAnchor).isEqualTo(LocalTime.of(10, 0)) // skewed +1h
|
||||
assertThat(fireFromNext).isEqualTo(LocalTime.of(9, 0)) // correct
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a winter-anchored offset drifts one hour on a summer occurrence`() {
|
||||
// Known limitation: one fixed MINUTES per series can't track DST. An
|
||||
|
||||
@@ -5,8 +5,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -150,6 +154,94 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.defaultFormFields.first()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quick-switch defaults to every view enabled in default order`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
val config = prefs.quickSwitchConfig.first()
|
||||
assertThat(config.order).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||
assertThat(config.enabled).containsExactlyElementsIn(IMPLEMENTED_VIEWS)
|
||||
assertThat(config.cycle).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quick-switch config round-trips order and disabled views`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
val config = QuickSwitchConfig(
|
||||
order = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day),
|
||||
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
|
||||
)
|
||||
prefs.setQuickSwitchConfig(config)
|
||||
val loaded = prefs.quickSwitchConfig.first()
|
||||
assertThat(loaded.order).containsExactly(
|
||||
CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day,
|
||||
).inOrder()
|
||||
assertThat(loaded.enabled).containsExactly(CalendarView.Agenda, CalendarView.Month)
|
||||
assertThat(loaded.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quick-switch parse appends missing views enabled and drops unknowns`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
// Only Day (disabled) and Week stored, plus a bogus name; Month & Agenda absent.
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.QUICK_SWITCH_VIEWS_KEY] = "!Day,Week,Hologram"
|
||||
m
|
||||
}
|
||||
val config = prefs.quickSwitchConfig.first()
|
||||
// Stored order first (Day, Week), then the omitted views in default order.
|
||||
assertThat(config.order).containsExactly(
|
||||
CalendarView.Day, CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
|
||||
).inOrder()
|
||||
// Day was explicitly disabled; the appended Month & Agenda default enabled.
|
||||
assertThat(config.enabled).containsExactly(
|
||||
CalendarView.Week, CalendarView.Month, CalendarView.Agenda,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateQuickSwitch transforms the stored value, so intents compose`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
// A toggle then a reorder, each expressed against the *stored* config —
|
||||
// the reorder must not resurrect the just-disabled view (the stale-UI-
|
||||
// snapshot race this API exists to prevent).
|
||||
prefs.updateQuickSwitch { it.copy(enabled = it.enabled - CalendarView.Day) }
|
||||
val newOrder = listOf(CalendarView.Agenda, CalendarView.Month, CalendarView.Week, CalendarView.Day)
|
||||
prefs.updateQuickSwitch { it.copy(order = newOrder) }
|
||||
val config = prefs.quickSwitchConfig.first()
|
||||
assertThat(config.order).containsExactlyElementsIn(newOrder).inOrder()
|
||||
assertThat(config.enabled).containsExactly(
|
||||
CalendarView.Agenda, CalendarView.Month, CalendarView.Week,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.drawerViewOrder.first()).containsExactlyElementsIn(IMPLEMENTED_VIEWS).inOrder()
|
||||
prefs.setDrawerViewOrder(
|
||||
listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month),
|
||||
)
|
||||
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
||||
CalendarView.Agenda, CalendarView.Day, CalendarView.Week, CalendarView.Month,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawer order parse appends missing views and drops unknowns`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.DRAWER_VIEW_ORDER_KEY] = "Agenda,Nope,Week"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.drawerViewOrder.first()).containsExactly(
|
||||
CalendarView.Agenda, CalendarView.Week, CalendarView.Month, CalendarView.Day,
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
@@ -245,6 +337,26 @@ class SettingsPrefsTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage per-calendar override values drop the entry, the none sentinel stays explicit`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
// Only "none" deliberately means an explicit no-reminder override; a
|
||||
// value with no valid minutes is garbage and must drop its entry so the
|
||||
// calendar inherits the global default instead of silently losing
|
||||
// reminders. Partially-valid values keep their salvageable minutes.
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
m[SettingsPrefs.CALENDAR_REMINDER_OVERRIDE_KEY] = "5=none;7=abc;8=;9=30,abc;11=15"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.perCalendarReminderOverride.first()).containsExactly(
|
||||
5L, emptyList<Int>(), 9L, listOf(30), 11L, listOf(15),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
@@ -328,6 +440,19 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
// Existing installs have no stamp key — that must read as 0.
|
||||
assertThat(prefs.brandFontStamp.first()).isEqualTo(0)
|
||||
assertThat(prefs.plainFontStamp.first()).isEqualTo(0)
|
||||
prefs.bumpCustomFontStamp(FontRole.BRAND)
|
||||
prefs.bumpCustomFontStamp(FontRole.BRAND)
|
||||
prefs.bumpCustomFontStamp(FontRole.PLAIN)
|
||||
assertThat(prefs.brandFontStamp.first()).isEqualTo(2)
|
||||
assertThat(prefs.plainFontStamp.first()).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
|
||||
|
||||
@@ -52,4 +52,20 @@ class PostableAlertsTest {
|
||||
|
||||
assertThat(postable).isEqualTo(due)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alert with unknown calendar id 0 is never treated as disabled`() {
|
||||
// Pre-upgrade snooze PendingIntents carry no calendar id (defaults to 0L).
|
||||
val preUpgrade = alert(1, calendarId = 0L)
|
||||
|
||||
assertThat(preUpgrade.isForDisabledCalendar(disabledCalendarIds = setOf(0L))).isFalse()
|
||||
assertThat(postableAlerts(listOf(preUpgrade), disabledCalendarIds = setOf(0L)))
|
||||
.containsExactly(preUpgrade)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isForDisabledCalendar matches only the disabled ids`() {
|
||||
assertThat(alert(1, calendarId = 200).isForDisabledCalendar(setOf(200))).isTrue()
|
||||
assertThat(alert(1, calendarId = 100).isForDisabledCalendar(setOf(200))).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package de.jeanlucmakiola.calendula.data.reminders
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
class SuppressedReminderStoreTest {
|
||||
|
||||
private fun newDataStore(tempDir: Path): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
produceFile = { tempDir.resolve("test_prefs.preferences_pb").toFile() },
|
||||
)
|
||||
|
||||
private fun alert(
|
||||
alertId: Long,
|
||||
calendarId: Long,
|
||||
endMillis: Long = Long.MAX_VALUE,
|
||||
title: String = "Event $alertId",
|
||||
location: String? = null,
|
||||
) = ReminderAlert(
|
||||
alertId = alertId,
|
||||
eventId = alertId * 10,
|
||||
calendarId = calendarId,
|
||||
beginMillis = 0L,
|
||||
endMillis = endMillis,
|
||||
title = title,
|
||||
location = location,
|
||||
isAllDay = false,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `encode then decode round-trips every field including delimiters`() {
|
||||
val original = alert(
|
||||
alertId = 7,
|
||||
calendarId = 42,
|
||||
endMillis = 123_456_789L,
|
||||
// Free-text with the field separator and other awkward characters.
|
||||
title = "Lunch | with | Alice",
|
||||
location = "Café, 3rd floor | room B",
|
||||
).copy(beginMillis = 100L, isAllDay = true)
|
||||
|
||||
val decoded = decodeStashEntry(encodeStashEntry(original))
|
||||
|
||||
assertThat(decoded).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode returns null for a malformed entry`() {
|
||||
assertThat(decodeStashEntry("not-a-valid-entry")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `null location round-trips`() {
|
||||
val original = alert(1, calendarId = 1, location = null)
|
||||
assertThat(decodeStashEntry(encodeStashEntry(original))).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recoverFor returns and removes only the re-enabled calendars`() = runTest {
|
||||
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||
val keep = alert(1, calendarId = 100)
|
||||
val recoverA = alert(2, calendarId = 200)
|
||||
val recoverB = alert(3, calendarId = 200)
|
||||
store.stash(listOf(keep, recoverA, recoverB), nowMillis = 0L)
|
||||
|
||||
val recovered = store.recoverFor(setOf(200L), nowMillis = 0L)
|
||||
|
||||
assertThat(recovered).containsExactly(recoverA, recoverB)
|
||||
// The still-disabled calendar's alert stays stashed; the recovered ones are gone.
|
||||
assertThat(store.recoverFor(setOf(100L, 200L), nowMillis = 0L)).containsExactly(keep)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stash drops alerts whose event already ended`() = runTest {
|
||||
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||
val past = alert(1, calendarId = 100, endMillis = 500L)
|
||||
val future = alert(2, calendarId = 100, endMillis = 2_000L)
|
||||
|
||||
store.stash(listOf(past, future), nowMillis = 1_000L)
|
||||
|
||||
assertThat(store.recoverFor(setOf(100L), nowMillis = 1_000L)).containsExactly(future)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `purgeExpired removes only entries past their event end`() = runTest {
|
||||
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||
// Stash both before "now" so neither is dropped on write, then advance time.
|
||||
store.stash(
|
||||
listOf(
|
||||
alert(1, calendarId = 100, endMillis = 500L),
|
||||
alert(2, calendarId = 100, endMillis = 2_000L),
|
||||
),
|
||||
nowMillis = 0L,
|
||||
)
|
||||
|
||||
store.purgeExpired(nowMillis = 1_000L)
|
||||
|
||||
assertThat(store.recoverFor(setOf(100L), nowMillis = 0L).map { it.alertId })
|
||||
.containsExactly(2L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stash replaces an existing entry with the same alert id`() = runTest {
|
||||
val store = SuppressedReminderStore(newDataStore(tempDir))
|
||||
store.stash(listOf(alert(1, calendarId = 100, title = "old")), nowMillis = 0L)
|
||||
store.stash(listOf(alert(1, calendarId = 100, title = "new")), nowMillis = 0L)
|
||||
|
||||
val recovered = store.recoverFor(setOf(100L), nowMillis = 0L)
|
||||
|
||||
assertThat(recovered).hasSize(1)
|
||||
assertThat(recovered.single().title).isEqualTo("new")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isRelevantAt is true up to the event end and false after`() {
|
||||
val a = alert(1, calendarId = 1, endMillis = 1_000L)
|
||||
assertThat(a.isRelevantAt(999L)).isTrue()
|
||||
assertThat(a.isRelevantAt(1_000L)).isTrue()
|
||||
assertThat(a.isRelevantAt(1_001L)).isFalse()
|
||||
}
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
}
|
||||
@@ -23,6 +23,37 @@ class SpecialDateFormattingTest {
|
||||
.isEqualTo("contact-anniversary:abc@calendula")
|
||||
}
|
||||
|
||||
private fun custom(label: String?, month: Int, day: Int) = ContactSpecialDate(
|
||||
lookupKey = "k",
|
||||
displayName = "Jane",
|
||||
type = SpecialDateType.Custom,
|
||||
month = month,
|
||||
day = day,
|
||||
year = null,
|
||||
label = label,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `birthday and anniversary uids ignore the discriminator`() {
|
||||
assertThat(birthday(5, 14, 1990).managedUid()).isEqualTo("contact-birthday:k@calendula")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two custom dates on one contact get distinct uids by label`() {
|
||||
assertThat(custom("Wedding", 6, 10).managedUid())
|
||||
.isEqualTo("contact-custom:k:wedding@calendula")
|
||||
assertThat(custom("Graduation", 9, 1).managedUid())
|
||||
.isNotEqualTo(custom("Wedding", 6, 10).managedUid())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `label-less custom dates fall back to month-day so distinct dates survive`() {
|
||||
assertThat(custom(null, 6, 10).managedUid())
|
||||
.isEqualTo("contact-custom:k:06-10@calendula")
|
||||
assertThat(custom(null, 9, 1).managedUid())
|
||||
.isNotEqualTo(custom(null, 6, 10).managedUid())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `anchor date uses the known year`() {
|
||||
assertThat(birthday(5, 14, 1990).anchorDate()).isEqualTo(LocalDate(1990, 5, 14))
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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)))
|
||||
}
|
||||
}
|
||||
@@ -91,4 +91,33 @@ class ViewBackStackTest {
|
||||
assertThat(viewBaseStack(CalendarView.Agenda, CalendarView.Agenda))
|
||||
.containsExactly(CalendarView.Agenda)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next cycles through the configured views in order, wrapping around`() {
|
||||
val cycle = listOf(CalendarView.Month, CalendarView.Agenda)
|
||||
assertThat(CalendarView.Month.next(cycle)).isEqualTo(CalendarView.Agenda)
|
||||
// Wraps back to the first from the last.
|
||||
assertThat(CalendarView.Agenda.next(cycle)).isEqualTo(CalendarView.Month)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next from a view outside the cycle lands on the first enabled view`() {
|
||||
// Day was disabled in the quick-switch cycle but is still the current view.
|
||||
val cycle = listOf(CalendarView.Week, CalendarView.Month)
|
||||
assertThat(CalendarView.Day.next(cycle)).isEqualTo(CalendarView.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `next on an empty cycle stays put`() {
|
||||
assertThat(CalendarView.Week.next(emptyList())).isEqualTo(CalendarView.Week)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quick-switch cycle keeps enabled views in their configured order`() {
|
||||
val config = QuickSwitchConfig(
|
||||
order = listOf(CalendarView.Agenda, CalendarView.Day, CalendarView.Month, CalendarView.Week),
|
||||
enabled = setOf(CalendarView.Agenda, CalendarView.Month),
|
||||
)
|
||||
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package de.jeanlucmakiola.calendula.ui.theme
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FontsTest {
|
||||
|
||||
@Test
|
||||
fun `fromToken round-trips every bundled font`() {
|
||||
BundledFont.entries.forEach { font ->
|
||||
assertThat(BundledFont.fromToken(font.token)).isEqualTo(font)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fromToken is null for system, custom, and unknown tokens`() {
|
||||
assertThat(BundledFont.fromToken(FONT_SYSTEM_TOKEN)).isNull()
|
||||
assertThat(BundledFont.fromToken(FONT_CUSTOM_TOKEN)).isNull()
|
||||
assertThat(BundledFont.fromToken("not-a-font")).isNull()
|
||||
assertThat(BundledFont.fromToken(null)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bumped custom-font stamp breaks font-settings equality`() {
|
||||
// Re-importing keeps the same "custom" token, so only the stamp can make
|
||||
// the settings re-emit and invalidate remember() keys downstream.
|
||||
val before = AppFontSettings(brand = FONT_CUSTOM_TOKEN, brandStamp = 1)
|
||||
assertThat(before.copy(brandStamp = 2)).isNotEqualTo(before)
|
||||
assertThat(before.copy(plainStamp = 1)).isNotEqualTo(before)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `system for both roles returns the untouched default typography`() {
|
||||
assertThat(calendulaTypography(brand = null, plain = null))
|
||||
.isSameInstanceAs(CalendulaTypography)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `brand drives display and headline, plain drives title, body, and label`() {
|
||||
val brand = BundledFont.Lora.family
|
||||
val plain = BundledFont.JetBrainsMono.family
|
||||
val typography = calendulaTypography(brand = brand, plain = plain)
|
||||
|
||||
assertThat(typography.displayLarge.fontFamily).isEqualTo(brand)
|
||||
assertThat(typography.headlineSmall.fontFamily).isEqualTo(brand)
|
||||
assertThat(typography.titleLarge.fontFamily).isEqualTo(plain)
|
||||
assertThat(typography.bodyMedium.fontFamily).isEqualTo(plain)
|
||||
assertThat(typography.labelSmall.fontFamily).isEqualTo(plain)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null role keeps that role's default family while the other is applied`() {
|
||||
val plain = BundledFont.Lora.family
|
||||
val typography = calendulaTypography(brand = null, plain = plain)
|
||||
|
||||
// Brand styles keep the Material default; plain styles switch.
|
||||
assertThat(typography.displayLarge.fontFamily)
|
||||
.isEqualTo(CalendulaTypography.displayLarge.fontFamily)
|
||||
assertThat(typography.bodyLarge.fontFamily).isEqualTo(plain)
|
||||
}
|
||||
}
|
||||
70
fastlane/metadata/android/en-US/changelogs/21300.txt
Normal file
70
fastlane/metadata/android/en-US/changelogs/21300.txt
Normal file
@@ -0,0 +1,70 @@
|
||||
### Added
|
||||
- Calendula now speaks Spanish and Italian. Both arrived as community
|
||||
translations through [Calendula's Weblate](https://weblate.dev.jeanlucmakiola.de/projects/calendula/)
|
||||
— a **huge thank you** to
|
||||
[kikerw](https://weblate.dev.jeanlucmakiola.de/user/kikerw/) for the Spanish
|
||||
translation and to
|
||||
[corrent](https://weblate.dev.jeanlucmakiola.de/user/corrent/) for the Italian
|
||||
one! Pick your language under Settings → Language or in Android's per-app
|
||||
language settings. Strings added in this very release may still show in
|
||||
English until the translations catch up. Want Calendula in your language?
|
||||
Translating happens entirely in the browser — every contribution is welcome.
|
||||
- See your contacts' birthdays and anniversaries in your calendar. A new,
|
||||
**optional** feature (Settings → Contact special dates) mirrors your contacts'
|
||||
birthdays, anniversaries and other dates into local "Birthdays",
|
||||
"Anniversaries" and "Other dates" calendars that stay in sync as your contacts
|
||||
change. Each is a normal local calendar, so you set its colour, visibility and
|
||||
reminders the usual way — new birthdays even start with a reminder a week before
|
||||
*and* on the day. The title format is yours to customise (`{name}`, `{year}`).
|
||||
This is the first feature to use the contacts permission: it is requested only
|
||||
when you turn the feature on, everything stays on your device (Calendula has no
|
||||
internet access), and your contacts are only ever read, never changed. Thanks
|
||||
to @moonj for the suggestion ([#15]).
|
||||
- Set more than one default reminder per calendar. A calendar's default
|
||||
reminders — and the global defaults under Settings → Notifications — can now
|
||||
hold several lead times instead of just one, so new events can start with, say,
|
||||
a reminder a week before *and* one on the day. The reminder pickers are now
|
||||
multi-select; per-calendar overrides can still inherit the global default or
|
||||
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
|
||||
- Widget headers now open the app. Tapping the month/year title on the month
|
||||
widget opens the app on the month view, and tapping the "Upcoming" title on
|
||||
the agenda widget opens it on your default view — so there's a one-tap way
|
||||
back into the app that lands where you'd expect, instead of only through a day
|
||||
or event. On the month widget, tapping anywhere on a day — not just the small
|
||||
date number — now opens that day, and the "today" button snaps the grid back
|
||||
to the current month in place. Thanks to @rgz46vic and @ptab for the
|
||||
suggestions ([#18], [#20]).
|
||||
- Make Calendula's text your own. Settings → Appearance gains **Headings font**
|
||||
and **Body font** pickers: keep the system default, choose a bundled face
|
||||
(Atkinson Hyperlegible, Lora, JetBrains Mono — each previewed in its own
|
||||
face), or load any `.ttf`/`.otf` file from your device, independently per
|
||||
role. Font size and colour stay with Android's accessibility scaling and the
|
||||
app theme, as discussed on the issue. Thanks to @abrossimow for the
|
||||
suggestion ([#19]).
|
||||
- Choose what the view-switch button cycles through. Settings → Appearance now
|
||||
lets you pick which views the top-right quick-switch button rotates between
|
||||
and drag them into your preferred order; views you switch off stay reachable
|
||||
from the navigation menu, which can be reordered the same way. Thanks to
|
||||
@abrossimow for the suggestion ([#24]).
|
||||
|
||||
### Fixed
|
||||
- Month widget arrows and "today" button work again. On release builds the
|
||||
prev/next-month arrows and the jump-to-today control on the month widget did
|
||||
nothing when tapped — code shrinking had stripped the tap handlers behind
|
||||
them. They respond again. Thanks to @rgz46vic for the report ([#18]).
|
||||
- Disabled calendars no longer notify. Reminders for events in a calendar you
|
||||
have disabled (Settings → Calendars) are now suppressed instead of still
|
||||
popping up — matching how a disabled calendar's events already stay hidden
|
||||
everywhere else in the app ([#17]).
|
||||
- Editing a single occurrence of a recurring event works again. Choosing **Only
|
||||
this event** and saving a change to one event in a repeating series silently
|
||||
did nothing — the change was rejected and the edit form simply reappeared with
|
||||
nothing applied. The edited occurrence is now stored correctly, so the change
|
||||
lands on just that one event and leaves the rest of the series untouched
|
||||
([#16]).
|
||||
- Every calendar can be picked when creating an event. The event editor's
|
||||
calendar picker was a fixed-height dialog, so with many calendars anything
|
||||
past the first nine or so was simply unreachable. It is now a full-screen,
|
||||
scrollable list grouped by account, with each calendar's colour shown.
|
||||
Thanks to @dschuermann for the report ([#29]).
|
||||
|
||||
@@ -11,7 +11,7 @@ composeBom = "2026.05.01"
|
||||
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
|
||||
# Pin explicitly to override the BOM (which ships stable 1.4.0).
|
||||
# Re-evaluate when 1.5.0 stable lands.
|
||||
material3 = "1.5.0-alpha21"
|
||||
material3 = "1.5.0-alpha23"
|
||||
datastore = "1.2.1"
|
||||
junit = "6.1.0"
|
||||
junitPlatform = "6.1.0"
|
||||
|
||||
92
licenses/fonts/AtkinsonHyperlegible-OFL.txt
Normal file
92
licenses/fonts/AtkinsonHyperlegible-OFL.txt
Normal file
@@ -0,0 +1,92 @@
|
||||
Copyright 2020 Braille Institute of America, Inc.
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
licenses/fonts/JetBrainsMono-OFL.txt
Normal file
93
licenses/fonts/JetBrainsMono-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
93
licenses/fonts/Lora-OFL.txt
Normal file
93
licenses/fonts/Lora-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
17
licenses/fonts/README.md
Normal file
17
licenses/fonts/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Bundled fonts
|
||||
|
||||
Calendula bundles the following fonts as optional user-selectable typefaces
|
||||
(Settings → Appearance → Fonts). Each is licensed under the SIL Open Font
|
||||
License 1.1; the full license text is included alongside this file.
|
||||
|
||||
| Font | Files (`app/src/main/res/font/`) | Copyright | License |
|
||||
|------|----------------------------------|-----------|---------|
|
||||
| Atkinson Hyperlegible | `atkinson_hyperlegible_regular.ttf`, `atkinson_hyperlegible_bold.ttf` | © 2020 Braille Institute of America, Inc. | [OFL](AtkinsonHyperlegible-OFL.txt) |
|
||||
| Lora (variable) | `lora.ttf` | © 2011 Cyreal | [OFL](Lora-OFL.txt) |
|
||||
| JetBrains Mono | `jetbrains_mono_regular.ttf`, `jetbrains_mono_bold.ttf` | © 2020 The JetBrains Mono Project Authors | [OFL](JetBrainsMono-OFL.txt) |
|
||||
|
||||
The device's default typeface (Roboto on most Android builds) remains the
|
||||
default and is not bundled.
|
||||
|
||||
Fonts a user loads from their own device via "Choose file…" are copied into the
|
||||
app's private storage and are not redistributed.
|
||||
Reference in New Issue
Block a user