DRY pass #2, batch 6, F19: Sheet, the bottom sheet with its title (#5372)

EditorPickers.kt Sheet(title, onDismiss, modifier, verticalArrangement):
a ModalBottomSheet holding a full-width column with the screen margin,
clear of the navigation bar, under SheetTitle. The filter, tag picker,
reminder and share sheets each built that. A site's extra (a scroll, ime
padding, bottom space) is now applied after the navigation-bar inset
rather than before it. The total inset is the same either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:53:51 -04:00
co-authored by Claude Opus 5.5
parent f2fac1674b
commit 709ccca64c
3 changed files with 167 additions and 167 deletions
@@ -1,19 +1,16 @@
package com.fabledsword.inkwell.ui package com.fabledsword.inkwell.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -115,53 +112,46 @@ internal fun FilterSheet(
onChange: (BoardFilters) -> Unit, onChange: (BoardFilters) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
ModalBottomSheet(onDismissRequest = onDismiss) { Sheet(
Column( title = R.string.filters,
modifier = onDismiss = onDismiss,
Modifier modifier = Modifier.verticalScroll(rememberScrollState()),
.fillMaxWidth() ) {
.padding(horizontal = 16.dp) FilterHeading(R.string.filters_show)
.navigationBarsPadding() FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
.verticalScroll(rememberScrollState()), FilterChip(
) { selected = filters.hasAttachment,
SheetTitle(R.string.filters) onClick = { onChange(filters.copy(hasAttachment = !filters.hasAttachment)) },
label = { Text(stringResource(R.string.filters_has_attachment)) },
)
FilterChip(
selected = filters.sharedWithMe,
onClick = { onChange(filters.copy(sharedWithMe = !filters.sharedWithMe)) },
label = { Text(stringResource(R.string.filters_shared_with_me)) },
)
}
FilterHeading(R.string.filters_show) // No heading and no empty row when there are no tags yet: a section that
// can only ever say "none" is noise in a sheet this small.
if (labels.isNotEmpty()) {
FilterHeading(R.string.filters_tags)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
FilterChip( labels.forEach { label ->
selected = filters.hasAttachment, FilterChip(
onClick = { onChange(filters.copy(hasAttachment = !filters.hasAttachment)) }, selected = label.id in filters.labelIds,
label = { Text(stringResource(R.string.filters_has_attachment)) }, onClick = { onChange(filters.toggleLabel(label.id)) },
) label = { Text(label.name) },
FilterChip( )
selected = filters.sharedWithMe,
onClick = { onChange(filters.copy(sharedWithMe = !filters.sharedWithMe)) },
label = { Text(stringResource(R.string.filters_shared_with_me)) },
)
}
// No heading and no empty row when there are no tags yet: a section that
// can only ever say "none" is noise in a sheet this small.
if (labels.isNotEmpty()) {
FilterHeading(R.string.filters_tags)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
labels.forEach { label ->
FilterChip(
selected = label.id in filters.labelIds,
onClick = { onChange(filters.toggleLabel(label.id)) },
label = { Text(label.name) },
)
}
} }
} }
}
if (filters.count > 0) { if (filters.count > 0) {
TextButton( TextButton(
onClick = { onChange(BoardFilters()) }, onClick = { onChange(BoardFilters()) },
modifier = Modifier.align(Alignment.End), modifier = Modifier.align(Alignment.End),
) { ) {
Text(stringResource(R.string.filters_clear)) Text(stringResource(R.string.filters_clear))
}
} }
} }
} }
@@ -1,8 +1,10 @@
package com.fabledsword.inkwell.ui package com.fabledsword.inkwell.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
@@ -85,69 +87,62 @@ fun LabelSheet(
.map { it.id } .map { it.id }
.toSet() .toSet()
ModalBottomSheet(onDismissRequest = onDismiss) { Sheet(
Column( title = R.string.label_picker_title,
modifier = onDismiss = onDismiss,
Modifier modifier = Modifier.imePadding(),
.fillMaxWidth() ) {
.padding(horizontal = 16.dp) PlainTextField(
.imePadding() value = typed,
.navigationBarsPadding(), onValueChange = { typed = it },
) { hint = R.string.label_new_hint,
SheetTitle(R.string.label_picker_title) singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions =
KeyboardActions(onDone = {
onAction(EditorAction.CreateLabel(typed))
typed = ""
}),
)
PlainTextField( // Capped rather than unbounded: a sheet that grows past the screen
value = typed, // makes its own scroll fight the sheet's drag gesture.
onValueChange = { typed = it }, LazyColumn(modifier = Modifier.heightIn(max = LABEL_LIST_MAX_HEIGHT)) {
hint = R.string.label_new_hint, items(items = labels, key = { it.id }) { label ->
singleLine = true, val fromTag = label.id in viaTag
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), Row(verticalAlignment = Alignment.CenterVertically) {
keyboardActions = Checkbox(
KeyboardActions(onDone = { checked = fromTag || label.id in manual,
onAction(EditorAction.CreateLabel(typed)) enabled = !fromTag,
typed = "" onCheckedChange = { on ->
}), val next = if (on) manual + label.id else manual - label.id
) onAction(EditorAction.SetLabels(next.toList()))
},
// Capped rather than unbounded: a sheet that grows past the screen )
// makes its own scroll fight the sheet's drag gesture. Text(
LazyColumn(modifier = Modifier.heightIn(max = LABEL_LIST_MAX_HEIGHT)) { text = label.name,
items(items = labels, key = { it.id }) { label -> style = MaterialTheme.typography.bodyLarge,
val fromTag = label.id in viaTag modifier = Modifier.padding(start = 4.dp),
Row(verticalAlignment = Alignment.CenterVertically) { )
Checkbox( if (fromTag) {
checked = fromTag || label.id in manual,
enabled = !fromTag,
onCheckedChange = { on ->
val next = if (on) manual + label.id else manual - label.id
onAction(EditorAction.SetLabels(next.toList()))
},
)
Text( Text(
text = label.name, text = stringResource(R.string.label_from_tag),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(start = 4.dp), color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
) )
if (fromTag) {
Text(
text = stringResource(R.string.label_from_tag),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
} }
} }
} }
}
if (labels.isEmpty()) { if (labels.isEmpty()) {
Text( Text(
text = stringResource(R.string.label_none_body), text = stringResource(R.string.label_none_body),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp), modifier = Modifier.padding(vertical = 16.dp),
) )
}
} }
} }
} }
@@ -170,60 +165,53 @@ fun ReminderSheet(
) { ) {
var exact by remember { mutableStateOf(false) } var exact by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) { Sheet(
Column( title = R.string.reminder_title,
modifier = onDismiss = onDismiss,
Modifier ) {
.fillMaxWidth() reminderPresets().forEach { (labelRes, at) ->
.padding(horizontal = 16.dp)
.navigationBarsPadding(),
) {
SheetTitle(R.string.reminder_title)
reminderPresets().forEach { (labelRes, at) ->
Text(
text = "${stringResource(labelRes)} · ${formatInstant(rfc3339(at))}",
style = MaterialTheme.typography.bodyLarge,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.SetReminder(rfc3339(at)))
onDismiss()
}.padding(vertical = 12.dp),
)
}
Text( Text(
text = stringResource(R.string.reminder_pick), text = "${stringResource(labelRes)} · ${formatInstant(rfc3339(at))}",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { exact = true } .clickable {
.padding(vertical = 12.dp), onAction(EditorAction.SetReminder(rfc3339(at)))
onDismiss()
}.padding(vertical = 12.dp),
) )
}
// Repeat only appears once there IS a reminder — a recurrence rule on Text(
// a note with no time to recur from is a setting that does nothing. text = stringResource(R.string.reminder_pick),
if (note.remindAt != null) { style = MaterialTheme.typography.bodyLarge,
RecurrenceChips( modifier =
current = note.recurrence, Modifier
onPick = { onAction(EditorAction.SetRecurrence(it)) }, .fillMaxWidth()
) .clickable { exact = true }
Text( .padding(vertical = 12.dp),
text = stringResource(R.string.reminder_clear), )
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error, // Repeat only appears once there IS a reminder — a recurrence rule on
modifier = // a note with no time to recur from is a setting that does nothing.
Modifier if (note.remindAt != null) {
.fillMaxWidth() RecurrenceChips(
.clickable { current = note.recurrence,
onAction(EditorAction.ClearReminder) onPick = { onAction(EditorAction.SetRecurrence(it)) },
onDismiss() )
}.padding(vertical = 12.dp), Text(
) text = stringResource(R.string.reminder_clear),
} style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error,
modifier =
Modifier
.fillMaxWidth()
.clickable {
onAction(EditorAction.ClearReminder)
onDismiss()
}.padding(vertical = 12.dp),
)
} }
} }
@@ -337,6 +325,36 @@ private fun RecurrenceChips(
} }
} }
/**
* A bottom sheet with its title: full width, the screen's side margin, and clear of
* the navigation bar. [modifier] adds to that, after the inset (ime padding, a
* scroll, extra space at the bottom).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun Sheet(
@StringRes title: Int,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
content: @Composable ColumnScope.() -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.navigationBarsPadding()
.then(modifier),
verticalArrangement = verticalArrangement,
) {
SheetTitle(title)
content()
}
}
}
@Composable @Composable
internal fun SheetTitle(labelRes: Int) { internal fun SheetTitle(labelRes: Int) {
Text( Text(
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -18,7 +17,6 @@ import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -75,23 +73,17 @@ fun ShareSheet(
onUnshare: (shareId: String) -> Unit, onUnshare: (shareId: String) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
ModalBottomSheet(onDismissRequest = onDismiss) { Sheet(
Column( title = R.string.share_title,
modifier = onDismiss = onDismiss,
Modifier modifier = Modifier.padding(bottom = 16.dp),
.fillMaxWidth() verticalArrangement = Arrangement.spacedBy(8.dp),
.padding(horizontal = 16.dp) ) {
.padding(bottom = 16.dp) when {
.navigationBarsPadding(), state.loading -> Muted(stringResource(R.string.share_loading))
verticalArrangement = Arrangement.spacedBy(8.dp), state.needsServer -> Muted(stringResource(R.string.share_needs_server))
) { state.loadError != null -> ErrorText(state.loadError)
SheetTitle(R.string.share_title) else -> ShareBody(state, onShare, onUnshare)
when {
state.loading -> Muted(stringResource(R.string.share_loading))
state.needsServer -> Muted(stringResource(R.string.share_needs_server))
state.loadError != null -> ErrorText(state.loadError)
else -> ShareBody(state, onShare, onUnshare)
}
} }
} }
} }