feat(write): event creation — form screen, FAB, last-used calendar (v1.2)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,10 @@ import dagger.hilt.android.qualifiers.ApplicationContext
|
|||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.Reminder
|
import de.jeanlucmakiola.calendula.domain.Reminder
|
||||||
|
import java.time.ZoneId
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -31,6 +33,9 @@ interface CalendarDataSource {
|
|||||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||||
fun eventDetail(eventId: Long): EventDetail?
|
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). */
|
/** Delete the whole event (for recurring events: the entire series). */
|
||||||
fun deleteEvent(eventId: Long)
|
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) {
|
override fun deleteEvent(eventId: Long) {
|
||||||
val deleted = resolver.delete(
|
val deleted = resolver.delete(
|
||||||
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
@@ -11,6 +12,9 @@ interface CalendarRepository {
|
|||||||
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
fun instances(range: ClosedRange<Instant>): Flow<List<EventInstance>>
|
||||||
suspend fun eventDetail(eventId: Long): EventDetail
|
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). */
|
/** Delete the whole event (for recurring events: the entire series). */
|
||||||
suspend fun deleteEvent(eventId: Long)
|
suspend fun deleteEvent(eventId: Long)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -69,6 +70,10 @@ class CalendarRepositoryImpl @Inject constructor(
|
|||||||
dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId)
|
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) {
|
override suspend fun deleteEvent(eventId: Long) = withContext(io) {
|
||||||
dataSource.deleteEvent(eventId)
|
dataSource.deleteEvent(eventId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package de.jeanlucmakiola.calendula.data.prefs
|
|||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
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<Long?> = 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 {
|
companion object {
|
||||||
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
internal val HIDDEN_IDS_KEY = stringPreferencesKey("hidden_calendar_ids")
|
||||||
|
internal val LAST_USED_CALENDAR_KEY = longPreferencesKey("last_used_calendar_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<EventFormProblem> = buildSet {
|
||||||
|
if (calendarId == null) add(EventFormProblem.NoCalendar)
|
||||||
|
val endsTooEarly = if (isAllDay) end.date < start.date else end < start
|
||||||
|
if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||||
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
import de.jeanlucmakiola.calendula.ui.day.DayScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailScreen
|
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.month.MonthScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
import de.jeanlucmakiola.calendula.ui.settings.SettingsScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
import de.jeanlucmakiola.calendula.ui.week.WeekScreen
|
||||||
@@ -66,6 +67,15 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
var showSettings by rememberSaveable { mutableStateOf(false) }
|
var showSettings by rememberSaveable { mutableStateOf(false) }
|
||||||
val onOpenSettings = { showSettings = true }
|
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<String?>(null) }
|
||||||
|
var heldCreateIso by remember { mutableStateOf<String?>(null) }
|
||||||
|
val onCreateEvent: (LocalDate) -> Unit = { date ->
|
||||||
|
heldCreateIso = date.toString()
|
||||||
|
createDateIso = date.toString()
|
||||||
|
}
|
||||||
|
|
||||||
val slideSpec = rememberCalendarSlideSpec()
|
val slideSpec = rememberCalendarSlideSpec()
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxSize()) {
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
@@ -75,12 +85,14 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
)
|
)
|
||||||
CalendarView.Day -> DayScreen(
|
CalendarView.Day -> DayScreen(
|
||||||
selectedView = view,
|
selectedView = view,
|
||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
|
onCreateEvent = onCreateEvent,
|
||||||
initialDateIso = pendingDayIso,
|
initialDateIso = pendingDayIso,
|
||||||
)
|
)
|
||||||
CalendarView.Month -> MonthScreen(
|
CalendarView.Month -> MonthScreen(
|
||||||
@@ -88,6 +100,7 @@ fun CalendarHost(modifier: Modifier = Modifier) {
|
|||||||
onSelectView = onSelectView,
|
onSelectView = onSelectView,
|
||||||
onOpenDay = onOpenDay,
|
onOpenDay = onOpenDay,
|
||||||
onOpenSettings = onOpenSettings,
|
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.
|
// Settings (M4) — full-screen destination, slides over the calendar.
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = showSettings,
|
visible = showSettings,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
@@ -28,12 +25,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -72,6 +67,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -108,6 +104,7 @@ fun DayScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
initialDateIso: String? = null,
|
initialDateIso: String? = null,
|
||||||
viewModel: DayViewModel = hiltViewModel(),
|
viewModel: DayViewModel = hiltViewModel(),
|
||||||
@@ -172,17 +169,12 @@ fun DayScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
CalendarFabColumn(
|
||||||
visible = !isOnToday,
|
todayVisible = !isOnToday,
|
||||||
enter = scaleIn(),
|
todayText = stringResource(R.string.day_today_action),
|
||||||
exit = scaleOut(),
|
onToday = jumpToToday,
|
||||||
) {
|
onCreate = { onCreateEvent(date) },
|
||||||
ExtendedFloatingActionButton(
|
)
|
||||||
onClick = jumpToToday,
|
|
||||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
|
||||||
text = { Text(stringResource(R.string.day_today_action)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
DayContent(
|
DayContent(
|
||||||
|
|||||||
@@ -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<PickerTarget?>(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<CalendarSource>,
|
||||||
|
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
|
||||||
@@ -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<CalendarSource>,
|
||||||
|
/** Validation problems; empty until a save was attempted. */
|
||||||
|
val problems: Set<EventFormProblem>,
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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<EventForm?>(null)
|
||||||
|
private val _saveState = MutableStateFlow<SaveUiState>(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<EventEditUiState?> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.month
|
package de.jeanlucmakiola.calendula.ui.month
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
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.background
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
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.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -62,6 +57,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||||
@@ -74,8 +70,11 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.datetime.DateTimeUnit
|
import kotlinx.datetime.DateTimeUnit
|
||||||
import kotlinx.datetime.DayOfWeek
|
import kotlinx.datetime.DayOfWeek
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.YearMonth
|
import kotlinx.datetime.YearMonth
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@@ -86,6 +85,7 @@ fun MonthScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: MonthViewModel = hiltViewModel(),
|
viewModel: MonthViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -147,17 +147,20 @@ fun MonthScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
CalendarFabColumn(
|
||||||
visible = !isOnCurrentMonth,
|
todayVisible = !isOnCurrentMonth,
|
||||||
enter = scaleIn(),
|
todayText = stringResource(R.string.month_today_action),
|
||||||
exit = scaleOut(),
|
onToday = jumpToToday,
|
||||||
) {
|
onCreate = {
|
||||||
ExtendedFloatingActionButton(
|
// Anchor on today when its month is shown, else the 1st.
|
||||||
onClick = jumpToToday,
|
val today = Clock.System.now()
|
||||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
text = { Text(stringResource(R.string.month_today_action)) },
|
onCreateEvent(
|
||||||
)
|
if (isOnCurrentMonth) today
|
||||||
}
|
else LocalDate(month.year, month.month, 1),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
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.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
@@ -31,12 +28,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -77,6 +72,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
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.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
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.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import kotlin.time.Clock
|
||||||
import java.time.format.TextStyle as JavaTextStyle
|
import java.time.format.TextStyle as JavaTextStyle
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -113,6 +112,7 @@ fun WeekScreen(
|
|||||||
onSelectView: (CalendarView) -> Unit,
|
onSelectView: (CalendarView) -> Unit,
|
||||||
onEventClick: (EventInstance) -> Unit,
|
onEventClick: (EventInstance) -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onCreateEvent: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: WeekViewModel = hiltViewModel(),
|
viewModel: WeekViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -174,17 +174,17 @@ fun WeekScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
AnimatedVisibility(
|
CalendarFabColumn(
|
||||||
visible = !isOnCurrentWeek,
|
todayVisible = !isOnCurrentWeek,
|
||||||
enter = scaleIn(),
|
todayText = stringResource(R.string.week_today_action),
|
||||||
exit = scaleOut(),
|
onToday = jumpToToday,
|
||||||
) {
|
onCreate = {
|
||||||
ExtendedFloatingActionButton(
|
// Anchor on today when it's in view, else the week's first day.
|
||||||
onClick = jumpToToday,
|
val today = Clock.System.now()
|
||||||
icon = { Icon(Icons.Default.Refresh, contentDescription = null) },
|
.toLocalDateTime(TimeZone.currentSystemDefault()).date
|
||||||
text = { Text(stringResource(R.string.week_today_action)) },
|
onCreateEvent(if (isOnCurrentWeek) today else weekStart)
|
||||||
)
|
},
|
||||||
}
|
)
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
WeekContent(
|
WeekContent(
|
||||||
|
|||||||
@@ -54,6 +54,19 @@
|
|||||||
<string name="event_delete_failed">Termin konnte nicht gelöscht werden</string>
|
<string name="event_delete_failed">Termin konnte nicht gelöscht werden</string>
|
||||||
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
<string name="event_delete_write_denied">Calendula braucht Schreibzugriff, um Termine zu löschen</string>
|
||||||
<string name="dialog_cancel">Abbrechen</string>
|
<string name="dialog_cancel">Abbrechen</string>
|
||||||
|
<string name="dialog_ok">OK</string>
|
||||||
|
|
||||||
|
<!-- Termin-Formular (v1.2 Erstellen) -->
|
||||||
|
<string name="event_edit_new_title">Neuer Termin</string>
|
||||||
|
<string name="event_edit_close">Schließen</string>
|
||||||
|
<string name="event_edit_save">Speichern</string>
|
||||||
|
<string name="event_edit_title_hint">Titel hinzufügen</string>
|
||||||
|
<string name="event_edit_starts">Beginn</string>
|
||||||
|
<string name="event_edit_ends">Ende</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Endet vor dem Beginn</string>
|
||||||
|
<string name="event_edit_error_no_calendar">Kein beschreibbarer Kalender verfügbar</string>
|
||||||
|
<string name="event_edit_save_failed">Termin konnte nicht gespeichert werden</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula braucht Schreibzugriff, um Termine zu erstellen</string>
|
||||||
<string name="event_detail_all_day">Ganztägig</string>
|
<string name="event_detail_all_day">Ganztägig</string>
|
||||||
<string name="event_detail_calendar">Kalender</string>
|
<string name="event_detail_calendar">Kalender</string>
|
||||||
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
|
<string name="event_detail_calendar_unknown">Unbekannter Kalender</string>
|
||||||
|
|||||||
@@ -55,6 +55,19 @@
|
|||||||
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
||||||
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
||||||
<string name="dialog_cancel">Cancel</string>
|
<string name="dialog_cancel">Cancel</string>
|
||||||
|
<string name="dialog_ok">OK</string>
|
||||||
|
|
||||||
|
<!-- Event form (v1.2 create) -->
|
||||||
|
<string name="event_edit_new_title">New event</string>
|
||||||
|
<string name="event_edit_close">Close</string>
|
||||||
|
<string name="event_edit_save">Save</string>
|
||||||
|
<string name="event_edit_title_hint">Add title</string>
|
||||||
|
<string name="event_edit_starts">Starts</string>
|
||||||
|
<string name="event_edit_ends">Ends</string>
|
||||||
|
<string name="event_edit_error_end_before_start">Ends before it starts</string>
|
||||||
|
<string name="event_edit_error_no_calendar">No writable calendar available</string>
|
||||||
|
<string name="event_edit_save_failed">Couldn\'t save the event</string>
|
||||||
|
<string name="event_edit_write_denied">Calendula needs write access to create events</string>
|
||||||
<string name="event_detail_all_day">All day</string>
|
<string name="event_detail_all_day">All day</string>
|
||||||
<string name="event_detail_calendar">Calendar</string>
|
<string name="event_detail_calendar">Calendar</string>
|
||||||
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import app.cash.turbine.test
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.datetime.LocalDate
|
||||||
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
import kotlinx.datetime.LocalTime
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import kotlinx.coroutines.test.runTest
|
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
|
@Test
|
||||||
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||||
val fake = FakeCalendarDataSource()
|
val fake = FakeCalendarDataSource()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.calendar
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +16,10 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||||
/** Set to make the next write call throw. */
|
/** Set to make the next write call throw. */
|
||||||
var writeError: Exception? = null
|
var writeError: Exception? = null
|
||||||
|
/** Id returned by the next [insertEvent]. */
|
||||||
|
var nextInsertId: Long = 100L
|
||||||
|
|
||||||
|
val insertedForms = mutableListOf<EventForm>()
|
||||||
val deletedEventIds = mutableListOf<Long>()
|
val deletedEventIds = mutableListOf<Long>()
|
||||||
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
|
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
|
||||||
|
|
||||||
@@ -26,6 +30,12 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
|||||||
instancesResult(beginMillis, endMillis)
|
instancesResult(beginMillis, endMillis)
|
||||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
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) {
|
override fun deleteEvent(eventId: Long) {
|
||||||
writeError?.let { throw it }
|
writeError?.let { throw it }
|
||||||
deletedEventIds += eventId
|
deletedEventIds += eventId
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,8 +45,8 @@ Domain bleibt pure Kotlin.
|
|||||||
|
|
||||||
| Slice | Inhalt | Status |
|
| Slice | Inhalt | Status |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| v1.1 | Write-Fundament: `WRITE_CALENDAR`, `canModifyContents`, Delete (Serie + einzelnes Vorkommen) | in Arbeit |
|
| 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 | offen |
|
| 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 |
|
| 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 |
|
| 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] `CalendarRepositoryImplTest`: delete-Pfade (Erfolg, Fehler)
|
||||||
- [x] `CalendarMapperTest`: Access-Level-Mapping
|
- [x] `CalendarMapperTest`: Access-Level-Mapping
|
||||||
|
|
||||||
## v1.2 — Create (Skizze)
|
## v1.2 — Create
|
||||||
|
|
||||||
- `EventForm`-Domain-Modell + Validierung (Ende > Start, Titel-Fallback)
|
- [x] `EventForm`-Domain-Modell + Validierung (`problems()`: EndBeforeStart,
|
||||||
- `EventEditScreen` (ein Formular für Create+Edit), M3-Date/Time-Picker
|
NoCalendar; leerer Titel und Instant-Events erlaubt)
|
||||||
- FAB auf allen drei Hauptansichten, vorbelegt mit sichtbarem Tag/Slot
|
- [x] `EventEditScreen` (ein Formular, ab v1.3 auch für Edit), M3-Date/Time-Picker
|
||||||
- `CalendarPrefs.defaultCalendarId` + Auswahl im Formular (nur beschreibbare
|
- [x] FAB-Stack auf allen drei Hauptansichten (`CalendarFabColumn`: "+" immer,
|
||||||
Kalender anbieten)
|
Heute-Pill darüber), vorbelegt mit dem sichtbaren Tag
|
||||||
- `insertEvent(form): Long` im DataSource (`DTSTART/DTEND/EVENT_TIMEZONE`,
|
- [x] Kalender-Vorauswahl: explizit > zuletzt benutzt
|
||||||
all-day in UTC)
|
(`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)
|
## v1.3 — Edit (Skizze)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user