Compare commits
4 Commits
e6dd627c85
...
3863e74857
| Author | SHA1 | Date | |
|---|---|---|---|
| 3863e74857 | |||
| 44d056f31f | |||
| 5c513a1b19 | |||
| 037d05b171 |
@@ -103,8 +103,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
|
||||||
}
|
}
|
||||||
// The user's custom-font choice, resolved to a Material typography
|
// The user's custom-font choice, resolved to a Material typography
|
||||||
// (issue #19). Recomputed only when a token changes; "system for both"
|
// (issue #19). Recomputed only when a token — or the custom-font
|
||||||
// returns the default scale untouched.
|
// 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 fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||||
val typography = remember(fonts, context) {
|
val typography = remember(fonts, context) {
|
||||||
calendulaTypography(
|
calendulaTypography(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey
|
|||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||||
@@ -128,6 +129,26 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[PLAIN_FONT_KEY] = token }
|
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) {
|
suspend fun setWeekStart(pref: WeekStartPref) {
|
||||||
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
|
||||||
}
|
}
|
||||||
@@ -241,6 +262,19 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
|
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
|
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
|
||||||
* are appended in default order and unknown names dropped. Absent key means
|
* are appended in default order and unknown names dropped. Absent key means
|
||||||
@@ -525,6 +559,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
|
||||||
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_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 WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||||
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||||
@@ -675,7 +711,15 @@ private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
|
|||||||
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
return stored.split(ENTRY_SEP).mapNotNull { entry ->
|
||||||
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
|
||||||
val id = parts[0].toLongOrNull() ?: 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()
|
}.toMap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,16 +17,28 @@ import kotlinx.coroutines.flow.first
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
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
|
* The due alerts that should actually surface as notifications: everything
|
||||||
* except alerts whose calendar the user has disabled in-app (mirroring the
|
* except alerts whose calendar the user has disabled in-app. The caller still
|
||||||
* event filtering in CalendarRepositoryImpl). The caller still marks the full
|
* marks the full due set fired, so suppressed alerts are not re-broadcast by the
|
||||||
* due set fired, so suppressed alerts are not re-broadcast by the provider.
|
* provider.
|
||||||
*/
|
*/
|
||||||
internal fun postableAlerts(
|
internal fun postableAlerts(
|
||||||
due: List<ReminderAlert>,
|
due: List<ReminderAlert>,
|
||||||
disabledCalendarIds: Set<Long>,
|
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
|
* 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 notifier: ReminderNotifier
|
||||||
@Inject lateinit var settingsPrefs: SettingsPrefs
|
@Inject lateinit var settingsPrefs: SettingsPrefs
|
||||||
@Inject lateinit var calendarPrefs: CalendarPrefs
|
@Inject lateinit var calendarPrefs: CalendarPrefs
|
||||||
|
@Inject lateinit var suppressedStore: SuppressedReminderStore
|
||||||
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
|
||||||
@@ -60,11 +73,16 @@ class EventReminderReceiver : BroadcastReceiver() {
|
|||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val due = alertStore.dueAlerts(now)
|
val due = alertStore.dueAlerts(now)
|
||||||
val disabled = calendarPrefs.disabledCalendarIds.first()
|
val disabled = calendarPrefs.disabledCalendarIds.first()
|
||||||
|
val postable = postableAlerts(due, disabled)
|
||||||
// Suppress reminders for disabled calendars, but still mark
|
// Suppress reminders for disabled calendars, but still mark
|
||||||
// every due alert fired so the provider stops re-broadcasting
|
// every due alert fired so the provider stops re-broadcasting
|
||||||
// the suppressed ones.
|
// the suppressed ones. Stash those suppressed alerts so
|
||||||
postableAlerts(due, disabled).forEach { notifier.post(it) }
|
// 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)
|
alertStore.markFired(due.map { it.alertId }, now)
|
||||||
|
suppressedStore.stash(due - postable.toSet(), now)
|
||||||
|
suppressedStore.purgeExpired(now)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pendingResult.finish()
|
pendingResult.finish()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.MainActivity
|
import de.jeanlucmakiola.calendula.MainActivity
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -33,6 +34,7 @@ import javax.inject.Singleton
|
|||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
private val calendarPrefs: CalendarPrefs,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
/** False when the user declined `POST_NOTIFICATIONS` or muted the app. */
|
||||||
@@ -44,6 +46,11 @@ class ReminderNotifier @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun post(alert: ReminderAlert) {
|
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()
|
ensureChannel()
|
||||||
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
val title = alert.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||||
val is24Hour = settingsPrefs.timeFormat.first()
|
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)
|
||||||
@@ -14,6 +14,8 @@ import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -45,6 +47,8 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
private val prefs: CalendarPrefs,
|
private val prefs: CalendarPrefs,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
|
private val suppressedStore: SuppressedReminderStore,
|
||||||
|
private val notifier: ReminderNotifier,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -144,7 +148,10 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val current = prefs.disabledCalendarIds.first()
|
val current = prefs.disabledCalendarIds.first()
|
||||||
val next = if (disabled) current + id else current - id
|
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 {
|
viewModelScope.launch {
|
||||||
val current = prefs.disabledCalendarIds.first()
|
val current = prefs.disabledCalendarIds.first()
|
||||||
val next = if (disabled) current + ids else current - ids.toSet()
|
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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -116,12 +117,16 @@ fun <T> OptionPicker(
|
|||||||
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
* Reminder-default picker, full-screen and **multi-select**: each [presets]
|
||||||
* lead time (plus any chosen custom value) is a checkbox row that toggles
|
* 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*
|
* independently, so a default can carry several reminders ("1 week before" *and*
|
||||||
* "on the day"). A per-calendar picker ([allowInherit]) gains an exclusive "Use
|
* "on the day"). Two exclusive checkmark rows sit above the list: an explicit
|
||||||
* default reminder" row above the list; choosing it defers to the global default
|
* "No reminder" (both pickers, so the empty state stays visible and reachable)
|
||||||
* and clears the checked times. With nothing checked and inherit off, the choice
|
* and, for a per-calendar picker ([allowInherit]), "Use default reminder", which
|
||||||
* is [CalendarReminderOverride.None] (an explicit no-reminder). A "Custom" row
|
* defers to the global default. Clearing the last checked time reverts to "Use
|
||||||
* expands an inline number field plus a unit selector to add an arbitrary lead
|
* default" on a per-calendar picker (so an accidental toggle-undo can't silently
|
||||||
* time to the set. Changes apply live via [onSelect]; the user leaves via back.
|
* 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
|
@Composable
|
||||||
fun ReminderDefaultPicker(
|
fun ReminderDefaultPicker(
|
||||||
@@ -132,48 +137,54 @@ fun ReminderDefaultPicker(
|
|||||||
onSelect: (CalendarReminderOverride) -> Unit,
|
onSelect: (CalendarReminderOverride) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Optimistic local state: the chosen override is authoritative while the
|
// Optimistic local state: once the user edits, the chosen override is
|
||||||
// picker is open, so quick successive toggles compose on each other instead
|
// authoritative while the picker is open, so quick successive toggles compose
|
||||||
// of racing the round-trip through the settings flow (which would drop a
|
// on each other instead of racing the round-trip through the settings flow
|
||||||
// toggle made before the previous write echoes back). Seeded once on open;
|
// (which would drop a toggle made before the previous write echoes back).
|
||||||
// the picker leaves composition on dismiss, so reopening re-reads [selected].
|
|
||||||
var current by remember { mutableStateOf(selected) }
|
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 inherits = current is CalendarReminderOverride.Inherit
|
||||||
|
val isNone = current is CalendarReminderOverride.None
|
||||||
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
val selectedMinutes = (current as? CalendarReminderOverride.Minutes)?.minutes.orEmpty()
|
||||||
// Presets plus any selected custom (non-preset) values, each as its own row.
|
// Custom (non-preset) lead times seen this session, so unchecking one keeps
|
||||||
val rows = presets + selectedMinutes.filter { it !in presets }.sorted()
|
// 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 customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
var amountText by rememberSaveable { mutableStateOf("") }
|
var amountText by rememberSaveable { mutableStateOf("") }
|
||||||
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) }
|
||||||
|
|
||||||
fun apply(override: CalendarReminderOverride) {
|
fun apply(override: CalendarReminderOverride) {
|
||||||
|
userEdited = true
|
||||||
current = override
|
current = override
|
||||||
onSelect(override)
|
onSelect(override)
|
||||||
}
|
}
|
||||||
// Emit the override for a new set of minutes: empty collapses to None, which
|
fun emit(minutes: List<Int>) = apply(reminderOverrideForMinutes(minutes, allowInherit))
|
||||||
// (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 toggle(minute: Int) =
|
fun toggle(minute: Int) =
|
||||||
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
emit(if (minute in selectedMinutes) selectedMinutes - minute else selectedMinutes + minute)
|
||||||
|
|
||||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
// Exclusive "use default" choice (per-calendar only): its own group above
|
// Exclusive choices in their own group above the multi-select list, since
|
||||||
// the multi-select list, since inheriting is mutually exclusive with
|
// each is mutually exclusive with picking specific times: "use default"
|
||||||
// picking specific times.
|
// (per-calendar only) and an explicit "no reminder" (both pickers, so the
|
||||||
|
// empty state stays visible and deliberately reachable).
|
||||||
if (allowInherit) {
|
if (allowInherit) {
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.reminder_use_default),
|
title = stringResource(R.string.reminder_use_default),
|
||||||
position = Position.Alone,
|
position = Position.Top,
|
||||||
selected = inherits,
|
selected = inherits,
|
||||||
trailing = if (inherits) {
|
trailing = if (inherits) {
|
||||||
{ SelectedCheck() }
|
{ SelectedCheck() }
|
||||||
@@ -182,8 +193,19 @@ fun ReminderDefaultPicker(
|
|||||||
},
|
},
|
||||||
onClick = { apply(CalendarReminderOverride.Inherit) },
|
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
|
val rowCount = rows.size + 1 // + the custom row
|
||||||
rows.forEachIndexed { index, minute ->
|
rows.forEachIndexed { index, minute ->
|
||||||
val checked = minute in selectedMinutes
|
val checked = minute in selectedMinutes
|
||||||
@@ -222,6 +244,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
|
* 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
|
* row above it (matching the grouped-row system, so the two read as one
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -43,8 +44,10 @@ private val RowGap: Dp = 2.dp
|
|||||||
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
|
* 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
|
* 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
|
* been dragged. The held row follows the finger while the others slide out of
|
||||||
* its way (animated); on release the held row settles into its slot, then the
|
* its way (animated); on release the order is committed immediately with a
|
||||||
* order is committed with a single [onReorder] call.
|
* 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
|
* [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)
|
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
|
||||||
@@ -66,16 +69,29 @@ fun <T> ReorderableColumn(
|
|||||||
var order by remember(items) { mutableStateOf(items) }
|
var order by remember(items) { mutableStateOf(items) }
|
||||||
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
var draggedKey by remember { mutableStateOf<Any?>(null) }
|
||||||
// Live translation of the held row from its slot (px); also drives the
|
// Live translation of the held row from its slot (px); also drives the
|
||||||
// release settle, animated back onto a slot before the order is committed.
|
// 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) }
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
// The running release/cancel settle, cancelled if a new drag pre-empts it.
|
// 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) }
|
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
|
||||||
// Whole slots dragged → the slot the held row currently hovers over.
|
// Whole slots dragged → the slot the held row currently hovers over. Derived
|
||||||
val targetIndex = draggedIndex?.let {
|
// so recomposition only fires when the *target slot* actually changes, not on
|
||||||
(it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
// 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)) {
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
|
||||||
order.forEachIndexed { index, item ->
|
order.forEachIndexed { index, item ->
|
||||||
@@ -112,16 +128,25 @@ fun <T> ReorderableColumn(
|
|||||||
val from = order.indexOfFirst { keyOf(it) == key }
|
val from = order.indexOfFirst { keyOf(it) == key }
|
||||||
if (from < 0) return@detectDragGestures
|
if (from < 0) return@detectDragGestures
|
||||||
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
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 {
|
settleJob = scope.launch {
|
||||||
// Settle the held row onto its target slot, then commit —
|
// Purely visual from here: ease the held row onto its slot, then
|
||||||
// resetting offset and slot in the same frame, so nothing jumps.
|
// release the drag state. Safe to cancel — the order was already
|
||||||
Animatable(dragOffset).animateTo((to - from) * pitchPx, tween(160)) {
|
// committed above.
|
||||||
dragOffset = value
|
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
|
||||||
}
|
|
||||||
if (to != from) {
|
|
||||||
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
|
||||||
onReorder(order)
|
|
||||||
}
|
|
||||||
draggedKey = null
|
draggedKey = null
|
||||||
dragOffset = 0f
|
dragOffset = 0f
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -671,6 +671,7 @@ private fun AppearanceScreen(
|
|||||||
title = stringResource(R.string.settings_font_headings),
|
title = stringResource(R.string.settings_font_headings),
|
||||||
role = FontRole.BRAND,
|
role = FontRole.BRAND,
|
||||||
selected = fonts.brand,
|
selected = fonts.brand,
|
||||||
|
stamp = fonts.brandStamp,
|
||||||
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
|
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
|
||||||
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
|
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
|
||||||
onDismiss = { showBrandFont = false },
|
onDismiss = { showBrandFont = false },
|
||||||
@@ -681,6 +682,7 @@ private fun AppearanceScreen(
|
|||||||
title = stringResource(R.string.settings_font_body),
|
title = stringResource(R.string.settings_font_body),
|
||||||
role = FontRole.PLAIN,
|
role = FontRole.PLAIN,
|
||||||
selected = fonts.plain,
|
selected = fonts.plain,
|
||||||
|
stamp = fonts.plainStamp,
|
||||||
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
|
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
|
||||||
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
|
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
|
||||||
onDismiss = { showPlainFont = false },
|
onDismiss = { showPlainFont = false },
|
||||||
@@ -773,7 +775,7 @@ private fun ViewsScreen(
|
|||||||
ReorderableColumn(
|
ReorderableColumn(
|
||||||
items = config.order,
|
items = config.order,
|
||||||
keyOf = { it },
|
keyOf = { it },
|
||||||
onReorder = { viewModel.setQuickSwitchConfig(config.copy(order = it)) },
|
onReorder = { viewModel.setQuickSwitchOrder(it) },
|
||||||
) { view, position, dragHandle, isDragging ->
|
) { view, position, dragHandle, isDragging ->
|
||||||
val checked = view in config.enabled
|
val checked = view in config.enabled
|
||||||
ViewRow(
|
ViewRow(
|
||||||
@@ -787,10 +789,7 @@ private fun ViewsScreen(
|
|||||||
checked = checked,
|
checked = checked,
|
||||||
// Keep the last two on: with fewer, the pill can't switch.
|
// Keep the last two on: with fewer, the pill can't switch.
|
||||||
enabled = !checked || canDisable,
|
enabled = !checked || canDisable,
|
||||||
onCheckedChange = { on ->
|
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||||
val enabled = if (on) config.enabled + view else config.enabled - view
|
|
||||||
viewModel.setQuickSwitchConfig(config.copy(enabled = enabled))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1431,6 +1430,7 @@ private fun FontPicker(
|
|||||||
title: String,
|
title: String,
|
||||||
role: FontRole,
|
role: FontRole,
|
||||||
selected: String,
|
selected: String,
|
||||||
|
stamp: Int,
|
||||||
onSelect: (String) -> Unit,
|
onSelect: (String) -> Unit,
|
||||||
onImport: (Uri) -> Unit,
|
onImport: (Uri) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@@ -1448,6 +1448,12 @@ private fun FontPicker(
|
|||||||
// System default + the bundled fonts + the "Choose file…" row.
|
// System default + the bundled fonts + the "Choose file…" row.
|
||||||
val rowCount = BundledFont.entries.size + 2
|
val rowCount = BundledFont.entries.size + 2
|
||||||
val isCustom = selected == FONT_CUSTOM_TOKEN
|
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) {
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
FontOptionRow(
|
FontOptionRow(
|
||||||
@@ -1479,7 +1485,7 @@ private fun FontPicker(
|
|||||||
stringResource(R.string.settings_font_choose_file)
|
stringResource(R.string.settings_font_choose_file)
|
||||||
},
|
},
|
||||||
// A loaded font previews in its own face; otherwise show an upload cue.
|
// A loaded font previews in its own face; otherwise show an upload cue.
|
||||||
preview = if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null,
|
preview = customPreview,
|
||||||
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
|
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
|
||||||
selected = isCustom,
|
selected = isCustom,
|
||||||
position = positionOf(rowCount - 1, rowCount),
|
position = positionOf(rowCount - 1, rowCount),
|
||||||
|
|||||||
@@ -159,8 +159,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val fontState: StateFlow<AppFontSettings> = combine(
|
val fontState: StateFlow<AppFontSettings> = combine(
|
||||||
prefs.brandFont,
|
prefs.brandFont,
|
||||||
prefs.plainFont,
|
prefs.plainFont,
|
||||||
) { brand, plain ->
|
prefs.brandFontStamp,
|
||||||
AppFontSettings(brand = brand, plain = plain)
|
prefs.plainFontStamp,
|
||||||
|
) { brand, plain, brandStamp, plainStamp ->
|
||||||
|
AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000L),
|
started = SharingStarted.WhileSubscribed(5_000L),
|
||||||
@@ -239,6 +241,10 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
|
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
|
||||||
if (ok) {
|
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)
|
setFont(role, FONT_CUSTOM_TOKEN)
|
||||||
} else {
|
} else {
|
||||||
_fontImportFailed.emit(Unit)
|
_fontImportFailed.emit(Unit)
|
||||||
@@ -319,8 +325,26 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQuickSwitchConfig(config: QuickSwitchConfig) {
|
/**
|
||||||
viewModelScope.launch { prefs.setQuickSwitchConfig(config) }
|
* 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>) {
|
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ private fun weightAxis(weight: Int) = FontVariation.Settings(FontVariation.weigh
|
|||||||
data class AppFontSettings(
|
data class AppFontSettings(
|
||||||
val brand: String = FONT_SYSTEM_TOKEN,
|
val brand: String = FONT_SYSTEM_TOKEN,
|
||||||
val plain: 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
@@ -198,6 +199,22 @@ class SettingsPrefsTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Test
|
||||||
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
|
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
@@ -319,6 +336,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
|
@Test
|
||||||
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
@@ -402,6 +439,19 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
|
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
|
@Test
|
||||||
fun `explicit week-start prefs resolve regardless of locale`() {
|
fun `explicit week-start prefs resolve regardless of locale`() {
|
||||||
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
|
assertThat(WeekStartPref.Day(DayOfWeek.MONDAY).resolveFirstDay(Locale.US))
|
||||||
|
|||||||
@@ -52,4 +52,20 @@ class PostableAlertsTest {
|
|||||||
|
|
||||||
assertThat(postable).isEqualTo(due)
|
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
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,15 @@ class FontsTest {
|
|||||||
assertThat(BundledFont.fromToken(null)).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
|
@Test
|
||||||
fun `system for both roles returns the untouched default typography`() {
|
fun `system for both roles returns the untouched default typography`() {
|
||||||
assertThat(calendulaTypography(brand = null, plain = null))
|
assertThat(calendulaTypography(brand = null, plain = null))
|
||||||
|
|||||||
Reference in New Issue
Block a user