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:
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user