feat(contacts): lock managed fields in the event editor

When editing an event in a special-dates calendar, the title, all-day/date and
recurrence are locked (the sync overwrites them) while reminders, location, notes
and colour stay editable — the field-level "managed" contract from the design.

- EventEditUiState.isManaged (calendar id ∈ managedCalendarIds while editing),
  wired through the ViewModel's state combine.
- InlineTextField/InlineField gain an `enabled` flag (dims + goes read-only);
  ScheduleRow dims and disables its date/time taps; the all-day switch and the
  recurrence card are disabled.
- A one-line "Managed by …" hint under the title explains what's editable.

The calendar picker needs no change — it's already create-only, and managed
events are always edits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-01 11:06:00 +02:00
parent 4540faf2a0
commit 4da9abe05b
5 changed files with 54 additions and 14 deletions

View File

@@ -36,6 +36,7 @@ fun InlineTextField(
textStyle: TextStyle = MaterialTheme.typography.titleMedium, textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true, singleLine: Boolean = true,
minLines: Int = 1, minLines: Int = 1,
enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text, keyboardType: KeyboardType = KeyboardType.Text,
capitalization: KeyboardCapitalization = KeyboardCapitalization.None, capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
imeAction: ImeAction = ImeAction.Default, imeAction: ImeAction = ImeAction.Default,
@@ -43,15 +44,17 @@ fun InlineTextField(
onImeAction: (() -> Unit)? = null, onImeAction: (() -> Unit)? = null,
) { ) {
val resolvedStyle = textStyle.copy( val resolvedStyle = textStyle.copy(
color = if (textStyle.color.isSpecified) { color = when {
textStyle.color // A disabled field reads as dimmed, like a locked value.
} else { !enabled -> MaterialTheme.colorScheme.onSurfaceVariant
MaterialTheme.colorScheme.onSurface textStyle.color.isSpecified -> textStyle.color
else -> MaterialTheme.colorScheme.onSurface
}, },
) )
BasicTextField( BasicTextField(
value = value, value = value,
onValueChange = onValueChange, onValueChange = onValueChange,
enabled = enabled,
textStyle = resolvedStyle, textStyle = resolvedStyle,
singleLine = singleLine, singleLine = singleLine,
minLines = minLines, minLines = minLines,

View File

@@ -435,6 +435,10 @@ private fun EventEditContent(
val form = state.form val form = state.form
val locale = currentLocale() val locale = currentLocale()
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
// The title, date and recurrence are managed by the special-dates sync, so
// they're locked here; everything else (reminders, location, notes) is the
// user's to edit.
val locked = state.isManaged
var picker by remember { mutableStateOf<PickerTarget?>(null) } var picker by remember { mutableStateOf<PickerTarget?>(null) }
var showCalendarPicker by rememberSaveable { mutableStateOf(false) } var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) } var showReminderPicker by rememberSaveable { mutableStateOf(false) }
@@ -519,6 +523,7 @@ private fun EventEditContent(
placeholder = stringResource(R.string.event_edit_title_hint), placeholder = stringResource(R.string.event_edit_title_hint),
textStyle = MaterialTheme.typography.headlineMedium textStyle = MaterialTheme.typography.headlineMedium
.copy(fontWeight = FontWeight.SemiBold), .copy(fontWeight = FontWeight.SemiBold),
enabled = !locked,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp) .padding(vertical = 4.dp)
@@ -531,6 +536,17 @@ private fun EventEditContent(
.height(3.dp) .height(3.dp)
.background(accent, RoundedCornerShape(2.dp)), .background(accent, RoundedCornerShape(2.dp)),
) )
if (locked) {
Spacer(Modifier.height(10.dp))
Text(
text = stringResource(
R.string.event_edit_managed_hint,
selectedCalendar?.displayName.orEmpty(),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
@@ -549,7 +565,11 @@ private fun EventEditContent(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
Switch(checked = form.isAllDay, onCheckedChange = viewModel::setAllDay) Switch(
checked = form.isAllDay,
onCheckedChange = viewModel::setAllDay,
enabled = !locked,
)
} }
}, },
) { ) {
@@ -561,6 +581,7 @@ private fun EventEditContent(
locale = locale, locale = locale,
onDateClick = { picker = PickerTarget.StartDate }, onDateClick = { picker = PickerTarget.StartDate },
onTimeClick = { picker = PickerTarget.StartTime }, onTimeClick = { picker = PickerTarget.StartTime },
enabled = !locked,
) )
ScheduleRow( ScheduleRow(
label = stringResource(R.string.event_edit_ends), label = stringResource(R.string.event_edit_ends),
@@ -570,6 +591,7 @@ private fun EventEditContent(
onDateClick = { picker = PickerTarget.EndDate }, onDateClick = { picker = PickerTarget.EndDate },
onTimeClick = { picker = PickerTarget.EndTime }, onTimeClick = { picker = PickerTarget.EndTime },
isError = EventFormProblem.EndBeforeStart in state.problems, isError = EventFormProblem.EndBeforeStart in state.problems,
enabled = !locked,
) )
if (EventFormProblem.EndBeforeStart in state.problems) { if (EventFormProblem.EndBeforeStart in state.problems) {
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
@@ -700,7 +722,7 @@ private fun EventEditContent(
EditCard( EditCard(
icon = Icons.Default.Repeat, icon = Icons.Default.Repeat,
iconContentDescription = null, iconContentDescription = null,
onClick = { showRecurrencePicker = true }, onClick = if (locked) null else ({ showRecurrencePicker = true }),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -1858,6 +1880,7 @@ private fun InlineField(
textStyle: TextStyle = MaterialTheme.typography.titleMedium, textStyle: TextStyle = MaterialTheme.typography.titleMedium,
singleLine: Boolean = true, singleLine: Boolean = true,
minLines: Int = 1, minLines: Int = 1,
enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text, keyboardType: KeyboardType = KeyboardType.Text,
modifier: Modifier = Modifier modifier: Modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1871,6 +1894,7 @@ private fun InlineField(
textStyle = textStyle, textStyle = textStyle,
singleLine = singleLine, singleLine = singleLine,
minLines = minLines, minLines = minLines,
enabled = enabled,
keyboardType = keyboardType, keyboardType = keyboardType,
) )
} }
@@ -1885,6 +1909,7 @@ private fun ScheduleRow(
onDateClick: () -> Unit, onDateClick: () -> Unit,
onTimeClick: () -> Unit, onTimeClick: () -> Unit,
isError: Boolean = false, isError: Boolean = false,
enabled: Boolean = true,
) { ) {
val dateFormat = remember(locale) { val dateFormat = remember(locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
@@ -1894,11 +1919,12 @@ private fun ScheduleRow(
timeOfDayFormatter(use24Hour, locale) timeOfDayFormatter(use24Hour, locale)
} }
// Tappable values read as links (primary), like the location on the // Tappable values read as links (primary), like the location on the
// detail screen; errors flip them to the error colour. // detail screen; errors flip them to the error colour; a locked (managed)
val valueColor = if (isError) { // row dims to a plain, non-tappable value.
MaterialTheme.colorScheme.error val valueColor = when {
} else { !enabled -> MaterialTheme.colorScheme.onSurfaceVariant
MaterialTheme.colorScheme.primary isError -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -1915,7 +1941,7 @@ private fun ScheduleRow(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = valueColor, color = valueColor,
modifier = Modifier modifier = Modifier
.clickable(onClick = onDateClick) .clickable(enabled = enabled, onClick = onDateClick)
.padding(vertical = 8.dp, horizontal = 6.dp), .padding(vertical = 8.dp, horizontal = 6.dp),
) )
if (!isAllDay) { if (!isAllDay) {
@@ -1924,7 +1950,7 @@ private fun ScheduleRow(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = valueColor, color = valueColor,
modifier = Modifier modifier = Modifier
.clickable(onClick = onTimeClick) .clickable(enabled = enabled, onClick = onTimeClick)
.padding(vertical = 8.dp, horizontal = 6.dp), .padding(vertical = 8.dp, horizontal = 6.dp),
) )
} }

View File

@@ -29,6 +29,12 @@ data class EventEditUiState(
val hiddenFields: List<EventFormField> = emptyList(), val hiddenFields: List<EventFormField> = emptyList(),
/** True while editing an existing event (the calendar is then fixed). */ /** True while editing an existing event (the calendar is then fixed). */
val isEditing: Boolean = false, val isEditing: Boolean = false,
/**
* True when the event's calendar is a contact special-dates calendar the app
* manages. While editing such an event the title, date and recurrence are
* locked (sync overwrites them); reminders, location and notes stay editable.
*/
val isManaged: Boolean = false,
/** /**
* Whether to focus the title and raise the keyboard when this is a fresh * Whether to focus the title and raise the keyboard when this is a fresh
* create form (issue #10). Mirrors the settings flag; the screen only acts * create form (issue #10). Mirrors the settings flag; the screen only acts

View File

@@ -165,7 +165,8 @@ class EventEditViewModel @Inject constructor(
::ExternalInputs, ::ExternalInputs,
).flowOn(io), ).flowOn(io),
colorPalette, colorPalette,
) { local, external, palette -> settingsPrefs.managedCalendarIds,
) { local, external, palette, managedIds ->
val form = local.form ?: return@combine null val form = local.form ?: return@combine null
val resolvedId = form.calendarId val resolvedId = form.calendarId
?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } } ?: external.lastUsed?.takeIf { id -> external.writable.any { it.id == id } }
@@ -180,6 +181,8 @@ class EventEditViewModel @Inject constructor(
visibleFields = visibleFields, visibleFields = visibleFields,
hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(), hiddenFields = (EventFormField.entries.toSet() - visibleFields).sorted(),
isEditing = local.editTarget != null, isEditing = local.editTarget != null,
// Editing an event in a managed calendar locks its synced fields.
isManaged = local.editTarget != null && resolvedId != null && resolvedId in managedIds,
autofocusTitle = external.autofocusTitle, autofocusTitle = external.autofocusTitle,
// A modified-occurrence exception can't carry its own rule, so // A modified-occurrence exception can't carry its own rule, so
// the scope dialog drops "only this event" after a rule change. // the scope dialog drops "only this event" after a rule change.

View File

@@ -68,6 +68,8 @@
<string name="event_edit_close">Close</string> <string name="event_edit_close">Close</string>
<string name="event_edit_save">Save</string> <string name="event_edit_save">Save</string>
<string name="event_edit_title_hint">Add title</string> <string name="event_edit_title_hint">Add title</string>
<!-- %1$s is the managing calendar's name, e.g. "Birthdays". -->
<string name="event_edit_managed_hint">Managed by “%1$s” — the title, date and repeat are kept in sync from your contacts. Reminders, location and notes are yours to edit.</string>
<string name="event_edit_starts">Starts</string> <string name="event_edit_starts">Starts</string>
<string name="event_edit_ends">Ends</string> <string name="event_edit_ends">Ends</string>
<string name="event_edit_error_end_before_start">Ends before it starts</string> <string name="event_edit_error_end_before_start">Ends before it starts</string>