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 327318b5..914f0ce3 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 @@ -716,6 +716,9 @@ private fun OptionalFormSection( } } +/** The one full-screen picker the form has open, if any. */ +private enum class EditSheet { Calendar, Reminder, Recurrence, Visibility, Color, Timezone, Field } + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun EventEditContent( @@ -733,18 +736,8 @@ private fun EventEditContent( // they're locked here; everything else (reminders, location, notes) is the // user's to edit. val locked = state.isManaged - // Read in the form's own window, not the picker's: the focused field lives - // here, so this is the controller that can put its keyboard away. - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current var picker by remember { mutableStateOf(null) } - var showCalendarPicker by rememberSaveable { mutableStateOf(false) } - var showReminderPicker by rememberSaveable { mutableStateOf(false) } - var showRecurrencePicker by rememberSaveable { mutableStateOf(false) } - var showVisibilityPicker by rememberSaveable { mutableStateOf(false) } - var showColorPicker by rememberSaveable { mutableStateOf(false) } - var showTimezonePicker by rememberSaveable { mutableStateOf(false) } - var showFieldPicker by rememberSaveable { mutableStateOf(false) } + var sheet by rememberSaveable { mutableStateOf(null) } // Pick a guest from contacts: a one-shot system picker returning the chosen // email row (email + display name). No READ_CONTACTS — the result Intent @@ -785,17 +778,6 @@ private fun EventEditContent( ?: MaterialTheme.colorScheme.primary val gap = 12.dp - // Issue #10: on a fresh create form (setting on, nothing typed yet) focus the - // title and raise the keyboard so the user can type straight away. Keyed on - // Unit so it runs once per open — not on every keystroke — and guarded so - // editing or an imported/prefilled form never grabs focus. - val titleFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) { - runCatching { titleFocusRequester.requestFocus() } - } - } - // Per-event colour applicability for the resolved calendar: // - palette calendars (Google, …) and local calendars always support it; // - synced calendars with no palette only when the user opted in, and even @@ -815,136 +797,22 @@ private fun EventEditContent( .verticalScroll(rememberScrollState()) .padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 40.dp), ) { - // Title: borderless headline, mirroring the detail screen's title + - // accent bar instead of a boxed Material text field. Multi-line so long - // titles wrap instead of scrolling off one line (#33); newlines are - // stripped in setTitle, so this stays a single logical line — the Enter - // key just has no effect. - InlineTextField( - value = form.title, - onValueChange = viewModel::setTitle, - placeholder = stringResource(R.string.event_edit_title_hint), - textStyle = MaterialTheme.typography.headlineMedium - .copy(fontWeight = FontWeight.SemiBold), - singleLine = false, - enabled = !locked, - modifier = Modifier - .fillMaxWidth() - // Ease the height change as the title wraps to another line - // instead of letting the accent bar + cards below jump. - .animateContentSizeMotion() - .padding(vertical = 4.dp) - .focusRequester(titleFocusRequester), + TitleSection( + state = state, + onTitleChange = viewModel::setTitle, + accent = accent, + calendarName = selectedCalendar?.displayName, ) - FieldLengthCounter( - length = form.title.length, - max = MAX_SINGLE_LINE_FIELD, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(10.dp)) - Box( - modifier = Modifier - .width(48.dp) - .height(3.dp) - .background(accent, RoundedCornerShape(2.dp)), - ) - if (locked) { - Spacer(Modifier.height(10.dp)) - Text( - text = stringResource( - R.string.event_edit_managed_hint, - selectedCalendar?.displayName - ?.orUnnamed() - .orEmpty(), - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } 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. - IconInfoCard( - icon = Icons.Default.Schedule, - iconContentDescription = null, - header = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = stringResource(R.string.event_detail_all_day), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), - ) - Switch( - checked = form.isAllDay, - onCheckedChange = viewModel::setAllDay, - enabled = !locked, - ) - } - }, - ) { - Spacer(Modifier.height(4.dp)) - val formZone = remember(form.timezone, deviceZone) { form.resolvedZone(deviceZone) } - val startAt = remember(form.start, formZone) { form.startInstant(formZone) } - val endAt = remember(form.end, formZone) { form.endInstant(formZone) } - ScheduleRow( - label = stringResource(R.string.event_edit_starts), - dateTime = form.start.dateTime, - instant = startAt, - zone = formZone, - isAllDay = form.isAllDay, - locale = locale, - onDateClick = { picker = PickerTarget.StartDate }, - onTimeClick = { picker = PickerTarget.StartTime }, - enabled = !locked, - ) - ScheduleRow( - label = stringResource(R.string.event_edit_ends), - dateTime = form.end.dateTime, - instant = endAt, - zone = formZone, - isAllDay = form.isAllDay, - locale = locale, - onDateClick = { picker = PickerTarget.EndDate }, - onTimeClick = { picker = PickerTarget.EndTime }, - isError = EventFormProblem.EndBeforeStart in state.problems, - enabled = !locked, - ) - 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, - ) - } - // The rows above edit a pinned event in *its own* zone, which is the - // time it was set at — but that says nothing about when it lands for - // whoever is reading it. Spell the local equivalent out rather than - // leaving the user to do the offset arithmetic. Absent unless the - // event is pinned somewhere other than here. - // Keyed rather than recomputed: this sits in the same Column as the - // title field, so it would otherwise re-parse the zone on every - // keystroke. - val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) { - form.isPinnedAwayFrom(deviceZone) - } - if (pinnedAway) { - Spacer(Modifier.height(2.dp)) - Text( - text = stringResource( - R.string.event_edit_timezone_local_time, - formatTimeRange(startAt, endAt, deviceZone, locale), - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + WhenCard( + state = state, + deviceZone = deviceZone, + locale = locale, + onAllDayChange = viewModel::setAllDay, + onPick = { picker = it }, + ) Spacer(Modifier.height(gap)) @@ -952,133 +820,36 @@ private fun EventEditContent( // different one while editing *moves* the event (copy+delete on save, // since CALENDAR_ID can't be updated in place). Managed special-dates // events stay locked: the contact sync owns their calendar. - IconInfoCard( - icon = Icons.Default.CalendarMonth, - iconContentDescription = stringResource(R.string.event_detail_calendar), - iconTint = accent, - onClick = { showCalendarPicker = true } + CalendarCard( + calendar = selectedCalendar, + accent = accent, + onClick = { sheet = EditSheet.Calendar } .takeIf { state.calendars.isNotEmpty() && !state.isManaged }, - ) { - Text( - text = selectedCalendar?.displayName - ?.orUnnamed() - ?: 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.Timezone in state.visibleFields) { Spacer(Modifier.height(gap)) - IconInfoCard( - icon = Icons.Default.Public, - iconContentDescription = null, - onClick = { showTimezonePicker = true }, - ) { - // Resolved at the event's own start, not at "now": the - // abbreviation and offset shown ("CEST · GMT+02:00") must be - // the ones that apply when the event actually happens. - val pinned = remember(form.timezone, form.start, locale) { - form.timezone - ?.let { id -> runCatching { id to TimeZone.of(id) }.getOrNull() } - ?.let { (id, zone) -> - timeZoneOptionOf( - id, - locale, - at = form.startInstant(zone).toJavaInstant(), - regionOf = ::icuTimeZoneRegion, - ) - } - } - PickerCardBody( - title = pinned?.label ?: stringResource(R.string.event_edit_timezone_device), - subtitle = pinned?.let { zoneDescriptor(it) } - ?: stringResource(R.string.event_edit_timezone_device_summary), - ) - } + TimezoneCard(form = form, locale = locale, onClick = { sheet = EditSheet.Timezone }) } OptionalFormSection(visible = EventFormField.Location in state.visibleFields) { Spacer(Modifier.height(gap)) - IconInfoCard( - icon = Icons.Default.Place, - iconContentDescription = stringResource(R.string.event_detail_location), - iconAlignment = Alignment.Top, - iconTopPadding = ((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp), - ) { - Row(verticalAlignment = Alignment.Top) { - Column(modifier = Modifier.weight(1f)) { - InlineTextField( - value = form.location, - onValueChange = viewModel::setLocation, - placeholder = stringResource(R.string.event_detail_location), - // A location is as often a meeting URL as an address, - // and "Zoom.us/j/123" reads wrong (#146). - capitalization = KeyboardCapitalization.None, - // Multi-line so a long address or meeting URL wraps and - // the card grows instead of scrolling off one line - // (#273). The field holds no newlines of its own, so - // the IME's action key closes the keyboard rather than - // inserting a break setLocation would only fold away. - singleLine = false, - imeAction = ImeAction.Done, - modifier = Modifier - .fillMaxWidth() - .animateContentSizeMotion() - .padding(vertical = 4.dp), - ) - FieldLengthCounter( - length = form.location.length, - max = MAX_SINGLE_LINE_FIELD, + LocationCard( + location = form.location, + onLocationChange = viewModel::setLocation, + onPickContact = { + runCatching { + pickContactAddress.launch( + Intent( + Intent.ACTION_PICK, + ContactsContract.CommonDataKinds.StructuredPostal.CONTENT_URI, + ), ) } - // The box is exactly one text line tall, so the button - // centres on the first line without making a single-line - // card taller than its text; requiredSize keeps the - // button's own 40dp ripple. - Box( - modifier = Modifier.height(firstLineHeight()), - contentAlignment = Alignment.Center, - ) { - IconButton( - onClick = { - runCatching { - pickContactAddress.launch( - Intent( - Intent.ACTION_PICK, - ContactsContract.CommonDataKinds.StructuredPostal - .CONTENT_URI, - ), - ) - } - }, - modifier = Modifier.requiredSize(40.dp), - ) { - Icon( - imageVector = Icons.Default.Contacts, - contentDescription = stringResource( - R.string.event_edit_location_from_contacts, - ), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), - ) - } - } - } - } + }, + ) } OptionalFormSection(visible = EventFormField.Description in state.visibleFields) { @@ -1101,75 +872,25 @@ private fun EventEditContent( OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) { Spacer(Modifier.height(gap)) - // A grouped list like guests: each reminder is its own tonal card - // with connected corners, the last row being "Add reminder". - val reminders = form.reminders - val rows = reminders.size + 1 - reminders.forEachIndexed { index, minutes -> - GroupedItemCard( - position = positionOf(index, rows), - icon = Icons.Default.Notifications, - title = reminderLeadTimeLabel(minutes), - removeContentDescription = stringResource(R.string.event_edit_remove_reminder), - onRemove = { viewModel.removeReminder(minutes) }, - ) - } - AddActionCard( - label = stringResource(R.string.event_edit_add_reminder), - position = positionOf(reminders.size, rows), - onClick = { showReminderPicker = true }, + RemindersSection( + reminders = form.reminders, + onRemove = viewModel::removeReminder, + onAdd = { sheet = EditSheet.Reminder }, ) } OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) { Spacer(Modifier.height(gap)) - IconInfoCard( - icon = Icons.Default.Repeat, - iconContentDescription = null, - onClick = if (locked) null else ({ showRecurrencePicker = true }), - ) { - PickerCardBody( - title = form.rrule?.let { recurrenceText(it, locale) } - ?: AnnotatedString(stringResource(R.string.event_edit_recurrence_none)), - subtitle = stringResource(R.string.event_detail_recurrence), - ) - if (EventFormProblem.RecurrenceEndsBeforeStart in state.problems) { - Spacer(Modifier.height(2.dp)) - Text( - text = stringResource(R.string.event_edit_error_recurrence_ends_before_start), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) - } - } + RecurrenceCard( + state = state, + locale = locale, + onClick = if (locked) null else ({ sheet = EditSheet.Recurrence }), + ) } OptionalFormSection(visible = EventFormField.Availability in state.visibleFields) { Spacer(Modifier.height(gap)) - IconInfoCard( - 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)) } - } - } + AvailabilityCard(availability = form.availability, onSelect = viewModel::setAvailability) } OptionalFormSection(visible = EventFormField.Visibility in state.visibleFields) { @@ -1177,7 +898,7 @@ private fun EventEditContent( IconInfoCard( icon = Icons.Default.Lock, iconContentDescription = null, - onClick = { showVisibilityPicker = true }, + onClick = { sheet = EditSheet.Visibility }, ) { PickerCardBody( title = stringResource(accessLevelLabel(form.accessLevel)), @@ -1188,64 +909,21 @@ private fun EventEditContent( OptionalFormSection(visible = EventFormField.Color in state.visibleFields) { Spacer(Modifier.height(gap)) - // The swatch the event will paint with: its own colour, else the - // calendar's. The Palette icon takes that colour as a preview. - val swatch = form.color ?: selectedCalendar?.color - IconInfoCard( - icon = Icons.Default.Palette, - iconContentDescription = stringResource(R.string.event_edit_color), - iconTint = if (colorSupported && swatch != null) { - eventFill(swatch, dark, soften) - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - onClick = { showColorPicker = true }.takeIf { colorSupported }, - ) { - PickerCardBody( - title = stringResource( - when { - !colorSupported -> R.string.event_edit_color_unsupported - form.color != null -> R.string.event_edit_color_custom - else -> R.string.event_edit_color_default - }, - ), - subtitle = stringResource( - if (colorSupported) R.string.event_edit_color else R.string.event_edit_color_unsupported_hint, - ), - showArrow = colorSupported, - ) { - if (colorSyncRisk) { - Spacer(Modifier.height(2.dp)) - Text( - text = stringResource(R.string.event_edit_color_sync_warning), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } + ColorCard( + color = form.color, + calendarColor = selectedCalendar?.color, + supported = colorSupported, + syncRisk = colorSyncRisk, + onClick = { sheet = EditSheet.Color }.takeIf { colorSupported }, + ) } OptionalFormSection(visible = EventFormField.Attendees in state.visibleFields) { Spacer(Modifier.height(gap)) - // Guests are an inline grouped list: each is a tonal card with a - // role toggle + remove, and the trailing card is an inline email - // field — type an address, press Done, it commits. Below sits an - // honest note — Calendula never sends invitations, so whether a - // guest is notified is the calendar backend's call. - val guests = form.attendees - val rows = guests.size + 1 - guests.forEachIndexed { index, attendee -> - GuestCard( - attendee = attendee, - position = positionOf(index, rows), - onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) }, - onRemove = { viewModel.removeAttendee(attendee) }, - ) - } - AddGuestInlineCard( - position = positionOf(guests.size, rows), - onAdd = { viewModel.addAttendee(it) }, + GuestsSection( + guests = form.attendees, + isLocalCalendar = isLocalCalendar, + viewModel = viewModel, onPickContact = { runCatching { pickContact.launch( @@ -1257,25 +935,12 @@ private fun EventEditContent( } }, ) - Spacer(Modifier.height(8.dp)) - Text( - text = stringResource( - if (isLocalCalendar) { - R.string.event_edit_attendees_note_local - } else { - R.string.event_edit_attendees_note_synced - }, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), - ) } OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) { Spacer(Modifier.height(20.dp)) TextButton( - onClick = { showFieldPicker = true }, + onClick = { sheet = EditSheet.Field }, modifier = Modifier.align(Alignment.CenterHorizontally), ) { Icon( @@ -1313,112 +978,570 @@ private fun EventEditContent( null -> Unit } - // A full-screen picker is a change of place, so the keyboard shouldn't - // follow it. The field keeps its text; only focus and the IME go. - LaunchedEffect(showCalendarPicker) { - if (showCalendarPicker) { - focusManager.clearFocus(force = true) - keyboardController?.hide() - } - } - - if (showCalendarPicker) { - CalendarPicker( - calendars = state.calendars, - selectedId = form.calendarId, - onSelect = { - viewModel.setCalendar(it) - showCalendarPicker = false - }, - // Close the picker first: it is a Compose Dialog in its own window, - // always above the activity's content, so the manager would - // otherwise open behind it and the tap would look dead. - onManageCalendars = onManageCalendars?.let { openManager -> - { - showCalendarPicker = false - openManager() - } - }, - onDismiss = { showCalendarPicker = false }, + sheet?.let { open -> + EditSheetHost( + sheet = open, + state = state, + viewModel = viewModel, + firstDayOfWeek = firstDayOfWeek, + colorSyncRisk = colorSyncRisk, + onManageCalendars = onManageCalendars, + onClose = { sheet = null }, ) } +} - if (showReminderPicker) { - ReminderPickerDialog( +/** + * The borderless title over the accent bar, mirroring the detail screen's title + * instead of a boxed Material text field. Locked events say which calendar owns + * them. + */ +@Composable +private fun TitleSection( + state: EventEditUiState, + onTitleChange: (String) -> Unit, + accent: Color, + calendarName: String?, +) { + val form = state.form + // Issue #10: on a fresh create form (setting on, nothing typed yet) focus the + // title and raise the keyboard so the user can type straight away. Keyed on + // Unit so it runs once per open — not on every keystroke — and guarded so + // editing or an imported/prefilled form never grabs focus. + val titleFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (state.autofocusTitle && !state.isEditing && form.title.isBlank()) { + runCatching { titleFocusRequester.requestFocus() } + } + } + // Multi-line so long titles wrap instead of scrolling off one line (#33); + // newlines are stripped in setTitle, so this stays a single logical line — + // the Enter key just has no effect. + InlineTextField( + value = form.title, + onValueChange = onTitleChange, + placeholder = stringResource(R.string.event_edit_title_hint), + textStyle = MaterialTheme.typography.headlineMedium + .copy(fontWeight = FontWeight.SemiBold), + singleLine = false, + enabled = !state.isManaged, + modifier = Modifier + .fillMaxWidth() + // Ease the height change as the title wraps to another line + // instead of letting the accent bar + cards below jump. + .animateContentSizeMotion() + .padding(vertical = 4.dp) + .focusRequester(titleFocusRequester), + ) + FieldLengthCounter( + length = form.title.length, + max = MAX_SINGLE_LINE_FIELD, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(10.dp)) + Box( + modifier = Modifier + .width(48.dp) + .height(3.dp) + .background(accent, RoundedCornerShape(2.dp)), + ) + if (state.isManaged) { + Spacer(Modifier.height(10.dp)) + Text( + text = stringResource( + R.string.event_edit_managed_hint, + calendarName?.orUnnamed().orEmpty(), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * "When" card: the icon centres on the all-day row (header); the start/end rows + * continue below in the same text column. + */ +@Composable +private fun WhenCard( + state: EventEditUiState, + deviceZone: TimeZone, + locale: Locale, + onAllDayChange: (Boolean) -> Unit, + onPick: (PickerTarget) -> Unit, +) { + val form = state.form + val locked = state.isManaged + IconInfoCard( + icon = Icons.Default.Schedule, + iconContentDescription = null, + header = { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.event_detail_all_day), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Switch( + checked = form.isAllDay, + onCheckedChange = onAllDayChange, + enabled = !locked, + ) + } + }, + ) { + Spacer(Modifier.height(4.dp)) + val formZone = remember(form.timezone, deviceZone) { form.resolvedZone(deviceZone) } + val startAt = remember(form.start, formZone) { form.startInstant(formZone) } + val endAt = remember(form.end, formZone) { form.endInstant(formZone) } + ScheduleRow( + label = stringResource(R.string.event_edit_starts), + dateTime = form.start.dateTime, + instant = startAt, + zone = formZone, + isAllDay = form.isAllDay, + locale = locale, + onDateClick = { onPick(PickerTarget.StartDate) }, + onTimeClick = { onPick(PickerTarget.StartTime) }, + enabled = !locked, + ) + ScheduleRow( + label = stringResource(R.string.event_edit_ends), + dateTime = form.end.dateTime, + instant = endAt, + zone = formZone, + isAllDay = form.isAllDay, + locale = locale, + onDateClick = { onPick(PickerTarget.EndDate) }, + onTimeClick = { onPick(PickerTarget.EndTime) }, + isError = EventFormProblem.EndBeforeStart in state.problems, + enabled = !locked, + ) + 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, + ) + } + // The rows above edit a pinned event in *its own* zone, which is the + // time it was set at — but that says nothing about when it lands for + // whoever is reading it. Spell the local equivalent out rather than + // leaving the user to do the offset arithmetic. Absent unless the + // event is pinned somewhere other than here. + // Keyed rather than recomputed: the form recomposes on every title + // keystroke, and this would otherwise re-parse the zone each time. + val pinnedAway = remember(form.timezone, form.isAllDay, deviceZone) { + form.isPinnedAwayFrom(deviceZone) + } + if (pinnedAway) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource( + R.string.event_edit_timezone_local_time, + formatTimeRange(startAt, endAt, deviceZone, locale), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun CalendarCard(calendar: CalendarSource?, accent: Color, onClick: (() -> Unit)?) { + IconInfoCard( + icon = Icons.Default.CalendarMonth, + iconContentDescription = stringResource(R.string.event_detail_calendar), + iconTint = accent, + onClick = onClick, + ) { + Text( + text = calendar?.displayName + ?.orUnnamed() + ?: stringResource(R.string.event_edit_error_no_calendar), + style = MaterialTheme.typography.titleMedium, + color = if (calendar == null) { + MaterialTheme.colorScheme.error + } else { + Color.Unspecified + }, + ) + calendar?.accountName?.takeIf { it.isNotBlank() }?.let { account -> + Text( + text = account, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun TimezoneCard(form: EventForm, locale: Locale, onClick: () -> Unit) { + IconInfoCard( + icon = Icons.Default.Public, + iconContentDescription = null, + onClick = onClick, + ) { + // Resolved at the event's own start, not at "now": the + // abbreviation and offset shown ("CEST · GMT+02:00") must be + // the ones that apply when the event actually happens. + val pinned = remember(form.timezone, form.start, locale) { + form.timezone + ?.let { id -> runCatching { id to TimeZone.of(id) }.getOrNull() } + ?.let { (id, zone) -> + timeZoneOptionOf( + id, + locale, + at = form.startInstant(zone).toJavaInstant(), + regionOf = ::icuTimeZoneRegion, + ) + } + } + PickerCardBody( + title = pinned?.label ?: stringResource(R.string.event_edit_timezone_device), + subtitle = pinned?.let { zoneDescriptor(it) } + ?: stringResource(R.string.event_edit_timezone_device_summary), + ) + } +} + +@Composable +private fun LocationCard( + location: String, + onLocationChange: (String) -> Unit, + onPickContact: () -> Unit, +) { + IconInfoCard( + icon = Icons.Default.Place, + iconContentDescription = stringResource(R.string.event_detail_location), + iconAlignment = Alignment.Top, + iconTopPadding = ((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp), + ) { + Row(verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + InlineTextField( + value = location, + onValueChange = onLocationChange, + placeholder = stringResource(R.string.event_detail_location), + // A location is as often a meeting URL as an address, + // and "Zoom.us/j/123" reads wrong (#146). + capitalization = KeyboardCapitalization.None, + // Multi-line so a long address or meeting URL wraps and + // the card grows instead of scrolling off one line + // (#273). The field holds no newlines of its own, so + // the IME's action key closes the keyboard rather than + // inserting a break setLocation would only fold away. + singleLine = false, + imeAction = ImeAction.Done, + modifier = Modifier + .fillMaxWidth() + .animateContentSizeMotion() + .padding(vertical = 4.dp), + ) + FieldLengthCounter( + length = location.length, + max = MAX_SINGLE_LINE_FIELD, + ) + } + // The box is exactly one text line tall, so the button + // centres on the first line without making a single-line + // card taller than its text; requiredSize keeps the + // button's own 40dp ripple. + Box( + modifier = Modifier.height(firstLineHeight()), + contentAlignment = Alignment.Center, + ) { + IconButton( + onClick = onPickContact, + modifier = Modifier.requiredSize(40.dp), + ) { + Icon( + imageVector = Icons.Default.Contacts, + contentDescription = stringResource( + R.string.event_edit_location_from_contacts, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } + } + } +} + +/** + * A grouped list like guests: each reminder is its own tonal card with + * connected corners, the last row being "Add reminder". + */ +@Composable +private fun RemindersSection(reminders: List, onRemove: (Int) -> Unit, onAdd: () -> Unit) { + val rows = reminders.size + 1 + reminders.forEachIndexed { index, minutes -> + GroupedItemCard( + position = positionOf(index, rows), + icon = Icons.Default.Notifications, + title = reminderLeadTimeLabel(minutes), + removeContentDescription = stringResource(R.string.event_edit_remove_reminder), + onRemove = { onRemove(minutes) }, + ) + } + AddActionCard( + label = stringResource(R.string.event_edit_add_reminder), + position = positionOf(reminders.size, rows), + onClick = onAdd, + ) +} + +@Composable +private fun RecurrenceCard(state: EventEditUiState, locale: Locale, onClick: (() -> Unit)?) { + IconInfoCard( + icon = Icons.Default.Repeat, + iconContentDescription = null, + onClick = onClick, + ) { + PickerCardBody( + title = state.form.rrule?.let { recurrenceText(it, locale) } + ?: AnnotatedString(stringResource(R.string.event_edit_recurrence_none)), + subtitle = stringResource(R.string.event_detail_recurrence), + ) + if (EventFormProblem.RecurrenceEndsBeforeStart in state.problems) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(R.string.event_edit_error_recurrence_ends_before_start), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AvailabilityCard(availability: Availability, onSelect: (Availability) -> Unit) { + IconInfoCard( + 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 = availability == Availability.Busy, + onClick = { onSelect(Availability.Busy) }, + shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), + ) { Text(stringResource(R.string.event_availability_busy)) } + SegmentedButton( + selected = availability == Availability.Free, + onClick = { onSelect(Availability.Free) }, + shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), + ) { Text(stringResource(R.string.event_availability_free)) } + } + } +} + +/** + * The swatch the event will paint with: its own [color], else the calendar's. + * The Palette icon takes that colour as a preview. + */ +@Composable +private fun ColorCard( + color: Int?, + calendarColor: Int?, + supported: Boolean, + syncRisk: Boolean, + onClick: (() -> Unit)?, +) { + val swatch = color ?: calendarColor + IconInfoCard( + icon = Icons.Default.Palette, + iconContentDescription = stringResource(R.string.event_edit_color), + iconTint = if (supported && swatch != null) { + eventFill(swatch, LocalFloretDarkTheme.current, LocalSoftenColors.current) + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + onClick = onClick, + ) { + PickerCardBody( + title = stringResource( + when { + !supported -> R.string.event_edit_color_unsupported + color != null -> R.string.event_edit_color_custom + else -> R.string.event_edit_color_default + }, + ), + subtitle = stringResource( + if (supported) R.string.event_edit_color else R.string.event_edit_color_unsupported_hint, + ), + showArrow = supported, + ) { + if (syncRisk) { + Spacer(Modifier.height(2.dp)) + Text( + text = stringResource(R.string.event_edit_color_sync_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** + * Guests are an inline grouped list: each is a tonal card with a role toggle + + * remove, and the trailing card is an inline email field — type an address, + * press Done, it commits. Below sits an honest note — Calendula never sends + * invitations, so whether a guest is notified is the calendar backend's call. + */ +@Composable +private fun GuestsSection( + guests: List, + isLocalCalendar: Boolean, + viewModel: EventEditViewModel, + onPickContact: () -> Unit, +) { + val rows = guests.size + 1 + guests.forEachIndexed { index, attendee -> + GuestCard( + attendee = attendee, + position = positionOf(index, rows), + onToggleOptional = { viewModel.setAttendeeOptional(attendee, it) }, + onRemove = { viewModel.removeAttendee(attendee) }, + ) + } + AddGuestInlineCard( + position = positionOf(guests.size, rows), + onAdd = { viewModel.addAttendee(it) }, + onPickContact = onPickContact, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource( + if (isLocalCalendar) { + R.string.event_edit_attendees_note_local + } else { + R.string.event_edit_attendees_note_synced + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp), + ) +} + +/** The open [sheet]; each one closes itself through [onClose] once it has applied its pick. */ +@Composable +private fun EditSheetHost( + sheet: EditSheet, + state: EventEditUiState, + viewModel: EventEditViewModel, + firstDayOfWeek: DayOfWeek, + colorSyncRisk: Boolean, + onManageCalendars: (() -> Unit)?, + onClose: () -> Unit, +) { + val form = state.form + // Read in the form's own window, not the picker's: the focused field lives + // there, so this is the controller that can put its keyboard away. + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + when (sheet) { + EditSheet.Calendar -> { + // A full-screen picker is a change of place, so the keyboard shouldn't + // follow it. The field keeps its text; only focus and the IME go. + LaunchedEffect(Unit) { + focusManager.clearFocus(force = true) + keyboardController?.hide() + } + CalendarPicker( + calendars = state.calendars, + selectedId = form.calendarId, + onSelect = { + viewModel.setCalendar(it) + onClose() + }, + // Close the picker first: it is a Compose Dialog in its own window, + // always above the activity's content, so the manager would + // otherwise open behind it and the tap would look dead. + onManageCalendars = onManageCalendars?.let { openManager -> + { + onClose() + openManager() + } + }, + onDismiss = onClose, + ) + } + EditSheet.Reminder -> ReminderPickerDialog( alreadyChosen = form.reminders, onSelect = { minutes -> viewModel.addReminder(minutes) - showReminderPicker = false + onClose() }, - onDismiss = { showReminderPicker = false }, + onDismiss = onClose, ) - } - - - if (showRecurrencePicker) { - RecurrencePickerDialog( + EditSheet.Recurrence -> RecurrencePickerDialog( current = form.rrule, startDate = form.start.date, firstDayOfWeek = firstDayOfWeek, onSelect = { rrule -> viewModel.setRecurrence(rrule) - showRecurrencePicker = false + onClose() }, - onDismiss = { showRecurrencePicker = false }, + onDismiss = onClose, ) - } - - if (showVisibilityPicker) { - VisibilityPickerDialog( + EditSheet.Visibility -> VisibilityPickerDialog( selected = form.accessLevel, onSelect = { level -> viewModel.setAccessLevel(level) - showVisibilityPicker = false + onClose() }, - onDismiss = { showVisibilityPicker = false }, + onDismiss = onClose, ) - } - - if (showTimezonePicker) { - TimeZonePickerDialog( + EditSheet.Timezone -> TimeZonePickerDialog( selected = form.timezone, deviceZoneId = ZoneId.systemDefault().id, recents = state.recentTimeZones, onSelect = viewModel::setTimezone, - onDismiss = { showTimezonePicker = false }, + onDismiss = onClose, ) - } - - if (showColorPicker) { - ColorPickerDialog( + EditSheet.Color -> ColorPickerDialog( palette = state.colorPalette, selected = form.color, hasExplicitColor = form.color != null, syncWarning = colorSyncRisk, onPickKey = { key, argb -> viewModel.setColorKey(key, argb) - showColorPicker = false + onClose() }, onPickRaw = { argb -> viewModel.setColorRaw(argb) - showColorPicker = false + onClose() }, onClear = { viewModel.clearColor() - showColorPicker = false + onClose() }, - onDismiss = { showColorPicker = false }, + onDismiss = onClose, ) - } - - if (showFieldPicker) { - FieldPickerDialog( + EditSheet.Field -> FieldPickerDialog( hiddenFields = state.hiddenFields, onSelect = { field -> viewModel.revealField(field) - showFieldPicker = false + onClose() }, - onDismiss = { showFieldPicker = false }, + onDismiss = onClose, ) } }