feat(agenda): limit how far ahead the agenda screen and widget show (#4) #44
@@ -5,6 +5,7 @@ title: "Crash: "
|
||||
labels:
|
||||
- bug
|
||||
- crash
|
||||
- priority:high
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Feature request
|
||||
about: Suggest an idea or improvement
|
||||
title: ""
|
||||
labels:
|
||||
- enhancement
|
||||
- feat
|
||||
---
|
||||
|
||||
### What would you like Calendula to do?
|
||||
|
||||
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
19
.gitea/ISSUE_TEMPLATE/question.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask how something works or get help using Calendula
|
||||
title: ""
|
||||
labels:
|
||||
- question
|
||||
---
|
||||
|
||||
### Your question
|
||||
|
||||
|
||||
### What you've tried
|
||||
<!-- so far, if anything -->
|
||||
|
||||
|
||||
### Context
|
||||
- Calendula version: <!-- Settings → bottom of the screen -->
|
||||
- Android version:
|
||||
- Device:
|
||||
@@ -7,6 +7,9 @@ import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
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 kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -112,6 +115,40 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agenda shows its top range bar — the "showing …" header and
|
||||
* the session range switcher (v2.11). Default ON.
|
||||
*/
|
||||
val agendaShowRangeBar: Flow<Boolean> = store.data.map { prefs ->
|
||||
prefs[AGENDA_SHOW_RANGE_BAR_KEY] ?: true
|
||||
}
|
||||
|
||||
suspend fun setAgendaShowRangeBar(enabled: Boolean) {
|
||||
store.edit { it[AGENDA_SHOW_RANGE_BAR_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar view the app opens on (M1). Defaults to [CalendarView.Week] —
|
||||
* the historical hard-coded startup view — so existing users see no change
|
||||
@@ -301,6 +338,9 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
|
||||
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
|
||||
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 AGENDA_SHOW_RANGE_BAR_KEY = booleanPreferencesKey("agenda_show_range_bar")
|
||||
internal val TIME_FORMAT_KEY = stringPreferencesKey("time_format")
|
||||
internal val SHOW_HOUR_LINES_KEY = booleanPreferencesKey("show_hour_lines")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* How far ahead the agenda (screen or widget) shows events, starting at today.
|
||||
*
|
||||
* Two flavours:
|
||||
* - **Rolling** windows of a fixed length: [Day] (today), [Week] (7 days),
|
||||
* [Month] (30 days), [Custom] (1–365 days). These never degenerate.
|
||||
* - **Calendar-aligned** windows that end at a period boundary: [ThisWeek] runs
|
||||
* through the end of the current week (respecting the week-start preference —
|
||||
* a Monday start means "everything before next Monday"); [ThisMonth] runs
|
||||
* through the last day of the current month. These shrink as the period ends.
|
||||
*/
|
||||
sealed interface AgendaRange {
|
||||
data object Day : AgendaRange
|
||||
data object Week : AgendaRange
|
||||
data object Month : AgendaRange
|
||||
data object ThisWeek : AgendaRange
|
||||
data object ThisMonth : 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
|
||||
}
|
||||
}
|
||||
|
||||
private const val DAYS_PER_WEEK = 7
|
||||
|
||||
/**
|
||||
* Inclusive number of days the window spans, starting at (and including)
|
||||
* [anchor]. The calendar-aligned ranges depend on the anchor day and, for
|
||||
* [AgendaRange.ThisWeek], the [weekStart] preference.
|
||||
*/
|
||||
fun AgendaRange.dayCount(anchor: LocalDate, weekStart: DayOfWeek): Int = when (this) {
|
||||
AgendaRange.Day -> 1
|
||||
AgendaRange.Week -> DAYS_PER_WEEK
|
||||
AgendaRange.Month -> 30
|
||||
AgendaRange.ThisWeek -> {
|
||||
// Days elapsed since the week's first day (0 when today *is* the start),
|
||||
// so the window is the rest of the week through the day before it repeats.
|
||||
val sinceWeekStart = ((anchor.dayOfWeek.ordinal - weekStart.ordinal) + DAYS_PER_WEEK) % DAYS_PER_WEEK
|
||||
DAYS_PER_WEEK - sinceWeekStart
|
||||
}
|
||||
AgendaRange.ThisMonth -> {
|
||||
val daysInMonth = YearMonth.of(anchor.year, anchor.month.ordinal + 1).lengthOfMonth()
|
||||
daysInMonth - anchor.day + 1
|
||||
}
|
||||
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"
|
||||
AgendaRange.ThisWeek -> "THIS_WEEK"
|
||||
AgendaRange.ThisMonth -> "THIS_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 == "THIS_WEEK" -> AgendaRange.ThisWeek
|
||||
stored == "THIS_MONTH" -> AgendaRange.ThisMonth
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete span the range covers, starting at [start] through [end]
|
||||
* (inclusive), for a human-readable header:
|
||||
* - [AgendaRange.Day] → a single medium date ("27 Jun 2026")
|
||||
* - [AgendaRange.ThisMonth] → month and year ("June 2026")
|
||||
* - everything else → "start – end" ("27 Jun – 3 Jul 2026")
|
||||
*/
|
||||
fun agendaRangeWindowSummary(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val javaStart = java.time.LocalDate.of(start.year, start.month.ordinal + 1, start.day)
|
||||
val javaEnd = java.time.LocalDate.of(end.year, end.month.ordinal + 1, end.day)
|
||||
val medium = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
return when (range) {
|
||||
AgendaRange.Day -> medium.format(javaStart)
|
||||
AgendaRange.ThisMonth -> DateTimeFormatter.ofPattern("LLLL yyyy", locale).format(javaStart)
|
||||
else -> "${DateTimeFormatter.ofPattern("d MMM", locale).format(javaStart)} – ${medium.format(javaEnd)}"
|
||||
}
|
||||
}
|
||||
|
||||
private const val CUSTOM_PREFIX = "CUSTOM:"
|
||||
@@ -2,22 +2,26 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.Coffee
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.DrawerValue
|
||||
@@ -35,7 +39,10 @@ import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -47,6 +54,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarAnimateItem
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
@@ -91,11 +100,13 @@ fun AgendaScreen(
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
|
||||
val isOnToday = when (val s = state) {
|
||||
is AgendaUiState.Success -> s.anchor == s.today
|
||||
else -> true
|
||||
}
|
||||
val successState = state as? AgendaUiState.Success
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -138,18 +149,129 @@ fun AgendaScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
AgendaContent(
|
||||
state = state,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
AgendaRangeBanner(
|
||||
range = s.range,
|
||||
start = s.anchor,
|
||||
end = s.rangeEnd,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
AgendaRangePill(
|
||||
range = s.range,
|
||||
isOverride = s.rangeIsOverride,
|
||||
onClick = { showRangePicker = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
AgendaContent(
|
||||
state = state,
|
||||
onRetry = viewModel::goToToday,
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showRangePicker) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.agenda_range_override_hint),
|
||||
selected = successState?.range ?: AgendaRange.Month,
|
||||
onSelect = viewModel::setRangeOverride,
|
||||
onDismiss = { showRangePicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A compact tonal pill showing the agenda's current range. Tapping it opens the
|
||||
* range picker as a session-only override. Filled with the primary container
|
||||
* while an override is active, so the temporary state is obvious.
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangePill(
|
||||
range: AgendaRange,
|
||||
isOverride: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val container = if (isOverride) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
val content = if (isOverride) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Surface(
|
||||
color = container,
|
||||
contentColor = content,
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.DateRange,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
text = agendaRangeLabel(range),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A header naming the concrete window currently shown, e.g. "Showing all events
|
||||
* for · Today, 27 Jun 2026" / "This week, 27 Jun – 3 Jul" / "This month, June 2026".
|
||||
*/
|
||||
@Composable
|
||||
private fun AgendaRangeBanner(
|
||||
range: AgendaRange,
|
||||
start: LocalDate,
|
||||
end: LocalDate,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val window = agendaRangeWindowSummary(range, start, end, locale)
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_range_showing_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = "${agendaRangeLabel(range)}, $window",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AgendaContent(
|
||||
state: AgendaUiState,
|
||||
@@ -257,7 +379,7 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.EventAvailable,
|
||||
imageVector = Icons.Filled.Coffee,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(48.dp),
|
||||
@@ -268,13 +390,6 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.agenda_empty_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,5 +53,13 @@ sealed interface AgendaUiState {
|
||||
val anchor: LocalDate,
|
||||
val today: LocalDate,
|
||||
val days: List<AgendaDay>,
|
||||
/** The range currently in effect — the saved default or a session override. */
|
||||
val range: AgendaRange,
|
||||
/** True when [range] is a temporary in-view override of the saved default. */
|
||||
val rangeIsOverride: Boolean,
|
||||
/** Last day the current [range] covers (inclusive), for the range header. */
|
||||
val rangeEnd: LocalDate,
|
||||
/** Whether to show the top range bar — header + switcher (toggle, on by default). */
|
||||
val showRangeBar: Boolean,
|
||||
) : AgendaUiState
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
@@ -17,10 +19,13 @@ import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import java.util.Locale
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
@@ -30,16 +35,24 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
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)
|
||||
@HiltViewModel
|
||||
class AgendaViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
settingsPrefs: SettingsPrefs,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
// The saved agenda range plus the range-bar visibility toggle.
|
||||
private val agendaSettings = combine(
|
||||
settingsPrefs.agendaScreenRange,
|
||||
settingsPrefs.agendaShowRangeBar,
|
||||
) { range, showBar -> AgendaSettings(range, showBar) }
|
||||
|
||||
// First day of the week, for the calendar-aligned "this week" range.
|
||||
private val weekStartDay = settingsPrefs.weekStart
|
||||
.map { it.resolveFirstDay(Locale.getDefault()) }
|
||||
|
||||
private val zone = TimeZone.currentSystemDefault()
|
||||
|
||||
private val todayDate: LocalDate
|
||||
@@ -48,14 +61,32 @@ class AgendaViewModel @Inject constructor(
|
||||
private val _anchor = MutableStateFlow(todayDate)
|
||||
val anchor: StateFlow<LocalDate> = _anchor
|
||||
|
||||
val state: StateFlow<AgendaUiState> = _anchor
|
||||
.flatMapLatest { anchor ->
|
||||
val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone)
|
||||
// A transient, in-view override of the saved agenda range. Held in memory
|
||||
// (not persisted), so it survives view switches and rotation within the
|
||||
// session but resets to the saved default when the app is relaunched.
|
||||
private val _rangeOverride = MutableStateFlow<AgendaRange?>(null)
|
||||
|
||||
val state: StateFlow<AgendaUiState> =
|
||||
combine(_anchor, agendaSettings, _rangeOverride, weekStartDay) { anchor, settings, override, weekStart ->
|
||||
AgendaParams(
|
||||
anchor = anchor,
|
||||
range = override ?: settings.range,
|
||||
rangeIsOverride = override != null && override != settings.range,
|
||||
weekStart = weekStart,
|
||||
showRangeBar = settings.showBar,
|
||||
)
|
||||
}
|
||||
.flatMapLatest { params ->
|
||||
val window = agendaRange(
|
||||
params.anchor,
|
||||
params.range.dayCount(params.anchor, params.weekStart) - 1,
|
||||
zone,
|
||||
)
|
||||
combine(
|
||||
repository.calendars(),
|
||||
repository.instances(range),
|
||||
repository.instances(window),
|
||||
) { calendars, instances ->
|
||||
buildState(anchor, calendars, instances)
|
||||
buildState(params, calendars, instances)
|
||||
}
|
||||
}
|
||||
.catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) }
|
||||
@@ -75,16 +106,47 @@ class AgendaViewModel @Inject constructor(
|
||||
_anchor.value = date
|
||||
}
|
||||
|
||||
/** Temporarily override the agenda range for this session (the bottom-left pill). */
|
||||
fun setRangeOverride(range: AgendaRange) {
|
||||
_rangeOverride.value = range
|
||||
}
|
||||
|
||||
private data class AgendaSettings(
|
||||
val range: AgendaRange,
|
||||
val showBar: Boolean,
|
||||
)
|
||||
|
||||
private data class AgendaParams(
|
||||
val anchor: LocalDate,
|
||||
val range: AgendaRange,
|
||||
val rangeIsOverride: Boolean,
|
||||
val weekStart: DayOfWeek,
|
||||
val showRangeBar: Boolean,
|
||||
)
|
||||
|
||||
private fun buildState(
|
||||
anchor: LocalDate,
|
||||
params: AgendaParams,
|
||||
calendars: List<CalendarSource>,
|
||||
instances: List<EventInstance>,
|
||||
): AgendaUiState {
|
||||
if (calendars.isEmpty()) {
|
||||
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
|
||||
}
|
||||
val anchor = params.anchor
|
||||
val days = groupAgendaDays(anchor, instances, zone)
|
||||
return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days)
|
||||
val rangeEnd = anchor.plus(
|
||||
params.range.dayCount(anchor, params.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
return AgendaUiState.Success(
|
||||
anchor = anchor,
|
||||
today = todayDate,
|
||||
days = days,
|
||||
range = params.range,
|
||||
rangeIsOverride = params.rangeIsOverride,
|
||||
rangeEnd = rangeEnd,
|
||||
showRangeBar = params.showRangeBar,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -30,6 +31,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@@ -37,6 +39,7 @@ import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.compose.ui.window.DialogWindowProvider
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
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
|
||||
@@ -260,6 +263,17 @@ private fun CustomReminderEditor(
|
||||
}
|
||||
}
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||
@Composable
|
||||
private fun PickerDescription(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedCheck() {
|
||||
Icon(
|
||||
@@ -269,6 +283,149 @@ private fun SelectedCheck() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Agenda-range picker, full-screen. Two grouped lists — the calendar-aligned
|
||||
* options (today / this week / this month) and the rolling windows (next 7 / 30
|
||||
* days), with a "Custom" row that expands an inline day-count editor (1–365).
|
||||
* Mirrors [ReminderDefaultPicker]'s custom-expand pattern.
|
||||
*/
|
||||
@Composable
|
||||
fun AgendaRangePicker(
|
||||
title: String,
|
||||
description: String,
|
||||
selected: AgendaRange,
|
||||
onSelect: (AgendaRange) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val calendarAligned = listOf(AgendaRange.Day, AgendaRange.ThisWeek, AgendaRange.ThisMonth)
|
||||
val rolling = listOf(AgendaRange.Week, AgendaRange.Month)
|
||||
val rollingRowCount = rolling.size + 1 // + the custom row
|
||||
val customSelected = selected is AgendaRange.Custom
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable {
|
||||
mutableStateOf((selected as? AgendaRange.Custom)?.days?.toString() ?: "")
|
||||
}
|
||||
|
||||
val rangeRow: @Composable (AgendaRange, Position) -> Unit = { option, position ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = agendaRangeLabel(option),
|
||||
position = position,
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss) {
|
||||
PickerDescription(description)
|
||||
|
||||
// Calendar-aligned windows.
|
||||
calendarAligned.forEachIndexed { index, option ->
|
||||
rangeRow(option, positionOf(index, calendarAligned.size))
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// Rolling windows + custom.
|
||||
rolling.forEachIndexed { index, option ->
|
||||
rangeRow(option, positionOf(index, rollingRowCount))
|
||||
}
|
||||
GroupedRow(
|
||||
title = if (customSelected) {
|
||||
agendaRangeLabel(selected)
|
||||
} else {
|
||||
stringResource(R.string.agenda_range_custom)
|
||||
},
|
||||
position = if (customExpanded) Position.Top else positionOf(rolling.size, rollingRowCount),
|
||||
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.ThisWeek -> stringResource(R.string.agenda_range_this_week)
|
||||
AgendaRange.ThisMonth -> stringResource(R.string.agenda_range_this_month)
|
||||
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
|
||||
private fun reminderOverrideLabel(override: CalendarReminderOverride): String = when (override) {
|
||||
CalendarReminderOverride.Inherit -> stringResource(R.string.reminder_use_default)
|
||||
|
||||
@@ -93,6 +93,8 @@ import de.jeanlucmakiola.calendula.ui.crash.openIssueTracker
|
||||
import de.jeanlucmakiola.calendula.ui.crash.submitCrashReport
|
||||
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.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.CollapsingScaffold
|
||||
import de.jeanlucmakiola.calendula.ui.common.GroupedRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
@@ -430,23 +432,20 @@ private fun AppearanceScreen(
|
||||
var showWeekStart by remember { mutableStateOf(false) }
|
||||
var showTimeFormat by remember { mutableStateOf(false) }
|
||||
var showDefaultView by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showAgendaWidgetRange by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_appearance),
|
||||
onBack = onBack,
|
||||
) {
|
||||
// Theme & colour
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_theme),
|
||||
summary = themeLabel(state.themeMode),
|
||||
position = Position.Top,
|
||||
onClick = { showTheme = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
summary = stringResource(state.defaultView.labelRes),
|
||||
position = Position.Middle,
|
||||
onClick = { showDefaultView = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_dynamic_color),
|
||||
summary = if (state.dynamicColorAvailable) {
|
||||
@@ -454,7 +453,7 @@ private fun AppearanceScreen(
|
||||
} else {
|
||||
stringResource(R.string.settings_dynamic_color_unavailable)
|
||||
},
|
||||
position = Position.Middle,
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.dynamicColor,
|
||||
@@ -468,6 +467,16 @@ private fun AppearanceScreen(
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Calendar — view, week, and timeline formatting
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_default_view),
|
||||
summary = stringResource(state.defaultView.labelRes),
|
||||
position = Position.Top,
|
||||
onClick = { showDefaultView = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_week_start),
|
||||
summary = weekStartLabel(state.weekStart),
|
||||
@@ -492,6 +501,34 @@ private fun AppearanceScreen(
|
||||
},
|
||||
onClick = { viewModel.setShowHourLines(!state.showHourLines) },
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Agenda
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
summary = agendaRangeLabel(state.agendaScreenRange),
|
||||
position = Position.Top,
|
||||
onClick = { showAgendaScreenRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
summary = agendaRangeLabel(state.agendaWidgetRange),
|
||||
position = Position.Middle,
|
||||
onClick = { showAgendaWidgetRange = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_agenda_range_bar),
|
||||
summary = stringResource(R.string.settings_agenda_range_bar_hint),
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.agendaShowRangeBar,
|
||||
onCheckedChange = viewModel::setAgendaShowRangeBar,
|
||||
)
|
||||
},
|
||||
onClick = { viewModel.setAgendaShowRangeBar(!state.agendaShowRangeBar) },
|
||||
)
|
||||
}
|
||||
|
||||
if (showTheme) {
|
||||
@@ -514,6 +551,24 @@ private fun AppearanceScreen(
|
||||
onDismiss = { showWeekStart = false },
|
||||
)
|
||||
}
|
||||
if (showAgendaScreenRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
description = stringResource(R.string.settings_agenda_range_hint),
|
||||
selected = state.agendaScreenRange,
|
||||
onSelect = viewModel::setAgendaScreenRange,
|
||||
onDismiss = { showAgendaScreenRange = false },
|
||||
)
|
||||
}
|
||||
if (showAgendaWidgetRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_widget_range),
|
||||
description = stringResource(R.string.settings_agenda_widget_range_hint),
|
||||
selected = state.agendaWidgetRange,
|
||||
onSelect = viewModel::setAgendaWidgetRange,
|
||||
onDismiss = { showAgendaWidgetRange = false },
|
||||
)
|
||||
}
|
||||
if (showTimeFormat) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_time_format),
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,12 @@ data class SettingsUiState(
|
||||
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
|
||||
/** Whether the week/day timeline draws an hour separator line (v2.11). */
|
||||
val showHourLines: Boolean = false,
|
||||
/** 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,
|
||||
/** Whether the agenda shows its top range bar — header + switcher (v2.11). */
|
||||
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,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.glance.appwidget.GlanceAppWidgetManager
|
||||
import androidx.glance.appwidget.state.updateAppWidgetState
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarReminderOverride
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
@@ -12,7 +17,12 @@ 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.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -21,12 +31,15 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: SettingsPrefs,
|
||||
repository: CalendarRepository,
|
||||
@ApplicationContext private val appContext: Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val dynamicColorAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
@@ -74,13 +87,24 @@ class SettingsViewModel @Inject constructor(
|
||||
) { overrides, allDayOverrides, calendars ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars)
|
||||
},
|
||||
combine(prefs.defaultView, prefs.timeFormat) { view, timeFormat -> view to timeFormat },
|
||||
prefs.showHourLines,
|
||||
) { base, defaults, overrides, viewAndTimeFormat, showHourLines ->
|
||||
combine(
|
||||
prefs.defaultView,
|
||||
prefs.agendaScreenRange,
|
||||
prefs.agendaWidgetRange,
|
||||
prefs.timeFormat,
|
||||
prefs.showHourLines,
|
||||
) { view, screenRange, widgetRange, timeFormat, showHourLines ->
|
||||
ViewSettings(view, screenRange, widgetRange, timeFormat, showHourLines)
|
||||
},
|
||||
prefs.agendaShowRangeBar,
|
||||
) { base, defaults, overrides, views, showRangeBar ->
|
||||
base.copy(
|
||||
defaultView = viewAndTimeFormat.first,
|
||||
timeFormat = viewAndTimeFormat.second,
|
||||
showHourLines = showHourLines,
|
||||
defaultView = views.defaultView,
|
||||
agendaScreenRange = views.agendaScreenRange,
|
||||
agendaWidgetRange = views.agendaWidgetRange,
|
||||
timeFormat = views.timeFormat,
|
||||
showHourLines = views.showHourLines,
|
||||
agendaShowRangeBar = showRangeBar,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -96,6 +120,12 @@ class SettingsViewModel @Inject constructor(
|
||||
initialValue = SettingsUiState(dynamicColorAvailable = dynamicColorAvailable),
|
||||
)
|
||||
|
||||
// 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,
|
||||
// the last call reads the final committed pref and renders it.
|
||||
private val widgetRefreshMutex = Mutex()
|
||||
|
||||
private data class ReminderDefaults(
|
||||
val allowColor: Boolean,
|
||||
val defaultReminder: Int?,
|
||||
@@ -110,6 +140,14 @@ class SettingsViewModel @Inject constructor(
|
||||
val calendars: List<CalendarSource>,
|
||||
)
|
||||
|
||||
private data class ViewSettings(
|
||||
val defaultView: CalendarView,
|
||||
val agendaScreenRange: AgendaRange,
|
||||
val agendaWidgetRange: AgendaRange,
|
||||
val timeFormat: TimeFormatPref,
|
||||
val showHourLines: Boolean,
|
||||
)
|
||||
|
||||
fun setThemeMode(mode: ThemeMode) {
|
||||
viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||
}
|
||||
@@ -119,7 +157,40 @@ class SettingsViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun setWeekStart(pref: WeekStartPref) {
|
||||
viewModelScope.launch { prefs.setWeekStart(pref) }
|
||||
viewModelScope.launch {
|
||||
prefs.setWeekStart(pref)
|
||||
// Both widgets depend on the week start (month weekday header; the
|
||||
// agenda widget's "this week" range).
|
||||
widgetRefreshMutex.withLock {
|
||||
AgendaWidget().updateAll(appContext)
|
||||
MonthWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaScreenRange(range: AgendaRange) {
|
||||
viewModelScope.launch { prefs.setAgendaScreenRange(range) }
|
||||
}
|
||||
|
||||
fun setAgendaWidgetRange(range: AgendaRange) {
|
||||
viewModelScope.launch {
|
||||
prefs.setAgendaWidgetRange(range)
|
||||
widgetRefreshMutex.withLock {
|
||||
// Push the range into each instance's Glance state and recompose.
|
||||
// The widget reads it via currentState, so this reflects reliably
|
||||
// even when updateAll only recomposes a live session (it does not
|
||||
// re-run provideGlance's data preamble).
|
||||
val manager = GlanceAppWidgetManager(appContext)
|
||||
manager.getGlanceIds(AgendaWidget::class.java).forEach { id ->
|
||||
updateAppWidgetState(appContext, id) { it[AGENDA_RANGE_KEY] = range.storageValue() }
|
||||
}
|
||||
AgendaWidget().updateAll(appContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAgendaShowRangeBar(enabled: Boolean) {
|
||||
viewModelScope.launch { prefs.setAgendaShowRangeBar(enabled) }
|
||||
}
|
||||
|
||||
fun setTimeFormat(pref: TimeFormatPref) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
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.groupAgendaDays
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -24,9 +25,6 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import java.util.Locale
|
||||
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
|
||||
* is chosen reactively in the composition, so one wide read covers ~13 months of
|
||||
@@ -49,9 +47,19 @@ sealed interface AgendaWidgetData {
|
||||
data object NeedsPermission : AgendaWidgetData
|
||||
data class Ready(
|
||||
val today: LocalDate,
|
||||
/**
|
||||
* Days with events across the **widest** selectable window. The displayed
|
||||
* range is sliced from this in the composition (read reactively from
|
||||
* Glance state), so a range change is pure recomposition rather than a
|
||||
* flaky widget session restart — the same approach the month widget uses.
|
||||
*/
|
||||
val days: List<AgendaDay>,
|
||||
/** Resolved clock convention for event time labels (the time-format pref). */
|
||||
val is24Hour: Boolean,
|
||||
/** First day of the week, for the calendar-aligned "this week" range. */
|
||||
val weekStart: DayOfWeek,
|
||||
/** Saved range pref — the fallback when an instance has no Glance state yet. */
|
||||
val savedRange: AgendaRange,
|
||||
) : AgendaWidgetData
|
||||
}
|
||||
|
||||
@@ -98,13 +106,22 @@ internal suspend fun Context.loadAgendaWidgetData(): AgendaWidgetData {
|
||||
val zone = systemZone()
|
||||
val anchor = today(zone)
|
||||
val ep = widgetEntryPoint()
|
||||
val instances = ep.calendarRepository().instances(agendaRange(anchor, AGENDA_WIDGET_DAYS, zone)).first()
|
||||
val is24Hour = ep.settingsPrefs().timeFormat.first()
|
||||
val prefs = ep.settingsPrefs()
|
||||
val savedRange = prefs.agendaWidgetRange.first()
|
||||
val weekStart = prefs.weekStart.first().resolveFirstDay(Locale.getDefault())
|
||||
// Load the widest selectable window once; the displayed range is sliced in
|
||||
// the composition from Glance state, so changing the range is a plain
|
||||
// recomposition and never depends on the widget session restarting.
|
||||
val window = agendaRange(anchor, AgendaRange.MAX_CUSTOM_DAYS - 1, zone)
|
||||
val instances = ep.calendarRepository().instances(window).first()
|
||||
val is24Hour = prefs.timeFormat.first()
|
||||
.is24Hour(android.text.format.DateFormat.is24HourFormat(this))
|
||||
return AgendaWidgetData.Ready(
|
||||
today = anchor,
|
||||
days = groupAgendaDays(anchor, instances, zone),
|
||||
is24Hour = is24Hour,
|
||||
weekStart = weekStart,
|
||||
savedRange = savedRange,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.glance.ColorFilter
|
||||
import androidx.glance.GlanceId
|
||||
import androidx.glance.GlanceModifier
|
||||
@@ -24,6 +25,8 @@ import androidx.glance.appwidget.lazy.items
|
||||
import androidx.glance.appwidget.provideContent
|
||||
import androidx.glance.appwidget.updateAll
|
||||
import androidx.glance.background
|
||||
import androidx.glance.currentState
|
||||
import androidx.glance.state.PreferencesGlanceStateDefinition
|
||||
import androidx.glance.layout.Alignment
|
||||
import androidx.glance.layout.Box
|
||||
import androidx.glance.layout.Column
|
||||
@@ -41,6 +44,9 @@ import androidx.glance.text.TextStyle
|
||||
import de.jeanlucmakiola.calendula.MainActivity
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.dayCount
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.parseAgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.pastelize
|
||||
@@ -63,8 +69,20 @@ import java.util.Locale
|
||||
* of events grouped under day headers (the Google "Schedule" widget model).
|
||||
* Reuses the app's [groupAgendaDays] grouping so it matches the in-app agenda.
|
||||
*/
|
||||
/**
|
||||
* Per-instance Glance state key holding the agenda range (as [AgendaRange.storageValue]).
|
||||
* The range is read reactively in the composition ([currentState]) so a settings
|
||||
* change is reflected by plain recomposition — `updateAll` does NOT reliably
|
||||
* re-run the `provideGlance` preamble for a live widget session, so loading the
|
||||
* range there and slicing it would silently keep the stale value (mirrors the
|
||||
* month widget's MONTH_INDEX_KEY approach).
|
||||
*/
|
||||
internal val AGENDA_RANGE_KEY = stringPreferencesKey("agenda_range")
|
||||
|
||||
class AgendaWidget : GlanceAppWidget() {
|
||||
|
||||
override val stateDefinition = PreferencesGlanceStateDefinition
|
||||
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val data = context.loadAgendaWidgetData()
|
||||
val dark = (context.resources.configuration.uiMode and
|
||||
@@ -102,12 +120,21 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
Spacer(GlanceModifier.height(4.dp))
|
||||
when (data) {
|
||||
AgendaWidgetData.NeedsPermission -> WidgetMessage(R.string.widget_needs_permission)
|
||||
is AgendaWidgetData.Ready ->
|
||||
if (data.days.isEmpty()) {
|
||||
is AgendaWidgetData.Ready -> {
|
||||
// Range read reactively from per-instance Glance state (falls back
|
||||
// to the saved pref for a freshly placed widget), then the wide
|
||||
// pre-loaded window is sliced to it — pure recomposition.
|
||||
val range = parseAgendaRange(currentState(AGENDA_RANGE_KEY), data.savedRange)
|
||||
val rangeEnd = data.today.plus(
|
||||
range.dayCount(data.today, data.weekStart) - 1,
|
||||
DateTimeUnit.DAY,
|
||||
)
|
||||
val visibleDays = data.days.filter { it.date <= rangeEnd }
|
||||
if (visibleDays.isEmpty()) {
|
||||
WidgetMessage(R.string.agenda_empty_title)
|
||||
} else {
|
||||
val rows = buildList {
|
||||
data.days.forEach { day ->
|
||||
visibleDays.forEach { day ->
|
||||
add(AgendaRow.Header(day.date, data.today))
|
||||
day.events.forEach { add(AgendaRow.Event(it)) }
|
||||
}
|
||||
@@ -121,6 +148,7 @@ private fun AgendaWidgetBody(data: AgendaWidgetData, dark: Boolean) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,8 +230,7 @@
|
||||
<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">Nichts geplant</string>
|
||||
<string name="agenda_empty_subtitle">Anstehende Termine erscheinen hier.</string>
|
||||
<string name="agenda_empty_title">Alles erledigt</string>
|
||||
|
||||
<!-- Terminsuche -->
|
||||
<string name="search_action">Suchen</string>
|
||||
@@ -282,6 +281,25 @@
|
||||
<string name="settings_time_format_24h">24-Stunden (14:00)</string>
|
||||
<string name="settings_hour_lines">Stundenlinien</string>
|
||||
<string name="settings_hour_lines_summary">Trennlinie zu jeder vollen Stunde in Wochen- und Tagesansicht anzeigen</string>
|
||||
<string name="settings_agenda_range">Agenda-Zeitraum</string>
|
||||
<string name="settings_agenda_range_hint">Wie weit im Voraus die Agenda-Ansicht Termine anzeigt.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda-Widget-Zeitraum</string>
|
||||
<string name="settings_agenda_widget_range_hint">Wie weit im Voraus das Agenda-Widget auf dem Startbildschirm Termine anzeigt.</string>
|
||||
<string name="settings_agenda_range_bar">Zeitraum-Leiste</string>
|
||||
<string name="settings_agenda_range_bar_hint">Eine Leiste oben in der Agenda anzeigen, die den dargestellten Zeitraum benennt, mit einer Schaltfläche zum Wechseln des Zeitraums für die Sitzung</string>
|
||||
<string name="agenda_range_day">Heute</string>
|
||||
<string name="agenda_range_this_week">Diese Woche</string>
|
||||
<string name="agenda_range_this_month">Dieser Monat</string>
|
||||
<string name="agenda_range_week">Nächste 7 Tage</string>
|
||||
<string name="agenda_range_month">Nächste 30 Tage</string>
|
||||
<string name="agenda_range_custom">Benutzerdefiniert…</string>
|
||||
<string name="agenda_range_custom_hint">Tage</string>
|
||||
<string name="agenda_range_override_hint">Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt.</string>
|
||||
<string name="agenda_range_showing_label">Alle anstehenden Termine für</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_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>
|
||||
|
||||
@@ -231,8 +231,7 @@
|
||||
<string name="agenda_today_action">Today</string>
|
||||
<string name="agenda_header_today">Today</string>
|
||||
<string name="agenda_header_tomorrow">Tomorrow</string>
|
||||
<string name="agenda_empty_title">Nothing scheduled</string>
|
||||
<string name="agenda_empty_subtitle">Upcoming events will show up here.</string>
|
||||
<string name="agenda_empty_title">You\'re all caught up</string>
|
||||
|
||||
<!-- Event search -->
|
||||
<string name="search_action">Search</string>
|
||||
@@ -275,6 +274,25 @@
|
||||
<string name="settings_time_format_24h">24-hour (14:00)</string>
|
||||
<string name="settings_hour_lines">Hour lines</string>
|
||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
|
||||
<string name="settings_agenda_range">Agenda range</string>
|
||||
<string name="settings_agenda_range_hint">How far ahead the Agenda screen lists events.</string>
|
||||
<string name="settings_agenda_widget_range">Agenda widget range</string>
|
||||
<string name="settings_agenda_widget_range_hint">How far ahead the agenda home-screen widget lists events.</string>
|
||||
<string name="settings_agenda_range_bar">Range bar</string>
|
||||
<string name="settings_agenda_range_bar_hint">Show a bar at the top of the agenda naming the dates shown, with a button to switch the range for the session</string>
|
||||
<string name="agenda_range_day">Today</string>
|
||||
<string name="agenda_range_this_week">This week</string>
|
||||
<string name="agenda_range_this_month">This month</string>
|
||||
<string name="agenda_range_week">Next 7 days</string>
|
||||
<string name="agenda_range_month">Next 30 days</string>
|
||||
<string name="agenda_range_custom">Custom…</string>
|
||||
<string name="agenda_range_custom_hint">Days</string>
|
||||
<string name="agenda_range_override_hint">Just for now — resets to your saved range when you reopen Calendula.</string>
|
||||
<string name="agenda_range_showing_label">Showing all upcoming events for</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_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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
@@ -89,6 +90,18 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.showHourLines.first()).isTrue()
|
||||
}
|
||||
|
||||
@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
|
||||
fun `garbage stored enum falls back to default`(@TempDir tempDir: Path) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AgendaRangeTest {
|
||||
|
||||
// A reference anchor: 2026-06-17 is a Wednesday.
|
||||
private val wednesday = LocalDate(2026, 6, 17)
|
||||
|
||||
@Test
|
||||
fun `rolling ranges have fixed 1, 7, 30 day counts regardless of anchor`() {
|
||||
assertThat(AgendaRange.Day.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(1)
|
||||
assertThat(AgendaRange.Week.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(7)
|
||||
assertThat(AgendaRange.Month.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `this week runs through the day before the week repeats`() {
|
||||
// Mon-start week: Wed → Wed,Thu,Fri,Sat,Sun = 5 days (everything before next Monday).
|
||||
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(5)
|
||||
// On the first day of the week the whole 7 days remain.
|
||||
val monday = LocalDate(2026, 6, 15)
|
||||
assertThat(AgendaRange.ThisWeek.dayCount(monday, DayOfWeek.MONDAY)).isEqualTo(7)
|
||||
// A Sunday-start week shifts the boundary: Wed → 4 days left (through Saturday).
|
||||
assertThat(AgendaRange.ThisWeek.dayCount(wednesday, DayOfWeek.SUNDAY)).isEqualTo(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `this month runs through the last day of the month`() {
|
||||
// June has 30 days: the 17th leaves 14 days (17..30 inclusive).
|
||||
assertThat(AgendaRange.ThisMonth.dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(14)
|
||||
// February 2026 (non-leap) has 28 days.
|
||||
assertThat(AgendaRange.ThisMonth.dayCount(LocalDate(2026, 2, 20), DayOfWeek.MONDAY))
|
||||
.isEqualTo(9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom day count is clamped to bounds`() {
|
||||
assertThat(AgendaRange.Custom(45).dayCount(wednesday, DayOfWeek.MONDAY)).isEqualTo(45)
|
||||
assertThat(AgendaRange.Custom(0).dayCount(wednesday, DayOfWeek.MONDAY))
|
||||
.isEqualTo(AgendaRange.MIN_CUSTOM_DAYS)
|
||||
assertThat(AgendaRange.Custom(9_999).dayCount(wednesday, DayOfWeek.MONDAY))
|
||||
.isEqualTo(AgendaRange.MAX_CUSTOM_DAYS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fixed ranges round-trip through storage`() {
|
||||
listOf(
|
||||
AgendaRange.Day,
|
||||
AgendaRange.Week,
|
||||
AgendaRange.Month,
|
||||
AgendaRange.ThisWeek,
|
||||
AgendaRange.ThisMonth,
|
||||
).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