Compare commits
12 Commits
fix/caldav
...
c8a4f90404
| Author | SHA1 | Date | |
|---|---|---|---|
| c8a4f90404 | |||
| 0a89ff9d0e | |||
| 0718afd5a6 | |||
| 3863e74857 | |||
| 44d056f31f | |||
| 5c513a1b19 | |||
| 037d05b171 | |||
| e6dd627c85 | |||
| 262b2b8273 | |||
| efca0c86c4 | |||
| 08cd95cfba | |||
| bfc9a0db39 |
@@ -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(
|
||||
|
||||
@@ -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,12 +8,14 @@ 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.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
|
||||
@@ -106,6 +108,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() }
|
||||
}
|
||||
@@ -219,6 +262,19 @@ class SettingsPrefs @Inject constructor(
|
||||
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
|
||||
@@ -501,6 +557,10 @@ class SettingsPrefs @Inject constructor(
|
||||
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")
|
||||
@@ -651,7 +711,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 }
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -43,8 +44,10 @@ private val RowGap: Dp = 2.dp
|
||||
* 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 held row settles into its slot, then the
|
||||
* order is committed with a single [onReorder] call.
|
||||
* 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)
|
||||
@@ -66,16 +69,29 @@ fun <T> ReorderableColumn(
|
||||
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, 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) }
|
||||
// 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.
|
||||
val targetIndex = draggedIndex?.let {
|
||||
(it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
|
||||
}
|
||||
// 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 ->
|
||||
@@ -112,16 +128,25 @@ fun <T> ReorderableColumn(
|
||||
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 {
|
||||
// Settle the held row onto its target slot, then commit —
|
||||
// resetting offset and slot in the same frame, so nothing jumps.
|
||||
Animatable(dragOffset).animateTo((to - from) * pitchPx, tween(160)) {
|
||||
dragOffset = value
|
||||
}
|
||||
if (to != from) {
|
||||
order = order.toMutableList().apply { add(to, removeAt(from)) }
|
||||
onReorder(order)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -120,12 +121,15 @@ 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.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
|
||||
@@ -943,7 +947,7 @@ private fun EventEditContent(
|
||||
}
|
||||
|
||||
if (showCalendarPicker) {
|
||||
CalendarPickerDialog(
|
||||
CalendarPicker(
|
||||
calendars = state.calendars,
|
||||
selectedId = form.calendarId,
|
||||
onSelect = {
|
||||
@@ -1931,34 +1935,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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -53,6 +55,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 +65,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 +80,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
|
||||
@@ -97,9 +102,11 @@ 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
|
||||
@@ -122,6 +129,10 @@ 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
|
||||
@@ -498,6 +509,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),
|
||||
@@ -534,6 +557,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),
|
||||
@@ -625,6 +666,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),
|
||||
@@ -712,7 +775,7 @@ private fun ViewsScreen(
|
||||
ReorderableColumn(
|
||||
items = config.order,
|
||||
keyOf = { it },
|
||||
onReorder = { viewModel.setQuickSwitchConfig(config.copy(order = it)) },
|
||||
onReorder = { viewModel.setQuickSwitchOrder(it) },
|
||||
) { view, position, dragHandle, isDragging ->
|
||||
val checked = view in config.enabled
|
||||
ViewRow(
|
||||
@@ -726,10 +789,7 @@ private fun ViewsScreen(
|
||||
checked = checked,
|
||||
// Keep the last two on: with fewer, the pill can't switch.
|
||||
enabled = !checked || canDisable,
|
||||
onCheckedChange = { on ->
|
||||
val enabled = if (on) config.enabled + view else config.enabled - view
|
||||
viewModel.setQuickSwitchConfig(config.copy(enabled = enabled))
|
||||
},
|
||||
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -1332,6 +1392,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) }
|
||||
|
||||
@@ -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
|
||||
@@ -10,6 +11,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -18,15 +21,21 @@ 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.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
|
||||
@@ -36,12 +45,14 @@ 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
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -140,6 +151,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,
|
||||
@@ -189,6 +223,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)
|
||||
@@ -262,8 +325,26 @@ class SettingsViewModel @Inject constructor(
|
||||
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>) {
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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.
@@ -274,6 +274,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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
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
|
||||
fun `drawer order defaults to the implemented order and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
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
|
||||
fun `all-day default round-trips, including none`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
@@ -402,6 +439,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
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
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