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