diff --git a/CHANGELOG.md b/CHANGELOG.md index e094fdc..dd2f610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). - **Announcements can be scheduled with a date picker.** Admin only. The start and expiry wanted a hand-typed `2026-12-31T00:00:00Z`, where one typo meant the server rejected the whole announcement. Both are now a date picker and a - time field, as is the expiry on an invite code. + time picker, as is the expiry on an invite code. - **Announcements can be set to show on the login page.** Admin only. The server has supported this for a long time and the setting simply wasn't on @@ -32,6 +32,15 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). screens down and left the search field scrolled off above its own results. Searching also waits for a pause in typing now, instead of asking the server again on every keystroke. +- **Times are picked from a clock instead of typed.** Front history entries + and admin schedules had separate hour and minute boxes with an AM/PM + toggle, and tapping one brought up the full keyboard. The time is now a + button next to the date that opens the standard time picker, which follows + the phone's 12/24 hour setting. +- **The front entry member list is shorter.** Adding or editing a front + entry listed every member before you could reach the times. It now shows + the first five plus anyone already picked, with "Show all" to expand. + Searching or filtering by group still shows every match. - **A banner image for your system.** Settings > Profile gains a banner beside the avatar, the same wide image a member can have, with the same cropper. Needs a server running 1.6.0 or later. diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt index 93e571e..34ea9b0 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/admin/AdminPanelScreen.kt @@ -31,7 +31,7 @@ import systems.lupine.sheaf.data.model.AnnouncementRead import systems.lupine.sheaf.data.model.AnnouncementUpdate import systems.lupine.sheaf.data.model.InviteCodeRead import systems.lupine.sheaf.ui.components.ErrorBanner -import systems.lupine.sheaf.ui.components.TimeInputRow +import systems.lupine.sheaf.ui.components.TimePickerButton import systems.lupine.sheaf.ui.components.datePickerDate import systems.lupine.sheaf.ui.components.datePickerMillis import systems.lupine.sheaf.ui.components.SectionHeader @@ -700,7 +700,7 @@ private fun AnnouncementDialog( } /** - * An optional point in time, as a checkbox plus a date button and a time row. + * An optional point in time, as a checkbox plus a date button and a time button. * * Replaces a free-text field that wanted a hand-typed `2026-12-31T00:00:00Z`: * one typo and the server rejected the whole announcement, and getting it right @@ -766,15 +766,18 @@ private fun ScheduleField( ) } if (local != null) { - OutlinedButton(onClick = { showDatePicker = true }, modifier = Modifier.fillMaxWidth()) { - Text(local.toLocalDate().format(DateTimeFormatter.ofPattern("MMM d, yyyy"))) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { showDatePicker = true }, modifier = Modifier.weight(1f)) { + Text(local.toLocalDate().format(DateTimeFormatter.ofPattern("MMM d, yyyy"))) + } + TimePickerButton( + time = local.toLocalTime(), + onTimeChange = { t -> + onChange(LocalDateTime.of(local.toLocalDate(), t).atZone(zone).toInstant()) + }, + modifier = Modifier.weight(1f), + ) } - TimeInputRow( - time = local.toLocalTime(), - onTimeChange = { time -> - onChange(LocalDateTime.of(local.toLocalDate(), time).atZone(zone).toInstant()) - }, - ) } } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimeInputRow.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimeInputRow.kt deleted file mode 100644 index 4f66e30..0000000 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimeInputRow.kt +++ /dev/null @@ -1,95 +0,0 @@ -package systems.lupine.sheaf.ui.components - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SegmentedButton -import androidx.compose.material3.SegmentedButtonDefaults -import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Text -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.unit.dp -import java.time.LocalTime -import java.time.format.DateTimeFormatter - -/** - * Hour / minute / AM-PM, as a row of fields. - * - * Shared so that every place in the app asking for a time of day asks for it - * the same way: front history entries, and the schedule on a server - * announcement. Pair it with a `DatePickerDialog` seeded through - * [datePickerMillis] to build a full timestamp. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TimeInputRow(time: LocalTime, onTimeChange: (LocalTime) -> Unit) { - // These must NOT be keyed on `time`: every accepted keystroke calls - // onTimeChange, which updates the parent `time`, which would re-key the - // remember, replace this text state, and snap the cursor back to the - // start, making the field impossible to type into. The fields are the - // source of truth while editing and `time` only ever changes via them. - var hourText by remember { mutableStateOf(time.format(DateTimeFormatter.ofPattern("h"))) } - var minuteText by remember { mutableStateOf(time.format(DateTimeFormatter.ofPattern("mm"))) } - var isPm by remember { mutableStateOf(time.hour >= 12) } - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - OutlinedTextField( - value = hourText, - onValueChange = { v -> - if (v.length <= 2 && v.all { it.isDigit() }) { - hourText = v - val h = v.toIntOrNull() ?: return@OutlinedTextField - if (h in 1..12) { - val hour24 = if (isPm) { if (h == 12) 12 else h + 12 } else { if (h == 12) 0 else h } - onTimeChange(time.withHour(hour24).withMinute(minuteText.toIntOrNull() ?: time.minute)) - } - } - }, - label = { Text("Hour") }, - modifier = Modifier.weight(1f), - singleLine = true, - ) - Text(":", style = MaterialTheme.typography.titleMedium) - OutlinedTextField( - value = minuteText, - onValueChange = { v -> - if (v.length <= 2 && v.all { it.isDigit() }) { - minuteText = v - val m = v.toIntOrNull() ?: return@OutlinedTextField - if (m in 0..59) onTimeChange(time.withMinute(m)) - } - }, - label = { Text("Min") }, - modifier = Modifier.weight(1f), - singleLine = true, - ) - SingleChoiceSegmentedButtonRow(modifier = Modifier.weight(1f)) { - SegmentedButton( - selected = !isPm, - onClick = { - isPm = false - val h = if (time.hour >= 12) time.hour - 12 else time.hour - onTimeChange(time.withHour(if (h == 0) 0 else h)) - }, - shape = SegmentedButtonDefaults.itemShape(index = 0, count = 2), - ) { Text("AM") } - SegmentedButton( - selected = isPm, - onClick = { - isPm = true - val h = if (time.hour < 12) time.hour + 12 else time.hour - onTimeChange(time.withHour(h)) - }, - shape = SegmentedButtonDefaults.itemShape(index = 1, count = 2), - ) { Text("PM") } - } - } -} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimePickerButton.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimePickerButton.kt new file mode 100644 index 0000000..50bef2e --- /dev/null +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimePickerButton.kt @@ -0,0 +1,50 @@ +package systems.lupine.sheaf.ui.components + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +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.Modifier +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** + * A button showing a time of day that opens the Material time picker. + * + * Shared so that every place in the app asking for a time of day asks for it + * the same way: front history entries, and the schedule on a server + * announcement. Pair it with a `DatePickerDialog` seeded through + * [datePickerMillis] to build a full timestamp. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimePickerButton(time: LocalTime, onTimeChange: (LocalTime) -> Unit, modifier: Modifier = Modifier) { + var open by remember { mutableStateOf(false) } + + if (open) { + val state = rememberTimePickerState(initialHour = time.hour, initialMinute = time.minute) + TimePickerDialog( + onDismissRequest = { open = false }, + confirmButton = { + TextButton(onClick = { + onTimeChange(LocalTime.of(state.hour, state.minute)) + open = false + }) { Text("OK") } + }, + dismissButton = { TextButton(onClick = { open = false }) { Text("Cancel") } }, + title = { Text("Select time") }, + ) { TimePicker(state = state) } + } + + OutlinedButton(onClick = { open = true }, modifier = modifier) { + Text(time.format(DateTimeFormatter.ofPattern("h:mm a"))) + } +} diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt index 135afee..2121b12 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/history/HistoryScreen.kt @@ -792,6 +792,7 @@ private fun FrontEntrySheet( ) } else { var memberQuery by remember { mutableStateOf("") } + var showAllMembers by remember { mutableStateOf(false) } if (groups.isNotEmpty()) { GroupFilterChips( groups = groups, @@ -818,7 +819,12 @@ private fun FrontEntrySheet( color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { - filteredMembers.forEach { member -> + val filtering = memberQuery.isNotBlank() || activeGroupId != null + val collapsed = !showAllMembers && !filtering && filteredMembers.size > 5 + val visibleMembers = if (collapsed) { + filteredMembers.filterIndexed { i, m -> i < 5 || m.id in selectedIds } + } else filteredMembers + visibleMembers.forEach { member -> Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -835,6 +841,11 @@ private fun FrontEntrySheet( Text(member.displayNameWithEmoji, style = MaterialTheme.typography.bodyMedium) } } + if (!filtering && filteredMembers.size > 5) { + TextButton(onClick = { showAllMembers = !showAllMembers }) { + Text(if (showAllMembers) "Show less" else "Show all (${filteredMembers.size})") + } + } } } @@ -845,8 +856,8 @@ private fun FrontEntrySheet( OutlinedButton(onClick = { showStartDatePicker = true }, modifier = Modifier.weight(1f)) { Text(startDate.format(DateTimeFormatter.ofPattern("MMM d, yyyy"))) } + TimePickerButton(time = startTime, onTimeChange = { startTime = it }, modifier = Modifier.weight(1f)) } - TimeInputRow(time = startTime, onTimeChange = { startTime = it }) HorizontalDivider() @@ -861,8 +872,8 @@ private fun FrontEntrySheet( OutlinedButton(onClick = { showEndDatePicker = true }, modifier = Modifier.weight(1f)) { Text(endDate.format(DateTimeFormatter.ofPattern("MMM d, yyyy"))) } + TimePickerButton(time = endTime, onTimeChange = { endTime = it }, modifier = Modifier.weight(1f)) } - TimeInputRow(time = endTime, onTimeChange = { endTime = it }) } HorizontalDivider()