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]
|
||||
|
||||
### 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
|
||||
|
||||
### Fixed
|
||||
@@ -1560,6 +1572,8 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#123]: https://codeberg.org/jlmakiola/calendula/issues/123
|
||||
[#163]: https://codeberg.org/jlmakiola/calendula/issues/163
|
||||
[#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
|
||||
[#187]: https://codeberg.org/jlmakiola/calendula/issues/187
|
||||
[#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.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -40,9 +39,9 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
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.hasEnded
|
||||
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.floret.identity.animateItemMotion
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
@@ -95,7 +96,6 @@ fun AgendaScreen(
|
||||
val showToday by viewModel.showToday.collectAsStateWithLifecycle()
|
||||
val weekStart by viewModel.weekStart.collectAsStateWithLifecycle()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
var showRangePicker by remember { mutableStateOf(false) }
|
||||
@@ -130,16 +130,16 @@ fun AgendaScreen(
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
AgendaTopBar(
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = viewModel::goToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -159,13 +159,24 @@ fun AgendaScreen(
|
||||
// One bar at the top: the "showing …" header on the left and the
|
||||
// session range switcher on the right (one settings toggle).
|
||||
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(
|
||||
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
|
||||
.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(
|
||||
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,
|
||||
* 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(
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
// A plain label rather than a CalendarTitleButton, so it takes that
|
||||
// one's inset and one-line clamp itself.
|
||||
Text(
|
||||
text = stringResource(R.string.view_agenda),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = AppBarSpacing.TitleInset),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -440,14 +459,16 @@ private fun AgendaTopBar(
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
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(
|
||||
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.animateDpAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
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.unit.Constraints
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
* 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)
|
||||
@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()) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
}
|
||||
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
|
||||
Crossfade(
|
||||
targetState = label,
|
||||
animationSpec = spec,
|
||||
@@ -37,8 +173,9 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = maxLines,
|
||||
overflow = overflow.overflow,
|
||||
softWrap = overflow.softWrap,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -12,6 +13,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -19,6 +21,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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 de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.LocalDate
|
||||
@@ -31,10 +35,9 @@ import kotlinx.datetime.LocalDate
|
||||
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
||||
* visible day, week start or month anchor).
|
||||
*
|
||||
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
|
||||
* start alignment: M3 places the title flush against the navigation icon's
|
||||
* trailing edge, and the title is hit-tested on top of it, so a negative offset
|
||||
* would swallow taps meant for the menu button.
|
||||
* [shortTitle] replaces [title] when the full one does not fit the width the app
|
||||
* bar hands the title slot (#165). Either way the line clamps to one and
|
||||
* ellipsises, so pass a [shortTitle] wherever there is something left to drop.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarTitleButton(
|
||||
@@ -42,6 +45,7 @@ fun CalendarTitleButton(
|
||||
currentDate: LocalDate,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
shortTitle: String = title,
|
||||
) {
|
||||
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
@@ -53,14 +57,27 @@ fun CalendarTitleButton(
|
||||
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||
role = Role.Button,
|
||||
) { showDatePicker = true }
|
||||
.padding(horizontal = 8.dp),
|
||||
.padding(horizontal = AppBarSpacing.TitleInset),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
val style = MaterialTheme.typography.titleLarge
|
||||
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) {
|
||||
val measurer = rememberTextMeasurer()
|
||||
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(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
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]
|
||||
* 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
|
||||
* disabled here stays reachable there.
|
||||
* disabled here stays reachable there — including when [cycle] is emptied and
|
||||
* the pill disappears altogether.
|
||||
*/
|
||||
data class QuickSwitchConfig(
|
||||
val order: List<CalendarView>,
|
||||
@@ -67,14 +68,15 @@ data class QuickSwitchConfig(
|
||||
val cycle: List<CalendarView> get() = order.filter { it in enabled }
|
||||
|
||||
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. */
|
||||
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.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -714,7 +713,7 @@ private fun DragCopy(
|
||||
width = with(density) { sizePx.width.toDp() },
|
||||
height = with(density) { sizePx.height.toDp() },
|
||||
)
|
||||
.padding(horizontal = 1.dp)
|
||||
.padding(horizontal = BLOCK_OUTER_INSET)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.02f * lift
|
||||
scaleY = 1f + 0.02f * lift
|
||||
@@ -724,14 +723,16 @@ private fun DragCopy(
|
||||
clip = false
|
||||
}
|
||||
.background(fill, shape)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp),
|
||||
) {
|
||||
Column {
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
if (label != null) {
|
||||
@@ -739,7 +740,8 @@ private fun DragCopy(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,39 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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
|
||||
* (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
|
||||
fun ViewSwitcherPill(
|
||||
current: CalendarView,
|
||||
cycle: List<CalendarView>,
|
||||
onCycle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
trailingInset: Dp = AppBarSpacing.ContainerTrailingInset,
|
||||
) {
|
||||
if (cycle.size < QuickSwitchConfig.MIN_CYCLE) return
|
||||
FilledTonalButton(
|
||||
onClick = onCycle,
|
||||
shape = MaterialTheme.shapes.large,
|
||||
modifier = modifier,
|
||||
modifier = modifier.padding(end = trailingInset),
|
||||
) {
|
||||
Text(stringResource(current.labelRes))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@@ -53,10 +52,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
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.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
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.CalendarView
|
||||
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.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.ghostAlpha
|
||||
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.eventDragAllowed
|
||||
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.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
@@ -170,21 +172,9 @@ fun DayScreen(
|
||||
initialDateIso?.let { viewModel.goToDate(LocalDate.parse(it)) }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
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) {
|
||||
is DayUiState.Success -> s.date == s.today
|
||||
else -> true
|
||||
@@ -242,19 +232,19 @@ fun DayScreen(
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
DayTopBar(
|
||||
date = date,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
onJumpToDate = jumpToDate,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -269,7 +259,6 @@ fun DayScreen(
|
||||
DayContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
@@ -287,7 +276,6 @@ fun DayScreen(
|
||||
private fun DayContent(
|
||||
state: DayUiState,
|
||||
slideDir: Int,
|
||||
topSectionColor: Color,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
@@ -348,7 +336,6 @@ private fun DayContent(
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
@@ -374,7 +361,6 @@ private fun DayContent(
|
||||
@Composable
|
||||
internal fun DaySuccess(
|
||||
state: DayUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
@@ -391,10 +377,10 @@ internal fun DaySuccess(
|
||||
onEventClick = onEventClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topSectionColor),
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
// Breathing room between the (colour-shifting) top section and the
|
||||
// scrolling timeline below.
|
||||
// Breathing room between the top section and the scrolling timeline
|
||||
// below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Timeline(
|
||||
state = state,
|
||||
@@ -414,20 +400,25 @@ private fun DayTopBar(
|
||||
currentYear: Int,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val (title, shortTitle) = remember(date, locale, currentYear) {
|
||||
formatDayTitle(date, locale, currentYear) to
|
||||
formatDayTitle(date, locale, currentYear, abbreviated = true)
|
||||
}
|
||||
TopAppBar(
|
||||
title = {
|
||||
CalendarTitleButton(
|
||||
title = formatDayTitle(date, locale, currentYear),
|
||||
title = title,
|
||||
currentDate = date,
|
||||
onJumpToDate = onJumpToDate,
|
||||
shortTitle = shortTitle,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -448,15 +439,17 @@ private fun DayTopBar(
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
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(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -526,11 +519,13 @@ private fun AllDayBar(
|
||||
.semantics { contentDescription = title },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
)
|
||||
@@ -691,6 +686,7 @@ private fun DayColumnCard(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = place.height,
|
||||
width = place.width,
|
||||
date = date,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
@@ -699,7 +695,7 @@ private fun DayColumnCard(
|
||||
.offset(x = place.x, y = place.y)
|
||||
.width(place.width)
|
||||
.height(place.height)
|
||||
.padding(horizontal = 1.dp),
|
||||
.padding(horizontal = BLOCK_OUTER_INSET),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -716,6 +712,7 @@ private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
width: Dp,
|
||||
date: LocalDate,
|
||||
dragController: TimelineDragController,
|
||||
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
|
||||
// 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.
|
||||
// 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 showTime = block.endMin - block.startMin >= 45 &&
|
||||
available >= titleLineHeight + timeLineHeight
|
||||
val showTime = available >= titleLineHeight + timeLineHeight
|
||||
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 fill = eventFill(block.event.color, dark, soften)
|
||||
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;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
@@ -781,11 +796,10 @@ private fun EventBlock(
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
BlockTitle(
|
||||
title = title,
|
||||
maxLines = titleMaxLines,
|
||||
textWidth = textWidth,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||
)
|
||||
@@ -794,6 +808,7 @@ private fun EventBlock(
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
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 =
|
||||
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(
|
||||
date = java.time.LocalDate.of(date.year, date.month.ordinal + 1, date.day),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = "EEEdMMM",
|
||||
skeleton = if (abbreviated) "dMMM" else "EEEdMMM",
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.day
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -41,7 +40,6 @@ internal fun DayViewPreview(
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
DaySuccess(
|
||||
state = state,
|
||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
|
||||
@@ -9,7 +9,6 @@ import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.floret.identity.predictiveBack
|
||||
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.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
@@ -150,6 +152,7 @@ fun EventDetailScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val copyField = rememberFieldCopier(snackbarHostState)
|
||||
|
||||
// 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.
|
||||
@@ -300,7 +303,7 @@ fun EventDetailScreen(
|
||||
reason = s.reason,
|
||||
onRetry = viewModel::retry,
|
||||
)
|
||||
is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
|
||||
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +385,11 @@ private fun DeleteEventDialog(
|
||||
|
||||
|
||||
@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 instance = detail.instance
|
||||
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
|
||||
// cancelled event strikes through its title.
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
val titleLabel = stringResource(R.string.event_detail_title)
|
||||
Text(
|
||||
text = instance.title.ifBlank { stringResource(R.string.event_untitled) },
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
@@ -408,7 +416,17 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
} else {
|
||||
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) {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
@@ -516,10 +534,11 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
// Location (conditional, tap → maps).
|
||||
instance.location?.takeIf { it.isNotBlank() }?.let { location ->
|
||||
val context = LocalContext.current
|
||||
val locationLabel = stringResource(R.string.event_detail_location)
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
icon = Icons.Default.Place,
|
||||
iconContentDescription = stringResource(R.string.event_detail_location),
|
||||
iconContentDescription = locationLabel,
|
||||
) {
|
||||
Text(
|
||||
text = location,
|
||||
@@ -527,7 +546,12 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { openInMaps(context, location) }
|
||||
.copyOnLongPress(
|
||||
label = locationLabel,
|
||||
text = location,
|
||||
copy = copyField,
|
||||
onTap = { openInMaps(context, location) },
|
||||
)
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
@@ -535,10 +559,15 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
|
||||
// Description (conditional). URLs are auto-linked.
|
||||
detail.description?.takeIf { it.isNotBlank() }?.let { description ->
|
||||
val descriptionLabel = stringResource(R.string.event_detail_description)
|
||||
Spacer(Modifier.height(gap))
|
||||
DetailCard(
|
||||
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 = linkifyUrls(description, MaterialTheme.colorScheme.primary),
|
||||
@@ -611,13 +640,14 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
private fun DetailCard(
|
||||
icon: ImageVector,
|
||||
iconContentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
|
||||
@@ -794,6 +794,9 @@ private fun EventEditContent(
|
||||
value = form.location,
|
||||
onValueChange = viewModel::setLocation,
|
||||
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
|
||||
.weight(1f)
|
||||
.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).
|
||||
* 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
|
||||
private fun InlineField(
|
||||
@@ -2235,6 +2240,7 @@ private fun InlineField(
|
||||
minLines: Int = 1,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences,
|
||||
modifier: Modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
@@ -2249,7 +2255,7 @@ private fun InlineField(
|
||||
minLines = minLines,
|
||||
enabled = enabled,
|
||||
keyboardType = keyboardType,
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
capitalization = capitalization,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ import androidx.compose.ui.geometry.Offset
|
||||
import kotlin.math.roundToInt
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||
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.MoveRequest
|
||||
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.graphics.Color
|
||||
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.pointerInput
|
||||
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.common.formatCalendarTitle
|
||||
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.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
@@ -212,7 +213,6 @@ fun MonthScreen(
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -298,10 +298,14 @@ fun MonthScreen(
|
||||
// carries the year instead of repeating it two lines further down. Dense has
|
||||
// no header to defer to, so it keeps the full title.
|
||||
val locale = currentLocale()
|
||||
val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
|
||||
titleMonth.year.toString()
|
||||
} else {
|
||||
formatMonthTitle(titleMonth, locale, currentYear = today.year)
|
||||
val (topBarTitle, topBarShortTitle) = remember(viewStyle, titleMonth, locale, today.year) {
|
||||
if (viewStyle == MonthViewStyle.Continuous) {
|
||||
val year = titleMonth.year.toString()
|
||||
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).
|
||||
@@ -386,19 +390,20 @@ fun MonthScreen(
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
MonthTopBar(
|
||||
title = topBarTitle,
|
||||
shortTitle = topBarShortTitle,
|
||||
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
onJumpToDate = jumpToDate,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -664,15 +669,16 @@ private fun ContinuousMonthContent(
|
||||
@Composable
|
||||
private fun MonthTopBar(
|
||||
title: String,
|
||||
shortTitle: String,
|
||||
titleDate: LocalDate,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
@@ -680,6 +686,7 @@ private fun MonthTopBar(
|
||||
title = title,
|
||||
currentDate = titleDate,
|
||||
onJumpToDate = onJumpToDate,
|
||||
shortTitle = shortTitle,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -700,11 +707,17 @@ private fun MonthTopBar(
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
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(
|
||||
modifier = Modifier
|
||||
.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.
|
||||
if (showWeekNumbers) Spacer(Modifier.width(WEEK_NUMBER_GUTTER))
|
||||
@@ -787,10 +800,10 @@ internal fun MonthGrid(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.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
|
||||
// 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),
|
||||
) {
|
||||
val month = state.month
|
||||
@@ -886,7 +899,7 @@ private fun ContinuousMonthBlock(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(horizontal = AppBarSpacing.Inset)
|
||||
.padding(bottom = CONTINUOUS_MONTH_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
@@ -974,7 +987,7 @@ internal fun DenseMonthGrid(
|
||||
// padding: a lazy list scrolls its rows *through* the before-content
|
||||
// padding, so scrolling to a week landed its row that far down with
|
||||
// the tail of the previous one showing above it (#191).
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(horizontal = AppBarSpacing.Inset)
|
||||
.padding(top = DENSE_HEADER_GAP),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
// Bottom inset clears the FAB stack so the last row stays tappable.
|
||||
@@ -1441,7 +1454,7 @@ internal fun SplitMonthGrid(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
state.weeks.forEach { week ->
|
||||
@@ -1806,7 +1819,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp)
|
||||
.padding(horizontal = AppBarSpacing.Inset)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
if (!dense) {
|
||||
@@ -2452,11 +2465,13 @@ private fun MonthBar(
|
||||
},
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
)
|
||||
@@ -2514,7 +2529,7 @@ private fun MonthGridLoading() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
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(
|
||||
date = java.time.LocalDate.of(ym.year, ym.month.ordinal + 1, 1),
|
||||
locale = locale,
|
||||
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
|
||||
* read-modify-write so a concurrent reorder can't clobber it. The
|
||||
* MIN_ENABLED floor is re-checked inside the transform, because the screen's
|
||||
* own guard reads an async-echoed snapshot.
|
||||
* read-modify-write so a concurrent reorder can't clobber it. Any number of
|
||||
* views may be turned off; below two the pill hides itself (#150).
|
||||
*/
|
||||
fun setQuickSwitchViewEnabled(view: CalendarView, enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
prefs.updateQuickSwitch { config ->
|
||||
val next = if (enabled) config.enabled + view else config.enabled - view
|
||||
if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
|
||||
config.copy(
|
||||
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.IMPLEMENTED_VIEWS
|
||||
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.agendaRangeLabel
|
||||
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).
|
||||
*
|
||||
* 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
|
||||
* [QuickSwitchConfig.MIN_ENABLED] targets.
|
||||
* off in the cycle is still reachable from the drawer, including when the cycle
|
||||
* is emptied and the pill disappears (#150).
|
||||
*/
|
||||
@Composable
|
||||
internal fun ViewsScreen(
|
||||
@@ -221,8 +220,6 @@ internal fun ViewsScreen(
|
||||
SectionHeader(stringResource(R.string.settings_quick_switch_header))
|
||||
SettingsHint(stringResource(R.string.settings_quick_switch_hint))
|
||||
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(
|
||||
items = config.order,
|
||||
keyOf = { it },
|
||||
@@ -238,8 +235,6 @@ internal fun ViewsScreen(
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
// Keep the last two on: with fewer, the pill can't switch.
|
||||
enabled = !checked || canDisable,
|
||||
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
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.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
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.semantics
|
||||
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.LayoutDirection
|
||||
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.EventDimAlpha
|
||||
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.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.ghostAlpha
|
||||
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.eventDragAllowed
|
||||
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.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
@@ -190,21 +192,9 @@ fun WeekScreen(
|
||||
derivedStateOf { if (dimCompleted) nowState.value else null }
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
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) {
|
||||
// True when today falls inside the displayed week — independent of which
|
||||
// weekday the user picked as the first day.
|
||||
@@ -265,19 +255,19 @@ fun WeekScreen(
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
WeekTopBar(
|
||||
weekStart = weekStart,
|
||||
currentYear = currentYear,
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
quickSwitchViews = quickSwitchViews,
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
onJumpToDate = jumpToDate,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -298,7 +288,6 @@ fun WeekScreen(
|
||||
WeekContent(
|
||||
state = state,
|
||||
slideDir = slideDir,
|
||||
topSectionColor = topSectionColor,
|
||||
onSwipeNext = goNext,
|
||||
onSwipePrev = goPrev,
|
||||
onRetry = jumpToToday,
|
||||
@@ -318,7 +307,6 @@ fun WeekScreen(
|
||||
private fun WeekContent(
|
||||
state: WeekUiState,
|
||||
slideDir: Int,
|
||||
topSectionColor: Color,
|
||||
onSwipeNext: () -> Unit,
|
||||
onSwipePrev: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
@@ -383,7 +371,6 @@ private fun WeekContent(
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
@@ -410,7 +397,6 @@ private fun WeekContent(
|
||||
@Composable
|
||||
internal fun WeekSuccess(
|
||||
state: WeekUiState.Success,
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
@@ -423,13 +409,13 @@ internal fun WeekSuccess(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topSectionColor),
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
WeekDayHeader(days = state.days, today = state.today, onOpenDay = onOpenDay)
|
||||
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
|
||||
}
|
||||
// Breathing room between the (colour-shifting) top section and the
|
||||
// scrolling timeline below.
|
||||
// Breathing room between the top section and the scrolling timeline
|
||||
// below.
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Timeline(
|
||||
state = state,
|
||||
@@ -449,20 +435,25 @@ private fun WeekTopBar(
|
||||
currentYear: Int,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
quickSwitchViews: List<CalendarView>,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
val locale = currentLocale()
|
||||
val (title, shortTitle) = remember(weekStart, locale, currentYear) {
|
||||
formatWeekTitle(weekStart, locale, currentYear) to
|
||||
formatWeekTitle(weekStart, locale, currentYear, abbreviated = true)
|
||||
}
|
||||
TopAppBar(
|
||||
title = {
|
||||
CalendarTitleButton(
|
||||
title = formatWeekTitle(weekStart, locale, currentYear),
|
||||
title = title,
|
||||
currentDate = weekStart,
|
||||
onJumpToDate = onJumpToDate,
|
||||
shortTitle = shortTitle,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -483,17 +474,17 @@ private fun WeekTopBar(
|
||||
}
|
||||
ViewSwitcherPill(
|
||||
current = selectedView,
|
||||
cycle = quickSwitchViews,
|
||||
onCycle = onCycleView,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
)
|
||||
},
|
||||
// Match the static top section exactly: plain surface, lifting to
|
||||
// surfaceContainer once content scrolls under the bar.
|
||||
// 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.surfaceContainer,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -663,11 +654,13 @@ private fun AllDayBar(
|
||||
.semantics { contentDescription = title },
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
val titleOverflow = eventTitleOverflow()
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
overflow = titleOverflow.overflow,
|
||||
softWrap = titleOverflow.softWrap,
|
||||
color = eventInk(fill),
|
||||
textDecoration = declinedDecoration(event.isDeclined),
|
||||
)
|
||||
@@ -847,7 +840,7 @@ private fun DayColumnCard(
|
||||
.offset(x = place.x, y = place.y)
|
||||
.width(place.width)
|
||||
.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
|
||||
// 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
|
||||
// afford both lines spends its space on the title.
|
||||
val showTime = block.endMin - block.startMin >= 45 &&
|
||||
block.laneCount == 1 &&
|
||||
// afford both lines spends its space on the title. Height decides that on
|
||||
// its own: 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 showTime = block.laneCount == 1 &&
|
||||
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
|
||||
// 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
|
||||
// the semantics description carries the full title either way.
|
||||
val showTitle = available >= titleLineHeight
|
||||
// Wrap the title across as many lines as the block can fit — but only once a
|
||||
// line is wide enough to hold more than a syllable. Below that the extra
|
||||
// lines just stack fragments of the word, and one ellipsised line reads
|
||||
// better.
|
||||
// The title is served first, out of everything the block has left once the
|
||||
// time is down to one line — but only takes the lines it will actually use,
|
||||
// and only wraps at all once a line is wide enough to hold more than a
|
||||
// syllable. Below that the extra lines just stack fragments of the word.
|
||||
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
|
||||
1
|
||||
} 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 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;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = 2.dp)
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
@@ -949,11 +965,10 @@ private fun EventBlock(
|
||||
) {
|
||||
Column {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
BlockTitle(
|
||||
title = title,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textWidth = textWidth,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
textDecoration = declinedDecoration(block.event.isDeclined),
|
||||
)
|
||||
@@ -962,6 +977,7 @@ private fun EventBlock(
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
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
|
||||
* 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)
|
||||
return formatCalendarTitle(
|
||||
date = java.time.LocalDate.of(weekStart.year, weekStart.month.ordinal + 1, 1),
|
||||
locale = locale,
|
||||
currentYear = currentYear,
|
||||
skeleton = "LLLL",
|
||||
skeleton = if (abbreviated) "LLL" else "LLLL",
|
||||
forceYear = weekEnd.year != weekStart.year,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.week
|
||||
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -47,7 +46,6 @@ internal fun WeekViewPreview(
|
||||
ScaledViewPreview(height = height, modifier = modifier) {
|
||||
WeekSuccess(
|
||||
state = state,
|
||||
topSectionColor = MaterialTheme.colorScheme.surface,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = state.allDayStripHeight(),
|
||||
dragController = rememberTimelineDragController(),
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
<string name="state_failure_no_calendars_action">Open system calendar settings</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) -->
|
||||
<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>
|
||||
@@ -188,6 +193,7 @@
|
||||
<string name="event_detail_all_day">All day</string>
|
||||
<string name="event_detail_calendar">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_description">Description</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_collapse">Show the day\'s events</string>
|
||||
<string name="settings_quick_switch_header">Quick-switch button</string>
|
||||
<string name="settings_quick_switch_hint">Choose which views the top-right button cycles through, and drag to reorder them. Turned-off views stay reachable from the navigation menu.</string>
|
||||
<string name="settings_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_hint">Drag to reorder the views listed in the navigation menu.</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()
|
||||
}
|
||||
|
||||
@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