feat: editable event attendees with contact picker #29
@@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.calendula.domain.Attendee
|
import de.jeanlucmakiola.calendula.domain.Attendee
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
@@ -536,6 +537,10 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
Log.w(TAG, "Failed to attach reminder ($minutes min) to event $eventId")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Guests are best-effort like reminders: a row that fails to attach is
|
||||||
|
// logged, not surfaced as a failed create. Calendula never sends an
|
||||||
|
// invitation — it only writes the rows; the backend decides delivery.
|
||||||
|
insertAttendees(eventId, form.attendees)
|
||||||
return eventId
|
return eventId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +570,12 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
if (updated.reminders.toSet() != original.reminders.toSet()) {
|
||||||
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
reconcileReminders(eventId, encodedReminders(updated, allDayReminderTimeMinutes))
|
||||||
}
|
}
|
||||||
|
// Same untouched-set guard for guests: only reconcile when the form's
|
||||||
|
// attendees actually changed, so unrelated edits never disturb the
|
||||||
|
// organizer/resource rows the form doesn't model.
|
||||||
|
if (updated.attendees.toSet() != original.attendees.toSet()) {
|
||||||
|
reconcileAttendees(eventId, updated.attendees)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun updateOccurrence(
|
override fun updateOccurrence(
|
||||||
@@ -587,6 +598,7 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
// Whether the provider copied the parent's reminder rows is its
|
// Whether the provider copied the parent's reminder rows is its
|
||||||
// business — reconciling against the actual rows handles both ways.
|
// business — reconciling against the actual rows handles both ways.
|
||||||
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
reconcileReminders(exceptionId, encodedReminders(form, allDayReminderTimeMinutes))
|
||||||
|
reconcileAttendees(exceptionId, form.attendees)
|
||||||
return exceptionId
|
return exceptionId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,6 +732,135 @@ class AndroidCalendarDataSource @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalised key an attendee is matched/deduped on (case-insensitive email). */
|
||||||
|
private fun EventAttendee.key(): String = email.trim().lowercase()
|
||||||
|
|
||||||
|
/** Distinct guests by email; blanks dropped (we key and write on the address). */
|
||||||
|
private fun List<EventAttendee>.distinctByEmail(): List<EventAttendee> {
|
||||||
|
val seen = HashSet<String>()
|
||||||
|
return filter { it.email.isNotBlank() && seen.add(it.key()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attendeeValues(eventId: Long, attendee: EventAttendee): ContentValues =
|
||||||
|
ContentValues().apply {
|
||||||
|
put(CalendarContract.Attendees.EVENT_ID, eventId)
|
||||||
|
put(CalendarContract.Attendees.ATTENDEE_EMAIL, attendee.email.trim())
|
||||||
|
attendee.name.trim().takeIf { it.isNotEmpty() }
|
||||||
|
?.let { put(CalendarContract.Attendees.ATTENDEE_NAME, it) }
|
||||||
|
// A plain guest — never the organizer (we don't fabricate scheduling
|
||||||
|
// identity; the backend owns that). Invited = awaiting their reply.
|
||||||
|
put(
|
||||||
|
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||||
|
CalendarContract.Attendees.RELATIONSHIP_ATTENDEE,
|
||||||
|
)
|
||||||
|
put(CalendarContract.Attendees.ATTENDEE_TYPE, attendee.providerType())
|
||||||
|
put(
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventAttendee.providerType(): Int = if (optional) {
|
||||||
|
CalendarContract.Attendees.TYPE_OPTIONAL
|
||||||
|
} else {
|
||||||
|
CalendarContract.Attendees.TYPE_REQUIRED
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort attendee inserts (like reminders); emails are never logged. */
|
||||||
|
private fun insertAttendees(eventId: Long, attendees: List<EventAttendee>) {
|
||||||
|
attendees.distinctByEmail().forEach { attendee ->
|
||||||
|
if (resolver.insert(
|
||||||
|
CalendarContract.Attendees.CONTENT_URI,
|
||||||
|
attendeeValues(eventId, attendee),
|
||||||
|
) == null
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class AttendeeRow(
|
||||||
|
val id: Long,
|
||||||
|
val email: String,
|
||||||
|
val relationship: Int,
|
||||||
|
val type: Int,
|
||||||
|
) {
|
||||||
|
val key: String get() = email.trim().lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun queryAttendeeRows(eventId: Long): List<AttendeeRow> = resolver.query(
|
||||||
|
CalendarContract.Attendees.CONTENT_URI,
|
||||||
|
arrayOf(
|
||||||
|
CalendarContract.Attendees._ID,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_EMAIL,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_RELATIONSHIP,
|
||||||
|
CalendarContract.Attendees.ATTENDEE_TYPE,
|
||||||
|
),
|
||||||
|
CalendarContract.Attendees.EVENT_ID + " = ?",
|
||||||
|
arrayOf(eventId.toString()),
|
||||||
|
null,
|
||||||
|
)?.use { c ->
|
||||||
|
buildList {
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
add(
|
||||||
|
AttendeeRow(
|
||||||
|
id = c.getLong(0),
|
||||||
|
email = c.getString(1).orEmpty(),
|
||||||
|
relationship = c.getInt(2),
|
||||||
|
type = c.getInt(3),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the event's *editable* guest rows match [attendees]. Only rows we
|
||||||
|
* own — has an email, not the organizer, not a resource — are reconciled;
|
||||||
|
* everything else the backend put there is left untouched. Matched rows keep
|
||||||
|
* their response status (only the required/optional type is updated if it
|
||||||
|
* changed); dropped rows are deleted; new guests inserted. Calendula sends
|
||||||
|
* nothing — the backend decides if anyone is notified on sync.
|
||||||
|
*/
|
||||||
|
private fun reconcileAttendees(eventId: Long, attendees: List<EventAttendee>) {
|
||||||
|
val target = attendees.distinctByEmail()
|
||||||
|
val targetByKey = target.associateBy { it.key() }
|
||||||
|
val editable = queryAttendeeRows(eventId).filter {
|
||||||
|
it.email.isNotBlank() &&
|
||||||
|
it.relationship != CalendarContract.Attendees.RELATIONSHIP_ORGANIZER &&
|
||||||
|
it.type != CalendarContract.Attendees.TYPE_RESOURCE
|
||||||
|
}
|
||||||
|
val editableKeys = editable.map { it.key }.toSet()
|
||||||
|
editable.forEach { row ->
|
||||||
|
val want = targetByKey[row.key]
|
||||||
|
if (want == null) {
|
||||||
|
resolver.delete(
|
||||||
|
CalendarContract.Attendees.CONTENT_URI,
|
||||||
|
CalendarContract.Attendees._ID + " = ?",
|
||||||
|
arrayOf(row.id.toString()),
|
||||||
|
)
|
||||||
|
} else if (row.type != want.providerType()) {
|
||||||
|
resolver.update(
|
||||||
|
CalendarContract.Attendees.CONTENT_URI,
|
||||||
|
ContentValues().apply {
|
||||||
|
put(CalendarContract.Attendees.ATTENDEE_TYPE, want.providerType())
|
||||||
|
},
|
||||||
|
CalendarContract.Attendees._ID + " = ?",
|
||||||
|
arrayOf(row.id.toString()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target.filterNot { it.key() in editableKeys }.forEach { attendee ->
|
||||||
|
if (resolver.insert(
|
||||||
|
CalendarContract.Attendees.CONTENT_URI,
|
||||||
|
attendeeValues(eventId, attendee),
|
||||||
|
) == null
|
||||||
|
) {
|
||||||
|
Log.w(TAG, "Failed to attach a guest to event $eventId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
private fun Map<String, Any?>.toContentValues(): ContentValues =
|
||||||
ContentValues().also { cv ->
|
ContentValues().also { cv ->
|
||||||
forEach { (column, value) ->
|
forEach { (column, value) ->
|
||||||
|
|||||||
@@ -41,6 +41,27 @@ data class EventForm(
|
|||||||
*/
|
*/
|
||||||
val colorKey: String? = null,
|
val colorKey: String? = null,
|
||||||
val color: Int? = null,
|
val color: Int? = null,
|
||||||
|
/**
|
||||||
|
* Guests the user has added/kept on the event. Calendula only writes these
|
||||||
|
* `Attendees` rows — it has no INTERNET and never sends an invitation
|
||||||
|
* itself; whether a guest is notified is decided downstream by the
|
||||||
|
* calendar's backend (local: no one; CalDAV/Google: the server/account).
|
||||||
|
* Read-only rows the form doesn't model (the organizer, resources) are
|
||||||
|
* preserved by the data layer, not carried here.
|
||||||
|
*/
|
||||||
|
val attendees: List<EventAttendee> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One editable guest: the user controls the [email] (the identity we dedup and
|
||||||
|
* write on), an optional display [name], and whether they're [optional] rather
|
||||||
|
* than required. Response status and the organizer/resource distinction are not
|
||||||
|
* user-editable, so they aren't modelled here.
|
||||||
|
*/
|
||||||
|
data class EventAttendee(
|
||||||
|
val email: String,
|
||||||
|
val name: String = "",
|
||||||
|
val optional: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +76,7 @@ enum class EventFormField {
|
|||||||
Availability,
|
Availability,
|
||||||
Visibility,
|
Visibility,
|
||||||
Color,
|
Color,
|
||||||
|
Attendees,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class EventFormProblem {
|
enum class EventFormProblem {
|
||||||
@@ -108,6 +130,24 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
// calendar's colour.
|
// calendar's colour.
|
||||||
colorKey = eventColorKey,
|
colorKey = eventColorKey,
|
||||||
color = eventColor,
|
color = eventColor,
|
||||||
|
// Only editable guests ride in the form: drop the organizer and
|
||||||
|
// resource rows (not user-editable) and any without an email (we key
|
||||||
|
// edits and dedup on the address). The data layer preserves the rows we
|
||||||
|
// don't carry here, so they're never clobbered on save.
|
||||||
|
attendees = attendees
|
||||||
|
.filter {
|
||||||
|
it.relationship != AttendeeRelationship.Organizer &&
|
||||||
|
it.type != AttendeeType.Resource
|
||||||
|
}
|
||||||
|
.mapNotNull { a ->
|
||||||
|
a.email?.takeIf { it.isNotBlank() }?.let { email ->
|
||||||
|
EventAttendee(
|
||||||
|
email = email,
|
||||||
|
name = a.name,
|
||||||
|
optional = a.type == AttendeeType.Optional,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,9 +157,10 @@ fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone):
|
|||||||
* while the form was open. The raw row times ride along because
|
* while the form was open. The raw row times ride along because
|
||||||
* [toEditForm] derives the form's times from the *tapped occurrence*, so
|
* [toEditForm] derives the form's times from the *tapped occurrence*, so
|
||||||
* re-deriving with the same occurrence would mask an externally moved
|
* re-deriving with the same occurrence would mask an externally moved
|
||||||
* event. Not covered (the form can't write them, and the dirty-checked
|
* event. Guests are covered (the form writes editable attendees), so an
|
||||||
* write can't clobber them): attendees, status, the user's own response,
|
* external attendee change now also trips the conflict check. Still not
|
||||||
* reminder methods, and a recurring event's duration.
|
* covered: status, the user's own response, reminder methods, the
|
||||||
|
* organizer/resource rows, and a recurring event's duration.
|
||||||
*/
|
*/
|
||||||
data class EditSnapshot(
|
data class EditSnapshot(
|
||||||
val form: EventForm,
|
val form: EventForm,
|
||||||
@@ -148,6 +189,7 @@ fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
|
|||||||
if (availability != Availability.Busy) add(EventFormField.Availability)
|
if (availability != Availability.Busy) add(EventFormField.Availability)
|
||||||
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
|
||||||
if (colorKey != null || color != null) add(EventFormField.Color)
|
if (colorKey != null || color != null) add(EventFormField.Color)
|
||||||
|
if (attendees.isNotEmpty()) add(EventFormField.Attendees)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
fun EventForm.problems(): Set<EventFormProblem> = buildSet {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -12,6 +13,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -36,6 +38,9 @@ fun InlineTextField(
|
|||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.None,
|
||||||
|
imeAction: ImeAction = ImeAction.Default,
|
||||||
|
/** Invoked when the IME action key (e.g. Done) is pressed. */
|
||||||
|
onImeAction: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val resolvedStyle = textStyle.copy(
|
val resolvedStyle = textStyle.copy(
|
||||||
color = if (textStyle.color.isSpecified) {
|
color = if (textStyle.color.isSpecified) {
|
||||||
@@ -53,7 +58,11 @@ fun InlineTextField(
|
|||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
capitalization = capitalization,
|
capitalization = capitalization,
|
||||||
|
imeAction = imeAction,
|
||||||
),
|
),
|
||||||
|
keyboardActions = onImeAction?.let { action ->
|
||||||
|
KeyboardActions(onAny = { action() })
|
||||||
|
} ?: KeyboardActions.Default,
|
||||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
decorationBox = { innerTextField ->
|
decorationBox = { innerTextField ->
|
||||||
Box {
|
Box {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.edit
|
|||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Patterns
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -30,7 +31,6 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||||
@@ -42,6 +42,9 @@ import androidx.compose.material.icons.filled.EventAvailable
|
|||||||
import androidx.compose.material.icons.filled.Lock
|
import androidx.compose.material.icons.filled.Lock
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
import androidx.compose.material.icons.filled.Palette
|
||||||
|
import androidx.compose.material.icons.filled.People
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
import androidx.compose.material.icons.filled.Place
|
import androidx.compose.material.icons.filled.Place
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.Public
|
||||||
import androidx.compose.material.icons.filled.Repeat
|
import androidx.compose.material.icons.filled.Repeat
|
||||||
@@ -49,11 +52,11 @@ import androidx.compose.material.icons.filled.Schedule
|
|||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.AssistChip
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -82,13 +85,17 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
@@ -97,6 +104,7 @@ import de.jeanlucmakiola.calendula.R
|
|||||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
@@ -115,6 +123,8 @@ import de.jeanlucmakiola.calendula.ui.common.DialogUnitDropdown
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
import de.jeanlucmakiola.calendula.ui.common.MILLIS_PER_DAY
|
||||||
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
import de.jeanlucmakiola.calendula.ui.common.InlineTextField
|
||||||
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
import de.jeanlucmakiola.calendula.ui.common.OptionCard
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.Position
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.positionOf
|
||||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
import de.jeanlucmakiola.calendula.ui.common.ReminderUnit
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||||
@@ -587,33 +597,24 @@ private fun EventEditContent(
|
|||||||
|
|
||||||
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
|
OptionalFormSection(visible = EventFormField.Reminders in state.visibleFields) {
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
// Reminders stack one per row (remove on the right edge); the
|
// A grouped list like guests: each reminder is its own tonal card
|
||||||
// add chip closes the card at the bottom. The card icon centres
|
// with connected corners, the last row being "Add reminder".
|
||||||
// on the first row, whatever that is.
|
val reminders = form.reminders
|
||||||
EditCard(
|
val rows = reminders.size + 1
|
||||||
|
reminders.forEachIndexed { index, minutes ->
|
||||||
|
GroupedItemCard(
|
||||||
|
position = positionOf(index, rows),
|
||||||
icon = Icons.Default.Notifications,
|
icon = Icons.Default.Notifications,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_reminders),
|
title = reminderLabel(minutes),
|
||||||
header = {
|
removeContentDescription = stringResource(R.string.event_edit_remove_reminder),
|
||||||
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) },
|
onRemove = { viewModel.removeReminder(minutes) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (form.reminders.isNotEmpty()) {
|
AddActionCard(
|
||||||
Spacer(Modifier.height(4.dp))
|
label = stringResource(R.string.event_edit_add_reminder),
|
||||||
AddReminderChip(onClick = { showReminderPicker = true })
|
position = positionOf(reminders.size, rows),
|
||||||
}
|
onClick = { showReminderPicker = true },
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
|
OptionalFormSection(visible = EventFormField.Recurrence in state.visibleFields) {
|
||||||
@@ -776,6 +777,42 @@ private fun EventEditContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) },
|
||||||
|
)
|
||||||
|
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()) {
|
OptionalFormSection(visible = state.hiddenFields.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(20.dp))
|
Spacer(Modifier.height(20.dp))
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -840,6 +877,7 @@ private fun EventEditContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (showRecurrencePicker) {
|
if (showRecurrencePicker) {
|
||||||
RecurrencePickerDialog(
|
RecurrencePickerDialog(
|
||||||
current = form.rrule,
|
current = form.rrule,
|
||||||
@@ -1262,44 +1300,221 @@ private fun recurrenceUnitLabel(freq: RecurrenceFreq): Int = when (freq) {
|
|||||||
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
RecurrenceFreq.Yearly -> R.string.recurrence_unit_years
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One chosen reminder: humanised lead time, remove pinned to the right edge. */
|
/** Corner shape for a card at [position] in a grouped run (mirrors GroupedRow). */
|
||||||
|
private fun groupedShape(position: Position, full: Dp = 20.dp, small: Dp = 6.dp): Shape =
|
||||||
|
when (position) {
|
||||||
|
Position.Alone -> RoundedCornerShape(full)
|
||||||
|
Position.Top -> RoundedCornerShape(
|
||||||
|
topStart = full, topEnd = full, bottomStart = small, bottomEnd = small,
|
||||||
|
)
|
||||||
|
Position.Middle -> RoundedCornerShape(small)
|
||||||
|
Position.Bottom -> RoundedCornerShape(
|
||||||
|
topStart = small, topEnd = small, bottomStart = full, bottomEnd = full,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 2dp gap that visually separates grouped cards (none after the last). */
|
||||||
|
private fun groupedGap(position: Position): Modifier = when (position) {
|
||||||
|
Position.Top, Position.Middle -> Modifier.padding(bottom = 2.dp)
|
||||||
|
Position.Bottom, Position.Alone -> Modifier
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry in a grouped form list (a guest, a reminder): a leading [icon],
|
||||||
|
* a [title] with optional [supporting] line, and a remove button — all on a
|
||||||
|
* tonal card whose corners come from its [position] in the run.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReminderRow(label: String, onRemove: () -> Unit) {
|
private fun GroupedItemCard(
|
||||||
|
position: Position,
|
||||||
|
icon: ImageVector,
|
||||||
|
title: String,
|
||||||
|
removeContentDescription: String,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
supporting: String? = null,
|
||||||
|
titleMaxLines: Int = Int.MAX_VALUE,
|
||||||
|
trailing: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = groupedShape(position),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(groupedGap(position)),
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = titleMaxLines,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (supporting != null) {
|
||||||
|
Text(
|
||||||
|
text = supporting,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (trailing != null) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
trailing()
|
||||||
|
}
|
||||||
|
IconButton(onClick = onRemove, modifier = Modifier.size(40.dp)) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = removeContentDescription,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One guest as a grouped-list card: avatar glyph, name/email, a tappable
|
||||||
|
* Required/Optional role chip, and remove. The email shows as the supporting
|
||||||
|
* line when a name is present, otherwise as the title.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun GuestCard(
|
||||||
|
attendee: EventAttendee,
|
||||||
|
position: Position,
|
||||||
|
onToggleOptional: (Boolean) -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
) {
|
||||||
|
val hasName = attendee.name.isNotBlank()
|
||||||
|
GroupedItemCard(
|
||||||
|
position = position,
|
||||||
|
icon = Icons.Default.Person,
|
||||||
|
title = if (hasName) attendee.name else attendee.email,
|
||||||
|
supporting = attendee.email.takeIf { hasName },
|
||||||
|
titleMaxLines = 1,
|
||||||
|
removeContentDescription = stringResource(R.string.event_edit_remove_guest),
|
||||||
|
onRemove = onRemove,
|
||||||
|
trailing = {
|
||||||
|
FilterChip(
|
||||||
|
selected = attendee.optional,
|
||||||
|
onClick = { onToggleOptional(!attendee.optional) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (attendee.optional) {
|
||||||
|
R.string.event_edit_attendee_optional
|
||||||
|
} else {
|
||||||
|
R.string.event_edit_attendee_required
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The trailing "Add …" action card of a grouped form list (opens a picker). */
|
||||||
|
@Composable
|
||||||
|
private fun AddActionCard(label: String, position: Position, onClick: () -> Unit) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = groupedShape(position),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(groupedGap(position)),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
modifier = Modifier.weight(1f),
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The trailing card of the guests list: an inline email field. Type an address
|
||||||
|
* and press Done (or tap the add button) to commit it as a guest; the field
|
||||||
|
* clears, ready for the next. Matches the form's inline-field input style — no
|
||||||
|
* dialog.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddReminderChip(onClick: () -> Unit) {
|
private fun AddGuestInlineCard(position: Position, onAdd: (String) -> Unit) {
|
||||||
AssistChip(
|
var text by rememberSaveable { mutableStateOf("") }
|
||||||
onClick = onClick,
|
val valid = remember(text) { Patterns.EMAIL_ADDRESS.matcher(text.trim()).matches() }
|
||||||
label = { Text(stringResource(R.string.event_edit_add)) },
|
fun commit() {
|
||||||
leadingIcon = {
|
if (valid) {
|
||||||
|
onAdd(text.trim())
|
||||||
|
text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
shape = groupedShape(position),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(groupedGap(position)),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
InlineTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
placeholder = stringResource(R.string.event_edit_add_guest_hint),
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
onImeAction = { commit() },
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
if (valid) {
|
||||||
|
IconButton(onClick = { commit() }, modifier = Modifier.size(40.dp)) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Add,
|
imageVector = Icons.Default.Add,
|
||||||
contentDescription = null,
|
contentDescription = stringResource(R.string.event_edit_add_guest),
|
||||||
modifier = Modifier.size(16.dp),
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
modifier = Modifier.size(22.dp),
|
||||||
},
|
|
||||||
border = null,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
private fun fieldLabel(field: EventFormField): Int = when (field) {
|
||||||
EventFormField.Location -> R.string.event_detail_location
|
EventFormField.Location -> R.string.event_detail_location
|
||||||
@@ -1309,6 +1524,7 @@ private fun fieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
EventFormField.Color -> R.string.event_edit_color
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
|
EventFormField.Attendees -> R.string.event_edit_attendees
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
||||||
@@ -1319,6 +1535,7 @@ private fun fieldIcon(field: EventFormField): ImageVector = when (field) {
|
|||||||
EventFormField.Availability -> Icons.Default.EventAvailable
|
EventFormField.Availability -> Icons.Default.EventAvailable
|
||||||
EventFormField.Visibility -> Icons.Default.Lock
|
EventFormField.Visibility -> Icons.Default.Lock
|
||||||
EventFormField.Color -> Icons.Default.Palette
|
EventFormField.Color -> Icons.Default.Palette
|
||||||
|
EventFormField.Attendees -> Icons.Default.People
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.domain.AccessLevel
|
|||||||
import de.jeanlucmakiola.calendula.domain.Availability
|
import de.jeanlucmakiola.calendula.domain.Availability
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
import de.jeanlucmakiola.calendula.domain.EditSnapshot
|
||||||
|
import de.jeanlucmakiola.calendula.domain.EventAttendee
|
||||||
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
@@ -353,6 +354,33 @@ class EventEditViewModel @Inject constructor(
|
|||||||
update { it.copy(reminders = it.reminders - minutes) }
|
update { it.copy(reminders = it.reminders - minutes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a guest, keyed on a trimmed lower-cased email so re-adding the same
|
||||||
|
* address just refreshes it (no duplicate row). A blank email is ignored —
|
||||||
|
* the email is what we write and dedup on.
|
||||||
|
*/
|
||||||
|
fun addAttendee(email: String, name: String = "", optional: Boolean = false) {
|
||||||
|
val trimmed = email.trim()
|
||||||
|
if (trimmed.isEmpty()) return
|
||||||
|
update { form ->
|
||||||
|
val attendee = EventAttendee(email = trimmed, name = name.trim(), optional = optional)
|
||||||
|
val rest = form.attendees.filterNot { it.email.equals(trimmed, ignoreCase = true) }
|
||||||
|
form.copy(attendees = rest + attendee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAttendee(attendee: EventAttendee) =
|
||||||
|
update { it.copy(attendees = it.attendees.filterNot { a -> a.email.equals(attendee.email, ignoreCase = true) }) }
|
||||||
|
|
||||||
|
/** Flip a guest between required and optional (the per-row role toggle). */
|
||||||
|
fun setAttendeeOptional(attendee: EventAttendee, optional: Boolean) = update { form ->
|
||||||
|
form.copy(
|
||||||
|
attendees = form.attendees.map { a ->
|
||||||
|
if (a.email.equals(attendee.email, ignoreCase = true)) a.copy(optional = optional) else a
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Moving the start drags the end along, preserving the duration. */
|
/** Moving the start drags the end along, preserving the duration. */
|
||||||
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
||||||
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
||||||
|
|||||||
@@ -872,6 +872,7 @@ private fun formFieldLabel(field: EventFormField): Int = when (field) {
|
|||||||
EventFormField.Availability -> R.string.event_edit_availability
|
EventFormField.Availability -> R.string.event_edit_availability
|
||||||
EventFormField.Visibility -> R.string.event_edit_visibility
|
EventFormField.Visibility -> R.string.event_edit_visibility
|
||||||
EventFormField.Color -> R.string.event_edit_color
|
EventFormField.Color -> R.string.event_edit_color
|
||||||
|
EventFormField.Attendees -> R.string.event_edit_attendees
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -77,6 +77,14 @@
|
|||||||
<string name="event_edit_add">Hinzufügen</string>
|
<string name="event_edit_add">Hinzufügen</string>
|
||||||
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
|
<string name="event_edit_add_reminder">Erinnerung hinzufügen</string>
|
||||||
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
|
<string name="event_edit_remove_reminder">Erinnerung entfernen</string>
|
||||||
|
<string name="event_edit_attendees">Gäste</string>
|
||||||
|
<string name="event_edit_add_guest">Gast hinzufügen</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Gast per E-Mail hinzufügen…</string>
|
||||||
|
<string name="event_edit_remove_guest">Gast entfernen</string>
|
||||||
|
<string name="event_edit_attendee_required">Erforderlich</string>
|
||||||
|
<string name="event_edit_attendee_optional">Optional</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula versendet keine Einladungen. Dein Kalenderkonto sendet Gästen bei der Synchronisierung unter Umständen eine E-Mail.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Auf diesem Gerät gespeichert. Niemand wird benachrichtigt.</string>
|
||||||
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
|
<string name="event_edit_reminder_custom">Benutzerdefiniert</string>
|
||||||
<string name="reminder_unit_minutes">Minuten</string>
|
<string name="reminder_unit_minutes">Minuten</string>
|
||||||
<string name="reminder_unit_hours">Stunden</string>
|
<string name="reminder_unit_hours">Stunden</string>
|
||||||
|
|||||||
@@ -78,6 +78,14 @@
|
|||||||
<string name="event_edit_add">Add</string>
|
<string name="event_edit_add">Add</string>
|
||||||
<string name="event_edit_add_reminder">Add reminder</string>
|
<string name="event_edit_add_reminder">Add reminder</string>
|
||||||
<string name="event_edit_remove_reminder">Remove reminder</string>
|
<string name="event_edit_remove_reminder">Remove reminder</string>
|
||||||
|
<string name="event_edit_attendees">Guests</string>
|
||||||
|
<string name="event_edit_add_guest">Add guest</string>
|
||||||
|
<string name="event_edit_add_guest_hint">Add a guest by email…</string>
|
||||||
|
<string name="event_edit_remove_guest">Remove guest</string>
|
||||||
|
<string name="event_edit_attendee_required">Required</string>
|
||||||
|
<string name="event_edit_attendee_optional">Optional</string>
|
||||||
|
<string name="event_edit_attendees_note_synced">Calendula doesn\'t send invitations. Your calendar account may email guests when it syncs.</string>
|
||||||
|
<string name="event_edit_attendees_note_local">Stored on this device. No one is notified.</string>
|
||||||
<string name="event_edit_reminder_custom">Custom</string>
|
<string name="event_edit_reminder_custom">Custom</string>
|
||||||
<string name="reminder_unit_minutes">minutes</string>
|
<string name="reminder_unit_minutes">minutes</string>
|
||||||
<string name="reminder_unit_hours">hours</string>
|
<string name="reminder_unit_hours">hours</string>
|
||||||
|
|||||||
@@ -211,12 +211,55 @@ class EventFormTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `changes the form cannot write do not fake a conflict`() {
|
fun `a reminder-method change the form cannot write does not fake a conflict`() {
|
||||||
|
// The form carries reminder minutes only, not the method — so a method
|
||||||
|
// flip the form can't express must not read as an external change.
|
||||||
|
val loaded = detail(reminders = listOf(Reminder(10, ReminderMethod.Alert)))
|
||||||
|
.toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
|
val fresh = detail(reminders = listOf(Reminder(10, ReminderMethod.Email)))
|
||||||
|
.toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
|
assertThat(fresh).isEqualTo(loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an external guest change now trips the conflict check`() {
|
||||||
|
// Attendees are writable now, so they ride in the snapshot: adding a
|
||||||
|
// guest externally is a real conflict, not invisible.
|
||||||
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
|
val loaded = detail().toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
val fresh = detail(
|
val fresh = detail(
|
||||||
attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted)),
|
attendees = listOf(Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction)),
|
||||||
).toEditSnapshot(0L, 3_600_000L, berlin)
|
).toEditSnapshot(0L, 3_600_000L, berlin)
|
||||||
assertThat(fresh).isEqualTo(loaded)
|
assertThat(fresh).isNotEqualTo(loaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm carries editable guests with name email and optional role`() {
|
||||||
|
val prefilled = detail(
|
||||||
|
attendees = listOf(
|
||||||
|
Attendee("Ada", "ada@example.org", AttendeeStatus.Accepted, type = AttendeeType.Required),
|
||||||
|
Attendee("", "bob@example.org", AttendeeStatus.NeedsAction, type = AttendeeType.Optional),
|
||||||
|
),
|
||||||
|
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||||
|
assertThat(prefilled.attendees).containsExactly(
|
||||||
|
EventAttendee(email = "ada@example.org", name = "Ada", optional = false),
|
||||||
|
EventAttendee(email = "bob@example.org", name = "", optional = true),
|
||||||
|
).inOrder()
|
||||||
|
assertThat(prefilled.populatedFields()).contains(EventFormField.Attendees)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toEditForm drops the organizer, resources and guests without an email`() {
|
||||||
|
val prefilled = detail(
|
||||||
|
attendees = listOf(
|
||||||
|
Attendee("Org", "org@example.org", AttendeeStatus.Accepted, AttendeeRelationship.Organizer),
|
||||||
|
Attendee("Room A", "room@example.org", AttendeeStatus.Accepted, type = AttendeeType.Resource),
|
||||||
|
Attendee("No address", null, AttendeeStatus.NeedsAction),
|
||||||
|
Attendee("Ada", "ada@example.org", AttendeeStatus.NeedsAction),
|
||||||
|
),
|
||||||
|
).toEditForm(beginMillis = 0L, endMillis = 3_600_000L, zone = berlin)
|
||||||
|
assertThat(prefilled.attendees).containsExactly(
|
||||||
|
EventAttendee(email = "ada@example.org", name = "Ada", optional = false),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user