Compare commits

...

10 Commits

Author SHA1 Message Date
Jean-Luc Makiola
27ce9a29c9 Capitalize sentences in the event form again (#146) (#265)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/265
2026-09-02 20:35:48 +02:00
5f28a8b333 Capitalize sentences in the event form again (#146)
InlineField pinned KeyboardCapitalization.None for every field, which
overrode floret-kit's Sentences default. The override moves to location,
which is as often a URL as an address.

Closes #146
2026-09-02 19:44:37 +02:00
Jean-Luc Makiola
ddf508e350 Long-press to copy title, location and description (#195) (#263)
Long-pressing one of the three free-text fields in the event view puts the whole field on the clipboard.

| Field | Tap | Long-press |
| --- | --- | --- |
| Title | — | copy |
| Location | opens maps (unchanged) | copy |
| Description | links still open (unchanged) | copy |

Times, calendar, recurrence and attendees stay uncopyable. The confirmation snackbar only shows below API 33 — Android 13+ raises its own clipboard chip. A failed copy is reported on every API level.

First clipboard use in the app, so the helper lives in `ui/common` as a `FieldCopier` plus a `copyOnLongPress` modifier. A field with no tap action gets the gesture through `pointerInput` and merged `onLongClick` semantics rather than a `clickable` that would announce a tap it doesn't have.

Deviation worth naming: the description takes the gesture on its card rather than its text. A linkified URL consumes the press over its own glyphs, so long-pressing a URL copies nothing either way — the card at least adds the icon and padding as target. A description that is nothing but a link stays awkward to copy.

Closes #195

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/263
2026-09-02 19:04:57 +02:00
dd3988c5bf Merge remote-tracking branch 'origin/main' into release/v2.20.0
# Conflicts:
#	CHANGELOG.md
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimelineDrag.kt
#	app/src/main/java/de/jeanlucmakiola/calendula/ui/month/MonthScreen.kt
2026-09-02 15:34:17 +02:00
Jean-Luc Makiola
03287ae25d Tidy the app bar at larger display and font scales (#165) (#261)
### Part A — the title shortens instead of wrapping

`CalendarTitleButton` measures the full title against the width the bar hands
the title slot (`BoxWithConstraints` + `rememberTextMeasurer`) and falls back to
a shorter form when it does not fit. One pass, so no flicker, and it adapts on
its own to locale, font scale and to the pill being absent after #150.

- Month and week fall back to the three-letter month ("Sep", "Mär").
- Day drops the weekday instead ("Wed, 2 Sep 2027" → "2 Sep 2027") — there is no
  shorter month form left there, `EEEdMMM` is already abbreviated.
- Either way the title now clamps to one line and ellipsises rather than growing
  the bar to two lines.

The choice itself is a pure `titleFor(title, shortTitle, titleWidth,
availableWidth)`, so it is unit-tested rather than only reachable through a
composable. `formatCalendarTitle` gained no new behaviour, so the widget header
is untouched.

### Part B — the trailing edge, not the whole rhythm

`AppBarSpacing` holds the shared values: the side inset (also consumed by the
month grid, so the pill lines up with the content under it), the title's start
inset, and the two trailing insets — one measured to a container's background,
one to an icon button's glyph.

**Deviation from the issue:** the issue asks for one edge inset at *both* ends
and one gap between adjacent controls. Only the trailing edge and the title
inset are shared here. M3 derives both the title's position and the gap between
two adjacent action icons from the icon buttons' width, so putting the leading
edge and the inter-icon gaps on the same rhythm meant narrowing the icon buttons
from 48.dp to 32.dp — that reads cramped and shrinks the touch targets. The
leading edge and the icon gaps stay on M3's defaults. The title's 8.dp start
inset stays real padding rather than a negative offset, so the menu button's hit
box is intact.

The agenda range bar picks its end inset from whether the view switcher is
showing: aligned to the pill's background when it is, to the search icon's glyph
once #150 hides it.

Closes #165

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/261
2026-09-02 15:25:38 +02:00
Jean-Luc Makiola
7e8119be02 Hide the quick-switch button below two views (#150) (#221)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/221
2026-08-18 17:14:43 +02:00
Jean-Luc Makiola
7e843aa740 Drop the ellipsis in week and day event chips too (#164) (#222)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/222
2026-08-18 17:14:12 +02:00
9775e0a652 Drop the ellipsis in week and day event chips too (#164)
#183 only covered the month grid, but a week column is just as narrow. The
all-day chips and the drag copy take the same treatment; the timed blocks
only when they are showing a single line, since wrapping needs softWrap on
and clipping with it on breaks at the last whole word.

The rule now lives in one helper instead of being copied per chip.
2026-08-18 15:57:17 +02:00
2cf7d590bd Hide the quick-switch button below two views (#150)
The pill only appears with something to switch between, so one enabled view
hides it just like zero. The floor that blocked disabling is gone from both
the settings screen and the view model.
2026-08-18 15:52:39 +02:00
Jean-Luc Makiola
8c76cbdf5e Show as much of a month event title as fits (#164) (#183)
Month-view event titles no longer truncate with "…". `MonthBar` — the single
chip renderer for every month style and for the drag's floating copy — now uses
`TextOverflow.Clip` with `softWrap = false`, so a title runs to the chip's edge
and clips mid-glyph instead of spending two characters' width on an ellipsis.

`softWrap = false` is load-bearing: `Clip` on its own still breaks a `maxLines = 1`
line at the last whole word, so "Team standup meeting" would render as "Team" —
less title than the ellipsis showed, not more.

Deviation from the issue: right-to-left layouts keep the ellipsis. 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 have lost its beginning. `Ellipsis` truncates at the logical end in both
directions, so RTL keeps it.

The Glance month widget needs nothing: its `Text` has no overflow parameter, and
a RemoteViews `TextView` with `maxLines = 1` and no ellipsize already clips.

Closes #164

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/183
2026-08-13 15:26:31 +02:00
24 changed files with 796 additions and 178 deletions

View File

@@ -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

View File

@@ -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,
) )
} }

View File

@@ -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
}

View File

@@ -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:3011: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,
) )
} }

View File

@@ -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

View File

@@ -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
} }
} }

View File

@@ -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
}
}
}
}

View File

@@ -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,
)

View File

@@ -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),
) )
} }

View File

@@ -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))
} }

View File

@@ -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:3011: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",
) )

View File

@@ -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(),

View File

@@ -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),

View File

@@ -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,
) )
} }

View File

@@ -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",
) )

View File

@@ -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,
)
} }
} }
} }

View File

@@ -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) },
) )
}, },

View File

@@ -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:3011: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,
) )
} }

View File

@@ -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(),

View File

@@ -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>

View File

@@ -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)
}
}

View File

@@ -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")
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
} }