Merge remote-tracking branch 'origin/release/v2.13.0' into feat/contact-special-dates
All checks were successful
Translations / check (pull_request) Successful in 26s
CI / ci (pull_request) Successful in 5m19s

# Conflicts:
#	app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt
#	app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt
This commit is contained in:
2026-07-03 15:41:38 +02:00
53 changed files with 2337 additions and 142 deletions

View File

@@ -34,8 +34,11 @@ import de.jeanlucmakiola.calendula.ui.common.DebugRibbon
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.settings.SettingsViewModel
import de.jeanlucmakiola.calendula.ui.theme.CalendulaTheme
import de.jeanlucmakiola.calendula.ui.theme.calendulaTypography
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
@@ -99,9 +102,22 @@ class MainActivity : AppCompatActivity() {
val use24Hour = remember(settings.timeFormat, context) {
settings.timeFormat.is24Hour(android.text.format.DateFormat.is24HourFormat(context))
}
// The user's custom-font choice, resolved to a Material typography
// (issue #19). Recomputed only when a token — or the custom-font
// re-import stamp AppFontSettings carries, so replacing the file
// behind an active "custom" token still refreshes — changes;
// "system for both" returns the default scale untouched.
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
val typography = remember(fonts, context) {
calendulaTypography(
brand = resolveFontFamily(fonts.brand, FontRole.BRAND, context),
plain = resolveFontFamily(fonts.plain, FontRole.PLAIN, context),
)
}
CalendulaTheme(
darkTheme = darkTheme,
dynamicColor = settings.dynamicColor,
typography = typography,
) {
Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider(
@@ -193,6 +209,12 @@ class MainActivity : AppCompatActivity() {
// An external date tap (launcher/clock) has no widget source, so it opens
// the day view rooted over the default home view (OpenDate source = null).
calendarTimeDateOrNull()?.let { return WidgetNavRequest.OpenDate(it.toString(), source = null) }
// A widget header tap: open a top-level view with no date drill-in. The
// empty string carried by [openViewIntent] means "the default home view".
if (hasExtra(EXTRA_OPEN_VIEW)) {
val name = getStringExtra(EXTRA_OPEN_VIEW).orEmpty()
return WidgetNavRequest.OpenView(CalendarView.entries.firstOrNull { it.name == name })
}
val source = sourceViewOrNull()
val eventId = getLongExtra(EXTRA_EVENT_ID, -1L)
return when {
@@ -244,6 +266,10 @@ class MainActivity : AppCompatActivity() {
private const val EXTRA_DATE_ISO = "de.jeanlucmakiola.calendula.extra.DATE_ISO"
private const val EXTRA_CREATE = "de.jeanlucmakiola.calendula.extra.CREATE"
// A widget header tap asking to open a top-level view (no date drill-in).
// Its value is the target [CalendarView] name, or "" for the default view.
private const val EXTRA_OPEN_VIEW = "de.jeanlucmakiola.calendula.extra.OPEN_VIEW"
// The [CalendarView] (by name) of the widget a launch came from. Roots the
// in-app back stack in that view; absent for non-widget launches (reminders).
private const val EXTRA_SOURCE_VIEW = "de.jeanlucmakiola.calendula.extra.SOURCE_VIEW"
@@ -307,5 +333,19 @@ class MainActivity : AppCompatActivity() {
putExtra(EXTRA_DATE_ISO, date.toString())
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
/**
* Open the app on a top-level [view] with no date drill-in — a widget
* header tap. A null [view] opens the user's default home view (the agenda
* widget's "Upcoming" title); a concrete view roots there over the default
* home (the month widget's month/year title → [CalendarView.Month]). The
* per-view data URI keeps distinct headers' PendingIntents from collapsing.
*/
fun openViewIntent(context: Context, view: CalendarView?): Intent =
Intent(context, MainActivity::class.java).apply {
data = "calendula://view/${view?.name ?: "default"}".toUri()
putExtra(EXTRA_OPEN_VIEW, view?.name ?: "")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
}

View File

@@ -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()
}
}

View File

@@ -8,11 +8,15 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek
@@ -105,6 +109,47 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DYNAMIC_COLOR_KEY] = enabled }
}
/**
* Custom-font tokens per Material typeface role (issue #19). Stored as opaque
* strings — "system", "custom", or a bundled font's token — resolved to a
* FontFamily at the theme layer; an unknown token degrades to the default.
*/
val brandFont: Flow<String> = store.data.map { prefs ->
prefs[BRAND_FONT_KEY] ?: FONT_SYSTEM_TOKEN
}
val plainFont: Flow<String> = store.data.map { prefs ->
prefs[PLAIN_FONT_KEY] ?: FONT_SYSTEM_TOKEN
}
suspend fun setBrandFont(token: String) {
store.edit { it[BRAND_FONT_KEY] = token }
}
suspend fun setPlainFont(token: String) {
store.edit { it[PLAIN_FONT_KEY] = token }
}
/**
* A per-role bump counter for the user-loaded custom font. Re-importing
* overwrites the same file under the same "custom" token, so the token alone
* can't signal the change; this stamp — carried into AppFontSettings — breaks
* value equality so the resolved FontFamily (and its previews) refresh. A
* missing key is 0 (existing installs); the first import bumps it to 1.
*/
val brandFontStamp: Flow<Int> = store.data.map { prefs -> prefs[BRAND_FONT_STAMP_KEY] ?: 0 }
val plainFontStamp: Flow<Int> = store.data.map { prefs -> prefs[PLAIN_FONT_STAMP_KEY] ?: 0 }
/** Bump [role]'s custom-font stamp after a re-import so equality-keyed caches refresh. */
suspend fun bumpCustomFontStamp(role: FontRole) {
val key = when (role) {
FontRole.BRAND -> BRAND_FONT_STAMP_KEY
FontRole.PLAIN -> PLAIN_FONT_STAMP_KEY
}
store.edit { it[key] = (it[key] ?: 0) + 1 }
}
suspend fun setWeekStart(pref: WeekStartPref) {
store.edit { it[WEEK_START_KEY] = pref.storageValue() }
}
@@ -203,6 +248,47 @@ class SettingsPrefs @Inject constructor(
store.edit { it[DEFAULT_VIEW_KEY] = view.name }
}
/**
* Quick-switch button customisation (#24): the ordered full view list plus
* which views are enabled in the cycle. Stored comma-joined by enum name, a
* "!" prefix marking a disabled view (e.g. "Month,Week,!Day,Agenda"). Missing
* views are appended enabled and unknown names dropped, so a future view
* defaults into the cycle. An absent key means [QuickSwitchConfig.Default].
*/
val quickSwitchConfig: Flow<QuickSwitchConfig> = store.data.map { prefs ->
parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
}
suspend fun setQuickSwitchConfig(config: QuickSwitchConfig) {
store.edit { it[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(config) }
}
/**
* Atomic read-modify-write of the quick-switch config: [transform] sees the
* value currently stored, parsed inside the edit block, not the async-echoed
* UI snapshot. Two rapid intents (a toggle then a drag) therefore compose
* instead of each re-serialising a stale copy of the other field over it.
*/
suspend fun updateQuickSwitch(transform: (QuickSwitchConfig) -> QuickSwitchConfig) {
store.edit { prefs ->
val current = parseQuickSwitch(prefs[QUICK_SWITCH_VIEWS_KEY])
prefs[QUICK_SWITCH_VIEWS_KEY] = serializeQuickSwitch(transform(current))
}
}
/**
* Navigation-drawer view order (#24). Comma-joined enum names; missing views
* are appended in default order and unknown names dropped. Absent key means
* [IMPLEMENTED_VIEWS] (the historical fixed order).
*/
val drawerViewOrder: Flow<List<CalendarView>> = store.data.map { prefs ->
parseViewOrder(prefs[DRAWER_VIEW_ORDER_KEY])
}
suspend fun setDrawerViewOrder(order: List<CalendarView>) {
store.edit { it[DRAWER_VIEW_ORDER_KEY] = order.joinToString(",") { view -> view.name } }
}
/**
* Optional event-form fields shown by default (the rest hide behind
* "more fields"). Stored comma-joined by enum name: an absent key means
@@ -524,9 +610,47 @@ class SettingsPrefs @Inject constructor(
.toSet()
}
/** Parse a plain comma-joined view order, completed to every implemented view. */
private fun parseViewOrder(stored: String?): List<CalendarView> =
completeViewOrder(
stored?.split(',').orEmpty()
.mapNotNull { name -> CalendarView.entries.firstOrNull { it.name == name.trim() } },
)
/** Parse the quick-switch config; "!"-prefixed names are disabled. */
private fun parseQuickSwitch(stored: String?): QuickSwitchConfig {
if (stored == null) return QuickSwitchConfig.Default
val parsed = stored.split(',').mapNotNull { raw ->
val token = raw.trim()
val disabled = token.startsWith("!")
val name = if (disabled) token.drop(1) else token
CalendarView.entries.firstOrNull { it.name == name }?.let { it to disabled }
}
val order = completeViewOrder(parsed.map { it.first })
// Only views explicitly stored disabled are excluded; anything appended
// (a view added in a later release) defaults into the cycle.
val disabled = parsed.filter { it.second }.map { it.first }.toSet()
return QuickSwitchConfig(order, order.filterNot { it in disabled }.toSet())
}
private fun serializeQuickSwitch(config: QuickSwitchConfig): String =
completeViewOrder(config.order).joinToString(",") { view ->
if (view in config.enabled) view.name else "!${view.name}"
}
/** Keep the given order (de-duplicated), then append any views it omits. */
private fun completeViewOrder(seen: List<CalendarView>): List<CalendarView> {
val ordered = seen.distinct()
return ordered + IMPLEMENTED_VIEWS.filterNot { it in ordered }
}
companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val BRAND_FONT_KEY = stringPreferencesKey("brand_font")
internal val PLAIN_FONT_KEY = stringPreferencesKey("plain_font")
internal val BRAND_FONT_STAMP_KEY = intPreferencesKey("brand_font_stamp")
internal val PLAIN_FONT_STAMP_KEY = intPreferencesKey("plain_font_stamp")
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
@@ -536,6 +660,8 @@ class SettingsPrefs @Inject constructor(
internal val PAST_EVENT_DISPLAY_KEY = stringPreferencesKey("agenda_past_event_display")
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
@@ -708,7 +834,15 @@ private fun parseReminderOverrides(stored: String?): Map<Long, List<Int>> {
return stored.split(ENTRY_SEP).mapNotNull { entry ->
val parts = entry.split(KEY_VALUE_SEP).takeIf { it.size == 2 } ?: return@mapNotNull null
val id = parts[0].toLongOrNull() ?: return@mapNotNull null
id to parts[1].toReminderList()
// Only the deliberate "none" sentinel means an explicit no-reminder
// override (empty list); a non-sentinel value that parses to no valid
// minutes is garbage and drops the entry, so the calendar inherits the
// global default rather than silently reading as "no reminder".
when (val value = parts[1]) {
NONE -> id to emptyList()
else -> value.toReminderList().takeIf { it.isNotEmpty() }?.let { id to it }
?: return@mapNotNull null
}
}.toMap()
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)

View File

@@ -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 }

View File

@@ -79,6 +79,11 @@ fun CalendarHost(
// correcting it. Brief blank first frame, matching the onboarding gate above.
val defaultView = viewModel.defaultView.collectAsStateWithLifecycle().value ?: return
// View customisation (#24): the quick-switch cycle and drawer order. Both have
// sensible non-empty initial values, so they're ready before the first frame.
val quickSwitchViews = viewModel.quickSwitchViews.collectAsStateWithLifecycle().value
val drawerViewOrder = viewModel.drawerViewOrder.collectAsStateWithLifecycle().value
var viewStack by rememberSaveable(stateSaver = viewStackSaver) {
mutableStateOf(listOf(defaultView))
}
@@ -212,6 +217,17 @@ fun CalendarHost(
detailKey = key
onWidgetNavConsumed()
}
is WidgetNavRequest.OpenView -> {
// A widget header tap: land on a top-level view with no date
// drill-in. Reveal it by dropping any covering overlay, then root
// the stack on the target (null → the default home) over the
// default home — so backing out returns to the default, then exits.
dismissCoveringOverlays()
createDateIso = null
pendingDayIso = null
viewStack = viewBaseStack(defaultView, req.view ?: defaultView)
onWidgetNavConsumed()
}
is WidgetNavRequest.Create -> {
// External "new event" entries (QS tile / launcher shortcut /
// widget) must land on top of whatever is open — the form overlay
@@ -260,6 +276,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
)
CalendarView.Day -> DayScreen(
selectedView = currentView,
@@ -269,6 +287,8 @@ fun CalendarHost(
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
initialDateIso = pendingDayIso,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
)
CalendarView.Month -> MonthScreen(
selectedView = currentView,
@@ -277,6 +297,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
)
CalendarView.Agenda -> AgendaScreen(
selectedView = currentView,
@@ -285,6 +307,8 @@ fun CalendarHost(
onOpenSettings = onOpenSettings,
onOpenSearch = onOpenSearch,
onCreateEvent = onCreateEvent,
quickSwitchViews = quickSwitchViews,
drawerViewOrder = drawerViewOrder,
)
}
}

View File

@@ -5,8 +5,10 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@@ -26,4 +28,21 @@ class CalendarHostViewModel @Inject constructor(
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = null,
)
/** Views the top-bar quick-switch pill cycles through, in the user's order (#24). */
val quickSwitchViews: StateFlow<List<CalendarView>> = prefs.quickSwitchConfig
.map { it.cycle }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
/** Order of the views in the navigation drawer (#24); every view always shown. */
val drawerViewOrder: StateFlow<List<CalendarView>> = prefs.drawerViewOrder
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = IMPLEMENTED_VIEWS,
)
}

View File

@@ -31,4 +31,13 @@ sealed interface WidgetNavRequest {
/** Open the create-event form prefilled for [dateIso] (today when null). */
data class Create(val dateIso: String?) : WidgetNavRequest
/**
* Open the app rooted on a top-level [view] with no date drill-in — a widget
* header tap. A null [view] means "the user's default home view" (the agenda
* widget's "Upcoming" title, issue #20); a concrete view roots there over the
* default home (the month widget's month/year title → [CalendarView.Month],
* issue #18), so backing out returns to the default view, then exits.
*/
data class OpenView(val view: CalendarView?) : WidgetNavRequest
}

View File

@@ -64,6 +64,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.Position
@@ -96,6 +97,8 @@ fun AgendaScreen(
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier,
viewModel: AgendaViewModel = hiltViewModel(),
) {
@@ -120,6 +123,7 @@ fun AgendaScreen(
CalendarDrawer(
currentView = selectedView,
currentDate = anchor,
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
@@ -140,7 +144,7 @@ fun AgendaScreen(
topBar = {
AgendaTopBar(
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,

View File

@@ -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) }
}
}

View File

@@ -59,6 +59,7 @@ fun CalendarDrawer(
onSelectView: (CalendarView) -> Unit,
onJumpToDate: (LocalDate) -> Unit,
onSettings: () -> Unit,
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
) {
var showDatePicker by remember { mutableStateOf(false) }
@@ -73,10 +74,10 @@ fun CalendarDrawer(
DrawerHeader()
DrawerSectionHeader(stringResource(R.string.view_section))
IMPLEMENTED_VIEWS.forEachIndexed { index, view ->
viewOrder.forEachIndexed { index, view ->
GroupedRow(
title = stringResource(view.labelRes),
position = positionOf(index, IMPLEMENTED_VIEWS.size),
position = positionOf(index, viewOrder.size),
selected = view == currentView,
minHeight = 56.dp,
leading = { Icon(view.icon, contentDescription = null) },

View File

@@ -45,11 +45,39 @@ val IMPLEMENTED_VIEWS: List<CalendarView> =
/** Next view in [available], wrapping around. Falls back to Month if absent. */
fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): CalendarView {
if (available.isEmpty()) return this
val i = available.indexOf(this)
if (i < 0) return available.firstOrNull() ?: CalendarView.Month
if (i < 0) return available.first()
return available[(i + 1) % available.size]
}
/**
* The user's customisation of the top-bar quick-switch button (#24): which views
* it cycles through ([enabled]) and in what [order]. [order] always lists every
* implemented view — the settings screen reorders the whole set — while [cycle]
* is the subset the pill actually steps through, in [order]. The navigation
* drawer keeps its own separate order and always lists every view, so a view
* disabled here stays reachable there.
*/
data class QuickSwitchConfig(
val order: List<CalendarView>,
val enabled: Set<CalendarView>,
) {
/** Views the pill steps through, in [order]. */
val cycle: List<CalendarView> get() = order.filter { it in enabled }
companion object {
/** All views, in default order, all enabled. */
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
/**
* Fewest views that keep the switch meaningful — a "switch" needs at
* least two targets, so the settings screen blocks disabling below this.
*/
const val MIN_ENABLED = 2
}
}
/**
* The top-level view back stack (bottom → top): the [default] home view always
* sits at the bottom. Pressing back pops one level until only the home view

View File

@@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
@@ -57,10 +58,16 @@ fun positionOf(index: Int, count: Int): Position = when {
}
/**
* The app's standard full-screen list scaffold: a collapsing [LargeTopAppBar]
* whose title shrinks into the bar (next to the back button) as the content
* scrolls. Content is a scrollable column that feeds the toolbar via nested
* scroll. Used by Settings and the calendar manager so they share one shell.
* The app's standard full-screen list scaffold. By default a collapsing
* [LargeTopAppBar] whose title shrinks into the bar (next to the back button) as
* the content scrolls — used by Settings and the calendar manager, where the
* large header sets the page. Content is a scrollable column that feeds the
* toolbar via nested scroll.
*
* Set [largeTopBar] to false for a pinned, single-line [TopAppBar] instead: the
* title sits in the bar from the start, with no expanded header to scroll past.
* Preferred for selection pickers, where the tall header is just wasted space
* above the options.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -68,12 +75,16 @@ fun CollapsingScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
largeTopBar: Boolean = true,
snackbarHost: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
content: @Composable ColumnScope.() -> Unit,
) {
val scrollBehavior =
val scrollBehavior = if (largeTopBar) {
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
} else {
TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
}
Scaffold(
modifier = modifier
.predictiveBack(onBack = onBack)
@@ -81,22 +92,34 @@ fun CollapsingScaffold(
.background(MaterialTheme.colorScheme.surface)
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
LargeTopAppBar(
title = { Text(title) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back),
)
}
},
actions = actions,
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
),
val navigationIcon = @Composable {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.settings_back),
)
}
}
val colors = TopAppBarDefaults.topAppBarColors(
scrolledContainerColor = MaterialTheme.colorScheme.surface,
)
if (largeTopBar) {
LargeTopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions,
scrollBehavior = scrollBehavior,
colors = colors,
)
} else {
TopAppBar(
title = { Text(title) },
navigationIcon = navigationIcon,
actions = actions,
scrollBehavior = scrollBehavior,
colors = colors,
)
}
},
snackbarHost = snackbarHost,
) { innerPadding ->
@@ -142,6 +165,9 @@ fun GroupedRow(
dimmed: Boolean = false,
container: Color? = null,
minHeight: Dp = 72.dp,
// The 2.dp separation between rows in a run. Suppressed by the reorderable
// list, which owns uniform spacing itself so every slot has the same pitch.
gapBelow: Boolean = true,
leading: @Composable (() -> Unit)? = null,
trailing: @Composable (() -> Unit)? = null,
onClick: (() -> Unit)? = null,
@@ -160,9 +186,10 @@ fun GroupedRow(
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
)
}
val gap = when (position) {
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
Position.Bottom, Position.Alone -> Modifier
val gap = when {
!gapBelow -> Modifier
position == Position.Top || position == Position.Middle -> Modifier.padding(bottom = 2.dp)
else -> Modifier
}
val itemColors = if (selected) {
ListItemDefaults.colors(

View File

@@ -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

View File

@@ -0,0 +1,183 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
/** Uniform row height for [ReorderableColumn]; a fixed pitch keeps drag maths exact. */
val ReorderableRowHeight: Dp = 64.dp
private val RowGap: Dp = 2.dp
/**
* A vertical list whose rows can be dragged into a new order by their handle.
*
* Built for the short, fixed grouped-card lists in Settings (#24) — no external
* dependency and no [androidx.compose.foundation.lazy.LazyColumn] (the settings
* screens are a single [androidx.compose.foundation.verticalScroll] column, which
* can't nest a scrolling list). Rows are a fixed [ReorderableRowHeight] with a
* uniform gap, so a row's target slot is simply how many whole pitches it has
* been dragged. The held row follows the finger while the others slide out of
* its way (animated); on release the order is committed immediately with a
* single [onReorder] call, then the held row eases into its new slot as a
* purely visual settle — safe to interrupt with another drag, since the
* commit itself never waits on it.
*
* [rowContent] receives the [Position] for the row's place in the order (to reuse
* [GroupedRow]'s card shaping — pass `gapBelow = false` there, this owns spacing)
* and a `dragHandle` [Modifier] to attach to the element that starts a drag.
*/
@Composable
fun <T> ReorderableColumn(
items: List<T>,
keyOf: (T) -> Any,
onReorder: (List<T>) -> Unit,
modifier: Modifier = Modifier,
rowContent: @Composable (item: T, position: Position, dragHandle: Modifier, isDragging: Boolean) -> Unit,
) {
val pitchPx = with(LocalDensity.current) { (ReorderableRowHeight + RowGap).toPx() }
val scope = rememberCoroutineScope()
// Local working copy; re-seeded when the incoming list changes (including the
// echo of our own committed order).
var order by remember(items) { mutableStateOf(items) }
var draggedKey by remember { mutableStateOf<Any?>(null) }
// Live translation of the held row from its slot (px); also drives the
// release settle — re-based onto the row's new slot once the order commits
// (see onDragEnd), then eased down to zero.
var dragOffset by remember { mutableFloatStateOf(0f) }
// The running release/cancel settle, cancelled if a new drag pre-empts it.
// Purely visual — the order commit (see onDragEnd) never depends on it.
var settleJob by remember { mutableStateOf<Job?>(null) }
val draggedIndex = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
// Whole slots dragged → the slot the held row currently hovers over. Derived
// so recomposition only fires when the *target slot* actually changes, not on
// every dragged pixel: dragOffset moves every frame during a drag, but the
// held row's own translation is already applied in the draw phase via
// graphicsLayer below, so only the neighbours' shift (which depends on this)
// needs to recompose, and only when a slot boundary is actually crossed.
// (Read as a plain val, not `by`, below: a delegated property has a custom
// getter and Kotlin won't smart-cast it in the `when` over `targetIndex` in
// the loop, so the derived value is captured into a real local instead.)
val targetIndex = remember(items, pitchPx) {
derivedStateOf {
val idx = draggedKey?.let { key -> order.indexOfFirst { keyOf(it) == key }.takeIf { it >= 0 } }
idx?.let { (it + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex) }
}
}.value
Column(modifier, verticalArrangement = Arrangement.spacedBy(RowGap)) {
order.forEachIndexed { index, item ->
val key = keyOf(item)
val isDragged = key == draggedKey
// Slide neighbours by one pitch to open the gap the held row will drop
// into. Snap (not animate) once idle, so committing the new order — which
// moves each row's slot — doesn't visibly fight a lingering animation.
val shift = when {
draggedIndex == null || targetIndex == null || isDragged -> 0f
index in (draggedIndex + 1)..targetIndex -> -pitchPx
index in targetIndex until draggedIndex -> pitchPx
else -> 0f
}
val animatedShift by animateFloatAsState(
targetValue = shift,
animationSpec = if (draggedKey != null) spring(stiffness = Spring.StiffnessMediumLow) else snap(),
label = "reorderShift",
)
val dragHandle = Modifier.pointerInput(key) {
detectDragGestures(
onDragStart = {
settleJob?.cancel()
draggedKey = key
dragOffset = 0f
},
onDrag = { change, amount ->
change.consume()
dragOffset += amount.y
},
onDragEnd = {
val from = order.indexOfFirst { keyOf(it) == key }
if (from < 0) return@detectDragGestures
val to = (from + (dragOffset / pitchPx).roundToInt()).coerceIn(0, order.lastIndex)
if (to != from) {
// Commit synchronously and unconditionally, before the settle
// animation below runs — the commit must not depend on that
// coroutine reaching its end, or a new drag starting within the
// ~160ms settle window would cancel it and silently revert an
// already-finished reorder.
order = order.toMutableList().apply { add(to, removeAt(from)) }
onReorder(order)
// The row now lays out at slot `to` instead of `from`; re-base
// the live offset onto that new slot (same visual position,
// expressed relative to the new one) so the settle below eases
// it the rest of the way instead of jumping.
dragOffset -= (to - from) * pitchPx
}
settleJob = scope.launch {
// Purely visual from here: ease the held row onto its slot, then
// release the drag state. Safe to cancel — the order was already
// committed above.
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
draggedKey = null
dragOffset = 0f
}
},
onDragCancel = {
settleJob = scope.launch {
Animatable(dragOffset).animateTo(0f, tween(160)) { dragOffset = value }
draggedKey = null
dragOffset = 0f
}
},
)
}
Box(
Modifier
.height(ReorderableRowHeight)
.zIndex(if (isDragged) 1f else 0f)
.graphicsLayer {
translationY = if (isDragged) dragOffset else animatedShift
if (isDragged) {
scaleX = 1.02f
scaleY = 1.02f
shadowElevation = 8.dp.toPx()
shape = RoundedCornerShape(20.dp)
clip = false
}
},
) {
rowContent(item, positionOf(index, order.size), dragHandle, isDragged)
}
}
}
}

View File

@@ -72,6 +72,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
@@ -116,6 +117,8 @@ fun DayScreen(
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier,
initialDateIso: String? = null,
viewModel: DayViewModel = hiltViewModel(),
@@ -175,6 +178,7 @@ fun DayScreen(
CalendarDrawer(
currentView = selectedView,
currentDate = date,
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
@@ -196,7 +200,7 @@ fun DayScreen(
DayTopBar(
date = date,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,

View File

@@ -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.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.DialogAmountField
import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard
@@ -965,7 +969,7 @@ private fun EventEditContent(
}
if (showCalendarPicker) {
CalendarPickerDialog(
CalendarPicker(
calendars = state.calendars,
selectedId = form.calendarId,
onSelect = {
@@ -1957,34 +1961,64 @@ private fun ScheduleRow(
}
}
/**
* Full-screen calendar picker: a scrollable list of every writable calendar,
* grouped under its owning account like the visibility filter and calendar
* manager. Replaces the former fixed-height [AlertDialog], which clipped the
* list past ~9 rows so accounts with many calendars had unreachable entries
* (#29). Selecting a row calls [onSelect]; the caller closes the picker.
*/
@Composable
private fun CalendarPickerDialog(
private fun CalendarPicker(
calendars: List<CalendarSource>,
selectedId: Long?,
onSelect: (Long) -> Unit,
onDismiss: () -> Unit,
) {
val dark = isSystemInDarkTheme()
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.event_detail_calendar)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
calendars.forEach { calendar ->
OptionCard(
label = calendar.displayName,
onClick = { onSelect(calendar.id) },
icon = Icons.Default.CalendarMonth,
// The calendar's own colour carries its identity.
iconTint = pastelize(calendar.color, dark),
supportingText = calendar.accountName.takeIf { it.isNotBlank() },
selected = calendar.id == selectedId,
)
}
// Group by owning account (name, else type, else the calendar's own name),
// preserving the provider's order within and across groups — the same
// grouping [groupByAccount] applies for the drawer filter.
val groups = remember(calendars) {
calendars
.groupBy {
it.accountName.takeIf(String::isNotBlank)
?: it.accountType.takeIf(String::isNotBlank)
?: it.displayName
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
},
)
.toList()
}
FullScreenPicker(
title = stringResource(R.string.event_detail_calendar),
onDismiss = onDismiss,
) {
groups.forEach { (account, cals) ->
Text(
text = account,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp),
)
cals.forEachIndexed { index, calendar ->
val isSelected = calendar.id == selectedId
GroupedRow(
title = calendar.displayName,
position = positionOf(index, cals.size),
selected = isSelected,
leading = { CalendarColorChip(calendar.color) },
trailing = if (isSelected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = { onSelect(calendar.id) },
)
}
}
}
}

View File

@@ -68,6 +68,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
@@ -98,6 +99,8 @@ fun MonthScreen(
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier,
viewModel: MonthViewModel = hiltViewModel(),
) {
@@ -157,6 +160,7 @@ fun MonthScreen(
CalendarDrawer(
currentView = selectedView,
currentDate = LocalDate(month.year, month.month, 1),
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
@@ -178,7 +182,7 @@ fun MonthScreen(
MonthTopBar(
month = month,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,

View File

@@ -7,9 +7,11 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.drawable.Icon
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.widget.Toast
import androidx.annotation.RequiresApi
import android.text.format.DateFormat
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -43,6 +45,8 @@ import androidx.compose.material.icons.filled.Cake
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Favorite
@@ -53,6 +57,7 @@ import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -62,6 +67,7 @@ import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -76,6 +82,7 @@ import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -100,14 +107,21 @@ import de.jeanlucmakiola.calendula.qs.NewEventTileService
import de.jeanlucmakiola.calendula.ui.crash.CrashReportDialog
import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
import de.jeanlucmakiola.calendula.ui.common.FullScreenPicker
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.common.ReorderableColumn
import de.jeanlucmakiola.calendula.ui.common.ReorderableRowHeight
import de.jeanlucmakiola.calendula.ui.common.icon
import de.jeanlucmakiola.calendula.ui.common.labelRes
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.OptionPicker
@@ -121,13 +135,17 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
import de.jeanlucmakiola.calendula.ui.theme.BundledFont
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalTime
import java.time.format.TextStyle as JavaTextStyle
import java.util.Calendar
/** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { Appearance, EventForm, Notifications, SpecialDates }
private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates }
/**
* Token-based accent for a leading icon chip (container / on-container pair).
@@ -172,6 +190,13 @@ fun SettingsScreen(
) {
AppearanceScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility(
visible = section == SettingsSection.Views,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
ViewsScreen(state = state, viewModel = viewModel, onBack = { section = null })
}
AnimatedVisibility(
visible = section == SettingsSection.EventForm,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
@@ -222,6 +247,13 @@ private fun SettingsHub(
leading = { CategoryIcon(Icons.Default.Palette, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Appearance) },
)
GroupedRow(
title = stringResource(R.string.settings_section_views),
summary = stringResource(R.string.settings_views_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.SwapVert, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Views) },
)
GroupedRow(
title = stringResource(R.string.settings_section_event_form),
summary = stringResource(R.string.settings_event_form_subtitle),
@@ -502,6 +534,18 @@ private fun AppearanceScreen(
var showAgendaScreenRange by remember { mutableStateOf(false) }
var showAgendaWidgetRange by remember { mutableStateOf(false) }
var showPastEvents by remember { mutableStateOf(false) }
var showBrandFont by remember { mutableStateOf(false) }
var showPlainFont by remember { mutableStateOf(false) }
val fonts by viewModel.fontState.collectAsStateWithLifecycle()
// A picked file that didn't parse as a font: tell the user and keep the old choice.
val context = LocalContext.current
val importFailedMessage = stringResource(R.string.settings_font_import_failed)
LaunchedEffect(Unit) {
viewModel.fontImportFailed.collect {
Toast.makeText(context, importFailedMessage, Toast.LENGTH_LONG).show()
}
}
CollapsingScaffold(
title = stringResource(R.string.settings_section_appearance),
@@ -538,6 +582,24 @@ private fun AppearanceScreen(
Spacer(Modifier.height(16.dp))
// Fonts — the two Material typeface roles, each independently choosable
// (issue #19). Headings = brand (display/headline); body = plain
// (title/body/label). Both default to the system typeface.
GroupedRow(
title = stringResource(R.string.settings_font_headings),
summary = fontLabel(fonts.brand),
position = Position.Top,
onClick = { showBrandFont = true },
)
GroupedRow(
title = stringResource(R.string.settings_font_body),
summary = fontLabel(fonts.plain),
position = Position.Bottom,
onClick = { showPlainFont = true },
)
Spacer(Modifier.height(16.dp))
// Calendar — view, week, and timeline formatting
GroupedRow(
title = stringResource(R.string.settings_default_view),
@@ -629,6 +691,28 @@ private fun AppearanceScreen(
onDismiss = { showTheme = false },
)
}
if (showBrandFont) {
FontPicker(
title = stringResource(R.string.settings_font_headings),
role = FontRole.BRAND,
selected = fonts.brand,
stamp = fonts.brandStamp,
onSelect = { viewModel.setFont(FontRole.BRAND, it) },
onImport = { viewModel.importCustomFont(FontRole.BRAND, it) },
onDismiss = { showBrandFont = false },
)
}
if (showPlainFont) {
FontPicker(
title = stringResource(R.string.settings_font_body),
role = FontRole.PLAIN,
selected = fonts.plain,
stamp = fonts.plainStamp,
onSelect = { viewModel.setFont(FontRole.PLAIN, it) },
onImport = { viewModel.importCustomFont(FontRole.PLAIN, it) },
onDismiss = { showPlainFont = false },
)
}
if (showWeekStart) {
OptionPicker(
title = stringResource(R.string.settings_week_start),
@@ -689,6 +773,128 @@ private fun AppearanceScreen(
}
}
/**
* Views (#24): reorder the top-bar quick-switch cycle and choose which views it
* steps through, plus reorder the navigation-drawer list. Two independent lists —
* a view disabled in the quick-switch cycle is still reachable from the drawer,
* which always lists every view. The switch needs at least two targets, so the
* last [QuickSwitchConfig.MIN_ENABLED] enabled views can't be turned off.
*/
@Composable
private fun ViewsScreen(
state: SettingsUiState,
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
CollapsingScaffold(
title = stringResource(R.string.settings_section_views),
onBack = onBack,
) {
val config = state.quickSwitchConfig
SectionHeader(stringResource(R.string.settings_quick_switch_header))
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
Spacer(Modifier.height(8.dp))
// Turning a view off is blocked once only the minimum remain enabled.
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
ReorderableColumn(
items = config.order,
keyOf = { it },
onReorder = { viewModel.setQuickSwitchOrder(it) },
) { view, position, dragHandle, isDragging ->
val checked = view in config.enabled
ViewRow(
view = view,
position = position,
isDragging = isDragging,
dragHandle = dragHandle,
dimmed = !checked,
trailing = {
Switch(
checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
)
},
)
}
Spacer(Modifier.height(24.dp))
SectionHeader(stringResource(R.string.settings_drawer_order_header))
SettingsHint(stringResource(R.string.settings_drawer_order_hint))
Spacer(Modifier.height(8.dp))
ReorderableColumn(
items = state.drawerViewOrder,
keyOf = { it },
onReorder = { viewModel.setDrawerViewOrder(it) },
) { view, position, dragHandle, isDragging ->
ViewRow(
view = view,
position = position,
isDragging = isDragging,
dragHandle = dragHandle,
)
}
}
}
/** One reorderable view row: the view's icon and name, an optional [trailing]
* control, and a drag handle carrying the [dragHandle] gesture modifier. */
@Composable
private fun ViewRow(
view: CalendarView,
position: Position,
isDragging: Boolean,
dragHandle: Modifier,
dimmed: Boolean = false,
trailing: @Composable (() -> Unit)? = null,
) {
GroupedRow(
title = stringResource(view.labelRes),
position = position,
dimmed = dimmed,
minHeight = ReorderableRowHeight,
// The reorderable column owns the inter-row spacing (uniform pitch).
gapBelow = false,
container = if (isDragging) MaterialTheme.colorScheme.secondaryContainer else null,
leading = {
Icon(
imageVector = view.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
trailing = {
Row(verticalAlignment = Alignment.CenterVertically) {
trailing?.invoke()
if (trailing != null) Spacer(Modifier.width(8.dp))
Box(
modifier = dragHandle.size(48.dp),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = stringResource(R.string.reorder_drag_handle),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
)
}
/** Muted supporting text under a [SectionHeader], matching the form-fields hint. */
@Composable
private fun SettingsHint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
@Composable
private fun EventFormScreen(
state: SettingsUiState,
@@ -1501,6 +1707,152 @@ private fun themeLabel(mode: ThemeMode): String = stringResource(
},
)
/** The summary label for a stored font token (issue #19). */
@Composable
private fun fontLabel(token: String): String = when (token) {
FONT_SYSTEM_TOKEN -> stringResource(R.string.settings_font_system)
FONT_CUSTOM_TOKEN -> stringResource(R.string.settings_font_custom_selected)
else -> BundledFont.fromToken(token)?.let { stringResource(it.labelRes) }
?: stringResource(R.string.settings_font_system)
}
/**
* MIME types offered to the document picker so it lists only font files. Covers
* the modern `font/` types plus the legacy `application/` font aliases some
* providers still report. Anything that slips through is still validated by
* [de.jeanlucmakiola.calendula.data.fonts.CustomFontStore] before use.
*/
private val FONT_PICKER_MIME_TYPES = arrayOf(
"font/ttf",
"font/otf",
"font/sfnt",
"font/collection",
"application/x-font-ttf",
"application/x-font-otf",
"application/font-sfnt",
"application/vnd.ms-opentype",
)
/**
* Full-screen font chooser for one [FontRole]: the system default, each bundled
* font (previewed in its own face), and "Choose file…" which opens the system
* picker to load a .ttf/.otf. Selecting a system/bundled option applies at once;
* a file is validated and imported by the caller, switching to the custom font
* on success.
*/
@Composable
private fun FontPicker(
title: String,
role: FontRole,
selected: String,
stamp: Int,
onSelect: (String) -> Unit,
onImport: (Uri) -> Unit,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
) { uri ->
if (uri != null) {
onImport(uri)
onDismiss()
}
}
// System default + the bundled fonts + the "Choose file…" row.
val rowCount = BundledFont.entries.size + 2
val isCustom = selected == FONT_CUSTOM_TOKEN
// Resolving the custom face stats the disk and builds a fresh FontFamily, so
// memoise it; re-keyed on [stamp] (bumped on re-import) so a replaced file
// refreshes the preview while plain recompositions reuse the cached family.
val customPreview = remember(role, isCustom, stamp) {
if (isCustom) resolveFontFamily(FONT_CUSTOM_TOKEN, role, context) else null
}
FullScreenPicker(title = title, onDismiss = onDismiss) {
FontOptionRow(
label = stringResource(R.string.settings_font_system),
preview = FontFamily.Default,
selected = selected == FONT_SYSTEM_TOKEN,
position = positionOf(0, rowCount),
onClick = {
onSelect(FONT_SYSTEM_TOKEN)
onDismiss()
},
)
BundledFont.entries.forEachIndexed { index, font ->
FontOptionRow(
label = stringResource(font.labelRes),
preview = font.family,
selected = selected == font.token,
position = positionOf(index + 1, rowCount),
onClick = {
onSelect(font.token)
onDismiss()
},
)
}
FontOptionRow(
label = if (isCustom) {
stringResource(R.string.settings_font_custom_selected)
} else {
stringResource(R.string.settings_font_choose_file)
},
// A loaded font previews in its own face; otherwise show an upload cue.
preview = customPreview,
leadingIcon = if (isCustom) null else Icons.Default.UploadFile,
selected = isCustom,
position = positionOf(rowCount - 1, rowCount),
onClick = { launcher.launch(FONT_PICKER_MIME_TYPES) },
)
}
}
/**
* One row in the [FontPicker]: the font's name, a leading "Ag" sample rendered in
* the option's own [preview] face (or an [leadingIcon] cue when there's nothing
* to preview), and a check when it's the current selection.
*/
@Composable
private fun FontOptionRow(
label: String,
preview: FontFamily?,
selected: Boolean,
position: Position,
onClick: () -> Unit,
leadingIcon: ImageVector? = null,
) {
GroupedRow(
title = label,
position = position,
selected = selected,
leading = {
if (preview != null) {
Text(
text = "Ag",
fontFamily = preview,
style = MaterialTheme.typography.titleLarge,
)
} else if (leadingIcon != null) {
Icon(imageVector = leadingIcon, contentDescription = null)
}
},
trailing = if (selected) {
{
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
null
},
onClick = onClick,
)
}
/** Picker options: "Follow system" first, then Monday…Sunday in ISO order. */
private val WEEK_START_OPTIONS: List<WeekStartPref> =
listOf(WeekStartPref.Auto) + DayOfWeek.entries.map { WeekStartPref.Day(it) }

View File

@@ -11,6 +11,8 @@ import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
/**
* Settings screen state (M4). Persisted preferences are instant to read, so
@@ -39,6 +41,10 @@ data class SettingsUiState(
val agendaShowRangeBar: Boolean = true,
/** The calendar view the app opens on, and the home of the view back stack (M1). */
val defaultView: CalendarView = CalendarView.Week,
/** Which views the top-bar quick-switch button cycles through, and their order (#24). */
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
/** Order of the views in the navigation drawer (#24); every view is always listed. */
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */

View File

@@ -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
@@ -15,6 +16,7 @@ import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncEngine
import de.jeanlucmakiola.calendula.data.contacts.resolveTitleTemplate
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.fonts.CustomFontStore
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
import de.jeanlucmakiola.calendula.data.prefs.choiceFor
import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
@@ -25,17 +27,22 @@ import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.FontRole
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_PAST_DISPLAY_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AGENDA_RANGE_KEY
import de.jeanlucmakiola.calendula.widget.agenda.AgendaWidget
import de.jeanlucmakiola.calendula.widget.month.MonthWidget
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
@@ -45,6 +52,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel
@@ -117,8 +125,14 @@ class SettingsViewModel @Inject constructor(
prefs.autofocusEventTitle,
prefs.pastEventDisplay,
prefs.dimCompletedEvents,
::MiscSettings,
),
// View customisation (#24) folded into one flow so it fits this
// group — the outer combine is already at its five-arg limit.
combine(prefs.quickSwitchConfig, prefs.drawerViewOrder) { quickSwitch, drawer ->
ViewCustomization(quickSwitch, drawer)
},
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
},
) { base, defaults, overrides, views, misc ->
base.copy(
defaultView = views.defaultView,
@@ -130,6 +144,8 @@ class SettingsViewModel @Inject constructor(
autofocusEventTitle = misc.autofocusEventTitle,
pastEventDisplay = misc.pastEventDisplay,
dimCompletedEvents = misc.dimCompletedEvents,
quickSwitchConfig = misc.viewCustomization.quickSwitch,
drawerViewOrder = misc.viewCustomization.drawerOrder,
allowColorOnUnsupportedCalendars = defaults.allowColor,
defaultReminderMinutes = defaults.defaultReminder,
defaultAllDayReminderMinutes = defaults.allDayReminder,
@@ -146,6 +162,29 @@ class SettingsViewModel @Inject constructor(
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
)
/**
* The app-wide custom-font choice (issue #19). Its own flow — both this
* screen and [MainActivity] (which builds the typography) read it
* independently of the main settings combine.
*/
val fontState: StateFlow<AppFontSettings> = combine(
prefs.brandFont,
prefs.plainFont,
prefs.brandFontStamp,
prefs.plainFontStamp,
) { brand, plain, brandStamp, plainStamp ->
AppFontSettings(brand = brand, plain = plain, brandStamp = brandStamp, plainStamp = plainStamp)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = AppFontSettings(),
)
// Emitted when a picked font file couldn't be read as a font; the screen
// surfaces it and the previous selection stays put.
private val _fontImportFailed = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val fontImportFailed: SharedFlow<Unit> = _fontImportFailed
// Serialises widget redraws so a rapid flip-and-flip-back can't run two
// updateAll calls at once — concurrent calls coalesce around a stale read
// and can leave the widget on the intermediate value. Held sequentially,
@@ -180,6 +219,12 @@ class SettingsViewModel @Inject constructor(
val autofocusEventTitle: Boolean,
val pastEventDisplay: PastEventDisplay,
val dimCompletedEvents: Boolean,
val viewCustomization: ViewCustomization,
)
private data class ViewCustomization(
val quickSwitch: QuickSwitchConfig,
val drawerOrder: List<CalendarView>,
)
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
@@ -279,6 +324,35 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setDynamicColor(enabled) }
}
fun setFont(role: FontRole, token: String) {
viewModelScope.launch {
when (role) {
FontRole.BRAND -> prefs.setBrandFont(token)
FontRole.PLAIN -> prefs.setPlainFont(token)
}
}
}
/**
* Import a user-picked font file for [role]. Copies and validates it off the
* main thread; on success the role switches to the custom font, otherwise the
* previous choice is kept and [fontImportFailed] fires.
*/
fun importCustomFont(role: FontRole, uri: Uri) {
viewModelScope.launch {
val ok = withContext(io) { CustomFontStore.import(appContext, role, uri) }
if (ok) {
// Bump the stamp so replacing an already-active custom font (same
// file, same "custom" token) still breaks font-settings equality
// and refreshes the resolved typeface.
prefs.bumpCustomFontStamp(role)
setFont(role, FONT_CUSTOM_TOKEN)
} else {
_fontImportFailed.emit(Unit)
}
}
}
fun setWeekStart(pref: WeekStartPref) {
viewModelScope.launch {
prefs.setWeekStart(pref)
@@ -352,6 +426,32 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
/**
* Enable or disable [view] in the quick-switch cycle. Goes through an atomic
* read-modify-write so a concurrent reorder can't clobber this toggle (and
* vice versa) by re-serialising a stale snapshot. The MIN_ENABLED floor is
* re-checked inside the transform: the screen's own guard reads the
* async-echoed snapshot, so two quick disable-taps could both look allowed
* yet compose to a below-minimum cycle.
*/
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
viewModelScope.launch {
prefs.updateQuickSwitch { config ->
val next = if (enabled) config.enabled + view else config.enabled - view
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
}
}
}
/** Reorder the quick-switch views (atomic read-modify-write; keeps the enabled set). */
fun setQuickSwitchOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.updateQuickSwitch { it.copy(order = order) } }
}
fun setDrawerViewOrder(order: List<CalendarView>) {
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
}
fun setRemindersEnabled(enabled: Boolean) {
viewModelScope.launch { prefs.setRemindersEnabled(enabled) }
}

View 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),
)
}

View File

@@ -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,
)

View File

@@ -81,6 +81,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.NowLine
@@ -131,6 +132,8 @@ fun WeekScreen(
onOpenSettings: () -> Unit,
onOpenSearch: () -> Unit,
onCreateEvent: (LocalDate, Int?) -> Unit,
quickSwitchViews: List<CalendarView> = IMPLEMENTED_VIEWS,
drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
modifier: Modifier = Modifier,
viewModel: WeekViewModel = hiltViewModel(),
) {
@@ -195,6 +198,7 @@ fun WeekScreen(
CalendarDrawer(
currentView = selectedView,
currentDate = weekStart,
viewOrder = drawerViewOrder,
onSelectView = { view ->
onSelectView(view)
scope.launch { drawerState.close() }
@@ -216,7 +220,7 @@ fun WeekScreen(
WeekTopBar(
weekStart = weekStart,
selectedView = selectedView,
onCycleView = { onSelectView(selectedView.next()) },
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
onOpenDrawer = { scope.launch { drawerState.open() } },
onOpenSearch = onOpenSearch,
scrollBehavior = scrollBehavior,

View File

@@ -193,6 +193,8 @@ private fun AgendaHeader() {
modifier = GlanceModifier.fillMaxWidth().padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Tap the "Upcoming" title to open the app on the user's default view
// (issue #20) — a null target resolves to the default home in the host.
Text(
text = context.getString(R.string.widget_agenda_title),
style = TextStyle(
@@ -200,7 +202,11 @@ private fun AgendaHeader() {
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
),
modifier = GlanceModifier.defaultWeight(),
modifier = GlanceModifier
.defaultWeight()
.clickable(
actionStartActivity(MainActivity.openViewIntent(context, view = null)),
),
)
IconButton(
resId = R.drawable.ic_widget_refresh,

View File

@@ -194,6 +194,8 @@ private fun MonthHeader(label: String) {
),
),
)
// Tap the month/year title to open the app on the month view (issue #18),
// rooted over the default home so backing out returns there.
Text(
text = label,
style = TextStyle(
@@ -204,16 +206,17 @@ private fun MonthHeader(label: String) {
),
modifier = GlanceModifier
.defaultWeight()
.clickable(actionRunCallback<ResetMonthAction>()),
.clickable(
actionStartActivity(
MainActivity.openViewIntent(context, CalendarView.Month),
),
),
)
// The "today" button snaps the grid back to the current month in place.
HeaderIcon(
resId = R.drawable.ic_widget_today,
contentDescription = context.getString(R.string.widget_today),
onClick = GlanceModifier.clickable(
actionStartActivity(
MainActivity.openDateIntent(context, today(systemZone()), CalendarView.Month),
),
),
onClick = GlanceModifier.clickable(actionRunCallback<ResetMonthAction>()),
)
HeaderIcon(
resId = R.drawable.ic_widget_chevron_right,
@@ -301,6 +304,15 @@ private fun WeekRow(
}
}
/**
* Open [date]'s day view rooted in the month view (so back returns to the month
* grid) — the same target the in-app month grid uses when a day cell is tapped.
* Shared by every tappable part of a day column so, as in the app, a tap anywhere
* on a day opens it; only an event bar on top opts out to open its own detail.
*/
private fun openDayAction(context: Context, date: LocalDate) =
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month))
@Composable
private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW: Dp) {
val context = LocalContext.current
@@ -308,11 +320,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
modifier = GlanceModifier
.width(colW)
.height(DAY_NUMBER_HEIGHT)
// Tap a day number to open that day, rooted in the month view so back
// returns to the month grid.
.clickable(
actionStartActivity(MainActivity.openDateIntent(context, date, CalendarView.Month)),
),
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.Center,
) {
Box(
@@ -339,6 +347,7 @@ private fun DayNumber(date: LocalDate, isToday: Boolean, inMonth: Boolean, colW:
@Composable
private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) {
var col = 0
while (col < 7) {
@@ -352,7 +361,14 @@ private fun LaneRow(week: MonthWeek, lane: Int, dark: Boolean, colW: Dp) {
if (timed != null) {
SpanBar(event = timed, dark = dark, width = colW)
} else {
Box(GlanceModifier.width(colW).height(LANE_HEIGHT)) {}
// Empty lane cell: a tap opens that day, so blank space in a
// day column is a day-open target just like the number is.
Box(
GlanceModifier
.width(colW)
.height(LANE_HEIGHT)
.clickable(openDayAction(context, week.days[col])),
) {}
}
col += 1
}
@@ -401,14 +417,20 @@ private fun SpanBar(event: EventInstance, dark: Boolean, width: Dp) {
@Composable
private fun OverflowRow(week: MonthWeek, colW: Dp) {
val context = LocalContext.current
Row(modifier = GlanceModifier.fillMaxWidth()) {
week.days.forEachIndexed { col, date ->
val shownSpans = week.spans.count { col in it.startCol..it.endCol && it.lane < MAX_LANES }
val freeSlots = (MAX_LANES - shownSpans).coerceAtLeast(0)
val timedShown = minOf(freeSlots, week.timedByDay[date].orEmpty().size)
val hidden = (week.countByDay[date] ?: 0) - shownSpans - timedShown
// The overflow row is part of the day column too: tapping it (whether
// it shows "+N" or is blank) opens that day, same as the app.
Box(
modifier = GlanceModifier.width(colW).height(LANE_HEIGHT),
modifier = GlanceModifier
.width(colW)
.height(LANE_HEIGHT)
.clickable(openDayAction(context, date)),
contentAlignment = Alignment.CenterStart,
) {
if (hidden > 0) {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Calendula</string>
<string name="app_tagline">Ein moderner Kalender.</string>
<string name="state_loading">Lädt…</string>
<string name="state_retry">Erneut versuchen</string>
<string name="state_failure_unknown">Etwas ist schiefgelaufen.</string>
@@ -10,7 +10,6 @@
<string name="state_failure_no_calendars">Keine Kalender eingerichtet.</string>
<string name="state_failure_no_calendars_action">System-Kalender-Einstellungen öffnen</string>
<string name="state_failure_provider">Kalender konnte nicht gelesen werden.</string>
<!-- Permission-Flow (F1) -->
<string name="permission_rationale_title">Alle Termine, schön im Blick</string>
<string name="permission_rationale_body">Calendula braucht Zugriff auf deinen Kalender, um deine Termine zu zeigen und zu verwalten. Mehr verlangt die App zu Beginn nicht — und nichts verlässt je dein Gerät.</string>
@@ -26,7 +25,6 @@
<string name="permission_benefit_privacy_title">Kein Tracking, niemals</string>
<string name="permission_benefit_privacy_body">Keine Telemetrie, keine Analyse, keine Werbung.</string>
<string name="permission_privacy_footnote">Bleibt auf deinem Gerät · keine Internet-Berechtigung</string>
<!-- Monatsansicht (S1) -->
<string name="month_prev">Vorheriger Monat</string>
<string name="month_next">Nächster Monat</string>
@@ -35,14 +33,11 @@
<string name="month_open_menu">Menü öffnen</string>
<string name="month_action_settings">Einstellungen</string>
<string name="month_a11y_today_prefix">Heute</string>
<!-- Wochenansicht (S2) -->
<string name="week_today_action">Diese Woche</string>
<string name="week_number_label">KW</string>
<!-- Tagesansicht (S3) -->
<string name="day_today_action">Heute</string>
<!-- Event-Detail-Screen (S4) -->
<string name="event_detail_back">Zurück</string>
<string name="event_detail_edit">Bearbeiten</string>
@@ -61,7 +56,6 @@
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
<string name="dialog_cancel">Abbrechen</string>
<string name="dialog_ok">OK</string>
<!-- Termin-Formular (v1.2 Erstellen) -->
<string name="event_edit_new_title">Neuer Termin</string>
<string name="event_edit_close">Schließen</string>
@@ -94,7 +88,6 @@
<string name="reminder_unit_weeks">Wochen</string>
<string name="event_edit_availability">Verfügbarkeit</string>
<string name="event_edit_visibility">Sichtbarkeit</string>
<!-- Termin-Formular — eigene Terminfarbe -->
<string name="event_edit_color">Farbe</string>
<string name="event_edit_color_default">Kalenderfarbe</string>
@@ -103,7 +96,6 @@
<string name="event_edit_color_unsupported">Für diesen Kalender nicht verfügbar</string>
<string name="event_edit_color_unsupported_hint">Dieser Kalender stellt keine Farbpalette bereit. Du kannst eigene Farben für solche Kalender in den Einstellungen aktivieren.</string>
<string name="event_edit_color_sync_warning">Dieser Kalender verwirft oder überschreibt die Farbe unter Umständen bei der nächsten Synchronisierung.</string>
<!-- Termin-Formular — Speicher-Konflikt (v2.0) -->
<string name="event_edit_conflict_title">Termin wurde extern geändert</string>
<string name="event_edit_conflict_body">Während du bearbeitet hast, wurde dieser Termin anderswo geändert — durch Synchronisierung oder eine andere App. Was soll mit deinen Änderungen passieren?</string>
@@ -113,7 +105,6 @@
<string name="event_edit_conflict_discard_hint">Der Termin bleibt, wie er jetzt ist</string>
<string name="event_edit_gone_title">Termin wurde gelöscht</string>
<string name="event_edit_gone_body">Dieser Termin wurde zwischenzeitlich gelöscht, etwa auf einem anderen Gerät. Deine Änderungen können nicht mehr gespeichert werden.</string>
<!-- Termin-Formular — Wiederholungs-Picker (v1.3) -->
<string name="event_edit_recurrence_none">Wiederholt sich nicht</string>
<string name="event_edit_recurrence_custom">Benutzerdefiniert</string>
@@ -156,7 +147,6 @@
<string name="event_attendee_tentative">Vorläufig</string>
<string name="event_attendee_needs_action">Keine Antwort</string>
<string name="event_attendee_unknown"></string>
<!-- Event-Detail — vollständiges Auslesen (v0.6) -->
<string name="event_detail_reminders">Erinnerungen</string>
<string name="event_detail_timezone">Zeitzone</string>
@@ -196,10 +186,8 @@
<item quantity="one">%d Stunde</item>
<item quantity="other">%d Stunden</item>
</plurals>
<!-- Geteilte Event-Strings -->
<string name="event_untitled">(Ohne Titel)</string>
<!-- Erinnerungs-Benachrichtigungen (v1.4) -->
<string name="reminder_channel_name">Termin-Erinnerungen</string>
<string name="reminder_channel_description">Benachrichtigungen zu den Erinnerungszeiten deiner Termine</string>
@@ -215,23 +203,19 @@
<string name="reminder_onboarding_skip_button">Später</string>
<string name="reminder_action_snooze">Schlummern</string>
<string name="reminder_action_dismiss">Verwerfen</string>
<!-- View-Switcher (M1) -->
<string name="view_month">Monat</string>
<string name="view_week">Woche</string>
<string name="view_day">Tag</string>
<string name="view_agenda">Agenda</string>
<string name="view_section">Ansicht</string>
<!-- Zu Datum springen (Navigationsleiste) -->
<string name="drawer_jump_to_date">Zu Datum springen</string>
<!-- Agenda-Ansicht -->
<string name="agenda_today_action">Heute</string>
<string name="agenda_header_today">Heute</string>
<string name="agenda_header_tomorrow">Morgen</string>
<string name="agenda_empty_title">Alles erledigt</string>
<!-- Terminsuche -->
<string name="search_action">Suchen</string>
<string name="search_hint">Termine suchen</string>
@@ -239,7 +223,6 @@
<string name="search_clear">Löschen</string>
<string name="search_idle_hint">Durchsuche deine Termine nach Titel, Ort oder Notizen.</string>
<string name="search_empty">Keine Termine passen zu „%1$s“.</string>
<!-- Startbildschirm-Widgets -->
<string name="widget_agenda_title">Anstehend</string>
<string name="widget_agenda_label">Calendula Agenda</string>
@@ -250,19 +233,15 @@
<string name="widget_prev_month">Vorheriger Monat</string>
<string name="widget_next_month">Nächster Monat</string>
<string name="widget_today">Heute</string>
<!-- Verknüpfungen (Long-Press auf das App-Symbol) -->
<string name="shortcut_new_event_short">Neuer Termin</string>
<string name="shortcut_new_event_long">Neuen Termin erstellen</string>
<!-- Schnelleinstellungen-Kachel -->
<string name="qs_tile_new_event_label">Neuer Termin</string>
<string name="settings_qs_tile">Schnelleinstellungen-Kachel hinzufügen</string>
<string name="settings_qs_tile_hint">Eine Kachel „Neuer Termin“ zu den Schnelleinstellungen hinzufügen.</string>
<!-- Kalender-Filter (M3) -->
<string name="filter_title">Kalender</string>
<!-- Einstellungen (M4) -->
<string name="settings_title">Einstellungen</string>
<string name="settings_back">Zurück</string>
@@ -343,7 +322,6 @@
<string name="settings_about_logo_desc">Calendula-App-Symbol</string>
<string name="settings_report_problem">Problem melden</string>
<string name="settings_report_problem_hint">Absturzbericht senden oder Issue-Tracker öffnen</string>
<!-- Calendar manager -->
<string name="calendars_title">Kalender</string>
<string name="calendars_local_header">Deine Kalender</string>
@@ -398,7 +376,6 @@
<item quantity="one">%d bereits in diesem Kalender übersprungen.</item>
<item quantity="other">%d bereits in diesem Kalender übersprungen.</item>
</plurals>
<!-- Absturzberichte: vom Nutzer selbst als Gitea-Issue einreichbar -->
<string name="crash_dialog_title">Calendula ist abgestürzt</string>
<string name="crash_dialog_message">Calendula wurde beim letzten Mal unerwartet beendet. Du kannst bei der Behebung helfen, indem du diesen Bericht als Issue sendest. Er bleibt auf deinem Gerät, bis du ihn teilst, und enthält keine persönlichen Daten oder Kalenderinhalte — nur die technischen Angaben unten.</string>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="backup_interval_units">
<item>Minutes</item>
<item>Hours</item>
<item>Days</item>
<item>Weeks</item>
</string-array>
</resources>

View File

@@ -276,6 +276,15 @@
<string name="settings_default_view">Default view</string>
<string name="settings_dynamic_color">Dynamic colour</string>
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
<string name="settings_font_headings">Headings font</string>
<string name="settings_font_body">Body font</string>
<string name="settings_font_system">System default</string>
<string name="font_atkinson_hyperlegible">Atkinson Hyperlegible</string>
<string name="font_lora">Lora</string>
<string name="font_jetbrains_mono">JetBrains Mono</string>
<string name="settings_font_choose_file">Choose file…</string>
<string name="settings_font_custom_selected">Custom font</string>
<string name="settings_font_import_failed">Couldn\'t read that file as a font</string>
<string name="settings_week_start">Week starts on</string>
<string name="settings_week_start_auto">Automatic</string>
<string name="settings_time_format">Time format</string>
@@ -310,6 +319,12 @@
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
</plurals>
<string name="settings_section_views">Views</string>
<string name="settings_quick_switch_header">Quick-switch button</string>
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
<string name="settings_drawer_order_header">Navigation menu</string>
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
<string name="reorder_drag_handle">Drag to reorder</string>
<string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_autofocus_title">Focus title on new event</string>
@@ -344,6 +359,7 @@
<string name="settings_translate_hint">Add or improve a language on Weblate</string>
<!-- Hub category subtitles -->
<string name="settings_appearance_subtitle">Theme, default view, week start</string>
<string name="settings_views_subtitle">Quick-switch button and menu order</string>
<string name="settings_event_form_subtitle">Default fields for new events</string>
<string name="settings_notifications_subtitle">Event reminders</string>
<string name="settings_special_dates_subtitle">Contact birthdays &amp; anniversaries</string>