The first version of this picker invented its own visual language — free- standing bordered cards with a hand-rolled check — which matched nothing else in the app. It is now the family's standard shape: FullScreenPicker with PickerDescription and connected GroupedRows, tonal highlight and the shared SelectedCheck. Above the rows sits a live, scaled-down Month view that changes as you pick a style. It renders the *real* grid composables rather than a drawing of them, so the preview cannot drift from what it depicts: MonthGrid, ContinuousMonthGrid, SplitMonthGrid and SplitDayPane are now internal rather than private, and the sample month runs through the same layoutMonthWeeks/layoutCalendarWeek the live views use. The month, today's position, week start, colour softening and clock format are all real; only the events are stand-ins, since a settings screen has no business querying the provider for a thumbnail. Selecting applies immediately and leaves the picker open — closing on tap would hide the very thing the screen is for. Back exits, as in the App name picker. Scaling note worth keeping: Modifier.requiredSize looks like the way to force a full-viewport measurement, but it *centres* content that overflows the incoming constraints, which left only the grid's bottom-right corner inside the clip. A layout modifier that measures at Constraints.fixed and reports the scaled size has no overflow to align and no dependence on parent alignment. PickerDescription is internal now rather than duplicated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -198,7 +198,7 @@ private fun CustomReminderEditor(
|
||||
|
||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||
@Composable
|
||||
private fun PickerDescription(text: String) {
|
||||
internal fun PickerDescription(text: String) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
|
||||
@@ -454,7 +454,7 @@ private fun MonthTopBar(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||
internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||
val locale = currentLocale()
|
||||
val days = remember(weekStart, locale) {
|
||||
(0 until 7).map { offset ->
|
||||
@@ -503,7 +503,7 @@ private const val MAX_EVENT_ROWS = 3
|
||||
private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
||||
|
||||
@Composable
|
||||
private fun MonthGrid(
|
||||
internal fun MonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
@@ -545,7 +545,7 @@ private fun MonthGrid(
|
||||
* duplication #38 asks to be rid of.
|
||||
*/
|
||||
@Composable
|
||||
private fun ContinuousMonthGrid(
|
||||
internal fun ContinuousMonthGrid(
|
||||
state: ContinuousMonthUiState.Success,
|
||||
listState: LazyListState,
|
||||
showWeekNumbers: Boolean,
|
||||
@@ -682,7 +682,7 @@ private const val SPLIT_MAX_DOTS = 3
|
||||
* the layout. The full Day view stays one tap away on the pane's date header.
|
||||
*/
|
||||
@Composable
|
||||
private fun SplitMonthGrid(
|
||||
internal fun SplitMonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
selected: LocalDate,
|
||||
showWeekNumbers: Boolean,
|
||||
@@ -812,7 +812,7 @@ private fun SplitDots(events: List<EventInstance>, dark: Boolean) {
|
||||
* the Week and Agenda headers (#37).
|
||||
*/
|
||||
@Composable
|
||||
private fun SplitDayPane(
|
||||
internal fun SplitDayPane(
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
events: List<EventInstance>,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* A live, scaled-down Month view in a given [MonthViewStyle], for the settings
|
||||
* chooser.
|
||||
*
|
||||
* It renders the *real* grid composables rather than a drawing of them, so the
|
||||
* preview cannot drift from the thing it depicts: change a cell's shape or an
|
||||
* event bar's colour and every preview follows automatically. The trick is
|
||||
* [requiredSize] — it ignores the incoming constraints, so the grid lays itself
|
||||
* out at a full phone width and a plausible viewport height, and a
|
||||
* [graphicsLayer] scale shrinks the finished layout into the card.
|
||||
*
|
||||
* The month, today's position and the week start are all real; only the events
|
||||
* are stand-ins, since the settings screen has no business querying the
|
||||
* provider for a thumbnail.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MonthStylePreview(
|
||||
style: MonthViewStyle,
|
||||
weekStart: DayOfWeek,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / VIRTUAL_HEIGHT.value
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val today = remember(zone) { Clock.System.now().toLocalDateTime(zone).date }
|
||||
val sample = remember(today, weekStart, zone) { sampleMonthState(today, weekStart, zone) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// grid's own clickables see them, and give screen readers one label
|
||||
// instead of six weeks of day cells.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure the grid at a full phone viewport but report the scaled
|
||||
// size, so the node occupies exactly what it draws.
|
||||
//
|
||||
// Modifier.requiredSize would be the obvious way to force the larger
|
||||
// measurement, but it *centres* content that overflows the incoming
|
||||
// constraints — which pushed the grid to a negative offset and left
|
||||
// only its bottom-right corner inside the clip.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = VIRTUAL_HEIGHT.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = false)
|
||||
when (style) {
|
||||
MonthViewStyle.Paged -> MonthGrid(
|
||||
state = sample.month,
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Continuous -> ContinuousMonthGrid(
|
||||
state = sample.continuous,
|
||||
listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex =
|
||||
weekIndexOf(LocalDate(today.year, today.month, 1), weekStart),
|
||||
),
|
||||
showWeekNumbers = false,
|
||||
onOpenDay = {},
|
||||
)
|
||||
MonthViewStyle.Split -> {
|
||||
SplitMonthGrid(
|
||||
state = sample.month,
|
||||
selected = today,
|
||||
showWeekNumbers = false,
|
||||
onSelectDay = {},
|
||||
)
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
SplitDayPane(
|
||||
date = today,
|
||||
today = today,
|
||||
events = sample.month.instancesByDay[today].orEmpty(),
|
||||
zone = zone,
|
||||
onOpenDay = {},
|
||||
onEventClick = {},
|
||||
onCreateEvent = {},
|
||||
modifier = Modifier.fillMaxWidth().height(SPLIT_PANE_HEIGHT),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The viewport the preview pretends to be: a phone's width by the height a
|
||||
* calendar view gets under the top bar. Scaling from a fixed height keeps the
|
||||
* three styles comparable — each shows the same slice of screen.
|
||||
*/
|
||||
private val VIRTUAL_HEIGHT = 440.dp
|
||||
private val SPLIT_PANE_HEIGHT = 170.dp
|
||||
|
||||
private class SampleMonth(
|
||||
val month: MonthUiState.Success,
|
||||
val continuous: ContinuousMonthUiState.Success,
|
||||
)
|
||||
|
||||
/**
|
||||
* A month's worth of stand-in events, laid out through the same
|
||||
* [layoutMonthWeeks] / [layoutCalendarWeek] the live views use — so the preview
|
||||
* exercises the real span, lane and overflow logic rather than approximating it.
|
||||
*
|
||||
* Colours are raw ARGB on purpose: that is what the provider hands out for an
|
||||
* event, so a token here would misrepresent what the grid actually renders.
|
||||
*/
|
||||
private fun sampleMonthState(
|
||||
today: LocalDate,
|
||||
weekStart: DayOfWeek,
|
||||
zone: TimeZone,
|
||||
): SampleMonth {
|
||||
val ym = YearMonth(today.year, today.month)
|
||||
val first = LocalDate(ym.year, ym.month, 1)
|
||||
val events = sampleEvents(first, today, zone)
|
||||
|
||||
val weeks = layoutMonthWeeks(ym, weekStart, events, zone)
|
||||
val month = MonthUiState.Success(
|
||||
month = ym,
|
||||
today = today,
|
||||
weeks = weeks,
|
||||
instancesByDay = instancesByDay(weeks.flatMap { it.days }, events, zone),
|
||||
zone = zone,
|
||||
)
|
||||
|
||||
// Enough weeks either side of today for the continuous list to fill the
|
||||
// viewport and still have somewhere to scroll.
|
||||
val centre = weekIndexOf(today, weekStart)
|
||||
val window = (centre - 8)..(centre + 8)
|
||||
val continuous = ContinuousMonthUiState.Success(
|
||||
today = today,
|
||||
weeksByIndex = window.associateWith { index ->
|
||||
val days = (0 until 7).map {
|
||||
weekStartForIndex(index, weekStart).plus(it, DateTimeUnit.DAY)
|
||||
}
|
||||
layoutCalendarWeek(days, events, zone)
|
||||
},
|
||||
weekStart = weekStart,
|
||||
)
|
||||
return SampleMonth(month, continuous)
|
||||
}
|
||||
|
||||
private val SAMPLE_COLORS = listOf(
|
||||
0xFF3F7BD4.toInt(),
|
||||
0xFFCE5B4C.toInt(),
|
||||
0xFF4E9A6A.toInt(),
|
||||
0xFF8A63C7.toInt(),
|
||||
)
|
||||
|
||||
private fun sampleEvents(
|
||||
firstOfMonth: LocalDate,
|
||||
today: LocalDate,
|
||||
zone: TimeZone,
|
||||
): List<EventInstance> {
|
||||
var id = 0L
|
||||
fun next() = ++id
|
||||
|
||||
fun timed(day: LocalDate, hour: Int, length: Int, colorIndex: Int) = EventInstance(
|
||||
instanceId = next(),
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
start = day.atTime(hour, 0).toInstant(zone),
|
||||
end = day.atTime(hour + length, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
|
||||
fun allDay(from: LocalDate, days: Int, colorIndex: Int) = EventInstance(
|
||||
instanceId = next(),
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = SAMPLE_TITLES[(id.toInt() - 1) % SAMPLE_TITLES.size],
|
||||
// All-day events sit at UTC midnights with an exclusive end.
|
||||
start = from.atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
end = from.plus(days, DateTimeUnit.DAY).atTime(0, 0).toInstant(TimeZone.UTC),
|
||||
isAllDay = true,
|
||||
color = SAMPLE_COLORS[colorIndex % SAMPLE_COLORS.size],
|
||||
location = null,
|
||||
)
|
||||
|
||||
// Spread across the month so most weeks carry something, with one multi-day
|
||||
// bar to show a span bridging cells and a busy today for the split pane.
|
||||
return buildList {
|
||||
add(allDay(firstOfMonth.plus(9, DateTimeUnit.DAY), days = 3, colorIndex = 2))
|
||||
add(timed(firstOfMonth.plus(1, DateTimeUnit.DAY), 9, 1, 0))
|
||||
add(timed(firstOfMonth.plus(4, DateTimeUnit.DAY), 14, 2, 1))
|
||||
add(timed(firstOfMonth.plus(7, DateTimeUnit.DAY), 11, 1, 3))
|
||||
add(timed(firstOfMonth.plus(15, DateTimeUnit.DAY), 10, 1, 0))
|
||||
add(timed(firstOfMonth.plus(18, DateTimeUnit.DAY), 16, 1, 2))
|
||||
add(timed(firstOfMonth.plus(22, DateTimeUnit.DAY), 8, 2, 1))
|
||||
add(timed(firstOfMonth.plus(25, DateTimeUnit.DAY), 13, 1, 3))
|
||||
// Today, so the split pane's list and the grid's dots both have content.
|
||||
add(timed(today, 9, 1, 0))
|
||||
add(timed(today, 12, 1, 1))
|
||||
add(timed(today, 15, 2, 3))
|
||||
}
|
||||
}
|
||||
|
||||
private val SAMPLE_TITLES = listOf(
|
||||
"Standup",
|
||||
"Lunch",
|
||||
"Review",
|
||||
"Gym",
|
||||
"Call",
|
||||
"Workshop",
|
||||
"Dentist",
|
||||
"Trip",
|
||||
)
|
||||
@@ -1,297 +1,109 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.selection.selectableGroup
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthStylePreview
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
|
||||
/**
|
||||
* The Month view style chooser (#38, #53). Each option carries a schematic of the
|
||||
* layout it produces rather than a bare label — the three differ in *shape*, which
|
||||
* a word like "Continuous" doesn't convey on its own.
|
||||
* The Month view style chooser (#38, #53).
|
||||
*
|
||||
* Tapping applies immediately and the picker stays open, matching the App name
|
||||
* picker: the change is worth seeing land before leaving.
|
||||
* A live, scaled-down Month view sits above the options and changes as you pick
|
||||
* one, so the choice is made by looking rather than by reading "Continuous" and
|
||||
* guessing. Selecting therefore applies immediately and leaves the picker open —
|
||||
* closing on tap would hide the very thing the screen is for. Back exits, the
|
||||
* same as the App name picker, which stays open for the same reason.
|
||||
*
|
||||
* Below the preview it is the family's standard picker shape: connected grouped
|
||||
* rows with a tonal highlight and [SelectedCheck] on the current one.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MonthViewStylePicker(
|
||||
selected: MonthViewStyle,
|
||||
weekStart: DayOfWeek,
|
||||
onSelect: (MonthViewStyle) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val options = MonthViewStyle.entries
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.settings_month_view_style),
|
||||
onDismiss = onDismiss,
|
||||
predictiveBack = true,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_month_view_style_summary),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.selectableGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
MonthViewStyle.entries.forEach { style ->
|
||||
MonthStyleOptionCard(
|
||||
style = style,
|
||||
selected = style == selected,
|
||||
onClick = { onSelect(style) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One selectable style: its schematic, name and a line on what it does. Selection
|
||||
* reads three ways over — border weight, container tint and a check — so it never
|
||||
* rests on colour alone.
|
||||
*/
|
||||
@Composable
|
||||
private fun MonthStyleOptionCard(
|
||||
style: MonthViewStyle,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
val borderColor = if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant
|
||||
}
|
||||
val containerColor = if (selected) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(containerColor)
|
||||
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = shape)
|
||||
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||
.padding(all = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
MonthStyleSchematic(style)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(style.labelRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(style.descriptionRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
// Selection indicator: a filled check when active, an empty ring otherwise.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent)
|
||||
.then(
|
||||
if (selected) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, CircleShape)
|
||||
},
|
||||
),
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.height(PREVIEW_HEIGHT),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Crossfade(
|
||||
targetState = selected,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = "month-style-preview",
|
||||
) { shown ->
|
||||
// The preview renders the real grid at phone size and scales it
|
||||
// down, so its own corners are square — the frame rounds it.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PREVIEW_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
MonthStylePreview(
|
||||
style = shown,
|
||||
weekStart = weekStart,
|
||||
height = PREVIEW_HEIGHT,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val SCHEMATIC_WIDTH = 64.dp
|
||||
private val SCHEMATIC_HEIGHT = 60.dp
|
||||
private val SCHEMATIC_SHAPE = RoundedCornerShape(10.dp)
|
||||
|
||||
/**
|
||||
* A miniature of the layout, drawn from plain boxes on theme tokens — small
|
||||
* enough to read as an icon, literal enough that the three are told apart at a
|
||||
* glance: pages sit side by side, the continuous stream runs off both edges, the
|
||||
* split style stacks a dotted grid over a list.
|
||||
*/
|
||||
@Composable
|
||||
private fun MonthStyleSchematic(style: MonthViewStyle, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(width = SCHEMATIC_WIDTH, height = SCHEMATIC_HEIGHT)
|
||||
.clip(SCHEMATIC_SHAPE)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLowest)
|
||||
.clipToBounds(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
when (style) {
|
||||
MonthViewStyle.Paged -> PagedSchematic()
|
||||
MonthViewStyle.Continuous -> ContinuousSchematic()
|
||||
MonthViewStyle.Split -> SplitSchematic()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Two pages side by side, the second peeking in — a swipe away. */
|
||||
@Composable
|
||||
private fun PagedSchematic() {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
SchematicGrid(rows = 4, modifier = Modifier.weight(1f))
|
||||
// The next page, cropped by the container — the swipe affordance.
|
||||
SchematicGrid(rows = 4, modifier = Modifier.width(20.dp), dim = true)
|
||||
}
|
||||
}
|
||||
|
||||
/** One column of weeks running off both edges: no page breaks, no repeated days. */
|
||||
@Composable
|
||||
private fun ContinuousSchematic() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
// Six rows in a 60dp box overflow deliberately: the stream is clipped top
|
||||
// and bottom rather than fitting neatly, which is the whole idea.
|
||||
repeat(6) { index ->
|
||||
SchematicWeekRow(dim = index == 0 || index == 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A squished dotted grid over the selected day's list. */
|
||||
@Composable
|
||||
private fun SplitSchematic() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
SchematicGrid(rows = 3, dotted = true)
|
||||
// The day pane: two stand-in event rows.
|
||||
repeat(2) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)),
|
||||
PickerDescription(stringResource(R.string.settings_month_view_style_summary))
|
||||
options.forEachIndexed { index, style ->
|
||||
val isSelected = style == selected
|
||||
GroupedRow(
|
||||
title = stringResource(style.labelRes),
|
||||
summary = stringResource(style.descriptionRes),
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
// Applies straight away; the preview above is the confirmation,
|
||||
// so there is nothing to dismiss for.
|
||||
onClick = { onSelect(style) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** [rows] week rows of seven cells; [dotted] shrinks the cells to event dots. */
|
||||
@Composable
|
||||
private fun SchematicGrid(
|
||||
rows: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
dim: Boolean = false,
|
||||
dotted: Boolean = false,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(if (dotted) 4.dp else 3.dp),
|
||||
) {
|
||||
repeat(rows) {
|
||||
if (dotted) SchematicDotRow(dim) else SchematicWeekRow(dim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A week as seven filled cells. */
|
||||
@Composable
|
||||
private fun SchematicWeekRow(dim: Boolean = false, modifier: Modifier = Modifier) {
|
||||
val color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
.copy(alpha = if (dim) 0.18f else 0.38f)
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(5.dp)
|
||||
.clip(RoundedCornerShape(1.5.dp))
|
||||
.background(color),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A week as seven event dots — the split style's compact grid. */
|
||||
@Composable
|
||||
private fun SchematicDotRow(dim: Boolean = false, modifier: Modifier = Modifier) {
|
||||
val color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
.copy(alpha = if (dim) 0.18f else 0.38f)
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(3.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val PREVIEW_HEIGHT = 240.dp
|
||||
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||
|
||||
@@ -104,6 +104,7 @@ import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||
@@ -915,6 +916,8 @@ private fun ViewsScreen(
|
||||
if (showMonthStyle) {
|
||||
MonthViewStylePicker(
|
||||
selected = state.monthViewStyle,
|
||||
// The preview is a real grid, so it uses the real week start too.
|
||||
weekStart = state.weekStart.resolveFirstDay(currentLocale()),
|
||||
onSelect = viewModel::setMonthViewStyle,
|
||||
onDismiss = { showMonthStyle = false },
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user