From 74882c1a996b973a278517f40ceec34b133ba553 Mon Sep 17 00:00:00 2001 From: Nocturnal <241546228+fullynocturnal@users.noreply.github.com> Date: Sat, 26 Sep 2026 02:06:03 +0000 Subject: [PATCH 1/2] feat(history): collapse the front entry member list and use a time picker The add/edit front entry sheet now shows the first 5 members plus anyone already selected, with a "Show all" toggle to expand the list. Searching or picking a group filter still shows every match. The typed hour/minute fields and AM/PM toggle are replaced by a button next to the date that opens the Material 3 time picker. --- .../lupine/sheaf/ui/history/HistoryScreen.kt | 112 ++++++++---------- 1 file changed, 47 insertions(+), 65 deletions(-) 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 37b8d9c..c46a0ed 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 @@ -731,6 +731,8 @@ private fun FrontEntrySheet( var showStartDatePicker by remember { mutableStateOf(false) } var showEndDatePicker by remember { mutableStateOf(false) } + var showStartTimePicker by remember { mutableStateOf(false) } + var showEndTimePicker by remember { mutableStateOf(false) } if (showStartDatePicker) { val pickerState = rememberDatePickerState( @@ -768,6 +770,22 @@ private fun FrontEntrySheet( ) { DatePicker(state = pickerState) } } + if (showStartTimePicker) { + FrontTimePickerDialog( + time = startTime, + onDismiss = { showStartTimePicker = false }, + onConfirm = { startTime = it; showStartTimePicker = false }, + ) + } + + if (showEndTimePicker) { + FrontTimePickerDialog( + time = endTime, + onDismiss = { showEndTimePicker = false }, + onConfirm = { endTime = it; showEndTimePicker = false }, + ) + } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { Column( @@ -792,6 +810,7 @@ private fun FrontEntrySheet( ) } else { var memberQuery by remember { mutableStateOf("") } + var showAllMembers by remember { mutableStateOf(false) } if (groups.isNotEmpty()) { GroupFilterChips( groups = groups, @@ -818,7 +837,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 +859,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 +874,10 @@ private fun FrontEntrySheet( OutlinedButton(onClick = { showStartDatePicker = true }, modifier = Modifier.weight(1f)) { Text(startDate.format(DateTimeFormatter.ofPattern("MMM d, yyyy"))) } + OutlinedButton(onClick = { showStartTimePicker = true }, modifier = Modifier.weight(1f)) { + Text(startTime.format(DateTimeFormatter.ofPattern("h:mm a"))) + } } - TimeInputRow(time = startTime, onTimeChange = { startTime = it }) HorizontalDivider() @@ -861,8 +892,10 @@ private fun FrontEntrySheet( OutlinedButton(onClick = { showEndDatePicker = true }, modifier = Modifier.weight(1f)) { Text(endDate.format(DateTimeFormatter.ofPattern("MMM d, yyyy"))) } + OutlinedButton(onClick = { showEndTimePicker = true }, modifier = Modifier.weight(1f)) { + Text(endTime.format(DateTimeFormatter.ofPattern("h:mm a"))) + } } - TimeInputRow(time = endTime, onTimeChange = { endTime = it }) } HorizontalDivider() @@ -913,69 +946,18 @@ private fun FrontEntrySheet( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private 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") } - } - } +private fun FrontTimePickerDialog(time: LocalTime, onDismiss: () -> Unit, onConfirm: (LocalTime) -> Unit) { + val state = rememberTimePickerState(initialHour = time.hour, initialMinute = time.minute) + TimePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { onConfirm(LocalTime.of(state.hour, state.minute)) }) { Text("OK") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + title = { Text("Select time") }, + ) { TimePicker(state = state) } } // ── Helpers ─────────────────────────────────────────────────────────────────── From 69ce56494a33f1eceee45167a9a8f3efd65b44b5 Mon Sep 17 00:00:00 2001 From: Nocturnal <241546228+fullynocturnal@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:09:23 +0000 Subject: [PATCH 2/2] refactor(components): share the time picker between history and admin #94 made TimeInputRow shared so every screen asks for a time of day the same way. Now that history uses the Material time picker, move it into ui/components as TimePickerButton and switch the admin schedule fields (announcement start and expiry, invite code expiry) over to it, so both screens still match. TimeInputRow has no callers left and is removed. Also adds changelog entries for the time picker and the shorter member list, and updates #94's entry to say time picker. --- CHANGELOG.md | 11 ++- .../lupine/sheaf/ui/admin/AdminPanelScreen.kt | 23 +++-- .../sheaf/ui/components/TimeInputRow.kt | 95 ------------------- .../sheaf/ui/components/TimePickerButton.kt | 50 ++++++++++ .../lupine/sheaf/ui/history/HistoryScreen.kt | 40 +------- 5 files changed, 75 insertions(+), 144 deletions(-) delete mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimeInputRow.kt create mode 100644 sheaf/app/src/main/java/systems/lupine/sheaf/ui/components/TimePickerButton.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index b5fe50f..4ceb8af 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 share view can show everyone set to Public.** Instead of adding members one at a time, turn on "Show everyone set to Public" and the view follows each member's privacy setting from then on. The list you picked by hand is 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 c46a0ed..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 @@ -731,8 +731,6 @@ private fun FrontEntrySheet( var showStartDatePicker by remember { mutableStateOf(false) } var showEndDatePicker by remember { mutableStateOf(false) } - var showStartTimePicker by remember { mutableStateOf(false) } - var showEndTimePicker by remember { mutableStateOf(false) } if (showStartDatePicker) { val pickerState = rememberDatePickerState( @@ -770,22 +768,6 @@ private fun FrontEntrySheet( ) { DatePicker(state = pickerState) } } - if (showStartTimePicker) { - FrontTimePickerDialog( - time = startTime, - onDismiss = { showStartTimePicker = false }, - onConfirm = { startTime = it; showStartTimePicker = false }, - ) - } - - if (showEndTimePicker) { - FrontTimePickerDialog( - time = endTime, - onDismiss = { showEndTimePicker = false }, - onConfirm = { endTime = it; showEndTimePicker = false }, - ) - } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { Column( @@ -874,9 +856,7 @@ private fun FrontEntrySheet( OutlinedButton(onClick = { showStartDatePicker = true }, modifier = Modifier.weight(1f)) { Text(startDate.format(DateTimeFormatter.ofPattern("MMM d, yyyy"))) } - OutlinedButton(onClick = { showStartTimePicker = true }, modifier = Modifier.weight(1f)) { - Text(startTime.format(DateTimeFormatter.ofPattern("h:mm a"))) - } + TimePickerButton(time = startTime, onTimeChange = { startTime = it }, modifier = Modifier.weight(1f)) } HorizontalDivider() @@ -892,9 +872,7 @@ private fun FrontEntrySheet( OutlinedButton(onClick = { showEndDatePicker = true }, modifier = Modifier.weight(1f)) { Text(endDate.format(DateTimeFormatter.ofPattern("MMM d, yyyy"))) } - OutlinedButton(onClick = { showEndTimePicker = true }, modifier = Modifier.weight(1f)) { - Text(endTime.format(DateTimeFormatter.ofPattern("h:mm a"))) - } + TimePickerButton(time = endTime, onTimeChange = { endTime = it }, modifier = Modifier.weight(1f)) } } @@ -946,20 +924,6 @@ private fun FrontEntrySheet( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun FrontTimePickerDialog(time: LocalTime, onDismiss: () -> Unit, onConfirm: (LocalTime) -> Unit) { - val state = rememberTimePickerState(initialHour = time.hour, initialMinute = time.minute) - TimePickerDialog( - onDismissRequest = onDismiss, - confirmButton = { - TextButton(onClick = { onConfirm(LocalTime.of(state.hour, state.minute)) }) { Text("OK") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, - title = { Text("Select time") }, - ) { TimePicker(state = state) } -} - // ── Helpers ─────────────────────────────────────────────────────────────────── private val timePattern = DateTimeFormatter.ofPattern("MMM d, h:mm a")