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>
405 lines
18 KiB
Kotlin
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
|
|
}
|
|
}
|