feat(nav): jump-to-date action in the navigation drawer
All checks were successful
CI / ci (push) Successful in 6m17s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 09:24:49 +02:00
parent b62f097392
commit 2943f3945d
11 changed files with 148 additions and 45 deletions

View File

@@ -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 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_KEY` (sync-safe); local/opted-in calendars write a raw
`EVENT_COLOR`; off-by-default setting for no-palette synced calendars `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.) (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. pass on the existing controls; new toggles ride in with their own features.
**Tier 2 — navigation & daily-driver completeness** **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 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)** **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 - 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) - 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, **Unranked / fill-in** — pinch-to-zoom time scale, tablet/foldable layouts,
full-text search, ICS file import. Pulled in opportunistically, not sequenced. full-text search, ICS file import. Pulled in opportunistically, not sequenced.

View File

@@ -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)
}
}

View File

@@ -17,12 +17,17 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -32,23 +37,31 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
import kotlinx.datetime.LocalDate
/** /**
* Navigation drawer shared by every top-level calendar screen. * Navigation drawer shared by every top-level calendar screen.
* *
* Uses the app's grouped-card design system (see [GroupedRow]): a branded * Uses the app's grouped-card design system (see [GroupedRow]): a branded
* header, the View switcher as a grouped card (the active view highlighted), * header, the View switcher as a grouped card (the active view highlighted),
* the per-calendar visibility filter (M3) inline, and a pinned Settings row. * a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
* The "View" section mirrors the top-bar switcher pill — tapping a view here * pinned Settings row. The "View" section mirrors the top-bar switcher pill —
* selects it (and closes the drawer) rather than cycling. The host screen owns * tapping a view here selects it (and closes the drawer) rather than cycling.
* the drawer state. * 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 @Composable
fun CalendarDrawer( fun CalendarDrawer(
currentView: CalendarView, currentView: CalendarView,
currentDate: LocalDate,
onSelectView: (CalendarView) -> Unit, onSelectView: (CalendarView) -> Unit,
onJumpToDate: (LocalDate) -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
) { ) {
var showDatePicker by remember { mutableStateOf(false) }
ModalDrawerSheet { ModalDrawerSheet {
// The whole sidebar scrolls as one — header, views, the calendar filter // The whole sidebar scrolls as one — header, views, the calendar filter
// and Settings all flow in a single scroll container. // 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)) Spacer(Modifier.height(16.dp))
DrawerSectionHeader(stringResource(R.string.filter_title)) DrawerSectionHeader(stringResource(R.string.filter_title))
@@ -87,6 +109,17 @@ fun CalendarDrawer(
Spacer(Modifier.height(8.dp)) 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. */ /** Branded header: the app-icon chip beside the app name. */

View File

@@ -151,6 +151,11 @@ fun DayScreen(
} }
viewModel.goToToday() 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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -159,10 +164,15 @@ fun DayScreen(
drawerContent = { drawerContent = {
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = date,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
}, },
onJumpToDate = { target ->
jumpToDate(target)
scope.launch { drawerState.close() }
},
onSettings = { onSettings = {
onOpenSettings() onOpenSettings()
scope.launch { drawerState.close() } scope.launch { drawerState.close() }

View File

@@ -51,8 +51,6 @@ import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -73,7 +71,6 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker import androidx.compose.material3.TimePicker
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.parseSimpleRecurrence
import de.jeanlucmakiola.calendula.domain.toRRule import de.jeanlucmakiola.calendula.domain.toRRule
import de.jeanlucmakiola.calendula.ui.common.CALENDAR_COLOR_PALETTE 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.ColorSwatchRow
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
import de.jeanlucmakiola.calendula.ui.common.InlineTextField import de.jeanlucmakiola.calendula.ui.common.InlineTextField
import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.currentLocale
@@ -786,12 +785,12 @@ private fun EventEditContent(
} }
when (picker) { when (picker) {
PickerTarget.StartDate -> DatePickerAlert( PickerTarget.StartDate -> CalendarDatePickerDialog(
initial = form.start.date, initial = form.start.date,
onConfirm = { viewModel.setStartDate(it); picker = null }, onConfirm = { viewModel.setStartDate(it); picker = null },
onDismiss = { picker = null }, onDismiss = { picker = null },
) )
PickerTarget.EndDate -> DatePickerAlert( PickerTarget.EndDate -> CalendarDatePickerDialog(
initial = form.end.date, initial = form.end.date,
onConfirm = { viewModel.setEndDate(it); picker = null }, onConfirm = { viewModel.setEndDate(it); picker = null },
onDismiss = { picker = null }, onDismiss = { picker = null },
@@ -1178,7 +1177,7 @@ private fun RecurrencePickerDialog(
) )
if (showUntilPicker) { if (showUntilPicker) {
DatePickerAlert( CalendarDatePickerDialog(
initial = untilDate ?: LocalDate.fromEpochDays( initial = untilDate ?: LocalDate.fromEpochDays(
(Clock.System.now().toEpochMilliseconds() / MILLIS_PER_DAY).toInt(), (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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun TimePickerAlert( private fun TimePickerAlert(
@@ -1775,5 +1743,3 @@ private fun CalendarPickerDialog(
}, },
) )
} }
private const val MILLIS_PER_DAY = 86_400_000L

View File

@@ -124,6 +124,11 @@ fun MonthScreen(
} }
viewModel.goToToday() 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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -132,10 +137,15 @@ fun MonthScreen(
drawerContent = { drawerContent = {
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = LocalDate(month.year, month.month, 1),
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
}, },
onJumpToDate = { target ->
jumpToDate(target)
scope.launch { drawerState.close() }
},
onSettings = { onSettings = {
onOpenSettings() onOpenSettings()
scope.launch { drawerState.close() } scope.launch { drawerState.close() }

View File

@@ -96,6 +96,11 @@ class MonthViewModel @Inject constructor(
_month.value = YearMonth(todayDate.year, todayDate.month) _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( private fun buildState(
ym: YearMonth, ym: YearMonth,
weekStart: DayOfWeek, weekStart: DayOfWeek,

View File

@@ -156,6 +156,11 @@ fun WeekScreen(
} }
viewModel.goToToday() 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( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -164,10 +169,15 @@ fun WeekScreen(
drawerContent = { drawerContent = {
CalendarDrawer( CalendarDrawer(
currentView = selectedView, currentView = selectedView,
currentDate = weekStart,
onSelectView = { view -> onSelectView = { view ->
onSelectView(view) onSelectView(view)
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
}, },
onJumpToDate = { target ->
jumpToDate(target)
scope.launch { drawerState.close() }
},
onSettings = { onSettings = {
onOpenSettings() onOpenSettings()
scope.launch { drawerState.close() } scope.launch { drawerState.close() }

View File

@@ -107,6 +107,11 @@ class WeekViewModel @Inject constructor(
_anchor.value = todayDate _anchor.value = todayDate
} }
/** Jump to the week containing [date] (drawer jump-to-date). */
fun goToDate(date: LocalDate) {
_anchor.value = date
}
private fun buildState( private fun buildState(
start: LocalDate, start: LocalDate,
calendars: List<CalendarSource>, calendars: List<CalendarSource>,

View File

@@ -198,6 +198,9 @@
<string name="view_day">Tag</string> <string name="view_day">Tag</string>
<string name="view_section">Ansicht</string> <string name="view_section">Ansicht</string>
<!-- Zu Datum springen (Navigationsleiste) -->
<string name="drawer_jump_to_date">Zu Datum springen</string>
<!-- Kalender-Filter (M3) --> <!-- Kalender-Filter (M3) -->
<string name="filter_title">Kalender</string> <string name="filter_title">Kalender</string>

View File

@@ -199,6 +199,9 @@
<string name="view_day">Day</string> <string name="view_day">Day</string>
<string name="view_section">View</string> <string name="view_section">View</string>
<!-- Jump to date (drawer) -->
<string name="drawer_jump_to_date">Jump to date</string>
<!-- Calendar filter (M3) --> <!-- Calendar filter (M3) -->
<string name="filter_title">Calendars</string> <string name="filter_title">Calendars</string>