4 Commits

Author SHA1 Message Date
bdedf47972 release: cut v1.2.1 — event-form polish
All checks were successful
Build and Release to F-Droid / ci (push) Successful in 2m5s
CI / ci (push) Successful in 7m59s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m36s
Version bumped to 1.2.1 / 10. No code changes beyond the version — 1.2.1 is
the reviewed-and-approved form polish: card design system, optional fields
with settings defaults, reworked reminders, OptionCard dialogs app-wide,
expressive theme on standard springs, direction-aware today jump, IME fix.
CHANGELOG [1.2.1] carries the details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:41:11 +02:00
a69be3da43 feat(edit): form redesign, optional fields, OptionCard dialogs, expressive motion
All checks were successful
CI / ci (push) Successful in 5m56s
Post-v1.2.0 design iteration on the event form, reviewed slice by slice
on-device:

- Form rebuilt on the detail screen's card system: tonal EditCards with
  gutter icons (centred on the first row, top-aligned for multiline),
  borderless inline fields (placeholders at half opacity), calendar-coloured
  title accent, no dividers, bare top bar
- Optional sections (location, description, reminders, availability,
  visibility) with per-user defaults in Settings ("New event form" toggles);
  hidden ones unfold via a "More fields" picker dialog
- Reminders: stacked rows + full-width borderless add; two-step picker
  (one-tap presets, then custom amount + minutes/hours/days/weeks dropdown);
  written as METHOD_ALERT Reminders rows. Availability busy/free segmented
  toggle; visibility selector with per-level icons
- OptionCard (ui/common) is now the app-wide selection-dialog standard;
  calendar picker, visibility, more-fields, reminder presets and the
  recurring-delete chooser all use it — radio-row dialogs removed
- MaterialExpressiveTheme with MotionScheme.standard() (expressive bounce
  felt overdone); FAB stack + field reveals animate on theme springs;
  jump-to-today slides toward today's actual direction
- IME: adjustResize + imePadding so the keyboard never pans the form
- Tests: form-field prefs round-trips, availability/access provider
  mappings; DE+EN strings throughout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:14:30 +02:00
779fa1d480 release: cut v1.2.0 — event creation
All checks were successful
CI / ci (push) Successful in 7m47s
Build and Release to F-Droid / ci (push) Successful in 2m5s
Build and Release to F-Droid / build-and-deploy (push) Successful in 8m34s
Version bumped to 1.2.0 / 9. No code changes beyond the version — 1.2.0 is
the create slice: event form, "+" FAB on every view, last-used-calendar
preselect, provider-correct all-day storage. CHANGELOG [1.2.0] carries the
details; ROADMAP/STATE mark slice v1.2 shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:27:17 +02:00
c59a071b82 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>
2026-06-11 13:27:08 +02:00
34 changed files with 2279 additions and 97 deletions

View File

@@ -62,7 +62,8 @@ guide here, not a contract — scope per slice is decided as we go.
| Version | Milestone | Status | | Version | Milestone | Status |
|---|---|---| |---|---|---|
| v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) | | v1.1 | Write foundation — `WRITE_CALENDAR`, read-only-calendar detection, delete (series + single occurrence) | complete (shipped 2026-06-11) |
| v1.2 | Create event — form, FAB, default-calendar pref | planned | | v1.2 | Create event — form, FAB, last-used-calendar preselect | complete (shipped 2026-06-11) |
| v1.2.1 | Form polish after on-device review — card design system, optional fields + settings defaults, OptionCard dialogs, expressive motion | complete (shipped 2026-06-11) |
| v1.3 | Edit event — shared form, series edit, reminders, simple recurrence picker | planned | | v1.3 | Edit event — shared form, series edit, reminders, simple recurrence picker | planned |
| v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned | | v2.0 | Quick-add, occurrence edit, conflict dialog, polish, release | planned |

View File

@@ -5,9 +5,11 @@
## Status ## Status
**Milestone:** v2.0 — Write support (milestone 2, in progress) **Milestone:** v2.0 — Write support (milestone 2, in progress)
**Phase:** v1.1.0 shipped 2026-06-11 (write foundation + delete). Milestone 2 **Phase:** v1.2.1 shipped 2026-06-11 — the create-form polish pass after
runs in four slices (`docs/superpowers/plans/2026-06-11-03-write-support.md`); Jean-Luc's on-device review (v1.2.0 and v1.1.0 shipped the same day).
next up is v1.2 (create event). Milestone 2 runs in four slices
(`docs/superpowers/plans/2026-06-11-03-write-support.md`); next up is v1.3
(edit event). Note: UI slices now hold release until his explicit approval.
## Progress ## Progress
@@ -35,7 +37,15 @@ next up is v1.2 (create event).
"only this event" via cancelled exception / "all events in the series"), "only this event" via cancelled exception / "all events in the series"),
repository + mapper tests repository + mapper tests
- [x] v1.2 create event — full-screen `EventEditScreen` (title, all-day,
M3 date/time pickers with duration-preserving start moves, writable-only
calendar picker preselecting the last-used calendar, location, description),
"+" FAB on all three views prefilled with the visible day, `insertEvent`
with provider-correct all-day normalisation (UTC midnights, exclusive end),
domain/mapper/repository tests
## Next ## Next
1. v1.2create event: form screen, FAB, default-calendar pref, `insertEvent` 1. v1.3edit event: reuse the form, series edit, reminder edit, simple
2. Monitor the F-Droid build/publish for v1.1.0 recurrence picker
2. Monitor the F-Droid build/publish for v1.1.0 / v1.2.0

View File

@@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [1.2.1] — 2026-06-11
### Added
- Optional event-form fields with user-controlled defaults: reminders,
availability (busy/free), and visibility (default/public/private/
confidential) joined location and description as form sections. Settings
gained a "New event form" section choosing which show by default; the rest
unfold via a "More fields" picker
- Reminders editor: stacked rows with right-bound remove, full-width add
action; the picker offers one-tap presets and a custom amount + unit
(minutes/hours/days/weeks) step
- `OptionCard` — the app's standard selection-dialog row (full-width tonal
card, optional icon + supporting line, highlighted selection). All dialogs
(calendar, visibility, more-fields, reminder presets, recurring-delete)
now use it; radio-row dialogs are retired
### Changed
- Event form redesigned onto the detail screen's design system: tonal cards
with gutter icons (top-aligned on tall cards), borderless inline text
fields, calendar-coloured accent bar under the title, no dividers, no
top-bar title; placeholders render clearly fainter than input
- M3 Expressive motion: the theme now provides a MotionScheme
(`MaterialExpressiveTheme`, standard springs — expressive bounce reviewed
as overdone), the FAB stack and "more fields" reveals animate on theme
springs
- The jump-to-today slide is direction-aware (future → today slides in from
the left, past → from the right)
- `versionName`/`versionCode` bumped to 1.2.1 / 10
### Fixed
- The keyboard no longer pans the whole event form; the screen stays
anchored and the focused field scrolls into view (`adjustResize` +
`imePadding`)
## [1.2.0] — 2026-06-11
### Added
- Create events (milestone 2, slice 2):
- A "+" FAB on the month, week, and day views opens a new full-screen event
form, prefilled with the visible day (today at the next full hour, or
09:00 on other days)
- The form covers title, all-day toggle, start/end with Material 3 date and
time pickers (moving the start drags the end along, preserving duration),
target calendar, location, and description
- The calendar picker offers only writable calendars and preselects the one
you last created an event in
- Validation on save ("ends before it starts", no writable calendar), with
the same contextual write-permission upgrade as delete
- All-day events are stored provider-correctly (UTC midnights, exclusive
end), timed events in the device time zone
### Changed
- The jump-to-today pill now stacks above the new "+" FAB instead of being
the only floating action
- `versionName`/`versionCode` bumped to 1.2.0 / 9
## [1.1.0] — 2026-06-11 ## [1.1.0] — 2026-06-11
### Added ### Added

View File

@@ -23,8 +23,8 @@ android {
applicationId = "de.jeanlucmakiola.calendula" applicationId = "de.jeanlucmakiola.calendula"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 36
versionCode = 8 versionCode = 10
versionName = "1.1.0" versionName = "1.2.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -18,7 +18,8 @@
tools:targetApi="35"> tools:targetApi="35">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true"> android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

View File

@@ -9,12 +9,15 @@ import android.database.Cursor
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.provider.CalendarContract import android.provider.CalendarContract
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext 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 +34,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 +91,43 @@ 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)
put(CalendarContract.Events.AVAILABILITY, form.availability.toProviderValue())
put(CalendarContract.Events.ACCESS_LEVEL, form.accessLevel.toProviderValue())
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}")
val eventId = ContentUris.parseId(uri)
// Best effort (spec §8): the event exists at this point — a reminder
// that fails to attach is logged, not surfaced as a failed create.
form.reminders.distinct().forEach { minutes ->
val reminder = ContentValues().apply {
put(CalendarContract.Reminders.EVENT_ID, eventId)
put(CalendarContract.Reminders.MINUTES, minutes)
put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT)
}
if (resolver.insert(CalendarContract.Reminders.CONTENT_URI, reminder) == null) {
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
}
}
return eventId
}
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),
@@ -152,4 +195,8 @@ class AndroidCalendarDataSource @Inject constructor(
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList { private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add) while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
} }
private companion object {
const val TAG = "CalendarDataSource"
}
} }

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
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,
)
}
internal fun Availability.toProviderValue(): Int = when (this) {
Availability.Busy -> CalendarContract.Events.AVAILABILITY_BUSY
Availability.Free -> CalendarContract.Events.AVAILABILITY_FREE
Availability.Tentative -> CalendarContract.Events.AVAILABILITY_TENTATIVE
}
internal fun AccessLevel.toProviderValue(): Int = when (this) {
AccessLevel.Default -> CalendarContract.Events.ACCESS_DEFAULT
AccessLevel.Confidential -> CalendarContract.Events.ACCESS_CONFIDENTIAL
AccessLevel.Private -> CalendarContract.Events.ACCESS_PRIVATE
AccessLevel.Public -> CalendarContract.Events.ACCESS_PUBLIC
}

View File

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

View File

@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -67,10 +68,38 @@ class SettingsPrefs @Inject constructor(
store.edit { it[WEEK_START_KEY] = pref.name } store.edit { it[WEEK_START_KEY] = pref.name }
} }
/**
* Optional event-form fields shown by default (the rest hide behind
* "more fields"). Stored comma-joined by enum name: an absent key means
* the factory default, an empty string means "none". Unknown names are
* dropped defensively, like the other enum prefs.
*/
val defaultFormFields: Flow<Set<EventFormField>> = store.data.map { prefs ->
parseFormFields(prefs[FORM_FIELDS_KEY])
}
suspend fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
store.edit { prefs ->
val current = parseFormFields(prefs[FORM_FIELDS_KEY])
val updated = if (enabled) current + field else current - field
prefs[FORM_FIELDS_KEY] = updated.joinToString(",") { it.name }
}
}
private fun parseFormFields(stored: String?): Set<EventFormField> = when (stored) {
null -> DEFAULT_FORM_FIELDS
else -> stored.split(',')
.mapNotNull { name -> EventFormField.entries.firstOrNull { it.name == name.trim() } }
.toSet()
}
companion object { companion object {
internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode")
internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color")
internal val WEEK_START_KEY = stringPreferencesKey("week_start") internal val WEEK_START_KEY = stringPreferencesKey("week_start")
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
internal val DEFAULT_FORM_FIELDS =
setOf(EventFormField.Location, EventFormField.Description)
} }
} }

View File

@@ -0,0 +1,51 @@
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 = "",
/** Reminder lead times in minutes before the start, deduplicated. */
val reminders: List<Int> = emptyList(),
val availability: Availability = Availability.Busy,
val accessLevel: AccessLevel = AccessLevel.Default,
)
/**
* The form's optional sections. Which ones show by default is a user setting;
* the rest unfold behind a "more fields" button.
*/
enum class EventFormField {
Location,
Description,
Reminders,
Availability,
Visibility,
}
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)
}

View File

@@ -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,

View File

@@ -0,0 +1,58 @@
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.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@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(MaterialTheme.motionScheme.fastSpatialSpec()),
exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()),
) {
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),
)
}
}
}

View File

@@ -0,0 +1,94 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/**
* The app's standard pick in a selection dialog: a full-width tonal card,
* optionally with a leading icon and a supporting line; the selected option
* is highlighted. Stack with 8dp gaps inside an AlertDialog — this is the
* only sanctioned selection-modal style (no radio rows, no bare text lists).
*/
@Composable
fun OptionCard(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
icon: ImageVector? = null,
/** Icon tint override, e.g. a calendar colour; unspecified follows selection. */
iconTint: Color = Color.Unspecified,
supportingText: String? = null,
selected: Boolean = false,
/** Label colour override, e.g. primary for an emphasised "Custom" entry. */
labelColor: Color = Color.Unspecified,
) {
val contentColor = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.onSurface
}
Surface(
onClick = onClick,
color = if (selected) {
MaterialTheme.colorScheme.secondaryContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHighest
},
shape = RoundedCornerShape(12.dp),
modifier = modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = when {
iconTint.isSpecified -> iconTint
selected -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.size(20.dp),
)
Spacer(Modifier.width(12.dp))
}
Column {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = if (labelColor.isSpecified) labelColor else contentColor,
)
if (supportingText != null) {
Text(
text = supportingText,
style = MaterialTheme.typography.bodySmall,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.8f)
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
}

View File

@@ -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(),
@@ -144,7 +141,15 @@ fun DayScreen(
var slideDir by remember { mutableIntStateOf(0) } var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() } val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() } val goPrev = { slideDir = -1; viewModel.goToPrev() }
val jumpToToday = { slideDir = 0; viewModel.goToToday() } // Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is DayUiState.Success -> if (s.today < s.date) -1 else 1
else -> 0
}
viewModel.goToToday()
}
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -172,17 +177,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(

View File

@@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -47,7 +46,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
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
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -68,7 +66,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.Role
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -95,6 +92,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventStatus import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
import de.jeanlucmakiola.calendula.ui.common.OptionCard
import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.currentLocale
import de.jeanlucmakiola.calendula.ui.common.pastelize import de.jeanlucmakiola.calendula.ui.common.pastelize
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
@@ -257,16 +255,16 @@ private fun DeleteEventDialog(
}, },
text = { text = {
if (isRecurring) { if (isRecurring) {
Column { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
DeleteChoiceRow( OptionCard(
selected = !wholeSeries,
label = stringResource(R.string.event_delete_option_occurrence), label = stringResource(R.string.event_delete_option_occurrence),
onSelect = { wholeSeries = false }, onClick = { wholeSeries = false },
selected = !wholeSeries,
) )
DeleteChoiceRow( OptionCard(
selected = wholeSeries,
label = stringResource(R.string.event_delete_option_series), label = stringResource(R.string.event_delete_option_series),
onSelect = { wholeSeries = true }, onClick = { wholeSeries = true },
selected = wholeSeries,
) )
} }
} else { } else {
@@ -289,20 +287,6 @@ private fun DeleteEventDialog(
) )
} }
@Composable
private fun DeleteChoiceRow(selected: Boolean, label: String, onSelect: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(selected = selected, role = Role.RadioButton, onClick = onSelect)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = null)
Spacer(Modifier.width(8.dp))
Text(label, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable @Composable
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) { private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
package de.jeanlucmakiola.calendula.ui.edit
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
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,
/** Optional sections currently rendered (settings defaults revealed). */
val visibleFields: Set<EventFormField> = emptySet(),
/** True while at least one optional section hides behind "more fields". */
val hasHiddenFields: Boolean = false,
)
/** 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
}

View File

@@ -0,0 +1,202 @@
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.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
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,
private val settingsPrefs: SettingsPrefs,
@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)
// Fields added through the "more fields" picker; folds back on reset().
private val _revealed = MutableStateFlow<Set<EventFormField>>(emptySet())
private data class LocalInputs(
val form: EventForm?,
val saveState: SaveUiState,
val showProblems: Boolean,
val revealed: Set<EventFormField>,
)
private data class ExternalInputs(
val writable: List<CalendarSource>,
val lastUsed: Long?,
val defaultFields: Set<EventFormField>,
)
val state: StateFlow<EventEditUiState?> = combine(
combine(_form, _saveState, _showProblems, _revealed, ::LocalInputs),
combine(
repository.calendars()
.map { calendars -> calendars.filter { it.canModifyContents } }
.catch { emit(emptyList()) },
prefs.lastUsedCalendarId,
settingsPrefs.defaultFormFields,
::ExternalInputs,
),
) { local, external ->
val form = local.form ?: return@combine null
val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
?: external.writable.firstOrNull()?.id
val resolved = form.copy(calendarId = resolvedId)
val visibleFields = external.defaultFields + local.revealed
EventEditUiState(
form = resolved,
calendars = external.writable,
problems = if (local.showProblems) resolved.problems() else emptySet(),
saveState = local.saveState,
visibleFields = visibleFields,
hasHiddenFields = visibleFields.size < EventFormField.entries.size,
)
}
.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
_revealed.value = emptySet()
}
/** Unfold one optional field, picked in the "more fields" dialog. */
fun revealField(field: EventFormField) {
_revealed.value = _revealed.value + field
}
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) }
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
fun addReminder(minutes: Int) = update {
it.copy(reminders = (it.reminders + minutes).distinct().sorted())
}
fun removeReminder(minutes: Int) = update {
it.copy(reminders = it.reminders - minutes)
}
/** 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)
}
}

View File

@@ -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(),
) { ) {
@@ -113,8 +113,14 @@ fun MonthScreen(
slideDir = -1 slideDir = -1
viewModel.goToPrev() viewModel.goToPrev()
} }
// Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = { val jumpToToday = {
slideDir = 0 slideDir = when (val s = state) {
is MonthUiState.Success ->
if (YearMonth(s.today.year, s.today.month) < s.month) -1 else 1
else -> 0
}
viewModel.goToToday() viewModel.goToToday()
} }
@@ -147,17 +153,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(

View File

@@ -47,6 +47,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
/** /**
* Settings (M4) — appearance (theme, dynamic colour, week start), language, * Settings (M4) — appearance (theme, dynamic colour, week start), language,
@@ -111,6 +112,22 @@ fun SettingsScreen(
onSelect = viewModel::setWeekStart, onSelect = viewModel::setWeekStart,
) )
HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_event_form))
Text(
text = stringResource(R.string.settings_form_fields_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp),
)
EventFormField.entries.forEach { field ->
FormFieldRow(
title = stringResource(formFieldLabel(field)),
checked = field in state.defaultFormFields,
onCheckedChange = { viewModel.setFormFieldDefault(field, it) },
)
}
HorizontalDivider(Modifier.padding(vertical = 8.dp)) HorizontalDivider(Modifier.padding(vertical = 8.dp))
SectionHeader(stringResource(R.string.settings_section_language)) SectionHeader(stringResource(R.string.settings_section_language))
LanguageRow() LanguageRow()
@@ -298,6 +315,35 @@ private fun AboutRow(title: String, value: String) {
} }
} }
@Composable
private fun FormFieldRow(
title: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
private fun formFieldLabel(field: EventFormField): Int = when (field) {
EventFormField.Location -> R.string.event_detail_location
EventFormField.Description -> R.string.event_detail_description
EventFormField.Reminders -> R.string.event_detail_reminders
EventFormField.Availability -> R.string.event_edit_availability
EventFormField.Visibility -> R.string.event_edit_visibility
}
@Composable @Composable
private fun themeLabel(mode: ThemeMode): String = stringResource( private fun themeLabel(mode: ThemeMode): String = stringResource(
when (mode) { when (mode) {

View File

@@ -1,7 +1,9 @@
package de.jeanlucmakiola.calendula.ui.settings package de.jeanlucmakiola.calendula.ui.settings
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
/** /**
* Settings screen state (M4). Persisted preferences are instant to read, so * Settings screen state (M4). Persisted preferences are instant to read, so
@@ -14,4 +16,6 @@ data class SettingsUiState(
val dynamicColor: Boolean = true, val dynamicColor: Boolean = true,
val dynamicColorAvailable: Boolean = true, val dynamicColorAvailable: Boolean = true,
val weekStart: WeekStartPref = WeekStartPref.AUTO, val weekStart: WeekStartPref = WeekStartPref.AUTO,
/** Optional event-form fields shown by default (rest behind "more fields"). */
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
) )

View File

@@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -26,12 +27,14 @@ class SettingsViewModel @Inject constructor(
prefs.themeMode, prefs.themeMode,
prefs.dynamicColor, prefs.dynamicColor,
prefs.weekStart, prefs.weekStart,
) { theme, dynamic, weekStart -> prefs.defaultFormFields,
) { theme, dynamic, weekStart, formFields ->
SettingsUiState( SettingsUiState(
themeMode = theme, themeMode = theme,
dynamicColor = dynamic && dynamicColorAvailable, dynamicColor = dynamic && dynamicColorAvailable,
dynamicColorAvailable = dynamicColorAvailable, dynamicColorAvailable = dynamicColorAvailable,
weekStart = weekStart, weekStart = weekStart,
defaultFormFields = formFields,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -50,4 +53,8 @@ class SettingsViewModel @Inject constructor(
fun setWeekStart(pref: WeekStartPref) { fun setWeekStart(pref: WeekStartPref) {
viewModelScope.launch { prefs.setWeekStart(pref) } viewModelScope.launch { prefs.setWeekStart(pref) }
} }
fun setFormFieldDefault(field: EventFormField, enabled: Boolean) {
viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) }
}
} }

View File

@@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -17,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext
* The Settings screen (later) can override useDynamicColor and themePreference, * The Settings screen (later) can override useDynamicColor and themePreference,
* but the V1 foundation just follows the system. * but the V1 foundation just follows the system.
*/ */
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun CalendulaTheme( fun CalendulaTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
@@ -32,9 +35,15 @@ fun CalendulaTheme(
else -> CalendulaLightFallback else -> CalendulaLightFallback
} }
MaterialTheme( // MaterialExpressiveTheme routes all component + custom motion through
// MaterialTheme.motionScheme (switches, chips, pickers, calendar slide,
// FAB, field reveal). The STANDARD scheme is a deliberate choice over
// expressive(): same spring choreography, but without the overshoot —
// the bouncy variant felt overdone in review (2026-06-11).
MaterialExpressiveTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = CalendulaTypography, typography = CalendulaTypography,
motionScheme = MotionScheme.standard(),
content = content, content = content,
) )
} }

View File

@@ -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(),
) { ) {
@@ -146,7 +146,15 @@ fun WeekScreen(
var slideDir by remember { mutableIntStateOf(0) } var slideDir by remember { mutableIntStateOf(0) }
val goNext = { slideDir = 1; viewModel.goToNext() } val goNext = { slideDir = 1; viewModel.goToNext() }
val goPrev = { slideDir = -1; viewModel.goToPrev() } val goPrev = { slideDir = -1; viewModel.goToPrev() }
val jumpToToday = { slideDir = 0; viewModel.goToToday() } // Slide toward today: viewing the future → today comes in from the left
// (back), viewing the past → from the right (forward).
val jumpToToday = {
slideDir = when (val s = state) {
is WeekUiState.Success -> if (s.today < s.weekStart) -1 else 1
else -> 0
}
viewModel.goToToday()
}
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -174,17 +182,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(

View File

@@ -54,6 +54,33 @@
<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_edit_more_fields">Weitere Felder</string>
<string name="event_edit_add">Hinzufügen</string>
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
<string name="reminder_unit_minutes">Minuten</string>
<string name="reminder_unit_hours">Stunden</string>
<string name="reminder_unit_days">Tage</string>
<string name="reminder_unit_weeks">Wochen</string>
<string name="event_edit_availability">Verfügbarkeit</string>
<string name="event_edit_visibility">Sichtbarkeit</string>
<string name="event_availability_busy">Beschäftigt</string>
<string name="event_access_default">Standard</string>
<string name="event_access_public">Öffentlich</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>
@@ -136,6 +163,8 @@
<string name="settings_week_start_auto">Automatisch</string> <string name="settings_week_start_auto">Automatisch</string>
<string name="settings_week_start_monday">Montag</string> <string name="settings_week_start_monday">Montag</string>
<string name="settings_week_start_sunday">Sonntag</string> <string name="settings_week_start_sunday">Sonntag</string>
<string name="settings_section_event_form">Termin-Formular</string>
<string name="settings_form_fields_hint">Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\"</string>
<string name="settings_section_language">Sprache</string> <string name="settings_section_language">Sprache</string>
<string name="settings_language">App-Sprache</string> <string name="settings_language">App-Sprache</string>
<string name="settings_language_auto">Systemstandard</string> <string name="settings_language_auto">Systemstandard</string>

View File

@@ -55,6 +55,33 @@
<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_edit_more_fields">More fields</string>
<string name="event_edit_add">Add</string>
<string name="event_edit_add_reminder">Add reminder</string>
<string name="event_edit_remove_reminder">Remove reminder</string>
<string name="event_edit_reminder_custom">Custom</string>
<string name="reminder_unit_minutes">minutes</string>
<string name="reminder_unit_hours">hours</string>
<string name="reminder_unit_days">days</string>
<string name="reminder_unit_weeks">weeks</string>
<string name="event_edit_availability">Availability</string>
<string name="event_edit_visibility">Visibility</string>
<string name="event_availability_busy">Busy</string>
<string name="event_access_default">Default</string>
<string name="event_access_public">Public</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>
@@ -137,6 +164,8 @@
<string name="settings_week_start_auto">Automatic</string> <string name="settings_week_start_auto">Automatic</string>
<string name="settings_week_start_monday">Monday</string> <string name="settings_week_start_monday">Monday</string>
<string name="settings_week_start_sunday">Sunday</string> <string name="settings_week_start_sunday">Sunday</string>
<string name="settings_section_event_form">New event form</string>
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
<string name="settings_section_language">Language</string> <string name="settings_section_language">Language</string>
<string name="settings_language">App language</string> <string name="settings_language">App language</string>
<string name="settings_language_auto">System default</string> <string name="settings_language_auto">System default</string>

View File

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

View File

@@ -0,0 +1,74 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.AccessLevel
import de.jeanlucmakiola.calendula.domain.Availability
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 `availability maps to the provider constants`() {
assertThat(Availability.Busy.toProviderValue())
.isEqualTo(CalendarContract.Events.AVAILABILITY_BUSY)
assertThat(Availability.Free.toProviderValue())
.isEqualTo(CalendarContract.Events.AVAILABILITY_FREE)
assertThat(Availability.Tentative.toProviderValue())
.isEqualTo(CalendarContract.Events.AVAILABILITY_TENTATIVE)
}
@Test
fun `access level maps to the provider constants`() {
assertThat(AccessLevel.Default.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_DEFAULT)
assertThat(AccessLevel.Private.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_PRIVATE)
assertThat(AccessLevel.Confidential.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_CONFIDENTIAL)
assertThat(AccessLevel.Public.toProviderValue())
.isEqualTo(CalendarContract.Events.ACCESS_PUBLIC)
}
@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)
}
}

View File

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

View File

@@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventFormField
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
@@ -60,6 +61,45 @@ class SettingsPrefsTest {
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM) assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.SYSTEM)
} }
@Test
fun `form fields default to location and description`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.defaultFormFields.first()).containsExactly(
EventFormField.Location,
EventFormField.Description,
)
}
@Test
fun `form field toggle round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
prefs.setFormFieldDefault(EventFormField.Reminders, enabled = true)
prefs.setFormFieldDefault(EventFormField.Location, enabled = false)
assertThat(prefs.defaultFormFields.first()).containsExactly(
EventFormField.Description,
EventFormField.Reminders,
)
}
@Test
fun `disabling every form field persists as none, not factory default`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir))
EventFormField.entries.forEach { prefs.setFormFieldDefault(it, enabled = false) }
assertThat(prefs.defaultFormFields.first()).isEmpty()
}
@Test
fun `unknown stored form-field names are dropped`(@TempDir tempDir: Path) = runTest {
val store = newDataStore(tempDir)
val prefs = SettingsPrefs(store)
store.updateData { p ->
val m = p.toMutablePreferences()
m[SettingsPrefs.FORM_FIELDS_KEY] = "Location,Hologram"
m
}
assertThat(prefs.defaultFormFields.first()).containsExactly(EventFormField.Location)
}
@Test @Test
fun `explicit week-start prefs resolve regardless of locale`() { fun `explicit week-start prefs resolve regardless of locale`() {
assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY) assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)

View File

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

View File

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