Files
flamenet-messenger-android/app/src/main/kotlin/io/flamenet/messenger/app/EventUi.kt
T
Paul HittandClaude Opus 5.5 f5ec45667a An event runs one to thirty-one days
DESIGN 45. days above 31 is read as 31 and below 1 as a single day, and
the event composer holds what it writes to the same range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:19:45 -04:00

405 lines
18 KiB
Kotlin

package io.flamenet.messenger.app
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TimePicker
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import io.flamenet.e2e.ChatEvents
import io.flamenet.e2e.Content
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/** The three answers, in the order the buttons sit. */
private val ANSWERS =
listOf(
Content.Rsvp.YES to "Going",
Content.Rsvp.MAYBE to "Maybe",
Content.Rsvp.NO to "Can't go",
)
/**
* An event in a conversation (§6.32): when and where, who is coming, and
* the three answers. Revisions and answers change this card; they never add
* another one.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun EventBubble(
line: Session.Line,
state: ChatEvents.State,
myUserId: Long,
/** A member's name as this phone knows it. */
nameOf: (Long) -> String,
onAnswer: (String) -> Unit,
onAddToCalendar: () -> Unit,
onLongPress: () -> Unit,
) {
val event = state.event
val mine = state.answers[myUserId]?.r
val shape = RoundedCornerShape(Kit.bubbleRadius)
val whenLine = remember(event) { EventText.whenLine(event) }
val theirZone = remember(event) { EventText.organizerLine(event) }
Column(
modifier =
Modifier
.fillMaxWidth(0.86f)
.clip(shape)
.background(MaterialTheme.colorScheme.surface)
.border(Kit.hairline, Portal.rule(), shape)
.combinedClickable(onClick = {}, onLongClick = onLongPress)
.padding(Kit.bubblePadding)
.testTag("event-${event.id}"),
verticalArrangement = Arrangement.spacedBy(Space.xs),
) {
Text("Event", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
// §6.32: a cancelled event's card reads "Cancelled: <title>".
Text(
if (event.cancelled) "Cancelled: ${event.shownTitle}" else event.shownTitle,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
color = if (event.cancelled) MaterialTheme.colorScheme.error else androidx.compose.ui.graphics.Color.Unspecified,
modifier = Modifier.testTag(if (event.cancelled) "event-cancelled" else "event-title"),
)
Text(whenLine, fontSize = 14.sp, modifier = Modifier.testTag("event-when"))
theirZone?.let {
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("event-their-time"))
}
event.shownPlace?.let { Text(it, fontSize = 14.sp, modifier = Modifier.testTag("event-place")) }
event.shownNote?.let { Text(it, fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("event-note")) }
Text(
when (state.organizer) {
myUserId -> "You organized this"
// Only a backup missing its organizer leaves this unknown,
// and then nobody can change the event.
0L -> "Organizer not known"
else -> "Organized by ${nameOf(state.organizer)}"
},
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (!event.cancelled) {
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs), modifier = Modifier.fillMaxWidth()) {
ANSWERS.forEach { (answer, label) ->
KitChip(
text = label,
selected = mine == answer,
onClick = { if (mine != answer) onAnswer(answer) },
modifier =
Modifier
.weight(1f)
.semantics { contentDescription = if (mine == answer) "$label, your answer" else label },
tag = "rsvp-$answer",
)
}
}
Text(
"Everyone in this chat sees your answer.",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.testTag("rsvp-visibility"),
)
}
AnswerLines(state, myUserId, nameOf)
if (!event.cancelled && EventText.calendarSpan(event) != null) {
KitSecondaryButton(
text = "Add to calendar",
onClick = onAddToCalendar,
modifier = Modifier.fillMaxWidth(),
tag = "event-add-to-calendar",
)
}
}
}
/** "Going 2: Sam, you", one line per answer anybody gave. */
@Composable
private fun AnswerLines(
state: ChatEvents.State,
myUserId: Long,
nameOf: (Long) -> String,
) {
val lines =
ANSWERS.mapNotNull { (answer, label) ->
val who = state.who(answer)
if (who.isEmpty()) {
null
} else {
"$label ${who.size}: " + who.joinToString(", ") { if (it == myUserId) "you" else nameOf(it) }
}
}
if (lines.isEmpty()) {
Text("No answers yet.", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.testTag("rsvp-none"))
} else {
lines.forEach { Text(it, fontSize = 12.sp, modifier = Modifier.testTag("rsvp-line")) }
}
}
/**
* The system's own add-event sheet (§6.32). Nothing is read from the
* calendar and no permission is asked for: the person sees the event in their
* calendar app and decides there whether to save it.
*/
object EventCalendar {
fun intent(event: Content.Event): Intent? {
val span = EventText.calendarSpan(event) ?: return null
val intent =
Intent(Intent.ACTION_INSERT)
.setData(CalendarContract.Events.CONTENT_URI)
.putExtra(CalendarContract.Events.TITLE, event.shownTitle)
.putExtra(CalendarContract.EXTRA_EVENT_BEGIN_TIME, span.beginMillis)
.putExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, span.allDay)
span.endMillis?.let { intent.putExtra(CalendarContract.EXTRA_EVENT_END_TIME, it) }
span.zone?.let { intent.putExtra(CalendarContract.Events.EVENT_TIMEZONE, it) }
event.shownPlace?.let { intent.putExtra(CalendarContract.Events.EVENT_LOCATION, it) }
event.shownNote?.let { intent.putExtra(CalendarContract.Events.DESCRIPTION, it) }
return intent
}
/** Hand the event to the calendar app. False when the phone has none that takes it. */
fun open(
context: Context,
event: Content.Event,
): Boolean {
val intent = intent(event) ?: return false
return try {
context.startActivity(intent)
true
} catch (e: ActivityNotFoundException) {
false
}
}
}
/**
* Make an event, or change one this account organized. A timed event carries
* this phone's zone, so a reader elsewhere can be told the organizer's time.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EventEditorDialog(
initial: Content.Event?,
onDismiss: () -> Unit,
onSave: (Content.Event) -> Unit,
) {
val zone = ZoneId.systemDefault()
val startAt = initial?.start?.let { java.time.Instant.ofEpochSecond(it).atZone(zone) }
val endAt = initial?.end?.let { java.time.Instant.ofEpochSecond(it).atZone(zone) }
val initialDay = initial?.day?.let { runCatching { LocalDate.parse(it) }.getOrNull() }
var title by remember { mutableStateOf(initial?.title.orEmpty()) }
var allDay by remember { mutableStateOf(initial?.allDay == true) }
var day by remember { mutableStateOf(startAt?.toLocalDate() ?: initialDay ?: LocalDate.now().plusDays(1)) }
var time by remember { mutableStateOf(startAt?.toLocalTime()?.withSecond(0)?.withNano(0) ?: LocalTime.of(18, 0)) }
var hasEnd by remember {
mutableStateOf(endAt != null || (initial?.days ?: 1) > 1)
}
var endDay by remember {
mutableStateOf(endAt?.toLocalDate() ?: initialDay?.plusDays(((initial?.days ?: 1) - 1).toLong()) ?: day)
}
var endTime by remember { mutableStateOf(endAt?.toLocalTime()?.withSecond(0)?.withNano(0) ?: time.plusHours(2)) }
var place by remember { mutableStateOf(initial?.place.orEmpty()) }
var note by remember { mutableStateOf(initial?.note.orEmpty()) }
var picking by remember { mutableStateOf<String?>(null) }
val startMoment = day.atTime(time).atZone(zone).toEpochSecond()
val endMoment = if (allDay) null else endDay.atTime(endTime).atZone(zone).toEpochSecond()
val endsBeforeItStarts =
hasEnd && if (allDay) endDay.isBefore(day) else (endMoment ?: startMoment) < startMoment
val ready = title.isNotBlank() && !endsBeforeItStarts
val dayFormat = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (initial == null) "New event" else "Change event") },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()).testTag("event-editor"),
verticalArrangement = Arrangement.spacedBy(Space.sm),
) {
OutlinedTextField(
value = title,
onValueChange = { title = it.take(Content.Event.MAX_TITLE) },
label = { Text("What") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("event-title-field"),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = allDay, onCheckedChange = { allDay = it }, modifier = Modifier.testTag("event-all-day"))
Spacer(Modifier.width(Space.sm))
Text("All day", fontSize = 14.sp)
}
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
TextButton(onClick = { picking = "day" }, modifier = Modifier.testTag("event-day")) {
Text(day.format(dayFormat))
}
if (!allDay) {
TextButton(onClick = { picking = "time" }, modifier = Modifier.testTag("event-time")) {
Text(time.format(timeFormat))
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = hasEnd, onCheckedChange = { hasEnd = it }, modifier = Modifier.testTag("event-has-end"))
Spacer(Modifier.width(Space.sm))
Text(if (allDay) "More than one day" else "Add an end", fontSize = 14.sp)
}
if (hasEnd) {
Row(horizontalArrangement = Arrangement.spacedBy(Space.xs)) {
TextButton(onClick = { picking = "endDay" }, modifier = Modifier.testTag("event-end-day")) {
Text(endDay.format(dayFormat))
}
if (!allDay) {
TextButton(onClick = { picking = "endTime" }, modifier = Modifier.testTag("event-end-time")) {
Text(endTime.format(timeFormat))
}
}
}
if (endsBeforeItStarts) {
Text("It ends before it starts.", fontSize = 12.sp, color = MaterialTheme.colorScheme.error)
}
}
OutlinedTextField(
value = place,
onValueChange = { place = it.take(Content.Event.MAX_PLACE) },
label = { Text("Where (optional)") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("event-place-field"),
)
OutlinedTextField(
value = note,
onValueChange = { note = it.take(Content.Event.MAX_NOTE) },
label = { Text("Note (optional)") },
minLines = 2,
modifier = Modifier.fillMaxWidth().testTag("event-note-field"),
)
Text(
"Everyone in this chat gets it, and sees who answers what. A place is " +
"shown as written; nothing looks it up.",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(
enabled = ready,
onClick = {
val days = (endDay.toEpochDay() - day.toEpochDay() + 1).toInt()
onSave(
Content.Event(
id = initial?.id ?: java.util.UUID.randomUUID().toString(),
rev = initial?.rev ?: 1,
title = title.trim(),
start = if (allDay) null else startMoment,
end = if (!allDay && hasEnd) endMoment else null,
day = if (allDay) day.toString() else null,
days = if (allDay && hasEnd) Content.Event.clampDays(days.toLong()) else null,
tz = if (allDay) null else zone.id,
place = place.trim().takeIf { it.isNotEmpty() },
note = note.trim().takeIf { it.isNotEmpty() },
),
)
},
modifier = Modifier.testTag("event-save"),
) { Text(if (initial == null) "Send" else "Send change") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
when (picking) {
"day", "endDay" -> {
val current = if (picking == "day") day else endDay
val state = rememberDatePickerState(initialSelectedDateMillis = current.toEpochDay() * 86_400_000L)
DatePickerDialog(
onDismissRequest = { picking = null },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let {
val chosen = LocalDate.ofEpochDay(it / 86_400_000L)
if (picking == "day") {
// Keep a following end the same distance away.
val shift = chosen.toEpochDay() - day.toEpochDay()
day = chosen
endDay = endDay.plusDays(shift)
} else {
endDay = chosen
}
}
picking = null
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = { picking = null }) { Text("Cancel") } },
) { DatePicker(state = state) }
}
"time", "endTime" -> {
val current = if (picking == "time") time else endTime
val state =
rememberTimePickerState(
initialHour = current.hour,
initialMinute = current.minute,
is24Hour = android.text.format.DateFormat.is24HourFormat(LocalContext.current),
)
AlertDialog(
onDismissRequest = { picking = null },
text = { TimePicker(state = state) },
confirmButton = {
TextButton(onClick = {
val chosen = LocalTime.of(state.hour, state.minute)
if (picking == "time") time = chosen else endTime = chosen
picking = null
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = { picking = null }) { Text("Cancel") } },
)
}
else -> Unit
}
}