Compare commits
4 Commits
39cc70be95
...
0df420e551
| Author | SHA1 | Date | |
|---|---|---|---|
| 0df420e551 | |||
| 488de490f9 | |||
| f15fffa799 | |||
| 1499b5803d |
@@ -0,0 +1,98 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibilityScope
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionScope
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* What lets the split style's compact grid *become* the full month rather than be
|
||||
* swapped for it (#53).
|
||||
*
|
||||
* The two grids are separate composables — the compact one draws dots, the
|
||||
* expanded one draws the paged style's bars and pills — so nothing about them is
|
||||
* shared by construction. Tagging the pieces that mean the same thing on both
|
||||
* sides with the same [MonthMorphKey] hands Compose enough to animate one into
|
||||
* the other: a day's cell grows, its number rides along, and each dot travels out
|
||||
* to the bar it stood for.
|
||||
*
|
||||
* It travels as a composition local rather than as parameters because the pieces
|
||||
* sit five levels below where the scopes exist, and because a null default is
|
||||
* exactly the right meaning for everyone else: the paged, continuous, and dense
|
||||
* styles share the same row and cell composables, provide nothing, and pay
|
||||
* nothing. Reduced motion provides nothing either, which leaves the plain
|
||||
* cross-fade underneath.
|
||||
*/
|
||||
internal sealed interface MonthMorphKey {
|
||||
/** A day's background pill — the structural anchor the rest rides on. */
|
||||
data class Cell(val date: LocalDate) : MonthMorphKey
|
||||
|
||||
data class DayNumber(val date: LocalDate) : MonthMorphKey
|
||||
|
||||
/**
|
||||
* One event *on one day*. Keyed by date as well as instance because a
|
||||
* multi-day event has a dot on every day it covers but only one bar, drawn
|
||||
* from where it starts: the start day's dot is the one that becomes the bar,
|
||||
* and the rest are left unmatched on purpose. They fade where they stand
|
||||
* while the bar sweeps out over them, which is the honest reading — one of
|
||||
* them could not become the bar without the others teleporting into it.
|
||||
*/
|
||||
data class Event(val date: LocalDate, val instanceId: Long) : MonthMorphKey
|
||||
|
||||
/** The grab handle, which slides from the pane's seam to the foot of the grid. */
|
||||
data object Handle : MonthMorphKey
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
internal class MonthMorphScope(
|
||||
val shared: SharedTransitionScope,
|
||||
val visibility: AnimatedVisibilityScope,
|
||||
)
|
||||
|
||||
internal val LocalMonthMorph = compositionLocalOf<MonthMorphScope?> { null }
|
||||
|
||||
/**
|
||||
* Tag content that is *the same thing* on both sides — a background pill, a day
|
||||
* number — so it animates between its two positions and sizes.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
internal fun Modifier.morphElement(key: MonthMorphKey): Modifier {
|
||||
val morph = LocalMonthMorph.current ?: return this
|
||||
return with(morph.shared) {
|
||||
this@morphElement.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(key),
|
||||
animatedVisibilityScope = morph.visibility,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag content that means the same thing but *is drawn differently* on each side —
|
||||
* a 5dp dot and a titled bar — so only the bounds are shared and the contents
|
||||
* cross-fade inside them.
|
||||
*
|
||||
* [ResizeMode.RemeasureToBounds][SharedTransitionScope.ResizeMode] rather than
|
||||
* scaling: a bar's title laid out at the dot's 5dp and then scaled up would
|
||||
* arrive as a smear. Remeasuring keeps the text at its real size throughout and
|
||||
* simply clips it while there is no room, so what grows is the pill, not the type.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
internal fun Modifier.morphBounds(key: MonthMorphKey): Modifier {
|
||||
val morph = LocalMonthMorph.current ?: return this
|
||||
return with(morph.shared) {
|
||||
this@morphBounds.sharedBounds(
|
||||
sharedContentState = rememberSharedContentState(key),
|
||||
animatedVisibilityScope = morph.visibility,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
resizeMode = SharedTransitionScope.ResizeMode.RemeasureToBounds,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
@@ -8,12 +11,13 @@ import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -60,16 +64,18 @@ import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
@@ -124,6 +130,7 @@ import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.abs
|
||||
import kotlin.time.Clock
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
@@ -573,6 +580,12 @@ internal fun MonthGrid(
|
||||
state: MonthUiState.Success,
|
||||
showWeekNumbers: Boolean,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
/**
|
||||
* Outlined when set, matching the compact grid's marker. Only the split
|
||||
* style's expanded form passes one — the paged style has no selection, and
|
||||
* marking a day there would invent a state it doesn't have (#53).
|
||||
*/
|
||||
selected: LocalDate? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -591,6 +604,7 @@ internal fun MonthGrid(
|
||||
inMonth = { it.month == month.month && it.year == month.year },
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onOpenDay,
|
||||
selected = selected,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
@@ -781,35 +795,79 @@ internal fun DenseMonthGrid(
|
||||
}
|
||||
}
|
||||
|
||||
/** Which way a drag went, decided once per gesture and then held. */
|
||||
private enum class DragAxis { Undecided, Horizontal, Vertical }
|
||||
|
||||
/**
|
||||
* The month-changing horizontal swipe, shared by the paged and split styles.
|
||||
* Accumulates the drag and commits past a threshold on release — the grid
|
||||
* doesn't follow the finger, so there is no distance to rubber-band against.
|
||||
* The month grid's drag gesture: horizontal pages the month, vertical expands or
|
||||
* collapses the split style (#53). Accumulates and commits past a threshold on
|
||||
* release — the grid doesn't follow the finger, so there is no distance to
|
||||
* rubber-band against.
|
||||
*
|
||||
* The threshold matches the week and day views'. It used to be 6dp, which is
|
||||
* inside the distance a tap wanders: brushing the grid changed the month, and a
|
||||
* page that turns on an unintended gesture reads as the animation misfiring
|
||||
* rather than as the gesture being over-eager.
|
||||
* The axis is **locked on the first movement and held for the whole gesture**, so
|
||||
* a drag can page or expand but never both. Two independent detectors on one
|
||||
* surface would each see their own component of a diagonal drag and both fire.
|
||||
*
|
||||
* The horizontal threshold matches the week and day views'. It used to be 6dp,
|
||||
* which is inside the distance a tap wanders: brushing the grid changed the
|
||||
* month, and a page that turns on an unintended gesture reads as the animation
|
||||
* misfiring rather than as the gesture being over-eager. The vertical one is
|
||||
* larger — swapping the whole layout out deserves a more deliberate pull than
|
||||
* stepping to the next month.
|
||||
*
|
||||
* [onExpand]/[onCollapse] are null for the paged style, which leaves the vertical
|
||||
* axis unclaimed: the lock still happens, so a vertical drag there does nothing
|
||||
* rather than being re-read as a page turn.
|
||||
*/
|
||||
@Composable
|
||||
private fun rememberMonthSwipeModifier(
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onExpand: (() -> Unit)? = null,
|
||||
onCollapse: (() -> Unit)? = null,
|
||||
): Modifier {
|
||||
val threshold = with(LocalDensity.current) { MONTH_SWIPE_THRESHOLD.toPx() }
|
||||
var dragAccum by remember { mutableFloatStateOf(0f) }
|
||||
return Modifier.pointerInput(Unit) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { dragAccum = 0f },
|
||||
onDragEnd = {
|
||||
when {
|
||||
dragAccum < -threshold -> onSwipeNext()
|
||||
dragAccum > threshold -> onSwipePrev()
|
||||
}
|
||||
dragAccum = 0f
|
||||
val density = LocalDensity.current
|
||||
val pageThreshold = with(density) { MONTH_SWIPE_THRESHOLD.toPx() }
|
||||
val expandThreshold = with(density) { MONTH_EXPAND_THRESHOLD.toPx() }
|
||||
return Modifier.pointerInput(onSwipeNext, onSwipePrev, onExpand, onCollapse) {
|
||||
var accum = Offset.Zero
|
||||
var axis = DragAxis.Undecided
|
||||
detectDragGestures(
|
||||
onDragStart = {
|
||||
accum = Offset.Zero
|
||||
axis = DragAxis.Undecided
|
||||
},
|
||||
onDragEnd = {
|
||||
when (axis) {
|
||||
DragAxis.Horizontal -> when {
|
||||
accum.x < -pageThreshold -> onSwipeNext()
|
||||
accum.x > pageThreshold -> onSwipePrev()
|
||||
}
|
||||
DragAxis.Vertical -> when {
|
||||
accum.y > expandThreshold -> onExpand?.invoke()
|
||||
accum.y < -expandThreshold -> onCollapse?.invoke()
|
||||
}
|
||||
DragAxis.Undecided -> Unit
|
||||
}
|
||||
accum = Offset.Zero
|
||||
axis = DragAxis.Undecided
|
||||
},
|
||||
onDragCancel = {
|
||||
accum = Offset.Zero
|
||||
axis = DragAxis.Undecided
|
||||
},
|
||||
onDrag = { _, drag ->
|
||||
accum += drag
|
||||
if (axis == DragAxis.Undecided) {
|
||||
// Ties go horizontal, keeping paging the default reading of an
|
||||
// ambiguous drag as it was before the vertical axis existed.
|
||||
axis = if (abs(accum.x) >= abs(accum.y)) {
|
||||
DragAxis.Horizontal
|
||||
} else {
|
||||
DragAxis.Vertical
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragCancel = { dragAccum = 0f },
|
||||
onHorizontalDrag = { _, drag -> dragAccum += drag },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -817,15 +875,26 @@ private fun rememberMonthSwipeModifier(
|
||||
/** Drag distance that commits a month change, matching the week and day views. */
|
||||
private val MONTH_SWIPE_THRESHOLD = 24.dp
|
||||
|
||||
/** Drag distance that commits an expand/collapse — deliberately longer than a page. */
|
||||
private val MONTH_EXPAND_THRESHOLD = 48.dp
|
||||
|
||||
/**
|
||||
* Split style content: the compact grid keeps the month swipe, the pane below it
|
||||
* lists whatever day is selected.
|
||||
* lists whatever day is selected — and a downward drag trades the pane away for
|
||||
* the full paged grid, an upward one brings it back (#53).
|
||||
*
|
||||
* The grid slides between months like the paged style, which it can only do
|
||||
* because it always reserves [SPLIT_GRID_ROWS] rows. Sized to its own month it
|
||||
* stood 4–6 rows tall, so every swipe shunted the pane up or down by a row on
|
||||
* top of swapping the grid — the pane now holds still and only the grid moves.
|
||||
*
|
||||
* Expansion is deliberately **not** a stored preference. It is a way to look at
|
||||
* the month you are on, not a fourth style; persisted, someone would expand it
|
||||
* once and later find their Split style permanently changed with nothing on
|
||||
* screen to explain why. [rememberSaveable] carries it across a rotation, which
|
||||
* is as long as it should live.
|
||||
*/
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
private fun SplitMonthContent(
|
||||
state: MonthUiState,
|
||||
@@ -840,56 +909,248 @@ private fun SplitMonthContent(
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
// Back collapses before it does anything else. Expanding replaces the whole
|
||||
// screen, so it is a place you can be, and every other place in the app can
|
||||
// be backed out of. Declared deeper than CalendarHost's view-stack handler,
|
||||
// which is what makes it win while it is enabled.
|
||||
BackHandler(enabled = expanded) { expanded = false }
|
||||
// The swipe wraps the grid rather than living inside it: mid-transition
|
||||
// there are two grids, and the gesture belongs to neither. The pane is left
|
||||
// out of it — it scrolls and is full of tappable rows.
|
||||
val swipeModifier = rememberMonthSwipeModifier(onSwipeNext, onSwipePrev)
|
||||
val swipeModifier = rememberMonthSwipeModifier(
|
||||
onSwipeNext = onSwipeNext,
|
||||
onSwipePrev = onSwipePrev,
|
||||
onExpand = { expanded = true },
|
||||
onCollapse = { expanded = false },
|
||||
)
|
||||
|
||||
when (state) {
|
||||
MonthUiState.Loading -> MonthGridLoading()
|
||||
is MonthUiState.Failure -> CalendarFailure(reason = state.reason, onRetry = onRetry)
|
||||
is MonthUiState.Success -> Column(Modifier.fillMaxSize()) {
|
||||
is MonthUiState.Success -> SharedTransitionLayout(Modifier.fillMaxSize()) {
|
||||
AnimatedContent(
|
||||
// The selection travels *with* the state so each page keeps its
|
||||
// own. Read from outside, both pages would show the incoming
|
||||
// one, and paging visibly threw the marker across the outgoing
|
||||
// grid — onto the new month's 1st, which the old grid still
|
||||
// shows among its trailing days — before the new page arrived.
|
||||
targetState = state to selected,
|
||||
modifier = swipeModifier,
|
||||
// Keyed on the month alone, so a provider notification refreshing
|
||||
// the month you are on — or a tap moving the selection within it
|
||||
// — updates in place instead of sliding.
|
||||
contentKey = { (s, _) -> s.month },
|
||||
transitionSpec = {
|
||||
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
|
||||
},
|
||||
label = "split-month-transition",
|
||||
) { (s, sel) ->
|
||||
SplitMonthGrid(
|
||||
state = s,
|
||||
selected = sel,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onSelectDay = onSelectDay,
|
||||
)
|
||||
targetState = expanded,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
// Both branches fill the same box — the grid grows into exactly the
|
||||
// room the pane gives up — so there is no size change to contain.
|
||||
// The travel between them is the shared elements' job, not a slide.
|
||||
transitionSpec = { fadeIn(fadeSpec) togetherWith fadeOut(fadeSpec) },
|
||||
label = "split-expand-transition",
|
||||
) { isExpanded ->
|
||||
CompositionLocalProvider(
|
||||
// Null under reduced motion: nothing is tagged, nothing
|
||||
// travels, and the cross-fade above is the whole transition.
|
||||
LocalMonthMorph provides if (reduceMotion) {
|
||||
null
|
||||
} else {
|
||||
MonthMorphScope(this@SharedTransitionLayout, this@AnimatedContent)
|
||||
},
|
||||
) {
|
||||
SplitMonthBody(
|
||||
expanded = isExpanded,
|
||||
state = state,
|
||||
selected = selected,
|
||||
slideDir = slideDir,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
swipeModifier = swipeModifier,
|
||||
onSelectDay = onSelectDay,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
onSetExpanded = { expanded = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
SplitDayPane(
|
||||
date = selected,
|
||||
today = state.today,
|
||||
// Null, not empty: the selection moves to the new month before
|
||||
// its data arrives, and a missing key means "not loaded yet".
|
||||
// Passing an empty list would claim the day was free.
|
||||
events = state.instancesByDay[selected],
|
||||
zone = state.zone,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The two faces of the split style, sharing one set of morph tags. */
|
||||
@Composable
|
||||
private fun SplitMonthBody(
|
||||
expanded: Boolean,
|
||||
state: MonthUiState.Success,
|
||||
selected: LocalDate,
|
||||
slideDir: Int,
|
||||
showWeekNumbers: Boolean,
|
||||
swipeModifier: Modifier,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
onSetExpanded: (Boolean) -> Unit,
|
||||
) {
|
||||
if (expanded) {
|
||||
SplitMonthExpanded(
|
||||
state = state,
|
||||
selected = selected,
|
||||
slideDir = slideDir,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
swipeModifier = swipeModifier,
|
||||
// A tap in the expanded grid picks the day and drops back, which
|
||||
// gives the expanded month a job — a chooser you dip into — rather
|
||||
// than a mode you can get stranded in. The collapse then runs with
|
||||
// the selection already set, so the pane arrives showing the day
|
||||
// you picked.
|
||||
onPickDay = {
|
||||
onSelectDay(it)
|
||||
onSetExpanded(false)
|
||||
},
|
||||
onCollapse = { onSetExpanded(false) },
|
||||
)
|
||||
} else {
|
||||
SplitMonthCollapsed(
|
||||
state = state,
|
||||
selected = selected,
|
||||
slideDir = slideDir,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
swipeModifier = swipeModifier,
|
||||
onSelectDay = onSelectDay,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
onExpand = { onSetExpanded(true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The split style at rest: compact grid, handle, then the selected day's events. */
|
||||
@Composable
|
||||
private fun SplitMonthCollapsed(
|
||||
state: MonthUiState.Success,
|
||||
selected: LocalDate,
|
||||
slideDir: Int,
|
||||
showWeekNumbers: Boolean,
|
||||
swipeModifier: Modifier,
|
||||
onSelectDay: (LocalDate) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateEvent: (LocalDate) -> Unit,
|
||||
onExpand: () -> Unit,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
AnimatedContent(
|
||||
// The selection travels *with* the state so each page keeps its
|
||||
// own. Read from outside, both pages would show the incoming
|
||||
// one, and paging visibly threw the marker across the outgoing
|
||||
// grid — onto the new month's 1st, which the old grid still
|
||||
// shows among its trailing days — before the new page arrived.
|
||||
targetState = state to selected,
|
||||
modifier = swipeModifier,
|
||||
// Keyed on the month alone, so a provider notification refreshing
|
||||
// the month you are on — or a tap moving the selection within it
|
||||
// — updates in place instead of sliding.
|
||||
contentKey = { (s, _) -> s.month },
|
||||
transitionSpec = {
|
||||
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
|
||||
},
|
||||
label = "split-month-transition",
|
||||
) { (s, sel) ->
|
||||
SplitMonthGrid(
|
||||
state = s,
|
||||
selected = sel,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onSelectDay = onSelectDay,
|
||||
)
|
||||
}
|
||||
SplitExpandHandle(expanded = false, onToggle = onExpand)
|
||||
SplitDayPane(
|
||||
date = selected,
|
||||
today = state.today,
|
||||
// Null, not empty: the selection moves to the new month before
|
||||
// its data arrives, and a missing key means "not loaded yet".
|
||||
// Passing an empty list would claim the day was free.
|
||||
events = state.instancesByDay[selected],
|
||||
zone = state.zone,
|
||||
onOpenDay = onOpenDay,
|
||||
onEventClick = onEventClick,
|
||||
onCreateEvent = onCreateEvent,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The split style pulled open: the pane is gone and the month gets the whole
|
||||
* screen in the paged style's own vocabulary — real event bars and pills instead
|
||||
* of dots. The handle stays, now at the foot of the grid, to pull it back.
|
||||
*/
|
||||
@Composable
|
||||
private fun SplitMonthExpanded(
|
||||
state: MonthUiState.Success,
|
||||
selected: LocalDate,
|
||||
slideDir: Int,
|
||||
showWeekNumbers: Boolean,
|
||||
swipeModifier: Modifier,
|
||||
onPickDay: (LocalDate) -> Unit,
|
||||
onCollapse: () -> Unit,
|
||||
) {
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
AnimatedContent(
|
||||
targetState = state to selected,
|
||||
modifier = Modifier.weight(1f).then(swipeModifier),
|
||||
contentKey = { (s, _) -> s.month },
|
||||
transitionSpec = {
|
||||
calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion)
|
||||
},
|
||||
label = "split-expanded-month-transition",
|
||||
) { (s, sel) ->
|
||||
MonthGrid(
|
||||
state = s,
|
||||
showWeekNumbers = showWeekNumbers,
|
||||
onOpenDay = onPickDay,
|
||||
selected = sel,
|
||||
)
|
||||
}
|
||||
SplitExpandHandle(expanded = true, onToggle = onCollapse)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The grab handle at the seam between grid and pane — the same M3 drag-handle
|
||||
* pill a bottom sheet uses, for the same reason: it advertises that the surface
|
||||
* moves.
|
||||
*
|
||||
* The drag itself lives on the grid, not here. This exists so the gesture is
|
||||
* findable at all, and it takes taps too — a hidden swipe is no use to someone
|
||||
* who never tries it, or who can't make the gesture.
|
||||
*/
|
||||
@Composable
|
||||
private fun SplitExpandHandle(
|
||||
expanded: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val label = stringResource(
|
||||
if (expanded) R.string.month_split_collapse else R.string.month_split_expand,
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(SPLIT_HANDLE_ROW_HEIGHT)
|
||||
.clickable(onClick = onToggle)
|
||||
.semantics { contentDescription = label },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(width = SPLIT_HANDLE_WIDTH, height = SPLIT_HANDLE_HEIGHT)
|
||||
// Tagged too, so it slides from the pane's seam down to the foot
|
||||
// of the grid rather than blinking out at one and in at the other.
|
||||
.morphElement(MonthMorphKey.Handle)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -902,7 +1163,9 @@ private fun SplitMonthContent(
|
||||
*/
|
||||
private val SPLIT_ROW_HEIGHT = 46.dp
|
||||
private val SPLIT_DOT_SIZE = 5.dp
|
||||
private const val SPLIT_MAX_DOTS = 3
|
||||
// Dots are capped by MAX_EVENT_ROWS, not a constant of their own: they stand for
|
||||
// the paged grid's lanes, so the two caps have to be the same number or a dot
|
||||
// would have no bar to become (#53).
|
||||
|
||||
/**
|
||||
* Rows the split grid always reserves — the most any month needs. A month that
|
||||
@@ -911,6 +1174,14 @@ private const val SPLIT_MAX_DOTS = 3
|
||||
*/
|
||||
private const val SPLIT_GRID_ROWS = 6
|
||||
|
||||
/**
|
||||
* The expand handle: M3's drag-handle pill (32×4dp), in a row tall enough to be a
|
||||
* comfortable tap target on its own.
|
||||
*/
|
||||
private val SPLIT_HANDLE_WIDTH = 32.dp
|
||||
private val SPLIT_HANDLE_HEIGHT = 4.dp
|
||||
private val SPLIT_HANDLE_ROW_HEIGHT = 24.dp
|
||||
|
||||
/**
|
||||
* The split style's grid (#53): the month compressed to day numbers and event
|
||||
* dots, with the selected day listed underneath by [SplitDayPane].
|
||||
@@ -945,11 +1216,15 @@ internal fun SplitMonthGrid(
|
||||
.fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
week.days.forEach { day ->
|
||||
week.days.forEachIndexed { col, day ->
|
||||
val inMonth = day.month == month.month && day.year == month.year
|
||||
// Seated by lane rather than gathered by colour, so each dot
|
||||
// is the event the expanded grid draws in that same lane.
|
||||
val seated = week.laneEvents(col, day, MAX_EVENT_ROWS)
|
||||
SplitDayCell(
|
||||
date = day,
|
||||
events = state.instancesByDay[day].orEmpty(),
|
||||
events = seated,
|
||||
hidden = (week.countByDay[day] ?: 0) - seated.size,
|
||||
isToday = day == state.today,
|
||||
// A page marks only the days its own month owns. Paging
|
||||
// moves the selection before this month's replacement
|
||||
@@ -979,7 +1254,7 @@ internal fun SplitMonthGrid(
|
||||
}
|
||||
|
||||
/**
|
||||
* One compact day: its number over up to [SPLIT_MAX_DOTS] event dots.
|
||||
* One compact day: its number over up to [MAX_EVENT_ROWS] lane-seated event dots.
|
||||
*
|
||||
* Selection and today are deliberately different signals — a tinted, outlined
|
||||
* cell versus the filled circle the other views already use for today — so the
|
||||
@@ -989,6 +1264,8 @@ internal fun SplitMonthGrid(
|
||||
private fun SplitDayCell(
|
||||
date: LocalDate,
|
||||
events: List<EventInstance>,
|
||||
/** Events on this day that didn't fit a lane, shown as a "+N" beside the dots. */
|
||||
hidden: Int,
|
||||
isToday: Boolean,
|
||||
isSelected: Boolean,
|
||||
inMonth: Boolean,
|
||||
@@ -1013,19 +1290,28 @@ private fun SplitDayCell(
|
||||
animationSpec = if (reduceMotion) snap() else fadeSpec,
|
||||
label = "split-day-selection",
|
||||
)
|
||||
// Background and content are separate layers, mirroring how the paged row is
|
||||
// built — and so the pill can be tagged for the morph on its own. Tagged as
|
||||
// one piece with its contents inside, the dots would be nested shared
|
||||
// elements within a shared element and travel twice.
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.clip(CELL_SHAPE)
|
||||
.background(background)
|
||||
.border(
|
||||
width = 1.5.dp,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
|
||||
shape = CELL_SHAPE,
|
||||
)
|
||||
.selectable(selected = isSelected, onClick = onClick),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.morphElement(MonthMorphKey.Cell(date))
|
||||
.clip(CELL_SHAPE)
|
||||
.background(background)
|
||||
.border(
|
||||
width = 1.5.dp,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = selection),
|
||||
shape = CELL_SHAPE,
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(top = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -1034,35 +1320,44 @@ private fun SplitDayCell(
|
||||
date = date,
|
||||
isToday = isToday,
|
||||
inMonth = inMonth,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().morphElement(MonthMorphKey.DayNumber(date)),
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
SplitDots(events = events, dark = dark)
|
||||
SplitDots(date = date, events = events, hidden = hidden, dark = dark)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Up to three colour dots for a day, plus a count when more are hidden. */
|
||||
/**
|
||||
* One dot per seated event, in lane order, plus a "+N" for those that didn't fit.
|
||||
*
|
||||
* Deliberately *not* de-duplicated by colour: [events] arrives lane-seated so the
|
||||
* dots line up with the bars the expanded grid draws, and collapsing two events
|
||||
* that share a calendar into one dot would both undercount the day and leave a
|
||||
* bar with no dot to grow out of.
|
||||
*/
|
||||
@Composable
|
||||
private fun SplitDots(events: List<EventInstance>, dark: Boolean) {
|
||||
private fun SplitDots(date: LocalDate, events: List<EventInstance>, hidden: Int, dark: Boolean) {
|
||||
if (events.isEmpty()) return
|
||||
val soften = LocalSoftenColors.current
|
||||
val colors = remember(events) { events.map { it.color }.distinct().take(SPLIT_MAX_DOTS) }
|
||||
val extra = events.size - colors.size
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
colors.forEach { argb ->
|
||||
events.forEach { event ->
|
||||
// The dot keeps its own circle and the bar its 4dp corners; they
|
||||
// cross-fade inside shared bounds, and at the dot's size the two
|
||||
// radii are a fraction of a pixel apart.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(SPLIT_DOT_SIZE)
|
||||
.background(eventFill(argb, dark, soften), CircleShape),
|
||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
if (extra > 0) {
|
||||
if (hidden > 0) {
|
||||
Text(
|
||||
text = "+$extra",
|
||||
text = "+$hidden",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -1277,6 +1572,8 @@ private fun MonthWeekRow(
|
||||
modifier: Modifier = Modifier,
|
||||
blankOutside: Boolean = false,
|
||||
labelMonthOnFirst: Boolean = false,
|
||||
/** See [MonthGrid]'s `selected`; null for every style but expanded split. */
|
||||
selected: LocalDate? = null,
|
||||
) {
|
||||
val dark = isSystemInDarkTheme()
|
||||
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
|
||||
@@ -1312,6 +1609,7 @@ private fun MonthWeekRow(
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(horizontal = CELL_GAP, vertical = 1.dp)
|
||||
.morphElement(MonthMorphKey.Cell(d))
|
||||
.background(
|
||||
color = when {
|
||||
inMonth(d) -> MaterialTheme.colorScheme.surfaceContainer
|
||||
@@ -1321,6 +1619,21 @@ private fun MonthWeekRow(
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerLow
|
||||
},
|
||||
shape = CELL_SHAPE,
|
||||
)
|
||||
// Scoped to the row's own month for the same reason the
|
||||
// compact grid scopes it: a boundary week shows the
|
||||
// neighbour month's dates too, and the marker belongs to
|
||||
// exactly one of them.
|
||||
.then(
|
||||
if (d == selected && inMonth(d)) {
|
||||
Modifier.border(
|
||||
width = 1.5.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = CELL_SHAPE,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1343,7 +1656,9 @@ private fun MonthWeekRow(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
modifier = Modifier.weight(1f),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.morphElement(MonthMorphKey.DayNumber(d)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1372,7 +1687,17 @@ private fun MonthWeekRow(
|
||||
)
|
||||
.width(colW * cols)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||
// Anchored on the day the bar starts *in this row*,
|
||||
// which is where its dot sat. A bar carried in from
|
||||
// the previous week starts at column 0, and column
|
||||
// 0's dot is the one that grows into it.
|
||||
.morphBounds(
|
||||
MonthMorphKey.Event(
|
||||
date = week.days[span.startCol],
|
||||
instanceId = span.event.instanceId,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
// Single-day timed pills + overflow, per column. Pills fill the
|
||||
@@ -1400,7 +1725,8 @@ private fun MonthWeekRow(
|
||||
)
|
||||
.width(colW)
|
||||
.height(EVENT_ROW_HEIGHT)
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||
.morphBounds(MonthMorphKey.Event(d, ev.instanceId)),
|
||||
)
|
||||
}
|
||||
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
|
||||
|
||||
@@ -39,6 +39,34 @@ data class MonthWeek(
|
||||
val countByDay: Map<LocalDate, Int>,
|
||||
)
|
||||
|
||||
/**
|
||||
* The events occupying each lane of [day] — column [col] of this week — in lane
|
||||
* order, capped at [laneCap] lanes.
|
||||
*
|
||||
* This is the same seating [spans]/[timedByDay] get when the paged grid draws a
|
||||
* week: bars keep the lane the row layout gave them, and the day's timed events
|
||||
* fill whatever slots are left, top-most first. Reading it here means the split
|
||||
* style's dots and the paged style's bars describe a day in the *same order*, so
|
||||
* dot _i_ and lane _i_ are the same event and one can morph into the other (#53).
|
||||
*
|
||||
* Deriving the dots independently — by distinct colour, as they first were —
|
||||
* left dot _i_ standing for no particular event, and quietly merged two events
|
||||
* that shared a calendar into a single dot.
|
||||
*/
|
||||
fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInstance> {
|
||||
val byLane = arrayOfNulls<EventInstance>(laneCap)
|
||||
spans.forEach { span ->
|
||||
if (span.lane < laneCap && col in span.startCol..span.endCol) {
|
||||
byLane[span.lane] = span.event
|
||||
}
|
||||
}
|
||||
val free = (0 until laneCap).filter { byLane[it] == null }
|
||||
timedByDay[day].orEmpty().take(free.size).forEachIndexed { i, event ->
|
||||
byLane[free[i]] = event
|
||||
}
|
||||
return byLane.filterNotNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the continuous style (#38): a vertical stream of *self-contained*
|
||||
* months rather than one undifferentiated run of weeks. Each month is keyed by
|
||||
|
||||
@@ -385,6 +385,8 @@
|
||||
<string name="month_style_split">Split</string>
|
||||
<string name="month_style_split_summary">A compact grid with dots for events, and the day you tap listed underneath.</string>
|
||||
<string name="month_split_no_events">Nothing scheduled</string>
|
||||
<string name="month_split_expand">Show the whole month</string>
|
||||
<string name="month_split_collapse">Show the day\'s events</string>
|
||||
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
||||
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.DayOfWeek
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.Month
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.YearMonth
|
||||
import kotlinx.datetime.atTime
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* What lets the split style's dots morph into the paged style's bars (#53): both
|
||||
* read a day off the *same* lane seating, so dot _i_ and lane _i_ are one event.
|
||||
*/
|
||||
class LaneEventsTest {
|
||||
|
||||
private val zone = TimeZone.UTC
|
||||
private val jul26 = YearMonth(2026, Month.JULY)
|
||||
|
||||
/** July 2026 starts on a Wednesday, so this row — Jul 6–12 — sits wholly inside it. */
|
||||
private fun rowOfJuly6(events: List<EventInstance>) =
|
||||
layoutMonthWeeks(jul26, DayOfWeek.MONDAY, events, zone)[1]
|
||||
|
||||
private fun allDay(from: LocalDate, toInclusive: LocalDate, id: Long, color: Int = BLUE) =
|
||||
EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "A$id",
|
||||
start = from.atTime(0, 0).toInstant(zone),
|
||||
end = toInclusive.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
|
||||
isAllDay = true,
|
||||
color = color,
|
||||
location = null,
|
||||
)
|
||||
|
||||
private fun timed(date: LocalDate, hour: Int, id: Long, color: Int = RED) = EventInstance(
|
||||
instanceId = id,
|
||||
eventId = id,
|
||||
calendarId = 1L,
|
||||
title = "T$id",
|
||||
start = date.atTime(hour, 0).toInstant(zone),
|
||||
end = date.atTime(hour + 1, 0).toInstant(zone),
|
||||
isAllDay = false,
|
||||
color = color,
|
||||
location = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a bar keeps its lane and the day's timed events fill what's left`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val meeting = timed(LocalDate(2026, 7, 7), hour = 9, id = 2L)
|
||||
val week = rowOfJuly6(listOf(bar, meeting))
|
||||
|
||||
// Jul 7 is column 1 of a Monday-anchored row starting Jul 6.
|
||||
assertThat(week.laneEvents(col = 1, day = LocalDate(2026, 7, 7), laneCap = 3))
|
||||
.containsExactly(bar, meeting)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a day the bar misses seats its own events from lane zero`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val monday = timed(LocalDate(2026, 7, 6), hour = 9, id = 2L)
|
||||
val week = rowOfJuly6(listOf(bar, monday))
|
||||
|
||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||
.containsExactly(monday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day bar is seated on every day it covers`() {
|
||||
val bar = allDay(LocalDate(2026, 7, 7), LocalDate(2026, 7, 9), id = 1L)
|
||||
val week = rowOfJuly6(listOf(bar))
|
||||
|
||||
(1..3).forEach { col ->
|
||||
val day = LocalDate(2026, 7, 6 + col)
|
||||
assertThat(week.laneEvents(col, day, laneCap = 3)).containsExactly(bar)
|
||||
}
|
||||
assertThat(week.laneEvents(col = 4, day = LocalDate(2026, 7, 10), laneCap = 3)).isEmpty()
|
||||
}
|
||||
|
||||
/** The bug the colour-gathered dots had: one dot for two events on one calendar. */
|
||||
@Test
|
||||
fun `events sharing a colour each keep their own lane`() {
|
||||
val first = timed(LocalDate(2026, 7, 6), hour = 9, id = 1L, color = RED)
|
||||
val second = timed(LocalDate(2026, 7, 6), hour = 14, id = 2L, color = RED)
|
||||
val week = rowOfJuly6(listOf(first, second))
|
||||
|
||||
assertThat(week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3))
|
||||
.containsExactly(first, second)
|
||||
.inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `seating stops at the cap and leaves the rest to the overflow count`() {
|
||||
val events = (0 until 5).map { timed(LocalDate(2026, 7, 6), hour = 8 + it, id = it + 1L) }
|
||||
val week = rowOfJuly6(events)
|
||||
|
||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||
assertThat(seated).hasSize(3)
|
||||
assertThat(seated).containsExactlyElementsIn(events.take(3)).inOrder()
|
||||
assertThat(week.countByDay[LocalDate(2026, 7, 6)]!! - seated.size).isEqualTo(2)
|
||||
}
|
||||
|
||||
/** A bar parked below the cap is out of view, so it takes no dot with it. */
|
||||
@Test
|
||||
fun `a bar beyond the cap is left out`() {
|
||||
val bars = (0 until 4).map {
|
||||
allDay(LocalDate(2026, 7, 6), LocalDate(2026, 7, 8), id = it + 1L)
|
||||
}
|
||||
val week = rowOfJuly6(bars)
|
||||
|
||||
val seated = week.laneEvents(col = 0, day = LocalDate(2026, 7, 6), laneCap = 3)
|
||||
assertThat(seated).hasSize(3)
|
||||
assertThat(week.spans.filter { it.lane >= 3 }.map { it.event })
|
||||
.containsNoneIn(seated)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BLUE = 0xFF3366CC.toInt()
|
||||
const val RED = 0xFFCC3333.toInt()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user