diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt index 22e6f33..317d2a7 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -2,21 +2,25 @@ 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.DateRange import androidx.compose.material.icons.filled.EventAvailable import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Search @@ -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,13 +149,84 @@ fun AgendaScreen( ) }, ) { innerPadding -> - AgendaContent( - state = state, - onRetry = viewModel::goToToday, - onEventClick = onEventClick, - modifier = Modifier - .padding(innerPadding) - .fillMaxSize(), + Box(modifier = Modifier.fillMaxSize()) { + AgendaContent( + state = state, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + // A bottom-left pill to peek at a different range for this + // session, mirroring the FAB column on the right. + successState?.let { s -> + AgendaRangePill( + range = s.range, + isOverride = s.rangeIsOverride, + onClick = { showRangePicker = true }, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(innerPadding) + .padding(16.dp), + ) + } + } + } + } + + 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, ) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt index 2ba5c3b..594b8f8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -53,5 +53,9 @@ sealed interface AgendaUiState { val anchor: LocalDate, val today: LocalDate, val days: List, + /** 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, ) : AgendaUiState } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt index 85a74c2..7b00397 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -22,6 +22,7 @@ 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 @@ -56,17 +57,31 @@ class AgendaViewModel @Inject constructor( private val _anchor = MutableStateFlow(todayDate) val anchor: StateFlow = _anchor + // 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(null) + val state: StateFlow = - combine(_anchor, screenRange, weekStartDay) { anchor, range, weekStart -> - Triple(anchor, range, weekStart) + combine(_anchor, screenRange, _rangeOverride, weekStartDay) { anchor, default, override, weekStart -> + AgendaParams( + anchor = anchor, + range = override ?: default, + rangeIsOverride = override != null && override != default, + weekStart = weekStart, + ) } - .flatMapLatest { (anchor, range, weekStart) -> - val window = agendaRange(anchor, range.dayCount(anchor, weekStart) - 1, zone) + .flatMapLatest { params -> + val window = agendaRange( + params.anchor, + params.range.dayCount(params.anchor, params.weekStart) - 1, + zone, + ) combine( repository.calendars(), repository.instances(window), ) { calendars, instances -> - buildState(anchor, calendars, instances) + buildState(params, calendars, instances) } } .catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) } @@ -86,16 +101,35 @@ 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 AgendaParams( + val anchor: LocalDate, + val range: AgendaRange, + val rangeIsOverride: Boolean, + val weekStart: DayOfWeek, + ) + private fun buildState( - anchor: LocalDate, + params: AgendaParams, calendars: List, instances: List, ): 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) + return AgendaUiState.Success( + anchor = anchor, + today = todayDate, + days = days, + range = params.range, + rangeIsOverride = params.rangeIsOverride, + ) } } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3b5477b..a5907ef 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -293,6 +293,7 @@ Nächste 30 Tage Benutzerdefiniert… Tage + Nur vorübergehend — wird beim erneuten Öffnen von Calendula auf deinen gespeicherten Zeitraum zurückgesetzt. %d Tag %d Tage diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 74ca96e..dff94df 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -286,6 +286,7 @@ Next 30 days Custom… Days + Just for now — resets to your saved range when you reopen Calendula. %d day %d days