From b0b30eef91c139d779198f83efd937f58998c66c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 17 Jun 2026 09:41:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(agenda):=20add=20Agenda=20view=20=E2=80=94?= =?UTF-8?q?=20upcoming=20events=20grouped=20by=20day?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fourth top-level view, alongside Month/Week/Day. A forward-looking LazyColumn of upcoming events grouped under sticky day headers, reusing the v2.3 grouped-list language (GroupedRow cards, color-rail leading). - AgendaViewModel loads a 60-day forward window from the anchor day (today by default; goToToday/goToDate drive the FAB + drawer jump), groups instances by local day (ongoing/multi-day clamped to the anchor), sorts all-day-first then by start. - AgendaScreen: same drawer + scaffold + view-switcher + FAB shell as Day; sticky "Today · …"/"Tomorrow · …" headers, event rows with time·location, plus empty/failure/loading states. - Wired into CalendarView (ViewAgenda icon), IMPLEMENTED_VIEWS, and CalendarHost; strings added (EN + DE). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../calendula/ui/CalendarHost.kt | 8 + .../calendula/ui/agenda/AgendaScreen.kt | 344 ++++++++++++++++++ .../calendula/ui/agenda/AgendaUiState.kt | 27 ++ .../calendula/ui/agenda/AgendaViewModel.kt | 110 ++++++ .../calendula/ui/common/CalendarView.kt | 11 +- app/src/main/res/values-de/strings.xml | 8 + app/src/main/res/values/strings.xml | 8 + 7 files changed, 511 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index d1cb75d..8536aba 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.ui.agenda.AgendaScreen import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec @@ -141,6 +142,13 @@ fun CalendarHost( onOpenSettings = onOpenSettings, onCreateEvent = onCreateEvent, ) + CalendarView.Agenda -> AgendaScreen( + selectedView = view, + onSelectView = onSelectView, + onEventClick = onEventClick, + onOpenSettings = onOpenSettings, + onCreateEvent = onCreateEvent, + ) } // Prefer the live key; fall back to the held one only while sliding out. 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 new file mode 100644 index 0000000..83d56a9 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaScreen.kt @@ -0,0 +1,344 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +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.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.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.Menu +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +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.CalendarDrawer +import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn +import de.jeanlucmakiola.calendula.ui.common.CalendarFailure +import de.jeanlucmakiola.calendula.ui.common.CalendarView +import de.jeanlucmakiola.calendula.ui.common.GroupedRow +import de.jeanlucmakiola.calendula.ui.common.Position +import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill +import de.jeanlucmakiola.calendula.ui.common.next +import de.jeanlucmakiola.calendula.ui.common.pastelize +import de.jeanlucmakiola.calendula.ui.common.positionOf +import kotlinx.coroutines.launch +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant +import java.time.format.TextStyle as JavaTextStyle +import java.util.Locale + +private val zone = TimeZone.currentSystemDefault() + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgendaScreen( + selectedView: CalendarView, + onSelectView: (CalendarView) -> Unit, + onEventClick: (EventInstance) -> Unit, + onOpenSettings: () -> Unit, + onCreateEvent: (LocalDate, Int?) -> Unit, + modifier: Modifier = Modifier, + viewModel: AgendaViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val anchor by viewModel.anchor.collectAsStateWithLifecycle() + + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + + val isOnToday = when (val s = state) { + is AgendaUiState.Success -> s.anchor == s.today + else -> true + } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + CalendarDrawer( + currentView = selectedView, + currentDate = anchor, + onSelectView = { view -> + onSelectView(view) + scope.launch { drawerState.close() } + }, + onJumpToDate = { target -> + viewModel.goToDate(target) + scope.launch { drawerState.close() } + }, + onSettings = { + onOpenSettings() + scope.launch { drawerState.close() } + }, + ) + }, + ) { + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + AgendaTopBar( + selectedView = selectedView, + onCycleView = { onSelectView(selectedView.next()) }, + onOpenDrawer = { scope.launch { drawerState.open() } }, + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + CalendarFabColumn( + todayVisible = !isOnToday, + todayText = stringResource(R.string.agenda_today_action), + onToday = viewModel::goToToday, + onCreate = { onCreateEvent(anchor, null) }, + ) + }, + ) { innerPadding -> + AgendaContent( + state = state, + onRetry = viewModel::goToToday, + onEventClick = onEventClick, + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + ) + } + } +} + +@Composable +private fun AgendaContent( + state: AgendaUiState, + onRetry: () -> Unit, + onEventClick: (EventInstance) -> Unit, + modifier: Modifier = Modifier, +) { + when (state) { + AgendaUiState.Loading -> Box(modifier) + is AgendaUiState.Failure -> Box(modifier) { + CalendarFailure(reason = state.reason, onRetry = onRetry) + } + is AgendaUiState.Success -> + if (state.days.isEmpty()) { + AgendaEmpty(modifier) + } else { + AgendaList(state = state, onEventClick = onEventClick, modifier = modifier) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun AgendaList( + state: AgendaUiState.Success, + onEventClick: (EventInstance) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier, + // Bottom inset clears the FAB stack so the last row stays tappable. + contentPadding = PaddingValues(top = 8.dp, bottom = 96.dp), + ) { + state.days.forEach { day -> + stickyHeader(key = "header-${day.date}") { + AgendaDayHeader(date = day.date, today = state.today) + } + itemsIndexed( + items = day.events, + key = { _, event -> event.instanceId }, + ) { index, event -> + AgendaEventRow( + event = event, + position = positionOf(index, day.events.size), + onClick = { onEventClick(event) }, + ) + } + item(key = "gap-${day.date}") { Spacer(Modifier.height(8.dp)) } + } + } +} + +@Composable +private fun AgendaDayHeader(date: LocalDate, today: LocalDate) { + Surface( + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = agendaDayLabel(date, today), + style = MaterialTheme.typography.titleSmall, + color = if (date == today) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp), + ) + } +} + +@Composable +private fun AgendaEventRow( + event: EventInstance, + position: Position, + onClick: () -> Unit, +) { + val dark = isSystemInDarkTheme() + val title = event.title.ifBlank { stringResource(R.string.event_untitled) } + GroupedRow( + title = title, + summary = agendaTimeSummary(event), + position = position, + minHeight = 64.dp, + leading = { + Box( + modifier = Modifier + .size(width = 6.dp, height = 36.dp) + .clip(RoundedCornerShape(3.dp)) + .background(pastelize(event.color, dark)), + ) + }, + onClick = onClick, + ) +} + +@Composable +private fun AgendaEmpty(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Filled.EventAvailable, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.agenda_empty_title), + 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, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AgendaTopBar( + selectedView: CalendarView, + onCycleView: () -> Unit, + onOpenDrawer: () -> Unit, + scrollBehavior: TopAppBarScrollBehavior, +) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.view_agenda), + style = MaterialTheme.typography.titleLarge, + ) + }, + navigationIcon = { + IconButton(onClick = onOpenDrawer) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = stringResource(R.string.month_open_menu), + ) + } + }, + actions = { + ViewSwitcherPill( + current = selectedView, + onCycle = onCycleView, + modifier = Modifier.padding(end = 8.dp), + ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + scrollBehavior = scrollBehavior, + ) +} + +/** "Today · Wed, 17. Jun 2026" — relative word for today/tomorrow, else the date. */ +@Composable +private fun agendaDayLabel(date: LocalDate, today: LocalDate): String { + val relative = when (date) { + today -> stringResource(R.string.agenda_header_today) + today.plus(1, DateTimeUnit.DAY) -> stringResource(R.string.agenda_header_tomorrow) + else -> null + } + val formatted = formatAgendaDate(date) + return if (relative != null) "$relative · $formatted" else formatted +} + +/** Time line under the title: "09:00 – 10:00 · Location", "All day", etc. */ +@Composable +private fun agendaTimeSummary(event: EventInstance): String { + val time = if (event.isAllDay) { + stringResource(R.string.event_detail_all_day) + } else { + "${formatTime(event.start)} – ${formatTime(event.end)}" + } + val location = event.location?.takeIf { it.isNotBlank() } + return if (location != null) "$time · $location" else time +} + +private fun formatTime(instant: Instant): String { + val t = instant.toLocalDateTime(zone).time + return "%02d:%02d".format(t.hour, t.minute) +} + +private fun formatAgendaDate(date: LocalDate): String { + val locale = Locale.getDefault() + val java = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day) + val weekday = java.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, locale) + val monthName = java.month.getDisplayName(JavaTextStyle.SHORT, locale) + return "$weekday, ${date.day}. $monthName ${date.year}" +} 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 new file mode 100644 index 0000000..80fefae --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaUiState.kt @@ -0,0 +1,27 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.FailureReason +import kotlinx.datetime.LocalDate + +/** One calendar day with at least one event, for the agenda list. */ +data class AgendaDay( + val date: LocalDate, + /** Events on this day, all-day first then ascending by start time. */ + val events: List, +) + +/** + * State for the Agenda view: a flat, forward-looking list of upcoming events + * grouped by day (only days that actually have events appear). + */ +sealed interface AgendaUiState { + data object Loading : AgendaUiState + data class Failure(val reason: FailureReason) : AgendaUiState + data class Success( + /** First day of the loaded window (today, or a jumped-to date). */ + val anchor: LocalDate, + val today: LocalDate, + val days: List, + ) : 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 new file mode 100644 index 0000000..5f2b700 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/agenda/AgendaViewModel.kt @@ -0,0 +1,110 @@ +package de.jeanlucmakiola.calendula.ui.agenda + +import androidx.lifecycle.ViewModel +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.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.FailureReason +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.stateIn +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.atTime +import kotlinx.datetime.plus +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +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, + @IoDispatcher private val io: CoroutineDispatcher, +) : ViewModel() { + + private val zone = TimeZone.currentSystemDefault() + + private val todayDate: LocalDate + get() = Clock.System.now().toLocalDateTime(zone).date + + private val _anchor = MutableStateFlow(todayDate) + val anchor: StateFlow = _anchor + + val state: StateFlow = _anchor + .flatMapLatest { anchor -> + val range = agendaRange(anchor, AGENDA_WINDOW_DAYS, zone) + combine( + repository.calendars(), + repository.instances(range), + ) { calendars, instances -> + buildState(anchor, calendars, instances) + } + } + .catch { emit(AgendaUiState.Failure(FailureReason.ProviderUnavailable)) } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = AgendaUiState.Loading, + ) + + fun goToToday() { + _anchor.value = todayDate + } + + /** Jump the agenda window to start on a specific date (drawer jump-to-date). */ + fun goToDate(date: LocalDate) { + _anchor.value = date + } + + private fun buildState( + anchor: LocalDate, + calendars: List, + instances: List, + ): AgendaUiState { + if (calendars.isEmpty()) { + return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured) + } + val days = instances + // An event that began before the window (ongoing/multi-day) still + // overlaps it; clamp its day to the anchor so it surfaces on top. + .groupBy { it.start.toLocalDateTime(zone).date.coerceAtLeast(anchor) } + .toSortedMap() + .map { (date, dayEvents) -> + AgendaDay( + date = date, + events = dayEvents.sortedWith( + compareByDescending { it.isAllDay } + .thenBy { it.start } + .thenBy { it.title }, + ), + ) + } + return AgendaUiState.Success(anchor = anchor, today = todayDate, days = days) + } +} + +/** Inclusive instant range from the start of [anchor] through [days] days ahead. */ +internal fun agendaRange(anchor: LocalDate, days: Int, zone: TimeZone): ClosedRange { + val from = anchor.atStartOfDayIn(zone) + val to = anchor.plus(days, DateTimeUnit.DAY).atTime(23, 59, 59).toInstant(zone) + return from..to +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt index b5766d5..e942999 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarView.kt @@ -5,17 +5,16 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CalendarViewDay import androidx.compose.material.icons.filled.CalendarViewMonth import androidx.compose.material.icons.filled.CalendarViewWeek +import androidx.compose.material.icons.filled.ViewAgenda import androidx.compose.ui.graphics.vector.ImageVector import de.jeanlucmakiola.calendula.R -/** - * The top-level calendar views the user can switch between (spec M1). - * Day is declared but not yet implemented (v0.5) — see [IMPLEMENTED_VIEWS]. - */ +/** The top-level calendar views the user can switch between (spec M1). */ enum class CalendarView { Month, Week, Day, + Agenda, } /** Switcher label, shared by the top-bar pill and the drawer's View section. */ @@ -25,6 +24,7 @@ val CalendarView.labelRes: Int CalendarView.Month -> R.string.view_month CalendarView.Week -> R.string.view_week CalendarView.Day -> R.string.view_day + CalendarView.Agenda -> R.string.view_agenda } /** Leading icon for the view in the drawer's View section. */ @@ -33,6 +33,7 @@ val CalendarView.icon: ImageVector CalendarView.Month -> Icons.Filled.CalendarViewMonth CalendarView.Week -> Icons.Filled.CalendarViewWeek CalendarView.Day -> Icons.Filled.CalendarViewDay + CalendarView.Agenda -> Icons.Filled.ViewAgenda } /** @@ -40,7 +41,7 @@ val CalendarView.icon: ImageVector * through these in order. */ val IMPLEMENTED_VIEWS: List = - listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day) + listOf(CalendarView.Month, CalendarView.Week, CalendarView.Day, CalendarView.Agenda) /** Next view in [available], wrapping around. Falls back to Month if absent. */ fun CalendarView.next(available: List = IMPLEMENTED_VIEWS): CalendarView { diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1590c42..4e8dd65 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -196,11 +196,19 @@ Monat Woche Tag + Agenda Ansicht Zu Datum springen + + Heute + Heute + Morgen + Nichts geplant + Anstehende Termine erscheinen hier. + Kalender diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 24f4844..d6f5933 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -197,11 +197,19 @@ Month Week Day + Agenda View Jump to date + + Today + Today + Tomorrow + Nothing scheduled + Upcoming events will show up here. + Calendars -- 2.49.1