Compare commits
10 Commits
v2.19.4
...
release/v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27ce9a29c9 | ||
| 5f28a8b333 | |||
|
|
ddf508e350 | ||
| dd3988c5bf | |||
|
|
03287ae25d | ||
|
|
7e8119be02 | ||
|
|
7e843aa740 | ||
| 9775e0a652 | |||
| 2cf7d590bd | |||
|
|
8c76cbdf5e |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Month-view event titles no longer end in an ellipsis. The "…" took the width
|
||||||
|
of a couple of characters and told you nothing you couldn't already see, so
|
||||||
|
the title now simply runs to the edge of its chip — a few more letters per
|
||||||
|
cell, which is often the difference between two events you can tell apart and
|
||||||
|
two you can't ([#164]).
|
||||||
|
- The calendar titles now shorten instead of being cut off. When the full month
|
||||||
|
name doesn't fit the top bar, the month and week views fall back to its
|
||||||
|
three-letter form and the day view drops the weekday, rather than trailing off
|
||||||
|
mid-word at large font sizes. The view switcher, the title and the agenda's
|
||||||
|
range bar also line up with the grid underneath them ([#165]).
|
||||||
|
|
||||||
## [2.19.4] — 2026-09-01
|
## [2.19.4] — 2026-09-01
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
@@ -1560,6 +1572,8 @@ automatically, with zero telemetry and no internet permission.
|
|||||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||||
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
[#173]: https://codeberg.org/jlmakiola/calendula/issues/173
|
||||||
|
[#164]: https://codeberg.org/jlmakiola/calendula/issues/164
|
||||||
|
[#165]: https://codeberg.org/jlmakiola/calendula/issues/165
|
||||||
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
[#180]: https://codeberg.org/jlmakiola/calendula/issues/180
|
||||||
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||||
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
[#191]: https://codeberg.org/jlmakiola/calendula/issues/191
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
|
||||||
import androidx.compose.material3.rememberDrawerState
|
import androidx.compose.material3.rememberDrawerState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -40,9 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -51,6 +50,8 @@ import de.jeanlucmakiola.calendula.data.prefs.PastEventDisplay
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||||
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
@@ -95,7 +96,6 @@ fun AgendaScreen(
|
|||||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showRangePicker by remember { mutableStateOf(false) }
|
var showRangePicker by remember { mutableStateOf(false) }
|
||||||
@@ -130,16 +130,16 @@ fun AgendaScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
AgendaTopBar(
|
AgendaTopBar(
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = viewModel::goToToday,
|
onToday = viewModel::goToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -159,13 +159,24 @@ fun AgendaScreen(
|
|||||||
// One bar at the top: the "showing …" header on the left and the
|
// One bar at the top: the "showing …" header on the left and the
|
||||||
// session range switcher on the right (one settings toggle).
|
// session range switcher on the right (one settings toggle).
|
||||||
successState?.takeIf { it.showRangeBar }?.let { s ->
|
successState?.takeIf { it.showRangeBar }?.let { s ->
|
||||||
|
// end lines the selector up with whatever ends the top bar:
|
||||||
|
// the view switcher's background, or — once #150 hides it —
|
||||||
|
// the search icon's glyph.
|
||||||
|
val selectorEnd = if (quickSwitchViews.size >= QuickSwitchConfig.MIN_CYCLE) {
|
||||||
|
AppBarSpacing.Inset
|
||||||
|
} else {
|
||||||
|
AppBarSpacing.IconTrailingInset
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
// end aligns the selector's right edge with the top-bar view
|
|
||||||
// switcher (its 8.dp margin + the app bar's 4.dp inset).
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
.padding(
|
||||||
|
start = RANGE_BAR_TEXT_INSET,
|
||||||
|
end = selectorEnd,
|
||||||
|
top = 8.dp,
|
||||||
|
bottom = 8.dp,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
AgendaRangeBanner(
|
AgendaRangeBanner(
|
||||||
range = s.range,
|
range = s.range,
|
||||||
@@ -245,6 +256,9 @@ private fun AgendaRangePill(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Optical start inset for the range bar's text, set against the title above it. */
|
||||||
|
private val RANGE_BAR_TEXT_INSET = 28.dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A header naming the concrete window currently shown under a "showing …" label,
|
* A header naming the concrete window currently shown under a "showing …" label,
|
||||||
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
* e.g. "27 Jun 2026" / "27 Jun – 3 Jul 2026" / "June 2026". The range's name
|
||||||
@@ -409,17 +423,22 @@ private fun AgendaEmpty(modifier: Modifier = Modifier) {
|
|||||||
private fun AgendaTopBar(
|
private fun AgendaTopBar(
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: TopAppBarScrollBehavior,
|
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
|
// A plain label rather than a CalendarTitleButton, so it takes that
|
||||||
|
// one's inset and one-line clamp itself.
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.view_agenda),
|
text = stringResource(R.string.view_agenda),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -440,14 +459,16 @@ private fun AgendaTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
|
cycle = quickSwitchViews,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||||
|
// it, but here the bar meets the header on the same surface and the
|
||||||
|
// tint is what makes that seam look like a separate block (#186).
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trailing-edge and title spacing shared by the four calendar app bars (#165).
|
||||||
|
*
|
||||||
|
* Only the trailing edge and the title's start are shared; the leading edge and
|
||||||
|
* the gaps between the action icons stay on M3's defaults, which derive both
|
||||||
|
* from the icon buttons' width.
|
||||||
|
*/
|
||||||
|
object AppBarSpacing {
|
||||||
|
|
||||||
|
/** Side inset shared by the bars' trailing edge and the month grid. */
|
||||||
|
val Inset = 8.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start inset on the title, taking it clear of the navigation icon M3 places
|
||||||
|
* it flush against. Real padding rather than a negative offset, which would
|
||||||
|
* swallow taps meant for the menu button.
|
||||||
|
*/
|
||||||
|
val TitleInset = 8.dp
|
||||||
|
|
||||||
|
/** M3's own padding around the actions row. */
|
||||||
|
internal val BarPadding = 4.dp
|
||||||
|
|
||||||
|
private val IconButtonSize = 48.dp
|
||||||
|
|
||||||
|
private val IconSize = 24.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End padding for a container-backed control that ends the bar, measured to
|
||||||
|
* its background. Coerced because [androidx.compose.foundation.layout.padding]
|
||||||
|
* throws on a negative value.
|
||||||
|
*/
|
||||||
|
val ContainerTrailingInset = (Inset - BarPadding).coerceAtLeast(0.dp)
|
||||||
|
|
||||||
|
/** Screen edge to the glyph of an icon button that ends the bar. */
|
||||||
|
val IconTrailingInset = BarPadding + (IconButtonSize - IconSize) / 2
|
||||||
|
}
|
||||||
@@ -4,30 +4,166 @@ import androidx.compose.animation.Crossfade
|
|||||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.animation.core.snap
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||||
|
|
||||||
|
/** Gap a timed block leaves to its neighbours in the column. */
|
||||||
|
val BLOCK_OUTER_INSET = 1.dp
|
||||||
|
|
||||||
|
/** Padding between a timed block's edge and its text. */
|
||||||
|
val BLOCK_TEXT_PADDING = 4.dp
|
||||||
|
|
||||||
|
/** Most lines a time label may wrap over before it is worth more than a title line. */
|
||||||
|
const val MAX_TIME_LINES = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lines [text] needs to render whole at [textWidth], capped at [max].
|
||||||
|
*
|
||||||
|
* Lets a block hand out its height by what the text actually asks for rather
|
||||||
|
* than by what would fit: a title that wants one line should not be given three
|
||||||
|
* that the time label could have used, and a week column is narrower than a
|
||||||
|
* "09:30–11:00" range so the range should not be assumed to want one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun blockTextLines(text: String, style: TextStyle, textWidth: Dp, max: Int): Int {
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
|
||||||
|
return remember(text, style, widthPx, max, measurer) {
|
||||||
|
if (max <= 1 || widthPx <= 0) {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
measurer.measure(
|
||||||
|
text = text,
|
||||||
|
style = style,
|
||||||
|
constraints = Constraints(maxWidth = widthPx),
|
||||||
|
).lineCount.coerceIn(1, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A timed block's title, over at most [maxLines].
|
||||||
|
*
|
||||||
|
* Wrapping and clipping pull against each other, which is why #164 left the
|
||||||
|
* ellipsis on multi-line chips: with `softWrap` on, the last visible line ends
|
||||||
|
* at a word boundary, so "Farmers Market" in a six-character column would clip
|
||||||
|
* to "Farmer" / "s" where the ellipsis at least reached "s Mar…".
|
||||||
|
*
|
||||||
|
* So the block wraps every line but the last through one `Text` and hands the
|
||||||
|
* remainder to a second that clips mid-glyph the way a single-line chip does.
|
||||||
|
* Every line is then full and none of them spends two of its few characters on
|
||||||
|
* a "…". RTL keeps the ellipsis for the reason [eventTitleOverflowFor] gives.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BlockTitle(
|
||||||
|
title: String,
|
||||||
|
maxLines: Int,
|
||||||
|
textWidth: Dp,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
textDecoration: TextDecoration? = null,
|
||||||
|
) {
|
||||||
|
val style = MaterialTheme.typography.labelMedium
|
||||||
|
val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||||
|
val measurer = rememberTextMeasurer()
|
||||||
|
val widthPx = with(LocalDensity.current) { textWidth.roundToPx() }
|
||||||
|
// Where the wrapped lines stop and the clipped tail starts — null when the
|
||||||
|
// title fits, and nothing needs splitting.
|
||||||
|
val headEnd = remember(title, style, widthPx, maxLines, rtl, measurer) {
|
||||||
|
if (rtl || maxLines < 2 || widthPx <= 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
val layout = measurer.measure(
|
||||||
|
text = title,
|
||||||
|
style = style,
|
||||||
|
constraints = Constraints(maxWidth = widthPx),
|
||||||
|
)
|
||||||
|
if (layout.lineCount <= maxLines) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
layout.getLineEnd(maxLines - 2, visibleEnd = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (headEnd == null) {
|
||||||
|
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
modifier = modifier,
|
||||||
|
style = style,
|
||||||
|
maxLines = maxLines,
|
||||||
|
overflow = overflow.overflow,
|
||||||
|
softWrap = overflow.softWrap,
|
||||||
|
color = color,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val tail = eventTitleOverflow(singleLine = true)
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = title.substring(0, headEnd),
|
||||||
|
style = style,
|
||||||
|
maxLines = maxLines - 1,
|
||||||
|
overflow = TextOverflow.Clip,
|
||||||
|
softWrap = true,
|
||||||
|
color = color,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = title.substring(headEnd).trimStart(),
|
||||||
|
style = style,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = tail.overflow,
|
||||||
|
softWrap = tail.softWrap,
|
||||||
|
color = color,
|
||||||
|
textDecoration = textDecoration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A timed block's own time label, crossfaded rather than replaced — the block
|
* A timed block's own time label, crossfaded rather than replaced — the block
|
||||||
* slides to its new slot, so the label shouldn't change in a single frame.
|
* slides to its new slot, so the label shouldn't change in a single frame.
|
||||||
|
*
|
||||||
|
* Overflows like a title does (#164): the "…" costs two characters of a string
|
||||||
|
* that is nothing but characters, so the label clips at the block's edge
|
||||||
|
* instead. [maxLines] lets a narrow column spend spare height on the range
|
||||||
|
* rather than losing its end.
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
fun BlockTimeLabel(
|
||||||
|
label: String,
|
||||||
|
color: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
maxLines: Int = 1,
|
||||||
|
) {
|
||||||
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||||
snap()
|
snap()
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||||
}
|
}
|
||||||
|
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = label,
|
targetState = label,
|
||||||
animationSpec = spec,
|
animationSpec = spec,
|
||||||
@@ -37,8 +173,9 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
|||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = maxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = overflow.overflow,
|
||||||
|
softWrap = overflow.softWrap,
|
||||||
color = color,
|
color = color,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -12,6 +13,7 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -19,6 +21,8 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.rememberTextMeasurer
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
@@ -31,10 +35,9 @@ import kotlinx.datetime.LocalDate
|
|||||||
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
||||||
* visible day, week start or month anchor).
|
* visible day, week start or month anchor).
|
||||||
*
|
*
|
||||||
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
|
* [shortTitle] replaces [title] when the full one does not fit the width the app
|
||||||
* start alignment: M3 places the title flush against the navigation icon's
|
* bar hands the title slot (#165). Either way the line clamps to one and
|
||||||
* trailing edge, and the title is hit-tested on top of it, so a negative offset
|
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
|
||||||
* would swallow taps meant for the menu button.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CalendarTitleButton(
|
fun CalendarTitleButton(
|
||||||
@@ -42,6 +45,7 @@ fun CalendarTitleButton(
|
|||||||
currentDate: LocalDate,
|
currentDate: LocalDate,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
shortTitle: String = title,
|
||||||
) {
|
) {
|
||||||
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -53,14 +57,27 @@ fun CalendarTitleButton(
|
|||||||
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||||
role = Role.Button,
|
role = Role.Button,
|
||||||
) { showDatePicker = true }
|
) { showDatePicker = true }
|
||||||
.padding(horizontal = 8.dp),
|
.padding(horizontal = AppBarSpacing.TitleInset),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text(
|
val style = MaterialTheme.typography.titleLarge
|
||||||
text = title,
|
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
val measurer = rememberTextMeasurer()
|
||||||
modifier = Modifier.weight(1f, fill = false),
|
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) {
|
||||||
)
|
titleFor(
|
||||||
|
title = title,
|
||||||
|
shortTitle = shortTitle,
|
||||||
|
titleWidth = measurer.measure(title, style).size.width,
|
||||||
|
availableWidth = constraints.maxWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = shown,
|
||||||
|
style = style,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.ArrowDropDown,
|
imageVector = Icons.Default.ArrowDropDown,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
@@ -79,3 +96,11 @@ fun CalendarTitleButton(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
|
||||||
|
internal fun titleFor(
|
||||||
|
title: String,
|
||||||
|
shortTitle: String,
|
||||||
|
titleWidth: Int,
|
||||||
|
availableWidth: Int,
|
||||||
|
): String = if (titleWidth <= availableWidth) title else shortTitle
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ fun CalendarView.next(available: List<CalendarView> = IMPLEMENTED_VIEWS): Calend
|
|||||||
* implemented view — the settings screen reorders the whole set — while [cycle]
|
* implemented view — the settings screen reorders the whole set — while [cycle]
|
||||||
* is the subset the pill actually steps through, in [order]. The navigation
|
* is the subset the pill actually steps through, in [order]. The navigation
|
||||||
* drawer keeps its own separate order and always lists every view, so a view
|
* drawer keeps its own separate order and always lists every view, so a view
|
||||||
* disabled here stays reachable there.
|
* disabled here stays reachable there — including when [cycle] is emptied and
|
||||||
|
* the pill disappears altogether.
|
||||||
*/
|
*/
|
||||||
data class QuickSwitchConfig(
|
data class QuickSwitchConfig(
|
||||||
val order: List<CalendarView>,
|
val order: List<CalendarView>,
|
||||||
@@ -67,14 +68,15 @@ data class QuickSwitchConfig(
|
|||||||
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
/**
|
||||||
|
* Fewest views that keep the switch meaningful. A single target is not a
|
||||||
|
* switch, so below this the pill is hidden rather than special-cased (#150);
|
||||||
|
* the drawer still reaches every view.
|
||||||
|
*/
|
||||||
|
const val MIN_CYCLE = 2
|
||||||
|
|
||||||
/** All views, in default order, all enabled. */
|
/** All views, in default order, all enabled. */
|
||||||
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
val Default = QuickSwitchConfig(IMPLEMENTED_VIEWS, IMPLEMENTED_VIEWS.toSet())
|
||||||
|
|
||||||
/**
|
|
||||||
* Fewest views that keep the switch meaningful — a "switch" needs at
|
|
||||||
* least two targets, so the settings screen blocks disabling below this.
|
|
||||||
*/
|
|
||||||
const val MIN_ENABLED = 2
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.onLongClick
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import de.jeanlucmakiola.calendula.R
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Puts one labelled field on the clipboard. */
|
||||||
|
fun interface FieldCopier {
|
||||||
|
operator fun invoke(label: String, text: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [FieldCopier] that confirms the copy only where the system doesn't (#195).
|
||||||
|
*
|
||||||
|
* Android 13 raises its own clipboard chip for every copy, so a snackbar on top
|
||||||
|
* of it reads as the app having done the job twice. A failure is always worth a
|
||||||
|
* word, though: the clipboard can refuse a very long description outright.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberFieldCopier(snackbarHostState: SnackbarHostState): FieldCopier {
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val confirmation = stringResource(R.string.field_copied)
|
||||||
|
val failure = stringResource(R.string.field_copy_failed)
|
||||||
|
return remember(clipboard, scope, snackbarHostState, confirmation, failure) {
|
||||||
|
FieldCopier { label, text ->
|
||||||
|
scope.launch {
|
||||||
|
val message = runCatching {
|
||||||
|
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(label, text)))
|
||||||
|
}.fold(
|
||||||
|
onSuccess = {
|
||||||
|
confirmation.takeIf {
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onFailure = { failure },
|
||||||
|
)
|
||||||
|
if (message != null) {
|
||||||
|
// Copying twice in a row shouldn't queue two four-second
|
||||||
|
// confirmations — the newest one wins.
|
||||||
|
snackbarHostState.currentSnackbarData?.dismiss()
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-press to copy [text] whole, filed on the clipboard under [label].
|
||||||
|
*
|
||||||
|
* [onTap] carries a field's existing tap action through; a field without one
|
||||||
|
* stays unclickable rather than growing a ripple that leads nowhere, and gets
|
||||||
|
* its long press announced through semantics instead. That branch merges the
|
||||||
|
* node it sits on, so a screen reader lands on the field itself and finds the
|
||||||
|
* action there — a bare container is never focused.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.copyOnLongPress(
|
||||||
|
label: String,
|
||||||
|
text: String,
|
||||||
|
copy: FieldCopier,
|
||||||
|
onTap: (() -> Unit)? = null,
|
||||||
|
): Modifier {
|
||||||
|
val actionLabel = stringResource(R.string.field_copy_action)
|
||||||
|
return if (onTap != null) {
|
||||||
|
combinedClickable(
|
||||||
|
onClick = onTap,
|
||||||
|
onLongClickLabel = actionLabel,
|
||||||
|
onLongClick = { copy(label, text) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
pointerInput(label, text, copy) {
|
||||||
|
detectTapGestures(
|
||||||
|
onLongPress = {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
copy(label, text)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}.semantics(mergeDescendants = true) {
|
||||||
|
onLongClick(actionLabel) {
|
||||||
|
copy(label, text)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
|
|
||||||
|
/** How an event chip's title should overflow: what to pass to `Text`. */
|
||||||
|
data class EventTitleOverflow(val overflow: TextOverflow, val softWrap: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overflow for an event chip's title (#164). Chips are narrow enough that the
|
||||||
|
* "…" costs a couple of readable characters, so the title runs to the chip's
|
||||||
|
* edge and clips mid-glyph instead.
|
||||||
|
*
|
||||||
|
* Two cases keep the ellipsis:
|
||||||
|
*
|
||||||
|
* - **[rtl].** With `softWrap` off Compose lays the line out at its full
|
||||||
|
* intrinsic width and clips to the node's left edge, which in RTL is the *end*
|
||||||
|
* of the string — an Arabic title would lose its beginning. The ellipsis
|
||||||
|
* truncates at the logical end in both directions.
|
||||||
|
* - **More than one line** ([singleLine] false). Wrapping needs `softWrap` on,
|
||||||
|
* and clipping with it on breaks the last line at the last whole word — less
|
||||||
|
* title than the ellipsis showed, not more.
|
||||||
|
*/
|
||||||
|
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
|
||||||
|
if (rtl || !singleLine) {
|
||||||
|
EventTitleOverflow(TextOverflow.Ellipsis, softWrap = true)
|
||||||
|
} else {
|
||||||
|
EventTitleOverflow(TextOverflow.Clip, softWrap = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [eventTitleOverflowFor] against the current layout direction. */
|
||||||
|
@Composable
|
||||||
|
fun eventTitleOverflow(singleLine: Boolean = true): EventTitleOverflow =
|
||||||
|
eventTitleOverflowFor(
|
||||||
|
rtl = LocalLayoutDirection.current == LayoutDirection.Rtl,
|
||||||
|
singleLine = singleLine,
|
||||||
|
)
|
||||||
@@ -34,7 +34,6 @@ import androidx.compose.ui.layout.onGloballyPositioned
|
|||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -714,7 +713,7 @@ private fun DragCopy(
|
|||||||
width = with(density) { sizePx.width.toDp() },
|
width = with(density) { sizePx.width.toDp() },
|
||||||
height = with(density) { sizePx.height.toDp() },
|
height = with(density) { sizePx.height.toDp() },
|
||||||
)
|
)
|
||||||
.padding(horizontal = 1.dp)
|
.padding(horizontal = BLOCK_OUTER_INSET)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
scaleX = 1f + 0.02f * lift
|
scaleX = 1f + 0.02f * lift
|
||||||
scaleY = 1f + 0.02f * lift
|
scaleY = 1f + 0.02f * lift
|
||||||
@@ -724,14 +723,16 @@ private fun DragCopy(
|
|||||||
clip = false
|
clip = false
|
||||||
}
|
}
|
||||||
.background(fill, shape)
|
.background(fill, shape)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp),
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = titleOverflow.overflow,
|
||||||
|
softWrap = titleOverflow.softWrap,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
)
|
)
|
||||||
if (label != null) {
|
if (label != null) {
|
||||||
@@ -739,7 +740,8 @@ private fun DragCopy(
|
|||||||
text = label,
|
text = label,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = titleOverflow.overflow,
|
||||||
|
softWrap = titleOverflow.softWrap,
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.common
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-bar pill that shows the current view and cycles to the next one on tap
|
* Top-bar pill that shows the current view and cycles to the next one on tap
|
||||||
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
* (spec M1: Month → Week → Day → Month, restricted to [IMPLEMENTED_VIEWS]).
|
||||||
|
*
|
||||||
|
* Renders nothing when [cycle] holds fewer than [QuickSwitchConfig.MIN_CYCLE]
|
||||||
|
* views (#150), so it drops straight into an app bar's `actions` slot without a
|
||||||
|
* wrapping condition — the same way [TodayAction] handles being turned off.
|
||||||
|
*
|
||||||
|
* [trailingInset] lands the pill's background on the bar's own rhythm; pass
|
||||||
|
* `0.dp` outside a top app bar, where M3's actions padding is not there to
|
||||||
|
* cancel (#165).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ViewSwitcherPill(
|
fun ViewSwitcherPill(
|
||||||
current: CalendarView,
|
current: CalendarView,
|
||||||
|
cycle: List<CalendarView>,
|
||||||
onCycle: () -> Unit,
|
onCycle: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
|
||||||
) {
|
) {
|
||||||
|
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onCycle,
|
onClick = onCycle,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
modifier = modifier,
|
modifier = modifier.padding(end = trailingInset),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(current.labelRes))
|
Text(stringResource(current.labelRes))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -53,10 +52,8 @@ 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.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -65,7 +62,6 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.customActions
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -81,7 +77,12 @@ import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MAX_TIME_LINES
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
@@ -97,6 +98,7 @@ import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
@@ -170,21 +172,9 @@ fun DayScreen(
|
|||||||
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// The all-day strip shares the app bar's scrolled colour so the whole top
|
|
||||||
// region elevates together once the timeline scrolls under it.
|
|
||||||
val topSectionColor by animateColorAsState(
|
|
||||||
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
},
|
|
||||||
label = "day-top-section-color",
|
|
||||||
)
|
|
||||||
|
|
||||||
val isOnToday = when (val s = state) {
|
val isOnToday = when (val s = state) {
|
||||||
is DayUiState.Success -> s.date == s.today
|
is DayUiState.Success -> s.date == s.today
|
||||||
else -> true
|
else -> true
|
||||||
@@ -242,19 +232,19 @@ fun DayScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
DayTopBar(
|
DayTopBar(
|
||||||
date = date,
|
date = date,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onJumpToDate = jumpToDate,
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -269,7 +259,6 @@ fun DayScreen(
|
|||||||
DayContent(
|
DayContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
@@ -287,7 +276,6 @@ fun DayScreen(
|
|||||||
private fun DayContent(
|
private fun DayContent(
|
||||||
state: DayUiState,
|
state: DayUiState,
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
topSectionColor: Color,
|
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
@@ -348,7 +336,6 @@ private fun DayContent(
|
|||||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is DayUiState.Success -> DaySuccess(
|
is DayUiState.Success -> DaySuccess(
|
||||||
state = s,
|
state = s,
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
@@ -374,7 +361,6 @@ private fun DayContent(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun DaySuccess(
|
internal fun DaySuccess(
|
||||||
state: DayUiState.Success,
|
state: DayUiState.Success,
|
||||||
topSectionColor: Color,
|
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
@@ -391,10 +377,10 @@ internal fun DaySuccess(
|
|||||||
onEventClick = onEventClick,
|
onEventClick = onEventClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(topSectionColor),
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
)
|
)
|
||||||
// Breathing room between the (colour-shifting) top section and the
|
// Breathing room between the top section and the scrolling timeline
|
||||||
// scrolling timeline below.
|
// below.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -414,20 +400,25 @@ private fun DayTopBar(
|
|||||||
currentYear: Int,
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val (title, shortTitle) = remember(date, locale, currentYear) {
|
||||||
|
formatDayTitle(date, locale, currentYear) to
|
||||||
|
formatDayTitle(date, locale, currentYear, abbreviated = true)
|
||||||
|
}
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
CalendarTitleButton(
|
CalendarTitleButton(
|
||||||
title = formatDayTitle(date, locale, currentYear),
|
title = title,
|
||||||
currentDate = date,
|
currentDate = date,
|
||||||
onJumpToDate = onJumpToDate,
|
onJumpToDate = onJumpToDate,
|
||||||
|
shortTitle = shortTitle,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -448,15 +439,17 @@ private fun DayTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
|
cycle = quickSwitchViews,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||||
|
// it, but here the bar meets the header on the same surface and the
|
||||||
|
// tint is what makes that seam look like a separate block (#186).
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,11 +519,13 @@ private fun AllDayBar(
|
|||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = titleOverflow.overflow,
|
||||||
|
softWrap = titleOverflow.softWrap,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
textDecoration = declinedDecoration(event.isDeclined),
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
)
|
)
|
||||||
@@ -691,6 +686,7 @@ private fun DayColumnCard(
|
|||||||
block = block,
|
block = block,
|
||||||
dark = dark,
|
dark = dark,
|
||||||
height = place.height,
|
height = place.height,
|
||||||
|
width = place.width,
|
||||||
date = date,
|
date = date,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
onClick = { onEventClick(block.event) },
|
onClick = { onEventClick(block.event) },
|
||||||
@@ -699,7 +695,7 @@ private fun DayColumnCard(
|
|||||||
.offset(x = place.x, y = place.y)
|
.offset(x = place.x, y = place.y)
|
||||||
.width(place.width)
|
.width(place.width)
|
||||||
.height(place.height)
|
.height(place.height)
|
||||||
.padding(horizontal = 1.dp),
|
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,6 +712,7 @@ private fun EventBlock(
|
|||||||
block: TimedBlock,
|
block: TimedBlock,
|
||||||
dark: Boolean,
|
dark: Boolean,
|
||||||
height: Dp,
|
height: Dp,
|
||||||
|
width: Dp,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
@@ -737,10 +734,28 @@ private fun EventBlock(
|
|||||||
// What's left for text once the 2.dp top/bottom padding is paid for. A block
|
// What's left for text once the 2.dp top/bottom padding is paid for. A block
|
||||||
// that cannot afford both lines spends its space on the title, and one too
|
// that cannot afford both lines spends its space on the title, and one too
|
||||||
// short even for that drops the title rather than serving a sliced one.
|
// short even for that drops the title rather than serving a sliced one.
|
||||||
|
// Height alone decides: a duration threshold would keep hiding the time on a
|
||||||
|
// half-hour block the user has pinched open to three times the room it needs.
|
||||||
val available = height - 4.dp
|
val available = height - 4.dp
|
||||||
val showTime = block.endMin - block.startMin >= 45 &&
|
val showTime = available >= titleLineHeight + timeLineHeight
|
||||||
available >= titleLineHeight + timeLineHeight
|
|
||||||
val showTitle = available >= titleLineHeight
|
val showTitle = available >= titleLineHeight
|
||||||
|
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||||
|
val titleMaxLines = if (showTime) 1 else 2
|
||||||
|
// The range only wraps out of a line the title has not claimed, which on a
|
||||||
|
// day column — wide enough for "09:30–11:00" several times over — means it
|
||||||
|
// never does, until lanes cut the column down.
|
||||||
|
val spare = available - titleLineHeight * titleMaxLines -
|
||||||
|
if (showTime) timeLineHeight else 0.dp
|
||||||
|
val timeMaxLines = if (showTime && spare >= timeLineHeight) {
|
||||||
|
blockTextLines(
|
||||||
|
text = timeLabel,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
textWidth = textWidth,
|
||||||
|
max = MAX_TIME_LINES,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
val soften = LocalSoftenColors.current
|
val soften = LocalSoftenColors.current
|
||||||
val fill = eventFill(block.event.color, dark, soften)
|
val fill = eventFill(block.event.color, dark, soften)
|
||||||
val zone = remember { TimeZone.currentSystemDefault() }
|
val zone = remember { TimeZone.currentSystemDefault() }
|
||||||
@@ -773,7 +788,7 @@ private fun EventBlock(
|
|||||||
// After clickable, so it is the inner node and wins the main pass;
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
// the tap still works, since a drag consumes the up.
|
// the tap still works, since a drag consumes the up.
|
||||||
.then(dragModifier)
|
.then(dragModifier)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = "$title, $timeLabel"
|
contentDescription = "$title, $timeLabel"
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
@@ -781,11 +796,10 @@ private fun EventBlock(
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
Text(
|
BlockTitle(
|
||||||
text = title,
|
title = title,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
maxLines = titleMaxLines,
|
||||||
maxLines = if (showTime) 1 else 2,
|
textWidth = textWidth,
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||||
)
|
)
|
||||||
@@ -794,6 +808,7 @@ private fun EventBlock(
|
|||||||
BlockTimeLabel(
|
BlockTimeLabel(
|
||||||
label = timeLabel,
|
label = timeLabel,
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
|
maxLines = timeMaxLines,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -829,10 +844,16 @@ private fun DayLoading() {
|
|||||||
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
private fun minToHm(min: Int, is24Hour: Boolean, locale: Locale): String =
|
||||||
formatMinuteOfDay(min, is24Hour, locale)
|
formatMinuteOfDay(min, is24Hour, locale)
|
||||||
|
|
||||||
private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
|
/** [abbreviated] drops the weekday, leaving the date itself (#165). */
|
||||||
|
private fun formatDayTitle(
|
||||||
|
date: LocalDate,
|
||||||
|
locale: Locale,
|
||||||
|
currentYear: Int,
|
||||||
|
abbreviated: Boolean = false,
|
||||||
|
): String =
|
||||||
formatCalendarTitle(
|
formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = "EEEdMMM",
|
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.day
|
package de.jeanlucmakiola.calendula.ui.day
|
||||||
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -41,7 +40,6 @@ internal fun DayViewPreview(
|
|||||||
ScaledViewPreview(height = height, modifier = modifier) {
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
DaySuccess(
|
DaySuccess(
|
||||||
state = state,
|
state = state,
|
||||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = state.allDayStripHeight(),
|
allDayHeight = state.allDayStripHeight(),
|
||||||
dragController = rememberTimelineDragController(),
|
dragController = rememberTimelineDragController(),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import android.net.Uri
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -98,6 +97,9 @@ import de.jeanlucmakiola.calendula.domain.timeZoneOptionOf
|
|||||||
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
import de.jeanlucmakiola.calendula.domain.zoneDescriptor
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.FieldCopier
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.copyOnLongPress
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.rememberFieldCopier
|
||||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||||
@@ -150,6 +152,7 @@ fun EventDetailScreen(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val copyField = rememberFieldCopier(snackbarHostState)
|
||||||
|
|
||||||
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
// Sharing is read-only, so it needs no WRITE_CALENDAR upgrade. The VM stages
|
||||||
// an .ics in the cache and hands back a content Uri for the chooser.
|
// an .ics in the cache and hands back a content Uri for the chooser.
|
||||||
@@ -300,7 +303,7 @@ fun EventDetailScreen(
|
|||||||
reason = s.reason,
|
reason = s.reason,
|
||||||
onRetry = viewModel::retry,
|
onRetry = viewModel::retry,
|
||||||
)
|
)
|
||||||
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +385,11 @@ private fun DeleteEventDialog(
|
|||||||
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
|
private fun EventDetailContent(
|
||||||
|
state: EventDetailUiState.Success,
|
||||||
|
copyField: FieldCopier,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
val detail = state.detail
|
val detail = state.detail
|
||||||
val instance = detail.instance
|
val instance = detail.instance
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
@@ -399,6 +406,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
// event, so it's left implicit — only Free is worth surfacing. A
|
// event, so it's left implicit — only Free is worth surfacing. A
|
||||||
// cancelled event strikes through its title.
|
// cancelled event strikes through its title.
|
||||||
Row(verticalAlignment = Alignment.Top) {
|
Row(verticalAlignment = Alignment.Top) {
|
||||||
|
val titleLabel = stringResource(R.string.event_detail_title)
|
||||||
Text(
|
Text(
|
||||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
@@ -408,7 +416,17 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.then(
|
||||||
|
// Nothing to copy off an untitled event — the placeholder
|
||||||
|
// isn't the event's own text.
|
||||||
|
if (instance.title.isNotBlank()) {
|
||||||
|
Modifier.copyOnLongPress(titleLabel, instance.title, copyField)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if (detail.availability == Availability.Free) {
|
if (detail.availability == Availability.Free) {
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
@@ -516,10 +534,11 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
// Location (conditional, tap → maps).
|
// Location (conditional, tap → maps).
|
||||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val locationLabel = stringResource(R.string.event_detail_location)
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.Default.Place,
|
icon = Icons.Default.Place,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
iconContentDescription = locationLabel,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = location,
|
text = location,
|
||||||
@@ -527,7 +546,12 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { openInMaps(context, location) }
|
.copyOnLongPress(
|
||||||
|
label = locationLabel,
|
||||||
|
text = location,
|
||||||
|
copy = copyField,
|
||||||
|
onTap = { openInMaps(context, location) },
|
||||||
|
)
|
||||||
.padding(vertical = 2.dp),
|
.padding(vertical = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -535,10 +559,15 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
|
|
||||||
// Description (conditional). URLs are auto-linked.
|
// Description (conditional). URLs are auto-linked.
|
||||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||||
|
val descriptionLabel = stringResource(R.string.event_detail_description)
|
||||||
Spacer(Modifier.height(gap))
|
Spacer(Modifier.height(gap))
|
||||||
DetailCard(
|
DetailCard(
|
||||||
icon = Icons.AutoMirrored.Filled.Notes,
|
icon = Icons.AutoMirrored.Filled.Notes,
|
||||||
iconContentDescription = stringResource(R.string.event_detail_description),
|
iconContentDescription = descriptionLabel,
|
||||||
|
// The gesture sits on the card so the icon and padding answer
|
||||||
|
// it too: every linkified URL owns the pointer over its own
|
||||||
|
// glyphs, which leaves the text itself a patchy target.
|
||||||
|
modifier = Modifier.copyOnLongPress(descriptionLabel, description, copyField),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
text = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||||
@@ -611,13 +640,14 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
|||||||
private fun DetailCard(
|
private fun DetailCard(
|
||||||
icon: ImageVector,
|
icon: ImageVector,
|
||||||
iconContentDescription: String?,
|
iconContentDescription: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.padding(16.dp),
|
modifier = Modifier.padding(16.dp),
|
||||||
|
|||||||
@@ -794,6 +794,9 @@ private fun EventEditContent(
|
|||||||
value = form.location,
|
value = form.location,
|
||||||
onValueChange = viewModel::setLocation,
|
onValueChange = viewModel::setLocation,
|
||||||
placeholder = stringResource(R.string.event_detail_location),
|
placeholder = stringResource(R.string.event_detail_location),
|
||||||
|
// A location is as often a meeting URL as an address,
|
||||||
|
// and "Zoom.us/j/123" reads wrong (#146).
|
||||||
|
capitalization = KeyboardCapitalization.None,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
@@ -2223,7 +2226,9 @@ private fun formatTimeRange(start: LocalDateTime, end: LocalDateTime, locale: Lo
|
|||||||
/**
|
/**
|
||||||
* Borderless text input used inside the cards (and as the headline title).
|
* Borderless text input used inside the cards (and as the headline title).
|
||||||
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
* Thin wrapper over the shared [InlineTextField] so the form and the rest of
|
||||||
* the app share one input style.
|
* the app share one input style. Sentence-case by default, as floret-kit is;
|
||||||
|
* a field holding an identifier rather than prose passes
|
||||||
|
* [KeyboardCapitalization.None].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun InlineField(
|
private fun InlineField(
|
||||||
@@ -2235,6 +2240,7 @@ private fun InlineField(
|
|||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
keyboardType: KeyboardType = KeyboardType.Text,
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
@@ -2249,7 +2255,7 @@ private fun InlineField(
|
|||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
keyboardType = keyboardType,
|
keyboardType = keyboardType,
|
||||||
capitalization = KeyboardCapitalization.None,
|
capitalization = capitalization,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
@@ -113,7 +114,6 @@ import androidx.compose.ui.graphics.graphicsLayer
|
|||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -134,6 +134,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
|
|||||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.AppBarSpacing
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||||
@@ -212,7 +213,6 @@ fun MonthScreen(
|
|||||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
}
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -298,10 +298,14 @@ fun MonthScreen(
|
|||||||
// carries the year instead of repeating it two lines further down. Dense has
|
// carries the year instead of repeating it two lines further down. Dense has
|
||||||
// no header to defer to, so it keeps the full title.
|
// no header to defer to, so it keeps the full title.
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
|
val (topBarTitle, topBarShortTitle) = remember(viewStyle, titleMonth, locale, today.year) {
|
||||||
titleMonth.year.toString()
|
if (viewStyle == MonthViewStyle.Continuous) {
|
||||||
} else {
|
val year = titleMonth.year.toString()
|
||||||
formatMonthTitle(titleMonth, locale, currentYear = today.year)
|
year to year
|
||||||
|
} else {
|
||||||
|
formatMonthTitle(titleMonth, locale, currentYear = today.year) to
|
||||||
|
formatMonthTitle(titleMonth, locale, currentYear = today.year, abbreviated = true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
|
||||||
@@ -386,19 +390,20 @@ fun MonthScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
MonthTopBar(
|
MonthTopBar(
|
||||||
title = topBarTitle,
|
title = topBarTitle,
|
||||||
|
shortTitle = topBarShortTitle,
|
||||||
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onJumpToDate = jumpToDate,
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -664,15 +669,16 @@ private fun ContinuousMonthContent(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun MonthTopBar(
|
private fun MonthTopBar(
|
||||||
title: String,
|
title: String,
|
||||||
|
shortTitle: String,
|
||||||
titleDate: LocalDate,
|
titleDate: LocalDate,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
@@ -680,6 +686,7 @@ private fun MonthTopBar(
|
|||||||
title = title,
|
title = title,
|
||||||
currentDate = titleDate,
|
currentDate = titleDate,
|
||||||
onJumpToDate = onJumpToDate,
|
onJumpToDate = onJumpToDate,
|
||||||
|
shortTitle = shortTitle,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -700,11 +707,17 @@ private fun MonthTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
|
cycle = quickSwitchViews,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior,
|
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||||
|
// it, but here the bar meets the header on the same surface and the
|
||||||
|
// tint is what makes that seam look like a separate block (#186).
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,7 +732,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
// Reserve the gutter so the weekday labels stay over their day columns.
|
// Reserve the gutter so the weekday labels stay over their day columns.
|
||||||
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
||||||
@@ -787,10 +800,10 @@ internal fun MonthGrid(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
// Match the weekday header's 8dp inset so day cells sit under their
|
// Match the weekday header's inset so day cells sit under their
|
||||||
// labels, and so the week-number gutter's centre lines up with the
|
// labels, and so the week-number gutter's centre lines up with the
|
||||||
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
// top bar's hamburger (4dp bar inset + 24dp half icon button).
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
val month = state.month
|
val month = state.month
|
||||||
@@ -886,7 +899,7 @@ private fun ContinuousMonthBlock(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp)
|
.padding(horizontal = AppBarSpacing.Inset)
|
||||||
.padding(bottom = CONTINUOUS_MONTH_GAP),
|
.padding(bottom = CONTINUOUS_MONTH_GAP),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
@@ -974,7 +987,7 @@ internal fun DenseMonthGrid(
|
|||||||
// padding: a lazy list scrolls its rows *through* the before-content
|
// padding: a lazy list scrolls its rows *through* the before-content
|
||||||
// padding, so scrolling to a week landed its row that far down with
|
// padding, so scrolling to a week landed its row that far down with
|
||||||
// the tail of the previous one showing above it (#191).
|
// the tail of the previous one showing above it (#191).
|
||||||
.padding(horizontal = 8.dp)
|
.padding(horizontal = AppBarSpacing.Inset)
|
||||||
.padding(top = DENSE_HEADER_GAP),
|
.padding(top = DENSE_HEADER_GAP),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||||
@@ -1441,7 +1454,7 @@ internal fun SplitMonthGrid(
|
|||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
state.weeks.forEach { week ->
|
state.weeks.forEach { week ->
|
||||||
@@ -1806,7 +1819,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 8.dp)
|
.padding(horizontal = AppBarSpacing.Inset)
|
||||||
.clipToBounds(),
|
.clipToBounds(),
|
||||||
) {
|
) {
|
||||||
if (!dense) {
|
if (!dense) {
|
||||||
@@ -2452,11 +2465,13 @@ private fun MonthBar(
|
|||||||
},
|
},
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = titleOverflow.overflow,
|
||||||
|
softWrap = titleOverflow.softWrap,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
textDecoration = declinedDecoration(event.isDeclined),
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
)
|
)
|
||||||
@@ -2514,7 +2529,7 @@ private fun MonthGridLoading() {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
repeat(6) {
|
repeat(6) {
|
||||||
@@ -2540,10 +2555,16 @@ private fun MonthGridLoading() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
|
/** [abbreviated] swaps the full month name for its three-letter form (#165). */
|
||||||
|
private fun formatMonthTitle(
|
||||||
|
ym: YearMonth,
|
||||||
|
locale: Locale,
|
||||||
|
currentYear: Int,
|
||||||
|
abbreviated: Boolean = false,
|
||||||
|
): String =
|
||||||
formatCalendarTitle(
|
formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = "LLLL",
|
skeleton = if (abbreviated) "LLL" else "LLLL",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -591,15 +591,15 @@ class SettingsViewModel @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enable or disable [view] in the quick-switch cycle, via an atomic
|
* Enable or disable [view] in the quick-switch cycle, via an atomic
|
||||||
* read-modify-write so a concurrent reorder can't clobber it. The
|
* read-modify-write so a concurrent reorder can't clobber it. Any number of
|
||||||
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
|
* views may be turned off; below two the pill hides itself (#150).
|
||||||
* own guard reads an async-echoed snapshot.
|
|
||||||
*/
|
*/
|
||||||
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
prefs.updateQuickSwitch { config ->
|
prefs.updateQuickSwitch { config ->
|
||||||
val next = if (enabled) config.enabled + view else config.enabled - view
|
config.copy(
|
||||||
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
enabled = if (enabled) config.enabled + view else config.enabled - view,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import de.jeanlucmakiola.calendula.ui.common.AgendaRangePicker
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
|
||||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||||
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
||||||
@@ -55,8 +54,8 @@ import java.time.format.TextStyle as JavaTextStyle
|
|||||||
* it belongs to, plus the two cross-view ordering lists (#24, #69).
|
* it belongs to, plus the two cross-view ordering lists (#24, #69).
|
||||||
*
|
*
|
||||||
* The quick-switch cycle and the drawer list are independent orders — a view
|
* The quick-switch cycle and the drawer list are independent orders — a view
|
||||||
* off in the cycle is still reachable from the drawer. The cycle needs at least
|
* off in the cycle is still reachable from the drawer, including when the cycle
|
||||||
* [QuickSwitchConfig.MIN_ENABLED] targets.
|
* is emptied and the pill disappears (#150).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewsScreen(
|
internal fun ViewsScreen(
|
||||||
@@ -221,8 +220,6 @@ internal fun ViewsScreen(
|
|||||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
// Turning a view off is blocked once only the minimum remain enabled.
|
|
||||||
val canDisable = config.enabled.size > QuickSwitchConfig.MIN_ENABLED
|
|
||||||
ReorderableColumn(
|
ReorderableColumn(
|
||||||
items = config.order,
|
items = config.order,
|
||||||
keyOf = { it },
|
keyOf = { it },
|
||||||
@@ -238,8 +235,6 @@ internal fun ViewsScreen(
|
|||||||
trailing = {
|
trailing = {
|
||||||
Switch(
|
Switch(
|
||||||
checked = checked,
|
checked = checked,
|
||||||
// Keep the last two on: with fewer, the pill can't switch.
|
|
||||||
enabled = !checked || canDisable,
|
|
||||||
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@@ -60,9 +59,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
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.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
@@ -72,7 +69,6 @@ import androidx.compose.ui.semantics.contentDescription
|
|||||||
import androidx.compose.ui.semantics.customActions
|
import androidx.compose.ui.semantics.customActions
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -91,7 +87,12 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
|
||||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.BlockTitle
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.MAX_TIME_LINES
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.blockTextLines
|
||||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||||
@@ -106,6 +107,7 @@ import de.jeanlucmakiola.calendula.ui.common.continuesBefore
|
|||||||
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
import de.jeanlucmakiola.calendula.ui.common.timedBlockShape
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
import de.jeanlucmakiola.calendula.ui.common.eventDragAllowed
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||||
|
import de.jeanlucmakiola.calendula.ui.common.eventTitleOverflow
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||||
@@ -190,21 +192,9 @@ fun WeekScreen(
|
|||||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||||
}
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
// The static header + all-day strip share the app bar's scrolled colour so
|
|
||||||
// the whole top region elevates together once the timeline scrolls under it.
|
|
||||||
val topSectionColor by animateColorAsState(
|
|
||||||
targetValue = if (scrollBehavior.state.overlappedFraction > 0.01f) {
|
|
||||||
MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.surface
|
|
||||||
},
|
|
||||||
label = "week-top-section-color",
|
|
||||||
)
|
|
||||||
|
|
||||||
val isOnCurrentWeek = when (val s = state) {
|
val isOnCurrentWeek = when (val s = state) {
|
||||||
// True when today falls inside the displayed week — independent of which
|
// True when today falls inside the displayed week — independent of which
|
||||||
// weekday the user picked as the first day.
|
// weekday the user picked as the first day.
|
||||||
@@ -265,19 +255,19 @@ fun WeekScreen(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
WeekTopBar(
|
WeekTopBar(
|
||||||
weekStart = weekStart,
|
weekStart = weekStart,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
selectedView = selectedView,
|
selectedView = selectedView,
|
||||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||||
|
quickSwitchViews = quickSwitchViews,
|
||||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||||
onOpenSearch = onOpenSearch,
|
onOpenSearch = onOpenSearch,
|
||||||
onJumpToDate = jumpToDate,
|
onJumpToDate = jumpToDate,
|
||||||
showTodayButton = todayInToolbar,
|
showTodayButton = todayInToolbar,
|
||||||
onToday = jumpToToday,
|
onToday = jumpToToday,
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
@@ -298,7 +288,6 @@ fun WeekScreen(
|
|||||||
WeekContent(
|
WeekContent(
|
||||||
state = state,
|
state = state,
|
||||||
slideDir = slideDir,
|
slideDir = slideDir,
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
onSwipeNext = goNext,
|
onSwipeNext = goNext,
|
||||||
onSwipePrev = goPrev,
|
onSwipePrev = goPrev,
|
||||||
onRetry = jumpToToday,
|
onRetry = jumpToToday,
|
||||||
@@ -318,7 +307,6 @@ fun WeekScreen(
|
|||||||
private fun WeekContent(
|
private fun WeekContent(
|
||||||
state: WeekUiState,
|
state: WeekUiState,
|
||||||
slideDir: Int,
|
slideDir: Int,
|
||||||
topSectionColor: Color,
|
|
||||||
onSwipeNext: () -> Unit,
|
onSwipeNext: () -> Unit,
|
||||||
onSwipePrev: () -> Unit,
|
onSwipePrev: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
@@ -383,7 +371,6 @@ private fun WeekContent(
|
|||||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||||
is WeekUiState.Success -> WeekSuccess(
|
is WeekUiState.Success -> WeekSuccess(
|
||||||
state = s,
|
state = s,
|
||||||
topSectionColor = topSectionColor,
|
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = allDayHeight,
|
allDayHeight = allDayHeight,
|
||||||
dragController = dragController,
|
dragController = dragController,
|
||||||
@@ -410,7 +397,6 @@ private fun WeekContent(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun WeekSuccess(
|
internal fun WeekSuccess(
|
||||||
state: WeekUiState.Success,
|
state: WeekUiState.Success,
|
||||||
topSectionColor: Color,
|
|
||||||
scrollState: ScrollState,
|
scrollState: ScrollState,
|
||||||
allDayHeight: Dp,
|
allDayHeight: Dp,
|
||||||
dragController: TimelineDragController,
|
dragController: TimelineDragController,
|
||||||
@@ -423,13 +409,13 @@ internal fun WeekSuccess(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(topSectionColor),
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
) {
|
) {
|
||||||
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
||||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||||
}
|
}
|
||||||
// Breathing room between the (colour-shifting) top section and the
|
// Breathing room between the top section and the scrolling timeline
|
||||||
// scrolling timeline below.
|
// below.
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
Timeline(
|
Timeline(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -449,20 +435,25 @@ private fun WeekTopBar(
|
|||||||
currentYear: Int,
|
currentYear: Int,
|
||||||
selectedView: CalendarView,
|
selectedView: CalendarView,
|
||||||
onCycleView: () -> Unit,
|
onCycleView: () -> Unit,
|
||||||
|
quickSwitchViews: List<CalendarView>,
|
||||||
onOpenDrawer: () -> Unit,
|
onOpenDrawer: () -> Unit,
|
||||||
onOpenSearch: () -> Unit,
|
onOpenSearch: () -> Unit,
|
||||||
onJumpToDate: (LocalDate) -> Unit,
|
onJumpToDate: (LocalDate) -> Unit,
|
||||||
showTodayButton: Boolean,
|
showTodayButton: Boolean,
|
||||||
onToday: () -> Unit,
|
onToday: () -> Unit,
|
||||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
|
||||||
) {
|
) {
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
|
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
|
||||||
|
formatWeekTitle(weekStart, locale, currentYear) to
|
||||||
|
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
|
||||||
|
}
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
CalendarTitleButton(
|
CalendarTitleButton(
|
||||||
title = formatWeekTitle(weekStart, locale, currentYear),
|
title = title,
|
||||||
currentDate = weekStart,
|
currentDate = weekStart,
|
||||||
onJumpToDate = onJumpToDate,
|
onJumpToDate = onJumpToDate,
|
||||||
|
shortTitle = shortTitle,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -483,17 +474,17 @@ private fun WeekTopBar(
|
|||||||
}
|
}
|
||||||
ViewSwitcherPill(
|
ViewSwitcherPill(
|
||||||
current = selectedView,
|
current = selectedView,
|
||||||
|
cycle = quickSwitchViews,
|
||||||
onCycle = onCycleView,
|
onCycle = onCycleView,
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
// Match the static top section exactly: plain surface, lifting to
|
// Deliberately flat: M3 lifts the bar to mark content scrolling under
|
||||||
// surfaceContainer once content scrolls under the bar.
|
// it, but here the bar meets the header on the same surface and the
|
||||||
|
// tint is what makes that seam look like a separate block (#186).
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||||
),
|
),
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,11 +654,13 @@ private fun AllDayBar(
|
|||||||
.semantics { contentDescription = title },
|
.semantics { contentDescription = title },
|
||||||
contentAlignment = Alignment.CenterStart,
|
contentAlignment = Alignment.CenterStart,
|
||||||
) {
|
) {
|
||||||
|
val titleOverflow = eventTitleOverflow()
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = titleOverflow.overflow,
|
||||||
|
softWrap = titleOverflow.softWrap,
|
||||||
color = eventInk(fill),
|
color = eventInk(fill),
|
||||||
textDecoration = declinedDecoration(event.isDeclined),
|
textDecoration = declinedDecoration(event.isDeclined),
|
||||||
)
|
)
|
||||||
@@ -847,7 +840,7 @@ private fun DayColumnCard(
|
|||||||
.offset(x = place.x, y = place.y)
|
.offset(x = place.x, y = place.y)
|
||||||
.width(place.width)
|
.width(place.width)
|
||||||
.height(place.height)
|
.height(place.height)
|
||||||
.padding(horizontal = 1.dp),
|
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -888,24 +881,47 @@ private fun EventBlock(
|
|||||||
// Only full-width (non-overlapping) blocks that are tall enough show the
|
// Only full-width (non-overlapping) blocks that are tall enough show the
|
||||||
// time. On narrow overlapping columns we drop it so the title can wrap to
|
// time. On narrow overlapping columns we drop it so the title can wrap to
|
||||||
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||||
// afford both lines spends its space on the title.
|
// afford both lines spends its space on the title. Height decides that on
|
||||||
val showTime = block.endMin - block.startMin >= 45 &&
|
// its own: a duration threshold would keep hiding the time on a half-hour
|
||||||
block.laneCount == 1 &&
|
// block the user has pinched open to three times the room it needs.
|
||||||
|
val showTime = block.laneCount == 1 &&
|
||||||
available >= titleLineHeight + timeLineHeight
|
available >= titleLineHeight + timeLineHeight
|
||||||
|
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
|
||||||
// A short block drops the title rather than serving a horizontally sliced
|
// A short block drops the title rather than serving a horizontally sliced
|
||||||
// one: half a letter reads as a rendering fault, while a bare colour chip
|
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||||
// reads as what it is — an event too brief to label. Tap still opens it, and
|
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||||
// the semantics description carries the full title either way.
|
// the semantics description carries the full title either way.
|
||||||
val showTitle = available >= titleLineHeight
|
val showTitle = available >= titleLineHeight
|
||||||
// Wrap the title across as many lines as the block can fit — but only once a
|
// The title is served first, out of everything the block has left once the
|
||||||
// line is wide enough to hold more than a syllable. Below that the extra
|
// time is down to one line — but only takes the lines it will actually use,
|
||||||
// lines just stack fragments of the word, and one ellipsised line reads
|
// and only wraps at all once a line is wide enough to hold more than a
|
||||||
// better.
|
// syllable. Below that the extra lines just stack fragments of the word.
|
||||||
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||||
|
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||||
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
|
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
|
||||||
1
|
1
|
||||||
} else {
|
} else {
|
||||||
(contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
blockTextLines(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
textWidth = textWidth,
|
||||||
|
max = titleBudget,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// A week column is narrower than a "09:30–11:00" range, so the label takes a
|
||||||
|
// second line rather than lose its end — but only out of a line the title
|
||||||
|
// measured itself as not needing, never one it would have filled.
|
||||||
|
val spare = available - titleLineHeight * titleMaxLines -
|
||||||
|
if (showTime) timeLineHeight else 0.dp
|
||||||
|
val timeMaxLines = if (showTime && spare >= timeLineHeight) {
|
||||||
|
blockTextLines(
|
||||||
|
text = timeLabel,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
textWidth = textWidth,
|
||||||
|
max = MAX_TIME_LINES,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
1
|
||||||
}
|
}
|
||||||
val dimCutoff = LocalDimCutoff.current
|
val dimCutoff = LocalDimCutoff.current
|
||||||
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
val dimmed = dimCutoff != null && block.event.hasEnded(dimCutoff)
|
||||||
@@ -941,7 +957,7 @@ private fun EventBlock(
|
|||||||
// After clickable, so it is the inner node and wins the main pass;
|
// After clickable, so it is the inner node and wins the main pass;
|
||||||
// the tap still works, since a drag consumes the up.
|
// the tap still works, since a drag consumes the up.
|
||||||
.then(dragModifier)
|
.then(dragModifier)
|
||||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp)
|
||||||
.semantics {
|
.semantics {
|
||||||
contentDescription = "$title, $timeLabel"
|
contentDescription = "$title, $timeLabel"
|
||||||
if (moveAction != null) customActions = listOf(moveAction)
|
if (moveAction != null) customActions = listOf(moveAction)
|
||||||
@@ -949,11 +965,10 @@ private fun EventBlock(
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
if (showTitle) {
|
if (showTitle) {
|
||||||
Text(
|
BlockTitle(
|
||||||
text = title,
|
title = title,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
maxLines = titleMaxLines,
|
maxLines = titleMaxLines,
|
||||||
overflow = TextOverflow.Ellipsis,
|
textWidth = textWidth,
|
||||||
color = eventInk(fill, alpha = 0.85f),
|
color = eventInk(fill, alpha = 0.85f),
|
||||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||||
)
|
)
|
||||||
@@ -962,6 +977,7 @@ private fun EventBlock(
|
|||||||
BlockTimeLabel(
|
BlockTimeLabel(
|
||||||
label = timeLabel,
|
label = timeLabel,
|
||||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||||
|
maxLines = timeMaxLines,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1033,13 +1049,18 @@ private fun minToHm(min: Int, is24Hour: Boolean, locale: java.util.Locale): Stri
|
|||||||
* "December" with no year would be actively misleading while the reader is
|
* "December" with no year would be actively misleading while the reader is
|
||||||
* looking straight at January dates.
|
* looking straight at January dates.
|
||||||
*/
|
*/
|
||||||
private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
|
private fun formatWeekTitle(
|
||||||
|
weekStart: LocalDate,
|
||||||
|
locale: Locale,
|
||||||
|
currentYear: Int,
|
||||||
|
abbreviated: Boolean = false,
|
||||||
|
): String {
|
||||||
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
val weekEnd = weekStart.plus(6, kotlinx.datetime.DateTimeUnit.DAY)
|
||||||
return formatCalendarTitle(
|
return formatCalendarTitle(
|
||||||
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||||
locale = locale,
|
locale = locale,
|
||||||
currentYear = currentYear,
|
currentYear = currentYear,
|
||||||
skeleton = "LLLL",
|
skeleton = if (abbreviated) "LLL" else "LLLL",
|
||||||
forceYear = weekEnd.year != weekStart.year,
|
forceYear = weekEnd.year != weekStart.year,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package de.jeanlucmakiola.calendula.ui.week
|
package de.jeanlucmakiola.calendula.ui.week
|
||||||
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -47,7 +46,6 @@ internal fun WeekViewPreview(
|
|||||||
ScaledViewPreview(height = height, modifier = modifier) {
|
ScaledViewPreview(height = height, modifier = modifier) {
|
||||||
WeekSuccess(
|
WeekSuccess(
|
||||||
state = state,
|
state = state,
|
||||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
|
||||||
scrollState = scrollState,
|
scrollState = scrollState,
|
||||||
allDayHeight = state.allDayStripHeight(),
|
allDayHeight = state.allDayStripHeight(),
|
||||||
dragController = rememberTimelineDragController(),
|
dragController = rememberTimelineDragController(),
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
<string name="state_failure_no_calendars_action">Open system calendar settings</string>
|
||||||
<string name="state_failure_provider">Could not read the calendar.</string>
|
<string name="state_failure_provider">Could not read the calendar.</string>
|
||||||
|
|
||||||
|
<!-- Long-press a field to copy it (#195) -->
|
||||||
|
<string name="field_copy_action">Copy</string>
|
||||||
|
<string name="field_copied">Copied to clipboard</string>
|
||||||
|
<string name="field_copy_failed">Couldn\'t copy that</string>
|
||||||
|
|
||||||
<!-- Permission flow (F1) -->
|
<!-- Permission flow (F1) -->
|
||||||
<string name="permission_rationale_title">See all your events, beautifully</string>
|
<string name="permission_rationale_title">See all your events, beautifully</string>
|
||||||
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string>
|
<string name="permission_rationale_body">Calendula needs access to your calendar to show and manage your events.</string>
|
||||||
@@ -188,6 +193,7 @@
|
|||||||
<string name="event_detail_all_day">All day</string>
|
<string name="event_detail_all_day">All day</string>
|
||||||
<string name="event_detail_calendar">Calendar</string>
|
<string name="event_detail_calendar">Calendar</string>
|
||||||
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
<string name="event_detail_calendar_unknown">Unknown calendar</string>
|
||||||
|
<string name="event_detail_title">Title</string>
|
||||||
<string name="event_detail_location">Location</string>
|
<string name="event_detail_location">Location</string>
|
||||||
<string name="event_detail_description">Description</string>
|
<string name="event_detail_description">Description</string>
|
||||||
<string name="event_detail_attendees">Attendees</string>
|
<string name="event_detail_attendees">Attendees</string>
|
||||||
@@ -498,7 +504,7 @@
|
|||||||
<string name="month_split_expand">Show the whole month</string>
|
<string name="month_split_expand">Show the whole month</string>
|
||||||
<string name="month_split_collapse">Show the day\'s events</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_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_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. With fewer than two views turned on the button is hidden. Turned-off views stay reachable from the navigation menu.</string>
|
||||||
<string name="settings_drawer_order_header">Navigation menu</string>
|
<string name="settings_drawer_order_header">Navigation menu</string>
|
||||||
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
|
<string name="settings_drawer_order_hint">Drag to reorder the views listed in the navigation menu.</string>
|
||||||
<string name="reorder_drag_handle">Drag to reorder</string>
|
<string name="reorder_drag_handle">Drag to reorder</string>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class AppBarSpacingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the pill's background ends one inset from the screen edge`() {
|
||||||
|
// The padding is applied inside M3's actions row, so it carries that
|
||||||
|
// row's own inset on top of whatever the pill asks for (#165).
|
||||||
|
val rendered = AppBarSpacing.BarPadding + AppBarSpacing.ContainerTrailingInset
|
||||||
|
assertThat(rendered).isEqualTo(AppBarSpacing.Inset)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `trailing insets stay non-negative`() {
|
||||||
|
// Modifier.padding throws on a negative value.
|
||||||
|
assertThat(AppBarSpacing.ContainerTrailingInset.value).isAtLeast(0f)
|
||||||
|
assertThat(AppBarSpacing.IconTrailingInset.value).isAtLeast(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an icon button's glyph ends further in than a container's edge`() {
|
||||||
|
assertThat(AppBarSpacing.IconTrailingInset.value)
|
||||||
|
.isGreaterThan(AppBarSpacing.Inset.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class CalendarTitleButtonTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps the full title when it fits`() {
|
||||||
|
assertThat(titleFor("September", "Sep", titleWidth = 200, availableWidth = 300))
|
||||||
|
.isEqualTo("September")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `keeps the full title when it fills the slot exactly`() {
|
||||||
|
assertThat(titleFor("September", "Sep", titleWidth = 300, availableWidth = 300))
|
||||||
|
.isEqualTo("September")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to the short title when the full one overflows`() {
|
||||||
|
assertThat(titleFor("September", "Sep", titleWidth = 400, availableWidth = 300))
|
||||||
|
.isEqualTo("Sep")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a caller with nothing to shorten still gets its own title back`() {
|
||||||
|
assertThat(titleFor("Wed, 2 Sep", "Wed, 2 Sep", titleWidth = 400, availableWidth = 300))
|
||||||
|
.isEqualTo("Wed, 2 Sep")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package de.jeanlucmakiola.calendula.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class EventTitleOverflowTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a single LTR line clips, so the title runs to the chip's edge`() {
|
||||||
|
val result = eventTitleOverflowFor(rtl = false, singleLine = true)
|
||||||
|
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
|
||||||
|
// Load-bearing: with softWrap on, a clipped line breaks at the last
|
||||||
|
// whole word and shows less title than the ellipsis did (#164).
|
||||||
|
assertThat(result.softWrap).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `RTL keeps the ellipsis, which truncates at the logical end`() {
|
||||||
|
// Clipping with softWrap off cuts at the node's left edge, which in RTL
|
||||||
|
// is the end of the string — the title would lose its beginning.
|
||||||
|
val result = eventTitleOverflowFor(rtl = true, singleLine = true)
|
||||||
|
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
|
||||||
|
assertThat(result.softWrap).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a multi-line block keeps the ellipsis, since wrapping needs softWrap`() {
|
||||||
|
val result = eventTitleOverflowFor(rtl = false, singleLine = false)
|
||||||
|
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
|
||||||
|
assertThat(result.softWrap).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi-line in RTL keeps the ellipsis too`() {
|
||||||
|
val result = eventTitleOverflowFor(rtl = true, singleLine = false)
|
||||||
|
assertThat(result.overflow).isEqualTo(TextOverflow.Ellipsis)
|
||||||
|
assertThat(result.softWrap).isTrue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,4 +120,30 @@ class ViewBackStackTest {
|
|||||||
)
|
)
|
||||||
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
assertThat(config.cycle).containsExactly(CalendarView.Agenda, CalendarView.Month).inOrder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cycle can be emptied down to no views at all`() {
|
||||||
|
// #150: the settings screen no longer holds a floor, so every view may go.
|
||||||
|
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = emptySet())
|
||||||
|
assertThat(config.cycle).isEmpty()
|
||||||
|
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one enabled view is below the cycle minimum, so the pill hides`() {
|
||||||
|
// A single target is not a switch — it hides rather than becoming a
|
||||||
|
// jump-to-one-view button, which would be dead once you were there.
|
||||||
|
val config = QuickSwitchConfig(order = IMPLEMENTED_VIEWS, enabled = setOf(CalendarView.Day))
|
||||||
|
assertThat(config.cycle).containsExactly(CalendarView.Day)
|
||||||
|
assertThat(config.cycle.size).isLessThan(QuickSwitchConfig.MIN_CYCLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two enabled views are enough to show the pill`() {
|
||||||
|
val config = QuickSwitchConfig(
|
||||||
|
order = IMPLEMENTED_VIEWS,
|
||||||
|
enabled = setOf(CalendarView.Day, CalendarView.Month),
|
||||||
|
)
|
||||||
|
assertThat(config.cycle.size).isAtLeast(QuickSwitchConfig.MIN_CYCLE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user