feat(agenda): limit how far ahead the agenda screen and widget show
Add independent agendaScreenRange and agendaWidgetRange preferences, each a rolling window: 1 day / 1 week / 1 month / custom 1–365 days (default Month). Rolling (not calendar-aligned) so the span never degenerates near a period boundary. The in-app Agenda screen and the agenda widget each read their own setting, configured via a new AgendaRangePicker with an inline custom-days editor. Closes #4 (Codeberg) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,9 @@ import androidx.datastore.preferences.core.edit
|
|||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.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.CalendarView
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -70,6 +73,28 @@ class SettingsPrefs @Inject constructor(
|
|||||||
store.edit { it[WEEK_START_KEY] = pref.name }
|
store.edit { it[WEEK_START_KEY] = pref.name }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far ahead the in-app Agenda screen shows events (v2.11). Defaults to
|
||||||
|
* [AgendaRange.Month] — a month of upcoming events. Independent of the
|
||||||
|
* widget's [agendaWidgetRange].
|
||||||
|
*/
|
||||||
|
val agendaScreenRange: Flow<AgendaRange> = store.data.map { prefs ->
|
||||||
|
parseAgendaRange(prefs[AGENDA_SCREEN_RANGE_KEY], AgendaRange.Month)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAgendaScreenRange(range: AgendaRange) {
|
||||||
|
store.edit { it[AGENDA_SCREEN_RANGE_KEY] = range.storageValue() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far ahead the agenda **widget** shows events (v2.11). Defaults to Month. */
|
||||||
|
val agendaWidgetRange: Flow<AgendaRange> = store.data.map { prefs ->
|
||||||
|
parseAgendaRange(prefs[AGENDA_WIDGET_RANGE_KEY], AgendaRange.Month)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setAgendaWidgetRange(range: AgendaRange) {
|
||||||
|
store.edit { it[AGENDA_WIDGET_RANGE_KEY] = range.storageValue() }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||||
* the historical hard-coded startup view — so existing users see no change
|
* the historical hard-coded startup view — so existing users see no change
|
||||||
@@ -259,6 +284,8 @@ class SettingsPrefs @Inject constructor(
|
|||||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||||
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
internal val WEEK_START_KEY = stringPreferencesKey("week_start")
|
||||||
|
internal val AGENDA_SCREEN_RANGE_KEY = stringPreferencesKey("agenda_screen_range")
|
||||||
|
internal val AGENDA_WIDGET_RANGE_KEY = stringPreferencesKey("agenda_widget_range")
|
||||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far ahead the agenda (screen or widget) shows events, as a rolling window
|
||||||
|
* starting at today. [Day]/[Week]/[Month] are fixed 1/7/30-day windows; [Custom]
|
||||||
|
* is an arbitrary day count. Rolling (not calendar-aligned) windows so the span
|
||||||
|
* never degenerates near a week/month boundary.
|
||||||
|
*/
|
||||||
|
sealed interface AgendaRange {
|
||||||
|
data object Day : AgendaRange
|
||||||
|
data object Week : AgendaRange
|
||||||
|
data object Month : AgendaRange
|
||||||
|
data class Custom(val days: Int) : AgendaRange
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Allowed bounds for a [Custom] day count. */
|
||||||
|
const val MIN_CUSTOM_DAYS = 1
|
||||||
|
const val MAX_CUSTOM_DAYS = 365
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inclusive number of days the window spans, starting at (and including) today. */
|
||||||
|
fun AgendaRange.dayCount(): Int = when (this) {
|
||||||
|
AgendaRange.Day -> 1
|
||||||
|
AgendaRange.Week -> 7
|
||||||
|
AgendaRange.Month -> 30
|
||||||
|
is AgendaRange.Custom -> days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stored representation: a fixed token, or `CUSTOM:<days>`. */
|
||||||
|
fun AgendaRange.storageValue(): String = when (this) {
|
||||||
|
AgendaRange.Day -> "DAY"
|
||||||
|
AgendaRange.Week -> "WEEK"
|
||||||
|
AgendaRange.Month -> "MONTH"
|
||||||
|
is AgendaRange.Custom -> "$CUSTOM_PREFIX$days"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a stored value; unknown/garbage falls back to [default]. */
|
||||||
|
fun parseAgendaRange(stored: String?, default: AgendaRange): AgendaRange = when {
|
||||||
|
stored == "DAY" -> AgendaRange.Day
|
||||||
|
stored == "WEEK" -> AgendaRange.Week
|
||||||
|
stored == "MONTH" -> AgendaRange.Month
|
||||||
|
stored != null && stored.startsWith(CUSTOM_PREFIX) -> {
|
||||||
|
val days = stored.removePrefix(CUSTOM_PREFIX).toIntOrNull()
|
||||||
|
if (days != null) {
|
||||||
|
AgendaRange.Custom(days.coerceIn(AgendaRange.MIN_CUSTOM_DAYS, AgendaRange.MAX_CUSTOM_DAYS))
|
||||||
|
} else {
|
||||||
|
default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> default
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CUSTOM_PREFIX = "CUSTOM:"
|
||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||||
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||||
@@ -30,16 +31,16 @@ import kotlin.time.Clock
|
|||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** How far ahead the agenda loads events from its anchor day. */
|
|
||||||
internal const val AGENDA_WINDOW_DAYS = 60
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AgendaViewModel @Inject constructor(
|
class AgendaViewModel @Inject constructor(
|
||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
|
settingsPrefs: SettingsPrefs,
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val screenRange = settingsPrefs.agendaScreenRange
|
||||||
|
|
||||||
private val zone = TimeZone.currentSystemDefault()
|
private val zone = TimeZone.currentSystemDefault()
|
||||||
|
|
||||||
private val todayDate: LocalDate
|
private val todayDate: LocalDate
|
||||||
@@ -48,12 +49,13 @@ class AgendaViewModel @Inject constructor(
|
|||||||
private val _anchor = MutableStateFlow(todayDate)
|
private val _anchor = MutableStateFlow(todayDate)
|
||||||
val anchor: StateFlow<LocalDate> = _anchor
|
val anchor: StateFlow<LocalDate> = _anchor
|
||||||
|
|
||||||
val state: StateFlow<AgendaUiState> = _anchor
|
val state: StateFlow<AgendaUiState> =
|
||||||
.flatMapLatest { anchor ->
|
combine(_anchor, screenRange) { anchor, range -> anchor to range }
|
||||||
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
|
.flatMapLatest { (anchor, range) ->
|
||||||
|
val window = agendaRange(anchor, range.dayCount() - 1, zone)
|
||||||
combine(
|
combine(
|
||||||
repository.calendars(),
|
repository.calendars(),
|
||||||
repository.instances(range),
|
repository.instances(window),
|
||||||
) { calendars, instances ->
|
) { calendars, instances ->
|
||||||
buildState(anchor, calendars, instances)
|
buildState(anchor, calendars, instances)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
@@ -37,6 +38,7 @@ import androidx.compose.ui.window.DialogProperties
|
|||||||
import androidx.compose.ui.window.DialogWindowProvider
|
import androidx.compose.ui.window.DialogWindowProvider
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
* Shared full-screen scaffold for selection pickers: a full-bleed [Dialog] that
|
||||||
@@ -269,6 +271,130 @@ private fun SelectedCheck() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agenda-range picker, full-screen: the fixed 1 day / 1 week / 1 month windows
|
||||||
|
* as grouped rows, plus a "Custom" row that expands an inline day-count editor
|
||||||
|
* (1–365). Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AgendaRangePicker(
|
||||||
|
title: String,
|
||||||
|
selected: AgendaRange,
|
||||||
|
onSelect: (AgendaRange) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val presets = listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month)
|
||||||
|
val customSelected = selected is AgendaRange.Custom
|
||||||
|
val rowCount = presets.size + 1 // + the custom row
|
||||||
|
|
||||||
|
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var amountText by rememberSaveable {
|
||||||
|
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||||
|
presets.forEachIndexed { index, option ->
|
||||||
|
val isSelected = option == selected
|
||||||
|
GroupedRow(
|
||||||
|
title = agendaRangeLabel(option),
|
||||||
|
position = positionOf(index, rowCount),
|
||||||
|
selected = isSelected,
|
||||||
|
trailing = if (isSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
onSelect(option)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GroupedRow(
|
||||||
|
title = if (customSelected) {
|
||||||
|
agendaRangeLabel(selected)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.agenda_range_custom)
|
||||||
|
},
|
||||||
|
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||||
|
selected = customSelected,
|
||||||
|
trailing = if (customSelected) {
|
||||||
|
{ SelectedCheck() }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
onClick = { customExpanded = !customExpanded },
|
||||||
|
)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = customExpanded,
|
||||||
|
enter = calendarExpandEnter(),
|
||||||
|
exit = calendarCollapseExit(),
|
||||||
|
) {
|
||||||
|
CustomDaysEditor(
|
||||||
|
amountText = amountText,
|
||||||
|
onAmountChange = { amountText = it },
|
||||||
|
onConfirm = { days ->
|
||||||
|
onSelect(AgendaRange.Custom(days))
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The expanded "Custom" day-count editor: an amount field (1–365) and Set. */
|
||||||
|
@Composable
|
||||||
|
private fun CustomDaysEditor(
|
||||||
|
amountText: String,
|
||||||
|
onAmountChange: (String) -> Unit,
|
||||||
|
onConfirm: (Int) -> Unit,
|
||||||
|
) {
|
||||||
|
val days = amountText.toIntOrNull()
|
||||||
|
?.takeIf { it in AgendaRange.MIN_CUSTOM_DAYS..AgendaRange.MAX_CUSTOM_DAYS }
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 22.dp, bottomEnd = 22.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
DialogAmountField(
|
||||||
|
value = amountText,
|
||||||
|
onValueChange = onAmountChange,
|
||||||
|
placeholder = "30",
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Text(
|
||||||
|
text = days?.let { pluralStringResource(R.plurals.agenda_range_days, it, it) }
|
||||||
|
?: stringResource(R.string.agenda_range_custom_hint),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = { days?.let(onConfirm) },
|
||||||
|
enabled = days != null,
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.reminder_custom_set))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label for an [AgendaRange] (used by the picker rows and settings summary). */
|
||||||
|
@Composable
|
||||||
|
fun agendaRangeLabel(range: AgendaRange): String = when (range) {
|
||||||
|
AgendaRange.Day -> stringResource(R.string.agenda_range_day)
|
||||||
|
AgendaRange.Week -> stringResource(R.string.agenda_range_week)
|
||||||
|
AgendaRange.Month -> stringResource(R.string.agenda_range_month)
|
||||||
|
is AgendaRange.Custom -> pluralStringResource(R.plurals.agenda_range_days, range.days, range.days)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||||
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
|||||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
import de.jeanlucmakiola.calendula.ui.common.calendarCollapseExit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
import de.jeanlucmakiola.calendula.ui.common.calendarExpandEnter
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
@@ -425,6 +427,8 @@ private fun AppearanceScreen(
|
|||||||
var showTheme by remember { mutableStateOf(false) }
|
var showTheme by remember { mutableStateOf(false) }
|
||||||
var showWeekStart by remember { mutableStateOf(false) }
|
var showWeekStart by remember { mutableStateOf(false) }
|
||||||
var showDefaultView by remember { mutableStateOf(false) }
|
var showDefaultView by remember { mutableStateOf(false) }
|
||||||
|
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||||
|
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
CollapsingScaffold(
|
CollapsingScaffold(
|
||||||
title = stringResource(R.string.settings_section_appearance),
|
title = stringResource(R.string.settings_section_appearance),
|
||||||
@@ -466,9 +470,21 @@ private fun AppearanceScreen(
|
|||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = stringResource(R.string.settings_week_start),
|
title = stringResource(R.string.settings_week_start),
|
||||||
summary = weekStartLabel(state.weekStart),
|
summary = weekStartLabel(state.weekStart),
|
||||||
position = Position.Bottom,
|
position = Position.Middle,
|
||||||
onClick = { showWeekStart = true },
|
onClick = { showWeekStart = true },
|
||||||
)
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||||
|
position = Position.Middle,
|
||||||
|
onClick = { showAgendaScreenRange = true },
|
||||||
|
)
|
||||||
|
GroupedRow(
|
||||||
|
title = stringResource(R.string.settings_agenda_widget_range),
|
||||||
|
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||||
|
position = Position.Bottom,
|
||||||
|
onClick = { showAgendaWidgetRange = true },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showTheme) {
|
if (showTheme) {
|
||||||
@@ -491,6 +507,22 @@ private fun AppearanceScreen(
|
|||||||
onDismiss = { showWeekStart = false },
|
onDismiss = { showWeekStart = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (showAgendaScreenRange) {
|
||||||
|
AgendaRangePicker(
|
||||||
|
title = stringResource(R.string.settings_agenda_range),
|
||||||
|
selected = state.agendaScreenRange,
|
||||||
|
onSelect = viewModel::setAgendaScreenRange,
|
||||||
|
onDismiss = { showAgendaScreenRange = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showAgendaWidgetRange) {
|
||||||
|
AgendaRangePicker(
|
||||||
|
title = stringResource(R.string.settings_agenda_widget_range),
|
||||||
|
selected = state.agendaWidgetRange,
|
||||||
|
onSelect = viewModel::setAgendaWidgetRange,
|
||||||
|
onDismiss = { showAgendaWidgetRange = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
if (showDefaultView) {
|
if (showDefaultView) {
|
||||||
OptionPicker(
|
OptionPicker(
|
||||||
title = stringResource(R.string.settings_default_view),
|
title = stringResource(R.string.settings_default_view),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +19,10 @@ data class SettingsUiState(
|
|||||||
val dynamicColor: Boolean = true,
|
val dynamicColor: Boolean = true,
|
||||||
val dynamicColorAvailable: Boolean = true,
|
val dynamicColorAvailable: Boolean = true,
|
||||||
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
val weekStart: WeekStartPref = WeekStartPref.AUTO,
|
||||||
|
/** How far ahead the in-app Agenda screen shows events (v2.11). */
|
||||||
|
val agendaScreenRange: AgendaRange = AgendaRange.Month,
|
||||||
|
/** How far ahead the agenda widget shows events (v2.11). */
|
||||||
|
val agendaWidgetRange: AgendaRange = AgendaRange.Month,
|
||||||
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
/** The calendar view the app opens on, and the home of the view back stack (M1). */
|
||||||
val defaultView: CalendarView = CalendarView.Week,
|
val defaultView: CalendarView = CalendarView.Week,
|
||||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -73,10 +74,16 @@ class SettingsViewModel @Inject constructor(
|
|||||||
) { overrides, allDayOverrides, calendars ->
|
) { overrides, allDayOverrides, calendars ->
|
||||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||||
},
|
},
|
||||||
prefs.defaultView,
|
combine(
|
||||||
) { base, defaults, overrides, defaultView ->
|
prefs.defaultView,
|
||||||
|
prefs.agendaScreenRange,
|
||||||
|
prefs.agendaWidgetRange,
|
||||||
|
) { view, screenRange, widgetRange -> ViewSettings(view, screenRange, widgetRange) },
|
||||||
|
) { base, defaults, overrides, views ->
|
||||||
base.copy(
|
base.copy(
|
||||||
defaultView = defaultView,
|
defaultView = views.defaultView,
|
||||||
|
agendaScreenRange = views.agendaScreenRange,
|
||||||
|
agendaWidgetRange = views.agendaWidgetRange,
|
||||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||||
defaultReminderMinutes = defaults.defaultReminder,
|
defaultReminderMinutes = defaults.defaultReminder,
|
||||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||||
@@ -106,6 +113,12 @@ class SettingsViewModel @Inject constructor(
|
|||||||
val calendars: List<CalendarSource>,
|
val calendars: List<CalendarSource>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ViewSettings(
|
||||||
|
val defaultView: CalendarView,
|
||||||
|
val agendaScreenRange: AgendaRange,
|
||||||
|
val agendaWidgetRange: AgendaRange,
|
||||||
|
)
|
||||||
|
|
||||||
fun setThemeMode(mode: ThemeMode) {
|
fun setThemeMode(mode: ThemeMode) {
|
||||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
}
|
}
|
||||||
@@ -118,6 +131,14 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAgendaScreenRange(range: AgendaRange) {
|
||||||
|
viewModelScope.launch { prefs.setAgendaScreenRange(range) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAgendaWidgetRange(range: AgendaRange) {
|
||||||
|
viewModelScope.launch { prefs.setAgendaWidgetRange(range) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDefaultView(view: CalendarView) {
|
fun setDefaultView(view: CalendarView) {
|
||||||
viewModelScope.launch { prefs.setDefaultView(view) }
|
viewModelScope.launch { prefs.setDefaultView(view) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaDay
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
import de.jeanlucmakiola.calendula.ui.agenda.agendaRange
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||||
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
import de.jeanlucmakiola.calendula.ui.agenda.groupAgendaDays
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
@@ -23,9 +24,6 @@ import kotlinx.datetime.toLocalDateTime
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
/** How far ahead the agenda widget loads (a month of upcoming events). */
|
|
||||||
private const val AGENDA_WIDGET_DAYS = 30
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* How far either side of today the month widget pre-loads. The displayed month
|
* How far either side of today the month widget pre-loads. The displayed month
|
||||||
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
||||||
@@ -91,8 +89,10 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
|||||||
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
if (!hasCalendarPermission()) return AgendaWidgetData.NeedsPermission
|
||||||
val zone = systemZone()
|
val zone = systemZone()
|
||||||
val anchor = today(zone)
|
val anchor = today(zone)
|
||||||
val repo = widgetEntryPoint().calendarRepository()
|
val ep = widgetEntryPoint()
|
||||||
val instances = repo.instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
val range = ep.settingsPrefs().agendaWidgetRange.first()
|
||||||
|
val window = agendaRange(anchor, range.dayCount() - 1, zone)
|
||||||
|
val instances = ep.calendarRepository().instances(window).first()
|
||||||
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
|
return AgendaWidgetData.Ready(today = anchor, days = groupAgendaDays(anchor, instances, zone))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,17 @@
|
|||||||
<string name="settings_week_start_auto">Automatisch</string>
|
<string name="settings_week_start_auto">Automatisch</string>
|
||||||
<string name="settings_week_start_monday">Montag</string>
|
<string name="settings_week_start_monday">Montag</string>
|
||||||
<string name="settings_week_start_sunday">Sonntag</string>
|
<string name="settings_week_start_sunday">Sonntag</string>
|
||||||
|
<string name="settings_agenda_range">Agenda-Zeitraum</string>
|
||||||
|
<string name="settings_agenda_widget_range">Agenda-Widget-Zeitraum</string>
|
||||||
|
<string name="agenda_range_day">1 Tag</string>
|
||||||
|
<string name="agenda_range_week">1 Woche</string>
|
||||||
|
<string name="agenda_range_month">1 Monat</string>
|
||||||
|
<string name="agenda_range_custom">Benutzerdefiniert…</string>
|
||||||
|
<string name="agenda_range_custom_hint">Tage</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d Tag</item>
|
||||||
|
<item quantity="other">%d Tage</item>
|
||||||
|
</plurals>
|
||||||
<string name="settings_section_event_form">Termin-Formular</string>
|
<string name="settings_section_event_form">Termin-Formular</string>
|
||||||
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
|
||||||
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
<string name="settings_color_unsupported">Farben auf nicht unterstützten Kalendern erlauben</string>
|
||||||
|
|||||||
@@ -271,6 +271,17 @@
|
|||||||
<string name="settings_week_start_auto">Automatic</string>
|
<string name="settings_week_start_auto">Automatic</string>
|
||||||
<string name="settings_week_start_monday">Monday</string>
|
<string name="settings_week_start_monday">Monday</string>
|
||||||
<string name="settings_week_start_sunday">Sunday</string>
|
<string name="settings_week_start_sunday">Sunday</string>
|
||||||
|
<string name="settings_agenda_range">Agenda range</string>
|
||||||
|
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||||
|
<string name="agenda_range_day">1 day</string>
|
||||||
|
<string name="agenda_range_week">1 week</string>
|
||||||
|
<string name="agenda_range_month">1 month</string>
|
||||||
|
<string name="agenda_range_custom">Custom…</string>
|
||||||
|
<string name="agenda_range_custom_hint">Days</string>
|
||||||
|
<plurals name="agenda_range_days">
|
||||||
|
<item quantity="one">%d day</item>
|
||||||
|
<item quantity="other">%d days</item>
|
||||||
|
</plurals>
|
||||||
<string name="settings_section_event_form">New event form</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_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||||
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
|||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
@@ -49,6 +50,18 @@ class SettingsPrefsTest {
|
|||||||
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY)
|
assertThat(prefs.weekStart.first()).isEqualTo(WeekStartPref.SUNDAY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `agenda ranges default to month and round-trip independently`(@TempDir tempDir: Path) = runTest {
|
||||||
|
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||||
|
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Month)
|
||||||
|
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Month)
|
||||||
|
|
||||||
|
prefs.setAgendaScreenRange(AgendaRange.Custom(14))
|
||||||
|
prefs.setAgendaWidgetRange(AgendaRange.Week)
|
||||||
|
assertThat(prefs.agendaScreenRange.first()).isEqualTo(AgendaRange.Custom(14))
|
||||||
|
assertThat(prefs.agendaWidgetRange.first()).isEqualTo(AgendaRange.Week)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
||||||
val store = newDataStore(tempDir)
|
val store = newDataStore(tempDir)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.agenda
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class AgendaRangeTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fixed ranges have 1, 7, 30 day counts`() {
|
||||||
|
assertThat(AgendaRange.Day.dayCount()).isEqualTo(1)
|
||||||
|
assertThat(AgendaRange.Week.dayCount()).isEqualTo(7)
|
||||||
|
assertThat(AgendaRange.Month.dayCount()).isEqualTo(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `custom day count is clamped to bounds`() {
|
||||||
|
assertThat(AgendaRange.Custom(45).dayCount()).isEqualTo(45)
|
||||||
|
assertThat(AgendaRange.Custom(0).dayCount()).isEqualTo(AgendaRange.MIN_CUSTOM_DAYS)
|
||||||
|
assertThat(AgendaRange.Custom(9_999).dayCount()).isEqualTo(AgendaRange.MAX_CUSTOM_DAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `fixed ranges round-trip through storage`() {
|
||||||
|
listOf(AgendaRange.Day, AgendaRange.Week, AgendaRange.Month).forEach { range ->
|
||||||
|
assertThat(parseAgendaRange(range.storageValue(), default = AgendaRange.Day))
|
||||||
|
.isEqualTo(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `custom range round-trips through storage`() {
|
||||||
|
val range = AgendaRange.Custom(14)
|
||||||
|
assertThat(range.storageValue()).isEqualTo("CUSTOM:14")
|
||||||
|
assertThat(parseAgendaRange("CUSTOM:14", default = AgendaRange.Day))
|
||||||
|
.isEqualTo(AgendaRange.Custom(14))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `garbage and null fall back to the default`() {
|
||||||
|
assertThat(parseAgendaRange(null, default = AgendaRange.Month)).isEqualTo(AgendaRange.Month)
|
||||||
|
assertThat(parseAgendaRange("YEAR", default = AgendaRange.Week)).isEqualTo(AgendaRange.Week)
|
||||||
|
assertThat(parseAgendaRange("CUSTOM:abc", default = AgendaRange.Day)).isEqualTo(AgendaRange.Day)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `out-of-range custom value clamps on parse`() {
|
||||||
|
assertThat(parseAgendaRange("CUSTOM:9999", default = AgendaRange.Day))
|
||||||
|
.isEqualTo(AgendaRange.Custom(AgendaRange.MAX_CUSTOM_DAYS))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user