From c59a071b82ead884ff1b26906da7fc82627ef322 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 11 Jun 2026 13:27:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(write):=20event=20creation=20=E2=80=94=20f?= =?UTF-8?q?orm=20screen,=20FAB,=20last-used=20calendar=20(v1.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second slice of milestone 2 (write support): - EventForm domain model + problems() validation (end-before-start, no-calendar; blank titles and instant events stay legal) - Full-screen EventEditScreen: title, all-day switch, M3 date/time pickers (moving the start preserves the duration), calendar picker limited to writable calendars, location, description. Save validates, requests the WRITE upgrade contextually, and closes on success - Calendar preselection: explicit pick > last-used (CalendarPrefs) > first writable calendar - insertEvent in the data source; EventWriteMapper (JVM-tested) normalises all-day events to UTC midnights with exclusive DTEND, timed events to the device zone - CalendarFabColumn shared by month/week/day: persistent "+" FAB anchored on the visible day, jump-to-today pill stacked above it - Tests: EventForm validation, write-time mapping (incl. DST-safe epoch check), repository createEvent delegation/error propagation Co-Authored-By: Claude Fable 5 --- .../data/calendar/CalendarDataSource.kt | 27 + .../data/calendar/CalendarRepository.kt | 4 + .../data/calendar/CalendarRepositoryImpl.kt | 5 + .../data/calendar/EventWriteMapper.kt | 35 ++ .../calendula/data/prefs/CalendarPrefs.kt | 14 + .../calendula/domain/EventForm.kt | 35 ++ .../calendula/ui/CalendarHost.kt | 27 + .../calendula/ui/common/CalendarFabColumn.kt | 55 ++ .../calendula/ui/day/DayScreen.kt | 24 +- .../calendula/ui/edit/EventEditScreen.kt | 550 ++++++++++++++++++ .../calendula/ui/edit/EventEditUiState.kt | 29 + .../calendula/ui/edit/EventEditViewModel.kt | 160 +++++ .../calendula/ui/month/MonthScreen.kt | 35 +- .../calendula/ui/week/WeekScreen.kt | 32 +- app/src/main/res/values-de/strings.xml | 13 + app/src/main/res/values/strings.xml | 13 + .../calendar/CalendarRepositoryImplTest.kt | 41 ++ .../data/calendar/EventWriteMapperTest.kt | 49 ++ .../data/calendar/FakeCalendarDataSource.kt | 10 + .../calendula/domain/EventFormTest.kt | 72 +++ .../plans/2026-06-11-03-write-support.md | 23 +- 21 files changed, 1195 insertions(+), 58 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 6f1bc31..3347c56 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -13,8 +13,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.Reminder +import java.time.ZoneId import javax.inject.Inject import javax.inject.Singleton @@ -31,6 +33,9 @@ interface CalendarDataSource { fun instances(beginMillis: Long, endMillis: Long): List fun eventDetail(eventId: Long): EventDetail? + /** Insert a new event; returns the new `Events._ID`. */ + fun insertEvent(form: EventForm): Long + /** Delete the whole event (for recurring events: the entire series). */ fun deleteEvent(eventId: Long) @@ -85,6 +90,28 @@ class AndroidCalendarDataSource @Inject constructor( } } + override fun insertEvent(form: EventForm): Long { + val times = form.toWriteTimes(ZoneId.systemDefault()) + val values = ContentValues().apply { + put( + CalendarContract.Events.CALENDAR_ID, + requireNotNull(form.calendarId) { "EventForm.calendarId is required" }, + ) + put(CalendarContract.Events.TITLE, form.title.trim()) + put(CalendarContract.Events.ALL_DAY, if (form.isAllDay) 1 else 0) + put(CalendarContract.Events.DTSTART, times.dtStartMillis) + put(CalendarContract.Events.DTEND, times.dtEndMillis) + put(CalendarContract.Events.EVENT_TIMEZONE, times.timezone) + form.location.trim().takeIf { it.isNotEmpty() } + ?.let { put(CalendarContract.Events.EVENT_LOCATION, it) } + form.description.trim().takeIf { it.isNotEmpty() } + ?.let { put(CalendarContract.Events.DESCRIPTION, it) } + } + val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values) + ?: throw WriteFailedException("insert event into calendar id=${form.calendarId}") + return ContentUris.parseId(uri) + } + override fun deleteEvent(eventId: Long) { val deleted = resolver.delete( ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt index b9836fa..ede6397 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.coroutines.flow.Flow import kotlin.time.Instant @@ -11,6 +12,9 @@ interface CalendarRepository { fun instances(range: ClosedRange): Flow> suspend fun eventDetail(eventId: Long): EventDetail + /** Create a new event from a validated form; returns the new `Events._ID`. */ + suspend fun createEvent(form: EventForm): Long + /** Delete the whole event (for recurring events: the entire series). */ suspend fun deleteEvent(eventId: Long) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt index f9b33be..5b6c3d3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow @@ -69,6 +70,10 @@ class CalendarRepositoryImpl @Inject constructor( dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) } + override suspend fun createEvent(form: EventForm): Long = withContext(io) { + dataSource.insertEvent(form) + } + override suspend fun deleteEvent(eventId: Long) = withContext(io) { dataSource.deleteEvent(eventId) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt new file mode 100644 index 0000000..b835077 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -0,0 +1,35 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import de.jeanlucmakiola.calendula.domain.EventForm +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toJavaLocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset + +/** Provider-ready DTSTART / DTEND / EVENT_TIMEZONE for an event write. */ +internal data class EventWriteTimes( + val dtStartMillis: Long, + val dtEndMillis: Long, + val timezone: String, +) + +/** + * All-day events live at UTC midnights with an exclusive DTEND (the + * CalendarContract convention — a one-day event ends at the next midnight); + * timed events resolve their wall-clock values in [zone]. + */ +internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDay) { + EventWriteTimes( + dtStartMillis = start.date.toJavaLocalDate() + .atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(), + dtEndMillis = end.date.toJavaLocalDate().plusDays(1) + .atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(), + timezone = "UTC", + ) +} else { + EventWriteTimes( + dtStartMillis = start.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), + dtEndMillis = end.toJavaLocalDateTime().atZone(zone).toInstant().toEpochMilli(), + timezone = zone.id, + ) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt index 8e62f3f..8f724e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/CalendarPrefs.kt @@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.prefs import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -38,7 +39,20 @@ class CalendarPrefs @Inject constructor( } } + /** + * The calendar the user last created an event in; preselected in the + * event form. Null until the first event is created. + */ + val lastUsedCalendarId: Flow = store.data.map { prefs -> + prefs[LAST_USED_CALENDAR_KEY] + } + + suspend fun setLastUsedCalendarId(id: Long) { + store.edit { prefs -> prefs[LAST_USED_CALENDAR_KEY] = id } + } + companion object { internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids") + internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id") } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt new file mode 100644 index 0000000..94a2103 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -0,0 +1,35 @@ +package de.jeanlucmakiola.calendula.domain + +import kotlinx.datetime.LocalDateTime + +/** + * User input for creating an event (and, from v1.3, editing one). Times are + * wall-clock values in the device zone; the data layer translates them to + * provider millis (all-day events normalise to UTC midnights there). + */ +data class EventForm( + val calendarId: Long?, + val title: String = "", + val isAllDay: Boolean = false, + val start: LocalDateTime, + val end: LocalDateTime, + val location: String = "", + val description: String = "", +) + +enum class EventFormProblem { + /** No target calendar — none picked and no writable calendar exists. */ + NoCalendar, + EndBeforeStart, +} + +/** + * Validation; an empty set means the form can be saved. A blank title is + * allowed (display falls back to "(No title)", matching the provider), and a + * zero-length timed event is allowed (spec §8: instant events exist). + */ +fun EventForm.problems(): Set = buildSet { + if (calendarId == null) add(EventFormProblem.NoCalendar) + val endsTooEarly = if (isAllDay) end.date < start.date else end < start + if (endsTooEarly) add(EventFormProblem.EndBeforeStart) +} 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 ef04a99..9c046e0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.day.DayScreen import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen +import de.jeanlucmakiola.calendula.ui.edit.EventEditScreen import de.jeanlucmakiola.calendula.ui.month.MonthScreen import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen import de.jeanlucmakiola.calendula.ui.week.WeekScreen @@ -66,6 +67,15 @@ fun CalendarHost(modifier: Modifier = Modifier) { var showSettings by rememberSaveable { mutableStateOf(false) } val onOpenSettings = { showSettings = true } + // Event form (v1.2 create) — same held-key pattern as the detail screen: + // [heldCreateIso] keeps the prefill date alive through the slide-out. + var createDateIso by rememberSaveable { mutableStateOf(null) } + var heldCreateIso by remember { mutableStateOf(null) } + val onCreateEvent: (LocalDate) -> Unit = { date -> + heldCreateIso = date.toString() + createDateIso = date.toString() + } + val slideSpec = rememberCalendarSlideSpec() Box(modifier = modifier.fillMaxSize()) { @@ -75,12 +85,14 @@ fun CalendarHost(modifier: Modifier = Modifier) { onSelectView = onSelectView, onEventClick = onEventClick, onOpenSettings = onOpenSettings, + onCreateEvent = onCreateEvent, ) CalendarView.Day -> DayScreen( selectedView = view, onSelectView = onSelectView, onEventClick = onEventClick, onOpenSettings = onOpenSettings, + onCreateEvent = onCreateEvent, initialDateIso = pendingDayIso, ) CalendarView.Month -> MonthScreen( @@ -88,6 +100,7 @@ fun CalendarHost(modifier: Modifier = Modifier) { onSelectView = onSelectView, onOpenDay = onOpenDay, onOpenSettings = onOpenSettings, + onCreateEvent = onCreateEvent, ) } @@ -108,6 +121,20 @@ fun CalendarHost(modifier: Modifier = Modifier) { } } + // Event form (v1.2) — full-screen destination, slides over the calendar. + AnimatedVisibility( + visible = createDateIso != null, + enter = slideInHorizontally(slideSpec) { it } + fadeIn(), + exit = slideOutHorizontally(slideSpec) { it } + fadeOut(), + ) { + (createDateIso ?: heldCreateIso)?.let { iso -> + EventEditScreen( + initialDateIso = iso, + onClose = { createDateIso = null }, + ) + } + } + // Settings (M4) — full-screen destination, slides over the calendar. AnimatedVisibility( visible = showSettings, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt new file mode 100644 index 0000000..92b56b6 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt @@ -0,0 +1,55 @@ +package de.jeanlucmakiola.calendula.ui.common + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import de.jeanlucmakiola.calendula.R + +/** + * The FAB stack shared by the three calendar views: a persistent "+" to + * create an event, with the jump-to-today pill appearing above it whenever + * the view isn't anchored on today. + */ +@Composable +fun CalendarFabColumn( + todayVisible: Boolean, + todayText: String, + onToday: () -> Unit, + onCreate: () -> Unit, +) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + AnimatedVisibility( + visible = todayVisible, + enter = scaleIn(), + exit = scaleOut(), + ) { + ExtendedFloatingActionButton( + onClick = onToday, + icon = { Icon(Icons.Default.Refresh, contentDescription = null) }, + text = { Text(todayText) }, + ) + } + FloatingActionButton(onClick = onCreate) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.event_edit_new_title), + ) + } + } +} 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 5d93b0a..8400a39 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 @@ -1,11 +1,8 @@ package de.jeanlucmakiola.calendula.ui.day import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures @@ -28,12 +25,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -72,6 +67,7 @@ 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.ViewSwitcherPill @@ -108,6 +104,7 @@ fun DayScreen( onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, + onCreateEvent: (LocalDate) -> Unit, modifier: Modifier = Modifier, initialDateIso: String? = null, viewModel: DayViewModel = hiltViewModel(), @@ -172,17 +169,12 @@ fun DayScreen( ) }, floatingActionButton = { - AnimatedVisibility( - visible = !isOnToday, - enter = scaleIn(), - exit = scaleOut(), - ) { - ExtendedFloatingActionButton( - onClick = jumpToToday, - icon = { Icon(Icons.Default.Refresh, contentDescription = null) }, - text = { Text(stringResource(R.string.day_today_action)) }, - ) - } + CalendarFabColumn( + todayVisible = !isOnToday, + todayText = stringResource(R.string.day_today_action), + onToday = jumpToToday, + onCreate = { onCreateEvent(date) }, + ) }, ) { innerPadding -> DayContent( 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 new file mode 100644 index 0000000..4e8204c --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -0,0 +1,550 @@ +package de.jeanlucmakiola.calendula.ui.edit + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +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 +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.jeanlucmakiola.calendula.R +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventFormProblem +import de.jeanlucmakiola.calendula.ui.common.currentLocale +import de.jeanlucmakiola.calendula.ui.common.pastelize +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toJavaLocalDate +import kotlinx.datetime.toJavaLocalTime +import kotlinx.datetime.toLocalDateTime +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import kotlin.time.Clock + +/** + * Full-screen event form (v1.2: create only). Opens prefilled from the FAB's + * anchor date; Save validates, writes via the repository, and closes. The + * calendar picker offers only writable calendars. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventEditScreen( + initialDateIso: String?, + onClose: () -> Unit, + viewModel: EventEditViewModel = hiltViewModel(), +) { + LaunchedEffect(initialDateIso) { + val date = initialDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + viewModel.openNew(date) + } + val state by viewModel.state.collectAsStateWithLifecycle() + + // The form is intentionally forgotten on every close (cancel or save) so + // the next FAB tap starts clean; it survives rotation because openNew + // no-ops while a form is set. + val close = { + viewModel.reset() + onClose() + } + BackHandler(onBack = close) + + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + // Contextual WRITE_CALENDAR upgrade for v1.0 installs, like delete. + val writePermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) viewModel.save() + } + val onSaveClick = { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.WRITE_CALENDAR, + ) == PackageManager.PERMISSION_GRANTED + if (granted) { + viewModel.save() + } else { + writePermissionLauncher.launch(Manifest.permission.WRITE_CALENDAR) + } + } + + val saveFailedMessage = stringResource(R.string.event_edit_save_failed) + val writeDeniedMessage = stringResource(R.string.event_edit_write_denied) + LaunchedEffect(state?.saveState) { + when (state?.saveState) { + SaveUiState.Saved -> close() + SaveUiState.Failed -> { + viewModel.consumeSaveResult() + snackbarHostState.showSnackbar(saveFailedMessage) + } + SaveUiState.NeedsPermission -> { + viewModel.consumeSaveResult() + snackbarHostState.showSnackbar(writeDeniedMessage) + } + SaveUiState.Idle, SaveUiState.Saving, null -> Unit + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.event_edit_new_title)) }, + navigationIcon = { + IconButton(onClick = close) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.event_edit_close), + ) + } + }, + actions = { + Button( + onClick = onSaveClick, + enabled = state != null && state?.saveState != SaveUiState.Saving, + modifier = Modifier.padding(end = 12.dp), + ) { + Text(stringResource(R.string.event_edit_save)) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + }, + ) { innerPadding -> + state?.let { s -> + EventEditContent( + state = s, + viewModel = viewModel, + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) + } + } +} + +private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime } + +@Composable +private fun EventEditContent( + state: EventEditUiState, + viewModel: EventEditViewModel, + modifier: Modifier = Modifier, +) { + val form = state.form + val locale = currentLocale() + var picker by remember { mutableStateOf(null) } + var showCalendarPicker by rememberSaveable { mutableStateOf(false) } + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp), + ) { + // Title: a large borderless field, MD3 "create" idiom. + TextField( + value = form.title, + onValueChange = viewModel::setTitle, + placeholder = { + Text( + text = stringResource(R.string.event_edit_title_hint), + style = MaterialTheme.typography.headlineSmall, + ) + }, + textStyle = MaterialTheme.typography.headlineSmall, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + ), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + // All-day toggle. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Text( + text = stringResource(R.string.event_detail_all_day), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay) + } + + Spacer(Modifier.height(8.dp)) + + // Start / end rows: tappable date + (for timed events) time labels. + DateTimeRow( + label = stringResource(R.string.event_edit_starts), + dateTime = form.start, + isAllDay = form.isAllDay, + locale = locale, + onDateClick = { picker = PickerTarget.StartDate }, + onTimeClick = { picker = PickerTarget.StartTime }, + ) + DateTimeRow( + label = stringResource(R.string.event_edit_ends), + dateTime = form.end, + isAllDay = form.isAllDay, + locale = locale, + onDateClick = { picker = PickerTarget.EndDate }, + onTimeClick = { picker = PickerTarget.EndTime }, + isError = EventFormProblem.EndBeforeStart in state.problems, + ) + if (EventFormProblem.EndBeforeStart in state.problems) { + Text( + text = stringResource(R.string.event_edit_error_end_before_start), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 40.dp, top = 2.dp), + ) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + // Calendar picker row. + val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } + val dark = isSystemInDarkTheme() + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = state.calendars.isNotEmpty()) { showCalendarPicker = true } + .padding(vertical = 8.dp), + ) { + Icon( + imageVector = Icons.Default.CalendarMonth, + contentDescription = stringResource(R.string.event_detail_calendar), + tint = selectedCalendar?.let { pastelize(it.color, dark) } + ?: MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = selectedCalendar?.displayName + ?: stringResource(R.string.event_edit_error_no_calendar), + style = MaterialTheme.typography.bodyLarge, + color = if (selectedCalendar == null) { + MaterialTheme.colorScheme.error + } else { + Color.Unspecified + }, + ) + selectedCalendar?.accountName?.takeIf { it.isNotBlank() }?.let { account -> + Text( + text = account, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = form.location, + onValueChange = viewModel::setLocation, + label = { Text(stringResource(R.string.event_detail_location)) }, + leadingIcon = { Icon(Icons.Default.Place, contentDescription = null) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = form.description, + onValueChange = viewModel::setDescription, + label = { Text(stringResource(R.string.event_detail_description)) }, + leadingIcon = { Icon(Icons.AutoMirrored.Filled.Notes, contentDescription = null) }, + minLines = 3, + modifier = Modifier.fillMaxWidth(), + ) + } + + when (picker) { + PickerTarget.StartDate -> DatePickerAlert( + initial = form.start.date, + onConfirm = { viewModel.setStartDate(it); picker = null }, + onDismiss = { picker = null }, + ) + PickerTarget.EndDate -> DatePickerAlert( + initial = form.end.date, + onConfirm = { viewModel.setEndDate(it); picker = null }, + onDismiss = { picker = null }, + ) + PickerTarget.StartTime -> TimePickerAlert( + initial = form.start.time, + onConfirm = { viewModel.setStartTime(it); picker = null }, + onDismiss = { picker = null }, + ) + PickerTarget.EndTime -> TimePickerAlert( + initial = form.end.time, + onConfirm = { viewModel.setEndTime(it); picker = null }, + onDismiss = { picker = null }, + ) + null -> Unit + } + + if (showCalendarPicker) { + CalendarPickerDialog( + calendars = state.calendars, + selectedId = form.calendarId, + onSelect = { + viewModel.setCalendar(it) + showCalendarPicker = false + }, + onDismiss = { showCalendarPicker = false }, + ) + } +} + +/** One schedule row: label, then tappable date and (unless all-day) time. */ +@Composable +private fun DateTimeRow( + label: String, + dateTime: LocalDateTime, + isAllDay: Boolean, + locale: Locale, + onDateClick: () -> Unit, + onTimeClick: () -> Unit, + isError: Boolean = false, +) { + val dateFormat = remember(locale) { + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) + } + val timeFormat = remember(locale) { + DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) + } + val contentColor = if (isError) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurface + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 40.dp, top = 8.dp, bottom = 8.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + text = dateFormat.format(dateTime.date.toJavaLocalDate()), + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + modifier = Modifier + .clickable(onClick = onDateClick) + .padding(8.dp), + ) + if (!isAllDay) { + Text( + text = timeFormat.format(dateTime.time.toJavaLocalTime()), + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + modifier = Modifier + .clickable(onClick = onTimeClick) + .padding(8.dp), + ) + } + } +} + +@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( + initial: LocalTime, + onConfirm: (LocalTime) -> Unit, + onDismiss: () -> Unit, +) { + val state = rememberTimePickerState( + initialHour = initial.hour, + initialMinute = initial.minute, + ) + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { onConfirm(LocalTime(state.hour, state.minute)) }) { + Text(stringResource(R.string.dialog_ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + text = { TimePicker(state = state) }, + ) +} + +@Composable +private fun CalendarPickerDialog( + calendars: List, + selectedId: Long?, + onSelect: (Long) -> Unit, + onDismiss: () -> Unit, +) { + val dark = isSystemInDarkTheme() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.event_detail_calendar)) }, + text = { + Column { + calendars.forEach { calendar -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = calendar.id == selectedId, + role = Role.RadioButton, + onClick = { onSelect(calendar.id) }, + ) + .padding(vertical = 8.dp), + ) { + RadioButton(selected = calendar.id == selectedId, onClick = null) + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.CalendarMonth, + contentDescription = null, + tint = pastelize(calendar.color, dark), + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.width(8.dp)) + Column { + Text(calendar.displayName, style = MaterialTheme.typography.bodyLarge) + if (calendar.accountName.isNotBlank()) { + Text( + text = calendar.accountName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +private const val MILLIS_PER_DAY = 86_400_000L diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt new file mode 100644 index 0000000..458b317 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -0,0 +1,29 @@ +package de.jeanlucmakiola.calendula.ui.edit + +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.EventFormProblem + +/** + * UI state for the event form (v1.2: create; v1.3 reuses it for edit). Null + * form means the screen hasn't been opened yet. + */ +data class EventEditUiState( + /** The form with its calendar id resolved (picked > last used > first writable). */ + val form: EventForm, + /** Calendars that accept writes — the only valid targets. */ + val calendars: List, + /** Validation problems; empty until a save was attempted. */ + val problems: Set, + val saveState: SaveUiState, +) + +/** One-shot state of a save request, mirroring DeleteUiState on the detail screen. */ +sealed interface SaveUiState { + data object Idle : SaveUiState + data object Saving : SaveUiState + data object Saved : SaveUiState + /** WRITE_CALENDAR was revoked between the tap and the provider call. */ + data object NeedsPermission : SaveUiState + data object Failed : SaveUiState +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt new file mode 100644 index 0000000..473d66b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -0,0 +1,160 @@ +package de.jeanlucmakiola.calendula.ui.edit + +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.data.prefs.CalendarPrefs +import de.jeanlucmakiola.calendula.domain.EventForm +import de.jeanlucmakiola.calendula.domain.problems +import kotlinx.coroutines.CoroutineDispatcher +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.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Clock +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant +import javax.inject.Inject + +/** + * Holds the event form being composed. The form's calendar id resolves to + * (user pick > last used > first writable); the resolved value is what the UI + * shows and what gets saved. + */ +@HiltViewModel +class EventEditViewModel @Inject constructor( + private val repository: CalendarRepository, + private val prefs: CalendarPrefs, + @IoDispatcher private val io: CoroutineDispatcher, +) : ViewModel() { + + private val _form = MutableStateFlow(null) + private val _saveState = MutableStateFlow(SaveUiState.Idle) + // Problems stay hidden until the first save attempt, so a half-filled + // form isn't already shouting errors. + private val _showProblems = MutableStateFlow(false) + + val state: StateFlow = combine( + _form, + repository.calendars() + .map { calendars -> calendars.filter { it.canModifyContents } } + .catch { emit(emptyList()) }, + prefs.lastUsedCalendarId, + _saveState, + _showProblems, + ) { form, writable, lastUsed, saveState, showProblems -> + if (form == null) return@combine null + val resolvedId = form.calendarId + ?: lastUsed?.takeIf { id -> writable.any { it.id == id } } + ?: writable.firstOrNull()?.id + val resolved = form.copy(calendarId = resolvedId) + EventEditUiState( + form = resolved, + calendars = writable, + problems = if (showProblems) resolved.problems() else emptySet(), + saveState = saveState, + ) + } + .flowOn(io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000L), + initialValue = null, + ) + + /** + * Initialise a fresh form for a new event on [date]. No-op when a form is + * already open, so user input survives configuration changes; [reset] + * clears it when the screen closes. + */ + fun openNew(date: LocalDate) { + if (_form.value != null) return + val zone = TimeZone.currentSystemDefault() + val now = Clock.System.now() + val start = if (date == now.toLocalDateTime(zone).date) { + // Today: the next full hour (may roll into tomorrow before midnight). + val hourMillis = 3_600_000L + val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis + Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone) + } else { + LocalDateTime(date, LocalTime(9, 0)) + } + val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone) + _form.value = EventForm(calendarId = null, start = start, end = end) + } + + /** Forget the open form; the next [openNew] starts clean. */ + fun reset() { + _form.value = null + _saveState.value = SaveUiState.Idle + _showProblems.value = false + } + + fun setTitle(value: String) = update { it.copy(title = value) } + fun setLocation(value: String) = update { it.copy(location = value) } + fun setDescription(value: String) = update { it.copy(description = value) } + fun setAllDay(value: Boolean) = update { it.copy(isAllDay = value) } + fun setCalendar(id: Long) = update { it.copy(calendarId = id) } + + /** Moving the start drags the end along, preserving the duration. */ + fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) } + fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) } + fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) } + fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) } + + /** Validate and write. Terminal results land in [saveState]. */ + fun save() { + val current = state.value ?: return + if (current.saveState == SaveUiState.Saving) return + val form = current.form + if (form.problems().isNotEmpty()) { + _showProblems.value = true + return + } + viewModelScope.launch { + _saveState.value = SaveUiState.Saving + _saveState.value = try { + repository.createEvent(form) + prefs.setLastUsedCalendarId(requireNotNull(form.calendarId)) + SaveUiState.Saved + } catch (e: CancellationException) { + throw e + } catch (e: SecurityException) { + SaveUiState.NeedsPermission + } catch (e: Exception) { + SaveUiState.Failed + } + } + } + + /** Reset [saveState] after the screen handled a terminal result. */ + fun consumeSaveResult() { + _saveState.value = SaveUiState.Idle + } + + private fun moveStart(transform: (LocalDateTime) -> LocalDateTime) = update { form -> + val zone = TimeZone.currentSystemDefault() + val newStart = transform(form.start) + val duration = form.end.toInstant(zone) - form.start.toInstant(zone) + val newEnd = (newStart.toInstant(zone) + duration).toLocalDateTime(zone) + form.copy(start = newStart, end = newEnd) + } + + private inline fun update(block: (EventForm) -> EventForm) { + _form.value = _form.value?.let(block) + } +} 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 fbf0f55..5ddcee6 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 @@ -1,10 +1,7 @@ package de.jeanlucmakiola.calendula.ui.month import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.interaction.MutableInteractionSource @@ -23,13 +20,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -62,6 +57,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R 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.ViewSwitcherPill @@ -74,8 +70,11 @@ import kotlinx.coroutines.launch import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DayOfWeek import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone import kotlinx.datetime.YearMonth import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle import java.util.Locale @@ -86,6 +85,7 @@ fun MonthScreen( onSelectView: (CalendarView) -> Unit, onOpenDay: (LocalDate) -> Unit, onOpenSettings: () -> Unit, + onCreateEvent: (LocalDate) -> Unit, modifier: Modifier = Modifier, viewModel: MonthViewModel = hiltViewModel(), ) { @@ -147,17 +147,20 @@ fun MonthScreen( ) }, floatingActionButton = { - AnimatedVisibility( - visible = !isOnCurrentMonth, - enter = scaleIn(), - exit = scaleOut(), - ) { - ExtendedFloatingActionButton( - onClick = jumpToToday, - icon = { Icon(Icons.Default.Refresh, contentDescription = null) }, - text = { Text(stringResource(R.string.month_today_action)) }, - ) - } + CalendarFabColumn( + todayVisible = !isOnCurrentMonth, + todayText = stringResource(R.string.month_today_action), + onToday = jumpToToday, + onCreate = { + // Anchor on today when its month is shown, else the 1st. + val today = Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()).date + onCreateEvent( + if (isOnCurrentMonth) today + else LocalDate(month.year, month.month, 1), + ) + }, + ) }, ) { innerPadding -> Column( 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 7fbfbef..2247f98 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 @@ -1,11 +1,8 @@ package de.jeanlucmakiola.calendula.ui.week import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectHorizontalDragGestures @@ -31,12 +28,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -77,6 +72,7 @@ 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.ViewSwitcherPill @@ -88,7 +84,10 @@ import de.jeanlucmakiola.calendula.ui.common.pastelize import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock import java.time.format.TextStyle as JavaTextStyle import java.util.Locale import kotlin.math.roundToInt @@ -113,6 +112,7 @@ fun WeekScreen( onSelectView: (CalendarView) -> Unit, onEventClick: (EventInstance) -> Unit, onOpenSettings: () -> Unit, + onCreateEvent: (LocalDate) -> Unit, modifier: Modifier = Modifier, viewModel: WeekViewModel = hiltViewModel(), ) { @@ -174,17 +174,17 @@ fun WeekScreen( ) }, floatingActionButton = { - AnimatedVisibility( - visible = !isOnCurrentWeek, - enter = scaleIn(), - exit = scaleOut(), - ) { - ExtendedFloatingActionButton( - onClick = jumpToToday, - icon = { Icon(Icons.Default.Refresh, contentDescription = null) }, - text = { Text(stringResource(R.string.week_today_action)) }, - ) - } + CalendarFabColumn( + todayVisible = !isOnCurrentWeek, + todayText = stringResource(R.string.week_today_action), + onToday = jumpToToday, + onCreate = { + // Anchor on today when it's in view, else the week's first day. + val today = Clock.System.now() + .toLocalDateTime(TimeZone.currentSystemDefault()).date + onCreateEvent(if (isOnCurrentWeek) today else weekStart) + }, + ) }, ) { innerPadding -> WeekContent( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a19c62f..c84a168 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -54,6 +54,19 @@ Termin konnte nicht gelöscht werden Calendula braucht Schreibzugriff, um Termine zu löschen Abbrechen + OK + + + Neuer Termin + Schließen + Speichern + Titel hinzufügen + Beginn + Ende + Endet vor dem Beginn + Kein beschreibbarer Kalender verfügbar + Termin konnte nicht gespeichert werden + Calendula braucht Schreibzugriff, um Termine zu erstellen Ganztägig Kalender Unbekannter Kalender diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f9fa00..3b96c0d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -55,6 +55,19 @@ Couldn\'t delete the event Calendula needs write access to delete events Cancel + OK + + + New event + Close + Save + Add title + Starts + Ends + Ends before it starts + No writable calendar available + Couldn\'t save the event + Calendula needs write access to create events All day Calendar Unknown calendar diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt index 1d71cb7..4a6d903 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -7,8 +7,12 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance import kotlinx.coroutines.Dispatchers +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -157,6 +161,43 @@ class CalendarRepositoryImplTest { } } + @Test + fun `createEvent delegates and returns the new id`(@TempDir tempDir: Path) = runTest { + val fake = FakeCalendarDataSource().apply { nextInsertId = 77L } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined) + val form = EventForm( + calendarId = 1L, + title = "Stand-up", + start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15)), + ) + + val id = repo.createEvent(form) + + assertThat(id).isEqualTo(77L) + assertThat(fake.insertedForms).containsExactly(form) + } + + @Test + fun `createEvent propagates write failures`(@TempDir tempDir: Path) = runTest { + val fake = FakeCalendarDataSource().apply { + writeError = WriteFailedException("insert event") + } + val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined) + val form = EventForm( + calendarId = 1L, + start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0)), + end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(10, 0)), + ) + + try { + repo.createEvent(form) + error("Expected WriteFailedException") + } catch (expected: WriteFailedException) { + assertThat(expected.message).contains("insert") + } + } + @Test fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest { val fake = FakeCalendarDataSource() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt new file mode 100644 index 0000000..87f4bd3 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -0,0 +1,49 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventForm +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import org.junit.jupiter.api.Test +import java.time.ZoneId + +class EventWriteMapperTest { + + private val berlin: ZoneId = ZoneId.of("Europe/Berlin") + + private fun form( + isAllDay: Boolean = false, + start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)), + end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 30)), + ): EventForm = EventForm(calendarId = 1L, isAllDay = isAllDay, start = start, end = end) + + @Test + fun `timed event resolves wall clock in the given zone`() { + val times = form().toWriteTimes(berlin) + // 2026-06-11 is CEST (UTC+2): 10:00 local == 08:00Z. + assertThat(times.dtStartMillis).isEqualTo(1_781_164_800_000L) + assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(90L * 60L * 1000L) + assertThat(times.timezone).isEqualTo("Europe/Berlin") + } + + @Test + fun `all-day event lives at UTC midnights with exclusive end`() { + val times = form(isAllDay = true).toWriteTimes(berlin) + assertThat(times.timezone).isEqualTo("UTC") + assertThat(times.dtStartMillis % 86_400_000L).isEqualTo(0L) + // Single-day all-day event: DTEND is the NEXT UTC midnight. + assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(86_400_000L) + } + + @Test + fun `multi-day all-day event spans every covered day`() { + val times = form( + isAllDay = true, + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), + end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(0, 0)), + ).toWriteTimes(berlin) + // 11th, 12th, 13th inclusive = 3 days. + assertThat(times.dtEndMillis - times.dtStartMillis).isEqualTo(3L * 86_400_000L) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt index bca4b03..fc30ccb 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventInstance /** @@ -15,7 +16,10 @@ internal class FakeCalendarDataSource : CalendarDataSource { var eventDetailResult: (Long) -> EventDetail? = { null } /** Set to make the next write call throw. */ var writeError: Exception? = null + /** Id returned by the next [insertEvent]. */ + var nextInsertId: Long = 100L + val insertedForms = mutableListOf() val deletedEventIds = mutableListOf() val deletedOccurrences = mutableListOf>() @@ -26,6 +30,12 @@ internal class FakeCalendarDataSource : CalendarDataSource { instancesResult(beginMillis, endMillis) override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId) + override fun insertEvent(form: EventForm): Long { + writeError?.let { throw it } + insertedForms += form + return nextInsertId + } + override fun deleteEvent(eventId: Long) { writeError?.let { throw it } deletedEventIds += eventId diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt new file mode 100644 index 0000000..5e3705f --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/EventFormTest.kt @@ -0,0 +1,72 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import org.junit.jupiter.api.Test + +class EventFormTest { + + private fun form( + calendarId: Long? = 1L, + isAllDay: Boolean = false, + start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)), + end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)), + ): EventForm = EventForm(calendarId = calendarId, isAllDay = isAllDay, start = start, end = end) + + @Test + fun `valid timed form has no problems`() { + assertThat(form().problems()).isEmpty() + } + + @Test + fun `missing calendar is a problem`() { + assertThat(form(calendarId = null).problems()) + .containsExactly(EventFormProblem.NoCalendar) + } + + @Test + fun `timed end before start is a problem`() { + val bad = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0))) + assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart) + } + + @Test + fun `zero-length timed event is allowed`() { + val instant = form(end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0))) + assertThat(instant.problems()).isEmpty() + } + + @Test + fun `all-day single day is allowed even though times match`() { + val allDay = form( + isAllDay = true, + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), + end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), + ) + assertThat(allDay.problems()).isEmpty() + } + + @Test + fun `all-day end date before start date is a problem`() { + val bad = form( + isAllDay = true, + start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(0, 0)), + end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(0, 0)), + ) + assertThat(bad.problems()).containsExactly(EventFormProblem.EndBeforeStart) + } + + @Test + fun `problems accumulate`() { + val bad = form( + calendarId = null, + end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)), + ) + assertThat(bad.problems()).containsExactly( + EventFormProblem.NoCalendar, + EventFormProblem.EndBeforeStart, + ) + } +} diff --git a/docs/superpowers/plans/2026-06-11-03-write-support.md b/docs/superpowers/plans/2026-06-11-03-write-support.md index 3c50b57..71a099d 100644 --- a/docs/superpowers/plans/2026-06-11-03-write-support.md +++ b/docs/superpowers/plans/2026-06-11-03-write-support.md @@ -45,8 +45,8 @@ Domain bleibt pure Kotlin. | Slice | Inhalt | Status | |---|---|---| -| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | in Arbeit | -| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | offen | +| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | ausgeliefert (v1.1.0, 2026-06-11) | +| v1.2 | Create: Event-Formular (Titel, Kalender, ganztägig, Start/Ende, Ort, Beschreibung), FAB, Default-Kalender-Pref | ausgeliefert (v1.2.0, 2026-06-11) | | v1.3 | Edit: Formular wiederverwendet, Serien-Edit, Reminder-Edit, einfacher Recurrence-Picker | offen | | v2.0 | Quick-Add, Occurrence-Edit, Konflikt-Dialog, Polish-Pass, Release | offen | @@ -82,15 +82,18 @@ Domain bleibt pure Kotlin. - [x] `CalendarRepositoryImplTest`: delete-Pfade (Erfolg, Fehler) - [x] `CalendarMapperTest`: Access-Level-Mapping -## v1.2 — Create (Skizze) +## v1.2 — Create -- `EventForm`-Domain-Modell + Validierung (Ende > Start, Titel-Fallback) -- `EventEditScreen` (ein Formular für Create+Edit), M3-Date/Time-Picker -- FAB auf allen drei Hauptansichten, vorbelegt mit sichtbarem Tag/Slot -- `CalendarPrefs.defaultCalendarId` + Auswahl im Formular (nur beschreibbare - Kalender anbieten) -- `insertEvent(form): Long` im DataSource (`DTSTART/DTEND/EVENT_TIMEZONE`, - all-day in UTC) +- [x] `EventForm`-Domain-Modell + Validierung (`problems()`: EndBeforeStart, + NoCalendar; leerer Titel und Instant-Events erlaubt) +- [x] `EventEditScreen` (ein Formular, ab v1.3 auch für Edit), M3-Date/Time-Picker +- [x] FAB-Stack auf allen drei Hauptansichten (`CalendarFabColumn`: "+" immer, + Heute-Pill darüber), vorbelegt mit dem sichtbaren Tag +- [x] Kalender-Vorauswahl: explizit > zuletzt benutzt + (`CalendarPrefs.lastUsedCalendarId` statt Settings-Eintrag) > erster + beschreibbarer; Picker bietet nur beschreibbare Kalender an +- [x] `insertEvent(form): Long` im DataSource; `EventWriteMapper` (JVM-testbar) + normalisiert all-day auf UTC-Mitternächte mit exklusivem DTEND ## v1.3 — Edit (Skizze)