feat(nav): jump-to-date action in the navigation drawer
All checks were successful
CI / ci (push) Successful in 6m17s
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -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<CalendarSource>,
|
||||
|
||||
Reference in New Issue
Block a user