@@ -198,7 +198,7 @@ private fun CustomReminderEditor(
|
|||||||
|
|
||||||
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
/** A short explanatory paragraph shown under a picker's title, above the rows. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun PickerDescription(text: String) {
|
internal fun PickerDescription(text: String) {
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ private fun MonthTopBar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val days = remember(weekStart, locale) {
|
val days = remember(weekStart, locale) {
|
||||||
(0 until 7).map { offset ->
|
(0 until 7).map { offset ->
|
||||||
@@ -503,7 +503,7 @@ private const val MAX_EVENT_ROWS = 3
|
|||||||
private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
private val CONTINUOUS_ROW_HEIGHT = 112.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MonthGrid(
|
internal fun MonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
onOpenDay: (LocalDate) -> Unit,
|
onOpenDay: (LocalDate) -> Unit,
|
||||||
@@ -545,7 +545,7 @@ private fun MonthGrid(
|
|||||||
* duplication #38 asks to be rid of.
|
* duplication #38 asks to be rid of.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContinuousMonthGrid(
|
internal fun ContinuousMonthGrid(
|
||||||
state: ContinuousMonthUiState.Success,
|
state: ContinuousMonthUiState.Success,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
showWeekNumbers: Boolean,
|
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.
|
* the layout. The full Day view stays one tap away on the pane's date header.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitMonthGrid(
|
internal fun SplitMonthGrid(
|
||||||
state: MonthUiState.Success,
|
state: MonthUiState.Success,
|
||||||
selected: LocalDate,
|
selected: LocalDate,
|
||||||
showWeekNumbers: Boolean,
|
showWeekNumbers: Boolean,
|
||||||
@@ -812,7 +812,7 @@ private fun SplitDots(events: List<EventInstance>, dark: Boolean) {
|
|||||||
* the Week and Agenda headers (#37).
|
* the Week and Agenda headers (#37).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun SplitDayPane(
|
internal fun SplitDayPane(
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
today: LocalDate,
|
today: LocalDate,
|
||||||
events: List<EventInstance>,
|
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
|
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.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.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.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.clipToBounds
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
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 androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
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.MonthViewStyle
|
||||||
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
import de.jeanlucmakiola.calendula.ui.month.descriptionRes
|
||||||
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
import de.jeanlucmakiola.calendula.ui.month.labelRes
|
||||||
import de.jeanlucmakiola.floret.components.FullScreenPicker
|
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
|
* The Month view style chooser (#38, #53).
|
||||||
* layout it produces rather than a bare label — the three differ in *shape*, which
|
|
||||||
* a word like "Continuous" doesn't convey on its own.
|
|
||||||
*
|
*
|
||||||
* Tapping applies immediately and the picker stays open, matching the App name
|
* A live, scaled-down Month view sits above the options and changes as you pick
|
||||||
* picker: the change is worth seeing land before leaving.
|
* 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
|
@Composable
|
||||||
internal fun MonthViewStylePicker(
|
internal fun MonthViewStylePicker(
|
||||||
selected: MonthViewStyle,
|
selected: MonthViewStyle,
|
||||||
|
weekStart: DayOfWeek,
|
||||||
onSelect: (MonthViewStyle) -> Unit,
|
onSelect: (MonthViewStyle) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val options = MonthViewStyle.entries
|
||||||
|
val reduceMotion = rememberReduceMotion()
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.settings_month_view_style),
|
title = stringResource(R.string.settings_month_view_style),
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
predictiveBack = true,
|
predictiveBack = true,
|
||||||
) {
|
) {
|
||||||
Text(
|
Box(
|
||||||
text = stringResource(R.string.settings_month_view_style_summary),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 24.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
)
|
.height(PREVIEW_HEIGHT),
|
||||||
Spacer(Modifier.height(24.dp))
|
contentAlignment = Alignment.Center,
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.selectableGroup(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
) {
|
||||||
MonthViewStyle.entries.forEach { style ->
|
Crossfade(
|
||||||
MonthStyleOptionCard(
|
targetState = selected,
|
||||||
style = style,
|
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||||
selected = style == selected,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) },
|
onClick = { onSelect(style) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
private val PREVIEW_HEIGHT = 240.dp
|
||||||
* One selectable style: its schematic, name and a line on what it does. Selection
|
private val PREVIEW_SHAPE = RoundedCornerShape(12.dp)
|
||||||
* 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)
|
|
||||||
},
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (selected) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** [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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ import de.jeanlucmakiola.floret.reminders.reminderOverrideFor
|
|||||||
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
import de.jeanlucmakiola.calendula.data.prefs.TimeFormatPref
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.WeekStartPref
|
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.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
|
||||||
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
import de.jeanlucmakiola.calendula.qs.NewEventTileService
|
||||||
@@ -915,6 +916,8 @@ private fun ViewsScreen(
|
|||||||
if (showMonthStyle) {
|
if (showMonthStyle) {
|
||||||
MonthViewStylePicker(
|
MonthViewStylePicker(
|
||||||
selected = state.monthViewStyle,
|
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,
|
onSelect = viewModel::setMonthViewStyle,
|
||||||
onDismiss = { showMonthStyle = false },
|
onDismiss = { showMonthStyle = false },
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user