From a69be3da43458b691e15fe3d1489a4744af57cc1 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Thu, 11 Jun 2026 15:14:30 +0200 Subject: [PATCH] feat(edit): form redesign, optional fields, OptionCard dialogs, expressive motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 31 + app/src/main/AndroidManifest.xml | 3 +- .../data/calendar/CalendarDataSource.kt | 22 +- .../data/calendar/EventWriteMapper.kt | 16 + .../calendula/data/prefs/SettingsPrefs.kt | 29 + .../calendula/domain/EventForm.kt | 16 + .../calendula/ui/common/CalendarFabColumn.kt | 7 +- .../calendula/ui/common/OptionCard.kt | 94 ++ .../calendula/ui/day/DayScreen.kt | 10 +- .../calendula/ui/detail/EventDetailScreen.kt | 32 +- .../calendula/ui/edit/EventEditScreen.kt | 941 ++++++++++++++---- .../calendula/ui/edit/EventEditUiState.kt | 5 + .../calendula/ui/edit/EventEditViewModel.kt | 70 +- .../calendula/ui/month/MonthScreen.kt | 8 +- .../calendula/ui/settings/SettingsScreen.kt | 46 + .../calendula/ui/settings/SettingsUiState.kt | 4 + .../ui/settings/SettingsViewModel.kt | 9 +- .../calendula/ui/theme/Theme.kt | 13 +- .../calendula/ui/week/WeekScreen.kt | 10 +- app/src/main/res/values-de/strings.xml | 16 + app/src/main/res/values/strings.xml | 16 + .../data/calendar/EventWriteMapperTest.kt | 25 + .../calendula/data/prefs/SettingsPrefsTest.kt | 40 + 23 files changed, 1236 insertions(+), 227 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OptionCard.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e6b3c..b4faff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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) + +### 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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4a1c66..0f8e8b0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,7 +18,8 @@ tools:targetApi="35"> + android:exported="true" + android:windowSoftInputMode="adjustResize"> diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt index 3347c56..f24cc5c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -9,6 +9,7 @@ import android.database.Cursor import android.os.Handler import android.os.Looper import android.provider.CalendarContract +import android.util.Log import dagger.hilt.android.qualifiers.ApplicationContext import de.jeanlucmakiola.calendula.domain.Attendee import de.jeanlucmakiola.calendula.domain.CalendarSource @@ -102,6 +103,8 @@ class AndroidCalendarDataSource @Inject constructor( 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() } @@ -109,7 +112,20 @@ class AndroidCalendarDataSource @Inject constructor( } val uri = resolver.insert(CalendarContract.Events.CONTENT_URI, values) ?: throw WriteFailedException("insert event into calendar id=${form.calendarId}") - return ContentUris.parseId(uri) + 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) { @@ -179,4 +195,8 @@ class AndroidCalendarDataSource @Inject constructor( private inline fun Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List = buildList { while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add) } + + private companion object { + const val TAG = "CalendarDataSource" + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index b835077..09dea01 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -1,5 +1,8 @@ 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 @@ -33,3 +36,16 @@ internal fun EventForm.toWriteTimes(zone: ZoneId): EventWriteTimes = if (isAllDa 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 +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt index 6c25196..c938d27 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefs.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import de.jeanlucmakiola.calendula.domain.EventFormField import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.datetime.DayOfWeek @@ -67,10 +68,38 @@ class SettingsPrefs @Inject constructor( 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> = 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 = when (stored) { + null -> DEFAULT_FORM_FIELDS + else -> stored.split(',') + .mapNotNull { name -> EventFormField.entries.firstOrNull { it.name == name.trim() } } + .toSet() + } + companion object { internal val THEME_MODE_KEY = stringPreferencesKey("theme_mode") internal val DYNAMIC_COLOR_KEY = booleanPreferencesKey("dynamic_color") 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) } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt index 94a2103..3f818fe 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/EventForm.kt @@ -15,8 +15,24 @@ data class EventForm( val end: LocalDateTime, val location: String = "", val description: String = "", + /** Reminder lead times in minutes before the start, deduplicated. */ + val reminders: List = 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, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt index 92b56b6..bc68617 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarFabColumn.kt @@ -8,9 +8,11 @@ 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 @@ -23,6 +25,7 @@ import de.jeanlucmakiola.calendula.R * 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, @@ -36,8 +39,8 @@ fun CalendarFabColumn( ) { AnimatedVisibility( visible = todayVisible, - enter = scaleIn(), - exit = scaleOut(), + enter = scaleIn(MaterialTheme.motionScheme.fastSpatialSpec()), + exit = scaleOut(MaterialTheme.motionScheme.fastSpatialSpec()), ) { ExtendedFloatingActionButton( onClick = onToday, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OptionCard.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OptionCard.kt new file mode 100644 index 0000000..d6f214b --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/OptionCard.kt @@ -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 + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt index 8400a39..5f93c40 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/day/DayScreen.kt @@ -141,7 +141,15 @@ fun DayScreen( var slideDir by remember { mutableIntStateOf(0) } val goNext = { slideDir = 1; viewModel.goToNext() } 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( drawerState = drawerState, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt index 155d3e7..3a67000 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailScreen.kt @@ -28,7 +28,6 @@ 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 @@ -47,7 +46,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost 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.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.Role import androidx.core.content.ContextCompat import androidx.compose.ui.res.pluralStringResource 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.Reminder 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.pastelize import kotlinx.datetime.TimeZone @@ -257,16 +255,16 @@ private fun DeleteEventDialog( }, text = { if (isRecurring) { - Column { - DeleteChoiceRow( - selected = !wholeSeries, + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OptionCard( label = stringResource(R.string.event_delete_option_occurrence), - onSelect = { wholeSeries = false }, + onClick = { wholeSeries = false }, + selected = !wholeSeries, ) - DeleteChoiceRow( - selected = wholeSeries, + OptionCard( label = stringResource(R.string.event_delete_option_series), - onSelect = { wholeSeries = true }, + onClick = { wholeSeries = true }, + selected = wholeSeries, ) } } 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 private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt index 4e8204c..9fa9caf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditScreen.kt @@ -5,46 +5,68 @@ import android.content.pm.PackageManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope 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.imePadding 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.layout.Arrangement import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions 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.Add +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.EventAvailable +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi 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.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface 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 @@ -60,16 +82,26 @@ 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.graphics.SolidColor +import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType 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.AccessLevel +import de.jeanlucmakiola.calendula.domain.Availability import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventFormField import de.jeanlucmakiola.calendula.domain.EventFormProblem +import de.jeanlucmakiola.calendula.ui.common.OptionCard import de.jeanlucmakiola.calendula.ui.common.currentLocale import de.jeanlucmakiola.calendula.ui.common.pastelize import kotlinx.datetime.LocalDate @@ -154,7 +186,9 @@ fun EventEditScreen( snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( - title = { Text(stringResource(R.string.event_edit_new_title)) }, + // No title — the form's own headline carries the screen, + // matching the detail screen's bare top bar. + title = {}, navigationIcon = { IconButton(onClick = close) { Icon( @@ -192,6 +226,29 @@ fun EventEditScreen( private enum class PickerTarget { StartDate, StartTime, EndDate, EndTime } +/** + * Wrapper for an optional form section: fields added via "more fields" + * spring open with the expressive spatial spec instead of popping in. + * (Initially-visible sections render without animating.) + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun OptionalFormSection( + visible: Boolean, + content: @Composable ColumnScope.() -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = expandVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + + fadeIn(MaterialTheme.motionScheme.fastEffectsSpec()), + exit = shrinkVertically(MaterialTheme.motionScheme.fastSpatialSpec()) + + fadeOut(MaterialTheme.motionScheme.fastEffectsSpec()), + ) { + Column(modifier = Modifier.fillMaxWidth(), content = content) + } +} + +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun EventEditContent( state: EventEditUiState, @@ -200,150 +257,261 @@ private fun EventEditContent( ) { val form = state.form val locale = currentLocale() + val dark = isSystemInDarkTheme() var picker by remember { mutableStateOf(null) } var showCalendarPicker by rememberSaveable { mutableStateOf(false) } + var showReminderPicker by rememberSaveable { mutableStateOf(false) } + var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } + var showFieldPicker by rememberSaveable { mutableStateOf(false) } + + val selectedCalendar = state.calendars.firstOrNull { it.id == form.calendarId } + // The accent ties the form to the detail screen's design language: the + // bar under the title takes the target calendar's colour. + val accent = selectedCalendar?.let { pastelize(it.color, dark) } + ?: MaterialTheme.colorScheme.primary + val gap = 12.dp Column( modifier = modifier + // Shrink the scroll viewport by the keyboard instead of letting + // the window pan — the title stays anchored and the focused + // field scrolls into view above the IME. + .imePadding() .verticalScroll(rememberScrollState()) .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp), ) { - // Title: a large borderless field, MD3 "create" idiom. - TextField( + // Title: borderless headline, mirroring the detail screen's title + + // accent bar instead of a boxed Material text field. + InlineField( 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(), + placeholder = stringResource(R.string.event_edit_title_hint), + textStyle = MaterialTheme.typography.headlineMedium + .copy(fontWeight = FontWeight.SemiBold), ) - - 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, + Spacer(Modifier.height(10.dp)) + Box( 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 -> + .width(48.dp) + .height(3.dp) + .background(accent, RoundedCornerShape(2.dp)), + ) + + Spacer(Modifier.height(20.dp)) + + // "When" card: the icon centres on the all-day row (header); the + // start/end rows continue below in the same text column. + EditCard( + icon = Icons.Default.Schedule, + iconContentDescription = null, + header = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { Text( - text = account, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = stringResource(R.string.event_detail_all_day), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay) + } + }, + ) { + Spacer(Modifier.height(4.dp)) + ScheduleRow( + label = stringResource(R.string.event_edit_starts), + dateTime = form.start, + isAllDay = form.isAllDay, + locale = locale, + onDateClick = { picker = PickerTarget.StartDate }, + onTimeClick = { picker = PickerTarget.StartTime }, + ) + ScheduleRow( + 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) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(R.string.event_edit_error_end_before_start), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + Spacer(Modifier.height(gap)) + + // Calendar card — tap anywhere to pick the target calendar. + EditCard( + icon = Icons.Default.CalendarMonth, + iconContentDescription = stringResource(R.string.event_detail_calendar), + iconTint = accent, + onClick = { showCalendarPicker = true }.takeIf { state.calendars.isNotEmpty() }, + ) { + Text( + text = selectedCalendar?.displayName + ?: stringResource(R.string.event_edit_error_no_calendar), + style = MaterialTheme.typography.titleMedium, + 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, + ) + } + } + + // Optional sections: which ones render by default is a setting; the + // rest unfold behind the "more fields" button below. + OptionalFormSection(visible = EventFormField.Location in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.Default.Place, + iconContentDescription = stringResource(R.string.event_detail_location), + ) { + InlineField( + value = form.location, + onValueChange = viewModel::setLocation, + placeholder = stringResource(R.string.event_detail_location), + ) + } + } + + OptionalFormSection(visible = EventFormField.Description in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.AutoMirrored.Filled.Notes, + iconContentDescription = stringResource(R.string.event_detail_description), + iconAtTop = true, + ) { + InlineField( + value = form.description, + onValueChange = viewModel::setDescription, + placeholder = stringResource(R.string.event_detail_description), + singleLine = false, + minLines = 3, + ) + } + } + + OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) { + Spacer(Modifier.height(gap)) + // Reminders stack one per row (remove on the right edge); the + // add chip closes the card at the bottom. The card icon centres + // on the first row, whatever that is. + EditCard( + icon = Icons.Default.Notifications, + iconContentDescription = stringResource(R.string.event_detail_reminders), + header = { + when (val first = form.reminders.firstOrNull()) { + null -> AddReminderChip(onClick = { showReminderPicker = true }) + else -> ReminderRow( + label = reminderLabel(first), + onRemove = { viewModel.removeReminder(first) }, + ) + } + }, + ) { + form.reminders.drop(1).forEach { minutes -> + ReminderRow( + label = reminderLabel(minutes), + onRemove = { viewModel.removeReminder(minutes) }, + ) + } + if (form.reminders.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + AddReminderChip(onClick = { showReminderPicker = true }) + } + } + } + + OptionalFormSection(visible = EventFormField.Availability in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.Default.EventAvailable, + iconContentDescription = null, + header = { + Text( + text = stringResource(R.string.event_edit_availability), + style = MaterialTheme.typography.titleMedium, + ) + }, + ) { + Spacer(Modifier.height(8.dp)) + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + selected = form.availability == Availability.Busy, + onClick = { viewModel.setAvailability(Availability.Busy) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) { Text(stringResource(R.string.event_availability_busy)) } + SegmentedButton( + selected = form.availability == Availability.Free, + onClick = { viewModel.setAvailability(Availability.Free) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { Text(stringResource(R.string.event_availability_free)) } + } + } + } + + OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) { + Spacer(Modifier.height(gap)) + EditCard( + icon = Icons.Default.Lock, + iconContentDescription = null, + onClick = { showVisibilityPicker = true }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(accessLevelLabel(form.accessLevel)), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.event_edit_visibility), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = 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(), - ) + OptionalFormSection(visible = state.hasHiddenFields) { + Spacer(Modifier.height(20.dp)) + TextButton( + onClick = { showFieldPicker = true }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.event_edit_more_fields)) + } + } } when (picker) { @@ -381,11 +549,447 @@ private fun EventEditContent( onDismiss = { showCalendarPicker = false }, ) } + + if (showReminderPicker) { + ReminderPickerDialog( + alreadyChosen = form.reminders, + onSelect = { minutes -> + viewModel.addReminder(minutes) + showReminderPicker = false + }, + onDismiss = { showReminderPicker = false }, + ) + } + + if (showVisibilityPicker) { + VisibilityPickerDialog( + selected = form.accessLevel, + onSelect = { level -> + viewModel.setAccessLevel(level) + showVisibilityPicker = false + }, + onDismiss = { showVisibilityPicker = false }, + ) + } + + if (showFieldPicker) { + FieldPickerDialog( + hiddenFields = EventFormField.entries.filterNot { it in state.visibleFields }, + onSelect = { field -> + viewModel.revealField(field) + showFieldPicker = false + }, + onDismiss = { showFieldPicker = false }, + ) + } +} + +/** Picks one hidden optional section to add to the form. */ +@Composable +private fun FieldPickerDialog( + hiddenFields: List, + onSelect: (EventFormField) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.event_edit_more_fields)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + hiddenFields.forEach { field -> + OptionCard( + label = stringResource(fieldLabel(field)), + onClick = { onSelect(field) }, + icon = fieldIcon(field), + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +/** Quick-pick lead times offered as chips in the reminder dialog. */ +private val REMINDER_QUICK_PICKS = listOf(0, 10, 30, 60, 1_440) + +private enum class ReminderUnit(val minutesFactor: Int) { + Minutes(1), + Hours(60), + Days(1_440), + Weeks(10_080), +} + +/** + * Reminder picker, two steps: the common lead times as a tappable list + * (one tap adds and closes), with "Custom" at the bottom switching to an + * amount-plus-unit editor — only that step needs an Add button. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReminderPickerDialog( + alreadyChosen: List, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, +) { + var customMode by rememberSaveable { mutableStateOf(false) } + var amountText by rememberSaveable { mutableStateOf("") } + var unit by rememberSaveable { mutableStateOf(ReminderUnit.Minutes) } + val customMinutes = amountText.toIntOrNull() + ?.takeIf { it in 1..999 } + ?.let { it * unit.minutesFactor } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.event_edit_add_reminder)) }, + text = { + if (!customMode) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + REMINDER_QUICK_PICKS.filterNot { it in alreadyChosen }.forEach { minutes -> + OptionCard( + label = reminderLabel(minutes), + onClick = { onSelect(minutes) }, + ) + } + OptionCard( + label = stringResource(R.string.event_edit_reminder_custom), + onClick = { customMode = true }, + labelColor = MaterialTheme.colorScheme.primary, + ) + } + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + // surfaceContainerHighest — the dialog itself sits on + // surfaceContainerHigh, so anything lower vanishes. + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(12.dp), + ) { + InlineField( + value = amountText, + onValueChange = { text -> + if (text.length <= 3 && text.all(Char::isDigit)) { + amountText = text + } + }, + placeholder = "10", + textStyle = MaterialTheme.typography.titleMedium, + keyboardType = KeyboardType.Number, + modifier = Modifier + .width(72.dp) + .padding(horizontal = 14.dp, vertical = 12.dp), + ) + } + Spacer(Modifier.width(12.dp)) + var unitMenuOpen by remember { mutableStateOf(false) } + Box { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHighest, + shape = RoundedCornerShape(12.dp), + onClick = { unitMenuOpen = true }, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding( + start = 14.dp, + end = 8.dp, + top = 12.dp, + bottom = 12.dp, + ), + ) { + Text( + text = stringResource(reminderUnitLabel(unit)), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.width(4.dp)) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + DropdownMenu( + expanded = unitMenuOpen, + onDismissRequest = { unitMenuOpen = false }, + ) { + ReminderUnit.entries.forEach { entry -> + DropdownMenuItem( + text = { Text(stringResource(reminderUnitLabel(entry))) }, + onClick = { + unit = entry + unitMenuOpen = false + }, + ) + } + } + } + } + } + }, + confirmButton = { + // The quick-pick list adds on tap; only the custom step needs Add. + if (customMode) { + TextButton( + enabled = customMinutes != null && customMinutes !in alreadyChosen, + onClick = { customMinutes?.let(onSelect) }, + ) { Text(stringResource(R.string.event_edit_add)) } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +/** One chosen reminder: humanised lead time, remove pinned to the right edge. */ +@Composable +private fun ReminderRow(label: String, onRemove: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = label, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.event_edit_remove_reminder), + modifier = Modifier.size(18.dp), + ) + } + } +} + +@Composable +private fun AddReminderChip(onClick: () -> Unit) { + AssistChip( + onClick = onClick, + label = { Text(stringResource(R.string.event_edit_add)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + }, + border = null, + modifier = Modifier.fillMaxWidth(), + ) +} + +private fun reminderUnitLabel(unit: ReminderUnit): Int = when (unit) { + ReminderUnit.Minutes -> R.string.reminder_unit_minutes + ReminderUnit.Hours -> R.string.reminder_unit_hours + ReminderUnit.Days -> R.string.reminder_unit_days + ReminderUnit.Weeks -> R.string.reminder_unit_weeks +} + +private fun fieldLabel(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 +} + +private fun fieldIcon(field: EventFormField): ImageVector = when (field) { + EventFormField.Location -> Icons.Default.Place + EventFormField.Description -> Icons.AutoMirrored.Filled.Notes + EventFormField.Reminders -> Icons.Default.Notifications + EventFormField.Availability -> Icons.Default.EventAvailable + EventFormField.Visibility -> Icons.Default.Lock +} + +/** + * Visibility selector: one card per level, each with its own icon; the + * current level is highlighted. Tap picks and closes. + */ +@Composable +private fun VisibilityPickerDialog( + selected: AccessLevel, + onSelect: (AccessLevel) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.event_edit_visibility)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + AccessLevel.entries.forEach { level -> + OptionCard( + label = stringResource(accessLevelLabel(level)), + onClick = { onSelect(level) }, + icon = accessLevelIcon(level), + selected = level == selected, + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) } + }, + ) +} + +private fun accessLevelIcon(level: AccessLevel): ImageVector = when (level) { + AccessLevel.Default -> Icons.Default.Tune + AccessLevel.Public -> Icons.Default.Public + AccessLevel.Private -> Icons.Default.Lock + AccessLevel.Confidential -> Icons.Default.VisibilityOff +} + +private fun accessLevelLabel(level: AccessLevel): Int = when (level) { + AccessLevel.Default -> R.string.event_access_default + AccessLevel.Public -> R.string.event_access_public + AccessLevel.Private -> R.string.event_access_private + AccessLevel.Confidential -> R.string.event_access_confidential +} + +/** Humanise a reminder lead time, mirroring the detail screen's rendering. */ +@Composable +private fun reminderLabel(minutes: Int): String = when { + minutes <= 0 -> stringResource(R.string.reminder_at_time) + minutes % 10_080 == 0 -> + pluralStringResource(R.plurals.reminder_weeks, minutes / 10_080, minutes / 10_080) + minutes % 1_440 == 0 -> + pluralStringResource(R.plurals.reminder_days, minutes / 1_440, minutes / 1_440) + minutes % 60 == 0 -> + pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60) + else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes) +} + +/** + * One info card mirroring the detail screen's DetailCard: tonal container, + * leading icon in the gutter, value to the right. Optionally clickable as a + * whole (calendar picker). + */ +@Composable +private fun EditCard( + icon: ImageVector, + iconContentDescription: String?, + iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant, + onClick: (() -> Unit)? = null, + // Multiline-input cards align the icon with the field's first text line. + iconAtTop: Boolean = false, + // Tall cards pass their first row here: the icon centres on it, and + // [content] continues below, indented to the same text column. + header: (@Composable ColumnScope.() -> Unit)? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val shape = RoundedCornerShape(16.dp) + val color = MaterialTheme.colorScheme.surfaceContainerHigh + val inner: @Composable () -> Unit = { + if (header != null) { + Column(modifier = Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = iconContentDescription, + tint = iconTint, + modifier = Modifier.size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f), content = header) + } + Column( + modifier = Modifier.padding(start = 40.dp), + content = content, + ) + } + } else { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = iconContentDescription, + tint = iconTint, + // 4dp mirrors InlineField's vertical padding, so a + // top-aligned icon (24dp) centres on the ~24sp first line. + modifier = Modifier + .padding(top = if (iconAtTop) 4.dp else 0.dp) + .size(24.dp), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f), content = content) + } + } + } + if (onClick != null) { + Surface( + onClick = onClick, + color = color, + shape = shape, + modifier = Modifier.fillMaxWidth(), + ) { inner() } + } else { + Surface( + color = color, + shape = shape, + modifier = Modifier.fillMaxWidth(), + ) { inner() } + } +} + +/** + * Borderless text input used inside the cards (and as the headline title) — + * no underline, no outline, just the card's tonal surface behind it. + */ +@Composable +private fun InlineField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + textStyle: TextStyle = MaterialTheme.typography.titleMedium, + singleLine: Boolean = true, + minLines: Int = 1, + keyboardType: KeyboardType = KeyboardType.Text, + modifier: Modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), +) { + val resolvedStyle = textStyle.copy( + color = if (textStyle.color.isSpecified) { + textStyle.color + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + textStyle = resolvedStyle, + singleLine = singleLine, + minLines = minLines, + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + decorationBox = { innerTextField -> + Box { + if (value.isEmpty()) { + // Clearly fainter than typed text, so a hint (e.g. the + // "10" in the reminder amount) never reads as prefilled. + Text( + text = placeholder, + style = resolvedStyle, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + innerTextField() + } + }, + modifier = modifier, + ) } /** One schedule row: label, then tappable date and (unless all-day) time. */ @Composable -private fun DateTimeRow( +private fun ScheduleRow( label: String, dateTime: LocalDateTime, isAllDay: Boolean, @@ -400,38 +1004,39 @@ private fun DateTimeRow( val timeFormat = remember(locale) { DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale) } - val contentColor = if (isError) { + // Tappable values read as links (primary), like the location on the + // detail screen; errors flip them to the error colour. + val valueColor = if (isError) { MaterialTheme.colorScheme.error } else { - MaterialTheme.colorScheme.onSurface + MaterialTheme.colorScheme.primary } Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(start = 40.dp, top = 8.dp, bottom = 8.dp), + modifier = Modifier.fillMaxWidth(), ) { Text( text = label, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), ) Text( text = dateFormat.format(dateTime.date.toJavaLocalDate()), - style = MaterialTheme.typography.bodyLarge, - color = contentColor, + style = MaterialTheme.typography.titleMedium, + color = valueColor, modifier = Modifier .clickable(onClick = onDateClick) - .padding(8.dp), + .padding(vertical = 8.dp, horizontal = 6.dp), ) if (!isAllDay) { Text( text = timeFormat.format(dateTime.time.toJavaLocalTime()), - style = MaterialTheme.typography.bodyLarge, - color = contentColor, + style = MaterialTheme.typography.titleMedium, + color = valueColor, modifier = Modifier .clickable(onClick = onTimeClick) - .padding(8.dp), + .padding(vertical = 8.dp, horizontal = 6.dp), ) } } @@ -505,39 +1110,17 @@ private fun CalendarPickerDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.event_detail_calendar)) }, text = { - Column { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { 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, - ) - } - } - } + OptionCard( + label = calendar.displayName, + onClick = { onSelect(calendar.id) }, + icon = Icons.Default.CalendarMonth, + // The calendar's own colour carries its identity. + iconTint = pastelize(calendar.color, dark), + supportingText = calendar.accountName.takeIf { it.isNotBlank() }, + selected = calendar.id == selectedId, + ) } } }, diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt index 458b317..914abb3 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditUiState.kt @@ -2,6 +2,7 @@ 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 /** @@ -16,6 +17,10 @@ data class EventEditUiState( /** Validation problems; empty until a save was attempted. */ val problems: Set, val saveState: SaveUiState, + /** Optional sections currently rendered (settings defaults ∪ revealed). */ + val visibleFields: Set = 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. */ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt index 473d66b..e27b37a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/edit/EventEditViewModel.kt @@ -6,7 +6,12 @@ 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 @@ -39,6 +44,7 @@ import javax.inject.Inject class EventEditViewModel @Inject constructor( private val repository: CalendarRepository, private val prefs: CalendarPrefs, + private val settingsPrefs: SettingsPrefs, @IoDispatcher private val io: CoroutineDispatcher, ) : ViewModel() { @@ -47,26 +53,46 @@ class EventEditViewModel @Inject constructor( // 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>(emptySet()) + + private data class LocalInputs( + val form: EventForm?, + val saveState: SaveUiState, + val showProblems: Boolean, + val revealed: Set, + ) + + private data class ExternalInputs( + val writable: List, + val lastUsed: Long?, + val defaultFields: Set, + ) val state: StateFlow = combine( - _form, - repository.calendars() - .map { calendars -> calendars.filter { it.canModifyContents } } - .catch { emit(emptyList()) }, - prefs.lastUsedCalendarId, - _saveState, - _showProblems, - ) { form, writable, lastUsed, saveState, showProblems -> - if (form == null) return@combine null + 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 - ?: lastUsed?.takeIf { id -> writable.any { it.id == id } } - ?: writable.firstOrNull()?.id + ?: 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 = writable, - problems = if (showProblems) resolved.problems() else emptySet(), - saveState = saveState, + calendars = external.writable, + problems = if (local.showProblems) resolved.problems() else emptySet(), + saveState = local.saveState, + visibleFields = visibleFields, + hasHiddenFields = visibleFields.size < EventFormField.entries.size, ) } .flowOn(io) @@ -102,6 +128,12 @@ class EventEditViewModel @Inject constructor( _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) } @@ -109,6 +141,16 @@ class EventEditViewModel @Inject constructor( 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) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt index 5ddcee6..f6a4da0 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt @@ -113,8 +113,14 @@ fun MonthScreen( slideDir = -1 viewModel.goToPrev() } + // Slide toward today: viewing the future → today comes in from the left + // (back), viewing the past → from the right (forward). 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() } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt index f97c1a2..dd43224 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsScreen.kt @@ -47,6 +47,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref +import de.jeanlucmakiola.calendula.domain.EventFormField /** * Settings (M4) — appearance (theme, dynamic colour, week start), language, @@ -111,6 +112,22 @@ fun SettingsScreen( 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)) SectionHeader(stringResource(R.string.settings_section_language)) 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 private fun themeLabel(mode: ThemeMode): String = stringResource( when (mode) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt index b9374d8..404697c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsUiState.kt @@ -1,7 +1,9 @@ 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.WeekStartPref +import de.jeanlucmakiola.calendula.domain.EventFormField /** * Settings screen state (M4). Persisted preferences are instant to read, so @@ -14,4 +16,6 @@ data class SettingsUiState( val dynamicColor: Boolean = true, val dynamicColorAvailable: Boolean = true, val weekStart: WeekStartPref = WeekStartPref.AUTO, + /** Optional event-form fields shown by default (rest behind "more fields"). */ + val defaultFormFields: Set = SettingsPrefs.DEFAULT_FORM_FIELDS, ) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt index db72770..801cf5c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/settings/SettingsViewModel.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref +import de.jeanlucmakiola.calendula.domain.EventFormField import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -26,12 +27,14 @@ class SettingsViewModel @Inject constructor( prefs.themeMode, prefs.dynamicColor, prefs.weekStart, - ) { theme, dynamic, weekStart -> + prefs.defaultFormFields, + ) { theme, dynamic, weekStart, formFields -> SettingsUiState( themeMode = theme, dynamicColor = dynamic && dynamicColorAvailable, dynamicColorAvailable = dynamicColorAvailable, weekStart = weekStart, + defaultFormFields = formFields, ) }.stateIn( scope = viewModelScope, @@ -50,4 +53,8 @@ class SettingsViewModel @Inject constructor( fun setWeekStart(pref: WeekStartPref) { viewModelScope.launch { prefs.setWeekStart(pref) } } + + fun setFormFieldDefault(field: EventFormField, enabled: Boolean) { + viewModelScope.launch { prefs.setFormFieldDefault(field, enabled) } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt index f5ae816..8b42f63 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/theme/Theme.kt @@ -2,7 +2,9 @@ package de.jeanlucmakiola.calendula.ui.theme import android.os.Build 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.dynamicLightColorScheme import androidx.compose.runtime.Composable @@ -17,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext * The Settings screen (later) can override useDynamicColor and themePreference, * but the V1 foundation just follows the system. */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun CalendulaTheme( darkTheme: Boolean = isSystemInDarkTheme(), @@ -32,9 +35,15 @@ fun CalendulaTheme( 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, typography = CalendulaTypography, + motionScheme = MotionScheme.standard(), content = content, ) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt index 2247f98..cf423c2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/week/WeekScreen.kt @@ -146,7 +146,15 @@ fun WeekScreen( var slideDir by remember { mutableIntStateOf(0) } val goNext = { slideDir = 1; viewModel.goToNext() } 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( drawerState = drawerState, diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c84a168..f6f1ec4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -67,6 +67,20 @@ Kein beschreibbarer Kalender verfügbar Termin konnte nicht gespeichert werden Calendula braucht Schreibzugriff, um Termine zu erstellen + Weitere Felder + Hinzufügen + Erinnerung hinzufügen + Erinnerung entfernen + Benutzerdefiniert + Minuten + Stunden + Tage + Wochen + Verfügbarkeit + Sichtbarkeit + Beschäftigt + Standard + Öffentlich Ganztägig Kalender Unbekannter Kalender @@ -149,6 +163,8 @@ Automatisch Montag Sonntag + Termin-Formular + Standardmäßig angezeigte Felder — alles Weitere liegt hinter \"Weitere Felder\" Sprache App-Sprache Systemstandard diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3b96c0d..b1f6e3f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -68,6 +68,20 @@ No writable calendar available Couldn\'t save the event Calendula needs write access to create events + More fields + Add + Add reminder + Remove reminder + Custom + minutes + hours + days + weeks + Availability + Visibility + Busy + Default + Public All day Calendar Unknown calendar @@ -150,6 +164,8 @@ Automatic Monday Sunday + New event form + Fields shown by default — everything else sits behind \"More fields\" Language App language System default diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt index 87f4bd3..096a874 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapperTest.kt @@ -1,6 +1,9 @@ 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 @@ -36,6 +39,28 @@ class EventWriteMapperTest { 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( diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt index 1e4c337..364f720 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/prefs/SettingsPrefsTest.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.EventFormField import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.DayOfWeek @@ -60,6 +61,45 @@ class SettingsPrefsTest { 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 fun `explicit week-start prefs resolve regardless of locale`() { assertThat(WeekStartPref.MONDAY.resolveFirstDay(Locale.US)).isEqualTo(DayOfWeek.MONDAY)