From 2943f3945dd422c050782c79c3390d4d0c992bb2 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 17 Jun 2026 09:24:49 +0200 Subject: [PATCH] feat(nav): jump-to-date action in the navigation drawer Add a "Jump to date" row to the drawer (under the View switcher) that opens an M3 date picker and navigates the active view to the chosen day, sliding in from the correct side. Wired across Month/Week/Day, each seeding the picker with its visible anchor (day / week-start / 1st-of-month). Extract the form's private date-picker into a shared ui/common/CalendarDatePickerDialog so the event form and the drawer share one picker; add goToDate() to the Month and Week view models. Reprioritises the roadmap: jump-to-date is now next; duplicate-event drops to the bottom as low-importance. Co-Authored-By: Claude Opus 4.8 (1M context) --- .planning/ROADMAP.md | 10 +++- .../ui/common/CalendarDatePickerDialog.kt | 52 +++++++++++++++++++ .../calendula/ui/common/CalendarDrawer.kt | 41 +++++++++++++-- .../calendula/ui/day/DayScreen.kt | 10 ++++ .../calendula/ui/edit/EventEditScreen.kt | 44 ++-------------- .../calendula/ui/month/MonthScreen.kt | 10 ++++ .../calendula/ui/month/MonthViewModel.kt | 5 ++ .../calendula/ui/week/WeekScreen.kt | 10 ++++ .../calendula/ui/week/WeekViewModel.kt | 5 ++ app/src/main/res/values-de/strings.xml | 3 ++ app/src/main/res/values/strings.xml | 3 ++ 11 files changed, 148 insertions(+), 45 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 09c7f4f..f3206a5 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -169,7 +169,9 @@ unblock a later item. Order is a plan, not a contract — revisit after each lan 4. ~~Per-event color~~ *(shipped v2.4.0)* — palette calendars write `EVENT_COLOR_KEY` (sync-safe); local/opted-in calendars write a raw `EVENT_COLOR`; off-by-default setting for no-palette synced calendars -5. **Duplicate event** *(next)* — detail action → prefilled create form; near-free on the tap-to-create prefill infra +Tier 1's create/edit + calendars arc is effectively closed. **Duplicate event** +was deprioritised (2026-06-17) as low-importance and dropped to the bottom of +the sequence; the next item is now **Jump-to-date** (formerly Tier 2). (Tier 2+ numbering below shifts accordingly; ranking unchanged.) @@ -223,7 +225,7 @@ Out of scope (no new settings *features* here) — this is a structure + style pass on the existing controls; new toggles ride in with their own features. **Tier 2 — navigation & daily-driver completeness** -5. Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap +5. Jump-to-date — drawer date picker (un-cut from V1); cheap, fills the nav gap *(next)* 6. Agenda view — the missing 4th view; serves daily-driver users *and* becomes the data source for the widget **Tier 3 — platform reach (depends on Tier 2)** @@ -240,6 +242,10 @@ pass on the existing controls; new toggles ride in with their own features. - Locations & People — contact address picker (no-permission, one-shot) is the safe entry; OSM autocomplete needs INTERNET - Move event to another calendar — sync-adapter minefield (copy+delete model) +**Bottom — deprioritised, not important** +- Duplicate event (detail action → prefilled create form) — moved here + 2026-06-17; cheap but low value, pick up only if asked + **Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts, full-text search, ICS file import. Pulled in opportunistically, not sequenced. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt new file mode 100644 index 0000000..9c6af33 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDatePickerDialog.kt @@ -0,0 +1,52 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import de.jeanlucmakiola.calendula.R +import kotlinx.datetime.LocalDate + +/** One UTC day in milliseconds — the unit the M3 [DatePicker] speaks. */ +const val MILLIS_PER_DAY: Long = 86_400_000L + +/** + * The app's standard Material 3 date picker, opened on [initial] and reporting + * the chosen day through [onConfirm]. Shared by the event form (start/end date, + * RRULE until) and the drawer's jump-to-date action. + * + * DatePicker speaks UTC-midnight millis; epoch-day arithmetic keeps the + * conversion zone-proof in both directions. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CalendarDatePickerDialog( + initial: LocalDate, + onConfirm: (LocalDate) -> Unit, + onDismiss: () -> Unit, +) { + val state = rememberDatePickerState( + initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY, + ) + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + state.selectedDateMillis?.let { millis -> + onConfirm(LocalDate.fromEpochDays((millis / MILLIS_PER_DAY).toInt())) + } + }, + ) { Text(stringResource(R.string.dialog_ok)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) { + DatePicker(state = state) + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt index 8fb6192..2b66b98 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt @@ -17,12 +17,17 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -32,23 +37,31 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList +import kotlinx.datetime.LocalDate /** * Navigation drawer shared by every top-level calendar screen. * * Uses the app's grouped-card design system (see [GroupedRow]): a branded * header, the View switcher as a grouped card (the active view highlighted), - * the per-calendar visibility filter (M3) inline, and a pinned Settings row. - * The "View" section mirrors the top-bar switcher pill — tapping a view here - * selects it (and closes the drawer) rather than cycling. The host screen owns - * the drawer state. + * a jump-to-date action, the per-calendar visibility filter (M3) inline, and a + * pinned Settings row. The "View" section mirrors the top-bar switcher pill — + * tapping a view here selects it (and closes the drawer) rather than cycling. + * The host screen owns the drawer state. + * + * [currentDate] seeds the jump-to-date picker (the visible day/week-start/month + * anchor); [onJumpToDate] navigates the active view to the chosen day. */ @Composable fun CalendarDrawer( currentView: CalendarView, + currentDate: LocalDate, onSelectView: (CalendarView) -> Unit, + onJumpToDate: (LocalDate) -> Unit, onSettings: () -> Unit, ) { + var showDatePicker by remember { mutableStateOf(false) } + ModalDrawerSheet { // The whole sidebar scrolls as one — header, views, the calendar filter // and Settings all flow in a single scroll container. @@ -71,6 +84,15 @@ fun CalendarDrawer( ) } + Spacer(Modifier.height(16.dp)) + GroupedRow( + title = stringResource(R.string.drawer_jump_to_date), + position = Position.Alone, + minHeight = 56.dp, + leading = { Icon(Icons.Filled.DateRange, contentDescription = null) }, + onClick = { showDatePicker = true }, + ) + Spacer(Modifier.height(16.dp)) DrawerSectionHeader(stringResource(R.string.filter_title)) @@ -87,6 +109,17 @@ fun CalendarDrawer( Spacer(Modifier.height(8.dp)) } } + + if (showDatePicker) { + CalendarDatePickerDialog( + initial = currentDate, + onConfirm = { + showDatePicker = false + onJumpToDate(it) + }, + onDismiss = { showDatePicker = false }, + ) + } } /** Branded header: the app-icon chip beside the app name. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 600b0ea..9e7ef84 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -151,6 +151,11 @@ fun DayScreen( } viewModel.goToToday() } + // Drawer jump-to-date: slide from the side the target lies on. + val jumpToDate: (LocalDate) -> Unit = { target -> + slideDir = if (target < date) -1 else 1 + viewModel.goToDate(target) + } ModalNavigationDrawer( drawerState = drawerState, @@ -159,10 +164,15 @@ fun DayScreen( drawerContent = { CalendarDrawer( currentView = selectedView, + currentDate = date, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } }, + onJumpToDate = { target -> + jumpToDate(target) + scope.launch { drawerState.close() } + }, onSettings = { onOpenSettings() scope.launch { drawerState.close() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 2424e59..3d1d917 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -51,8 +51,6 @@ import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip import androidx.compose.material3.Button -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -73,7 +71,6 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberDatePickerState import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -113,7 +110,9 @@ import de.jeanlucmakiola.calendula.domain.SimpleRecurrence import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence import de.jeanlucmakiola.calendula.domain.toRRule import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE +import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow +import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.currentLocale @@ -786,12 +785,12 @@ private fun EventEditContent( } when (picker) { - PickerTarget.StartDate -> DatePickerAlert( + PickerTarget.StartDate -> CalendarDatePickerDialog( initial = form.start.date, onConfirm = { viewModel.setStartDate(it); picker = null }, onDismiss = { picker = null }, ) - PickerTarget.EndDate -> DatePickerAlert( + PickerTarget.EndDate -> CalendarDatePickerDialog( initial = form.end.date, onConfirm = { viewModel.setEndDate(it); picker = null }, onDismiss = { picker = null }, @@ -1178,7 +1177,7 @@ private fun RecurrencePickerDialog( ) if (showUntilPicker) { - DatePickerAlert( + CalendarDatePickerDialog( initial = untilDate ?: LocalDate.fromEpochDays( (Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(), ), @@ -1688,37 +1687,6 @@ private fun ScheduleRow( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun DatePickerAlert( - initial: LocalDate, - onConfirm: (LocalDate) -> Unit, - onDismiss: () -> Unit, -) { - // DatePicker speaks UTC-midnight millis; epoch-day arithmetic keeps the - // conversion zone-proof in both directions. - val state = rememberDatePickerState( - initialSelectedDateMillis = initial.toEpochDays() * MILLIS_PER_DAY, - ) - DatePickerDialog( - onDismissRequest = onDismiss, - confirmButton = { - TextButton( - onClick = { - state.selectedDateMillis?.let { millis -> - onConfirm(LocalDate.fromEpochDays((millis / MILLIS_PER_DAY).toInt())) - } - }, - ) { Text(stringResource(R.string.dialog_ok)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } - }, - ) { - DatePicker(state = state) - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun TimePickerAlert( @@ -1775,5 +1743,3 @@ private fun CalendarPickerDialog( }, ) } - -private const val MILLIS_PER_DAY = 86_400_000L diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index beb43c6..9210063 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -124,6 +124,11 @@ fun MonthScreen( } viewModel.goToToday() } + // Drawer jump-to-date: slide from the side the target month lies on. + val jumpToDate: (LocalDate) -> Unit = { target -> + slideDir = if (YearMonth(target.year, target.month) < month) -1 else 1 + viewModel.goToDate(target) + } ModalNavigationDrawer( drawerState = drawerState, @@ -132,10 +137,15 @@ fun MonthScreen( drawerContent = { CalendarDrawer( currentView = selectedView, + currentDate = LocalDate(month.year, month.month, 1), onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } }, + onJumpToDate = { target -> + jumpToDate(target) + scope.launch { drawerState.close() } + }, onSettings = { onOpenSettings() scope.launch { drawerState.close() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt index 31328b5..57ea003 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthViewModel.kt @@ -96,6 +96,11 @@ class MonthViewModel @Inject constructor( _month.value = YearMonth(todayDate.year, todayDate.month) } + /** Jump to the month containing [date] (drawer jump-to-date). */ + fun goToDate(date: LocalDate) { + _month.value = YearMonth(date.year, date.month) + } + private fun buildState( ym: YearMonth, weekStart: DayOfWeek, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 65b2b9f..3e3660f 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -156,6 +156,11 @@ fun WeekScreen( } viewModel.goToToday() } + // Drawer jump-to-date: slide from the side the target week lies on. + val jumpToDate: (LocalDate) -> Unit = { target -> + slideDir = if (target < weekStart) -1 else 1 + viewModel.goToDate(target) + } ModalNavigationDrawer( drawerState = drawerState, @@ -164,10 +169,15 @@ fun WeekScreen( drawerContent = { CalendarDrawer( currentView = selectedView, + currentDate = weekStart, onSelectView = { view -> onSelectView(view) scope.launch { drawerState.close() } }, + onJumpToDate = { target -> + jumpToDate(target) + scope.launch { drawerState.close() } + }, onSettings = { onOpenSettings() scope.launch { drawerState.close() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt index 8c37bc7..a4418c1 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekViewModel.kt @@ -107,6 +107,11 @@ class WeekViewModel @Inject constructor( _anchor.value = todayDate } + /** Jump to the week containing [date] (drawer jump-to-date). */ + fun goToDate(date: LocalDate) { + _anchor.value = date + } + private fun buildState( start: LocalDate, calendars: List, diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index d2d652f..1590c42 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -198,6 +198,9 @@ Tag Ansicht + + Zu Datum springen + Kalender diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3043346..24f4844 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,6 +199,9 @@ Day View + + Jump to date + Calendars -- 2.49.1