Compare commits

..

19 Commits

Author SHA1 Message Date
c2fbfb0bef Trim the drag copy's layout note (#267) 2026-09-04 17:27:09 +02:00
8db99868af Keep the dragged copy inside the block it lifted off (#267)
The copy always draws the range but was budgeting height for the title
first, so a block too short to have shown either — Day hands its whole
height to the title there, and passed that as the lines it drew — spilled
text past a chip nothing clips.

The copy now pays for the range first and gives the title what of its
lines still fit, and the blocks report the lines they actually drew.
The 2.dp text inset the three of them each spelled out is now a constant.
2026-09-04 17:26:32 +02:00
3a29e6f676 Wrap the dragged copy's time as the block does (#267)
The floating copy hardcoded maxLines = 1 on its time label and its
title, so lifting a block whose text wrapped clipped it for as long as
it was held and snapped back on drop. The copy now measures the range
the same way the block does, and carries the block's title budget
through the drag.

Extracts that measurement into blockTimeLines, which Week and Day had
a copy of each.
2026-09-04 16:55:21 +02:00
Jean-Luc Makiola
76603be0b3 Draw an unanswered invitation as an outline (#230) (#268)
Events waiting on your answer now read differently from the ones you have answered.

`EventInstance.isDeclined` becomes an `EventResponse` enum — `Going` / `Invited` / `Declined` — so there is one source of truth for the column; `isDeclined` stays as an extension property, so the surfaces that only care about that case are untouched. A tentative "maybe" counts as `Going`.

An unanswered invitation is drawn as an outline: the calendar's colour on the border and on the title, at regular weight, filled with the surface the chip sits on. The outline leaves open whichever edges the event runs past, so a bar carried across a week boundary stays one bar. The accent is harmonised even when soften-colours is off — raw mode exists so a filled container matches the sync source, and an outlined chip has no container, only coloured text, which needs its lightness pinned or a pale calendar goes unreadable.

Applied to the month chips, the week and day all-day bars, both timed blocks and the drag copy.

Declined events also sort last in the month view now: the timed chips, the split style's day pane, and the all-day bars, which pack into a lane below the ones you answered. A day that overflows drops the events you said no to first. `layoutAllDay` had to stop deciding lanes from how far right each one reaches and track the columns it holds instead, or packing declined last would waste a lane.

### Deviations from the issue

The issue asked for declined invitations to be outlined too, with the title crossed out. They keep their filled container instead: at chip size the strike-through needs something under it to read against, and on a hollow chip it was illegible. Declined is still distinguished — filled and struck through, against filled and plain — and the sort order is the extra separation it gained.

Not covered, and each needs its own mark rather than this one: the agenda rows, search results and the split view's day pane carry a 6dp colour stripe rather than a filled chip, and both widgets are Glance, which has no border modifier. All four still mark declined only.

Closes #230

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/268
2026-09-04 16:45:07 +02:00
Jean-Luc Makiola
a4d77109fb Show the start time on wide month chips (#219) (#266)
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/266
2026-09-04 14:15:50 +02:00
72be175fca Hold the time apart from the title on any font (#219)
The medium-to-regular step is a no-op on Atkinson Hyperlegible, JetBrains
Mono and imported fonts, which ship no medium face — so the title takes
full ink against the time's 0.8 instead, and the four title sites share
the constant.

Also: the drag copy carries the time its source chip had rather than
deriving one with continuesLeft hardcoded false; the month grid takes
its zone from the state; times are formatted once per row and the chip
width measured once per grid; the chip geometry lives in one file; and
a screen reader hears the time whether or not the chip is wide enough
to draw it.
2026-09-04 14:07:55 +02:00
5be8884e9f Set event times in regular weight across the timeline views (#219) 2026-09-04 12:09:03 +02:00
065afdb2b8 Set the month chip time apart from its title (#219) 2026-09-04 11:56:36 +02:00
f7e9990c4e Show the start time on wide month chips (#219) 2026-09-02 21:23:07 +02:00
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
43 changed files with 1658 additions and 290 deletions

View File

@@ -7,6 +7,25 @@ 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]).
- Month-view events now show their start time before the title, where the chip
is wide enough to keep the title readable — in landscape, on a foldable and on
a tablet, and never at the cost of a narrow phone column. All-day events show
no time, and neither does a bar carried in from the previous week, whose start
is not in that row. Event times across the month, week and day views are also
set in a regular weight against the title's, so a time reads as a time rather
than as part of the name next to it ([#219]).
- 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 +1579,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
@@ -1569,5 +1590,6 @@ automatically, with zero telemetry and no internet permission.
[#225]: https://codeberg.org/jlmakiola/calendula/issues/225 [#225]: https://codeberg.org/jlmakiola/calendula/issues/225
[#228]: https://codeberg.org/jlmakiola/calendula/issues/228 [#228]: https://codeberg.org/jlmakiola/calendula/issues/228
[#234]: https://codeberg.org/jlmakiola/calendula/issues/234 [#234]: https://codeberg.org/jlmakiola/calendula/issues/234
[#219]: https://codeberg.org/jlmakiola/calendula/issues/219
[#248]: https://codeberg.org/jlmakiola/calendula/issues/248 [#248]: https://codeberg.org/jlmakiola/calendula/issues/248
[#253]: https://codeberg.org/jlmakiola/calendula/issues/253 [#253]: https://codeberg.org/jlmakiola/calendula/issues/253

View File

@@ -4,6 +4,7 @@ import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import android.provider.CalendarContract import android.provider.CalendarContract
import android.util.Log import android.util.Log
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
private const val TAG = "InstanceMapper" private const val TAG = "InstanceMapper"
@@ -39,7 +40,17 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0, isAllDay = getInt(InstanceProjection.IDX_ALL_DAY) != 0,
color = color, color = color,
location = getString(InstanceProjection.IDX_LOCATION), location = getString(InstanceProjection.IDX_LOCATION),
isDeclined = getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS) == response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)),
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
) )
} }
/**
* `SELF_ATTENDEE_STATUS` as the calendar surfaces read it: a tentative "maybe"
* counts as going, and everything that is not an open or refused invitation —
* your own events included — falls through to [EventResponse.Going].
*/
internal fun mapEventResponse(raw: Int): EventResponse = when (raw) {
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED -> EventResponse.Declined
CalendarContract.Attendees.ATTENDEE_STATUS_INVITED -> EventResponse.Invited
else -> EventResponse.Going
}

View File

@@ -1,6 +1,5 @@
package de.jeanlucmakiola.calendula.data.calendar package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
@@ -43,7 +42,6 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
location = getString(SearchProjection.IDX_LOCATION), location = getString(SearchProjection.IDX_LOCATION),
isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() || isRecurring = !getString(SearchProjection.IDX_RRULE).isNullOrEmpty() ||
!getString(SearchProjection.IDX_RDATE).isNullOrEmpty(), !getString(SearchProjection.IDX_RDATE).isNullOrEmpty(),
isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) == response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)),
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
) )
} }

View File

@@ -68,14 +68,35 @@ data class EventInstance(
*/ */
val isRecurring: Boolean = false, val isRecurring: Boolean = false,
/** /**
* This device user answered "no" to the invitation * This device user's own answer to the invitation, as far as the grids care
* (`Events.SELF_ATTENDEE_STATUS`). The event stays on the calendar — it is * (#180, #230).
* still an appointment someone expects an answer about — but every surface
* strikes it through, and it plans no reminders (#180).
*/ */
val isDeclined: Boolean = false, val response: EventResponse = EventResponse.Going,
) )
/**
* How this device user stands towards an event's invitation
* (`Events.SELF_ATTENDEE_STATUS`), reduced to the three cases the calendar
* surfaces draw differently (#230).
*/
enum class EventResponse {
/** Your own event, or one you accepted — including a tentative "maybe". */
Going,
/** Invited, no answer given yet: drawn as an outline so it reads as still open. */
Invited,
/**
* You answered "no". The event stays on the calendar — it is still an
* appointment someone expects an answer about — but every surface strikes it
* through, and it plans no reminders (#180).
*/
Declined,
}
/** Shorthand for the declined case, which most surfaces test on its own (#180). */
val EventInstance.isDeclined: Boolean get() = response == EventResponse.Declined
/** /**
* Whether this event has finished relative to [now] — its end is at or before * Whether this event has finished relative to [now] — its end is at or before
* the current instant. An in-progress event (already started but not yet ended) * the current instant. An in-progress event (already started but not yet ended)

View File

@@ -29,6 +29,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedTitle import de.jeanlucmakiola.calendula.ui.common.declinedTitle
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors

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,201 @@ 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.font.FontWeight
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
/** The same, above and below — what a block's height has to pay before any text. */
val BLOCK_TEXT_INSET = 2.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)
}
}
}
/**
* Lines the time label may take at [textWidth], out of the [spare] height left
* once the title and the label's own first line are paid for.
*
* 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.
*/
@Composable
fun blockTimeLines(label: String, textWidth: Dp, spare: Dp): Int {
val timeLineHeight = with(LocalDensity.current) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
return if (spare >= timeLineHeight) {
blockTextLines(
text = label,
// The style it is drawn in, or the budget measures a line the label
// never uses (#219).
style = MaterialTheme.typography.labelSmall.asEventTime(),
textWidth = textWidth,
max = MAX_TIME_LINES,
)
} else {
1
}
}
/**
* 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,
fontWeight: FontWeight? = null,
) {
// Folded into the style rather than passed to the Text, so the wrap measured
// below is the wrap that gets drawn.
val style = MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) }
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,
@@ -36,9 +207,11 @@ fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
) { text -> ) { text ->
Text( Text(
text = text, text = text,
style = MaterialTheme.typography.labelSmall, // Regular weight against the title's medium above it (#219).
maxLines = 1, style = MaterialTheme.typography.labelSmall.asEventTime(),
overflow = TextOverflow.Ellipsis, maxLines = maxLines,
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,165 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.isDeclined
/** Stroke an invitation you have not answered is outlined with. */
val EVENT_OUTLINE_WIDTH = 1.dp
/**
* The surface an event chip is drawn on, which an outlined one fills itself with
* so it reads as the background showing through rather than as a pale block laid
* over it. Defaults to the `surfaceContainer` the month cells and the timeline
* columns carry; the all-day strips sit on plain `surface` and a floating drag
* copy wants a lifted one, so both provide their own.
*/
val LocalChipGround = compositionLocalOf { Color.Unspecified }
/**
* Which edges of a chip the event runs past. Those edges are squared by
* [monthBarShape] / [timedBlockShape] so the cut reads as "this carries on", and
* an outline has to leave them open for the same reason — a stroke all the way
* round would close a bar carried across a week boundary into two boxes.
*/
@Immutable
data class ChipCuts(
val start: Boolean = false,
val end: Boolean = false,
val top: Boolean = false,
val bottom: Boolean = false,
)
/** [ChipCuts] for a month or all-day bar, which is only ever cut left and right. */
fun monthBarCuts(continuesLeft: Boolean, continuesRight: Boolean): ChipCuts =
ChipCuts(start = continuesLeft, end = continuesRight)
/** [ChipCuts] for a timed block, which is only ever cut top and bottom. */
fun timedBlockCuts(continuesBefore: Boolean, continuesAfter: Boolean): ChipCuts =
ChipCuts(top = continuesBefore, bottom = continuesAfter)
/**
* How one event's chip or block is painted, which turns on your answer to its
* invitation (#180, #230).
*
* An invitation you have not answered is drawn as an outline: its calendar's
* colour on the border and on the title, over the surface the chip sits on. So
* it holds a chip's shape and a chip's weight without claiming the filled
* container an event you are going to gets. A declined one keeps the fill and
* its strike-through.
*/
@Immutable
data class EventPaint(
val fill: Color,
/** Null for a filled chip; the border colour when the chip is outlined. */
val outline: Color?,
val titleInk: Color,
val secondaryInk: Color,
val decoration: TextDecoration?,
/**
* Weight to set the title at, or null to keep whatever the surface's own
* text style carries.
*/
val titleWeight: FontWeight?,
)
/** [EventPaint] for [event] on a [dark] scheme. */
@Composable
fun eventPaint(event: EventInstance, dark: Boolean): EventPaint {
val soften = LocalSoftenColors.current
if (event.response == EventResponse.Invited) {
// Harmonised even when the setting is off. Raw mode exists so a filled
// container matches what the sync source paints, and an outlined chip
// has no container — what it has is coloured *text*, which needs the
// lightness pinned against the surface or a pale calendar goes
// unreadable (eventTone returns the raw colour verbatim otherwise).
val accent = eventAccent(event.color, dark, soften = true)
val ground = LocalChipGround.current.takeIf { it != Color.Unspecified }
?: MaterialTheme.colorScheme.surfaceContainer
return EventPaint(
fill = ground,
outline = accent,
titleInk = accent,
// A neutral token rather than the accent faded: the time is the
// smallest text on the chip, and an alpha step off an accent that is
// itself only just clear of the surface is where legibility goes.
secondaryInk = MaterialTheme.colorScheme.onSurfaceVariant,
decoration = null,
// The label styles' medium weight is set to carry ink on a filled
// container. In a calendar colour on a plain one it thickens into
// something harder to read, so step it back to regular.
titleWeight = FontWeight.Normal,
)
}
val fill = eventFill(event.color, dark, soften)
return EventPaint(
fill = fill,
outline = null,
titleInk = eventInk(fill, alpha = TITLE_INK_ALPHA),
secondaryInk = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
decoration = declinedDecoration(event.isDeclined),
titleWeight = null,
)
}
/** [this] set at the weight [paint] asks a title for, for measuring and for drawing alike. */
fun TextStyle.withTitleWeight(paint: EventPaint): TextStyle =
if (paint.titleWeight == null) this else copy(fontWeight = paint.titleWeight)
/** Seats a chip or block on [shape]: its fill, plus the border when it has one. */
fun Modifier.eventSurface(
paint: EventPaint,
shape: Shape,
cuts: ChipCuts = ChipCuts(),
): Modifier {
val filled = background(paint.fill, shape)
val outline = paint.outline ?: return filled
return filled.clip(shape).drawBehind { drawChipOutline(outline, cuts) }
}
/**
* The border, stroked as one round-rect whose edges run past the chip on every
* side the event continues over. Clipped to the chip's own shape, so those
* strokes — and the corners that would have turned back in — fall outside and
* the edge stays open.
*/
private fun DrawScope.drawChipOutline(color: Color, cuts: ChipCuts) {
val stroke = EVENT_OUTLINE_WIDTH.toPx()
val radius = EVENT_CHIP_CORNER.toPx()
// Far enough out that the corner arc clears the clip too, not just the edge.
val bleed = radius + stroke
val rtl = layoutDirection == LayoutDirection.Rtl
val leftCut = if (rtl) cuts.end else cuts.start
val rightCut = if (rtl) cuts.start else cuts.end
val left = if (leftCut) -bleed else stroke / 2f
val top = if (cuts.top) -bleed else stroke / 2f
val right = if (rightCut) size.width + bleed else size.width - stroke / 2f
val bottom = if (cuts.bottom) size.height + bleed else size.height - stroke / 2f
drawRoundRect(
color = color,
topLeft = Offset(left, top),
size = Size(right - left, bottom - top),
cornerRadius = CornerRadius(radius),
style = Stroke(width = stroke),
)
}

View File

@@ -0,0 +1,77 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import de.jeanlucmakiola.floret.locale.currentLocale
private val TIME_WEIGHT = FontWeight.Normal
private val TIME_TRACKING = 0.sp
private val TIME_SPAN = SpanStyle(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
/**
* Ink for an event's title, against [SECONDARY_INK_ALPHA] for the time beside or
* beneath it. Full, so the pair separates on a typeface with no medium weight to
* step down from — Atkinson Hyperlegible and JetBrains Mono ship regular and bold
* only, and a user's imported font a single face, so [asEventTime] is a no-op
* there and the ink is the whole difference (#219).
*/
const val TITLE_INK_ALPHA = 1f
/**
* [this] set as an event's time rather than as its title: the label styles' medium
* weight and 0.5sp of tracking left a time reading as part of the title next to it.
*/
fun TextStyle.asEventTime(): TextStyle =
copy(fontWeight = TIME_WEIGHT, letterSpacing = TIME_TRACKING)
/** Text reading [time], set apart in [timeInk], before [title]. */
fun inlineTimeLabel(time: String?, title: String, timeInk: Color): AnnotatedString =
buildAnnotatedString {
if (time != null) {
withStyle(TIME_SPAN.copy(color = timeInk)) { append(time) }
append(" ")
}
append(title)
}
/**
* The characters of title that have to survive a time prefix for it to be worth
* its place — lowercase Latin of average advance, priced at the surface's own
* text style rather than guessed in dp.
*/
private const val TITLE_SAMPLE = "notepad"
/** The widest wall-clock time in either convention: two-digit hour, and a meridiem in 12-hour. */
private const val SAMPLE_HOUR = 12
private const val SAMPLE_MINUTE = 45
/**
* The narrowest run of [style] text that may carry a time in front of a title:
* wide enough for the widest time in the current convention plus [TITLE_SAMPLE].
*
* Measured rather than a device breakpoint, so it follows the font scale, the
* 12/24-hour setting, the locale's own time format and whatever else has
* already been taken off the width.
*/
@Composable
fun rememberInlineTimeWidth(style: TextStyle): Dp {
val measurer = rememberTextMeasurer()
val density = LocalDensity.current
val locale = currentLocale()
val sample = formatTimeOfDay(SAMPLE_HOUR, SAMPLE_MINUTE, LocalUse24HourFormat.current, locale)
return remember(sample, style, density, measurer) {
val label = inlineTimeLabel(sample, TITLE_SAMPLE, Color.Unspecified)
with(density) { measurer.measure(label, style).size.width.toDp() }
}
}

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

@@ -25,7 +25,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
@@ -34,7 +33,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
@@ -83,6 +81,13 @@ data class TimelineDrag(
val eventSpanMin: Int, val eventSpanMin: Int,
/** The event as the grid would draw it, one piece per day column it covers. */ /** The event as the grid would draw it, one piece per day column it covers. */
val pieces: List<TimelineDragPiece>, val pieces: List<TimelineDragPiece>,
/**
* Lines the block the finger picked up drew its title over, zero for one too
* short to have drawn it at all. The floating copy is that block's size, so
* it has to spend its height the same way or the text re-wraps under the
* finger and snaps back on drop (#267).
*/
val titleLines: Int,
/** /**
* True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left * True when [pieces] is only [edgeDragSlice]'s stand-in — the event has left
* every column this timeline shows. A hint under the finger, not where the * every column this timeline shows. A hint under the finger, not where the
@@ -313,6 +318,9 @@ class TimelineDragController {
*/ */
private var clipOffsetMin = 0 private var clipOffsetMin = 0
/** What the picked-up block drew its title over — see [TimelineDrag.titleLines]. */
private var titleLines = 0
/** /**
* The slot the block already occupied when it was picked up — not the same * The slot the block already occupied when it was picked up — not the same
* as its start, since the target snaps to the grid (09:07 lifts to 09:00). * as its start, since the target snaps to the grid (09:07 lifts to 09:00).
@@ -322,11 +330,13 @@ class TimelineDragController {
fun begin( fun begin(
block: TimedBlock, block: TimedBlock,
clipOffsetMin: Int, clipOffsetMin: Int,
titleLines: Int,
pointerInRoot: Offset, pointerInRoot: Offset,
blockInRoot: Offset, blockInRoot: Offset,
) { ) {
source = block source = block
this.clipOffsetMin = clipOffsetMin this.clipOffsetMin = clipOffsetMin
this.titleLines = titleLines
settling = null settling = null
isDragging = true isDragging = true
liftedInstanceId = block.event.instanceId liftedInstanceId = block.event.instanceId
@@ -345,6 +355,7 @@ class TimelineDragController {
fun cancel() { fun cancel() {
source = null source = null
clipOffsetMin = 0 clipOffsetMin = 0
titleLines = 0
isDragging = false isDragging = false
liftedInstanceId = null liftedInstanceId = null
originSlot = null originSlot = null
@@ -467,6 +478,7 @@ class TimelineDragController {
startMin = startMin, startMin = startMin,
eventStartMin = eventStartMin, eventStartMin = eventStartMin,
eventSpanMin = eventSpan, eventSpanMin = eventSpan,
titleLines = titleLines,
// Bounded to the columns this timeline actually shows: a day it // Bounded to the columns this timeline actually shows: a day it
// doesn't has no piece to draw. The write is unaffected. // doesn't has no piece to draw. The write is unaffected.
pieces = slices pieces = slices
@@ -605,7 +617,6 @@ const val SETTLE_FADE_MILLIS: Int = 250
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) { fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
var origin by remember { mutableStateOf(Offset.Zero) } var origin by remember { mutableStateOf(Offset.Zero) }
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val soften = LocalSoftenColors.current
val use24Hour = LocalUse24HourFormat.current val use24Hour = LocalUse24HourFormat.current
val locale = currentLocale() val locale = currentLocale()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
@@ -655,7 +666,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
animationSpec = tween(SETTLE_FADE_MILLIS), animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover", label = "drag-handover",
) )
val fill = eventFill(drag.event.color, dark, soften) val paint = eventPaint(drag.event, dark)
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) } val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
// Read off the event rather than the held block, so grabbing either half // Read off the event rather than the held block, so grabbing either half
// of an event that crosses midnight names the same hours. An end past // of an event that crosses midnight names the same hours. An end past
@@ -674,11 +685,13 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
topLeftInRoot = piece.topLeftInRoot, topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = origin, overlayOrigin = origin,
sizePx = piece.sizePx, sizePx = piece.sizePx,
fill = fill, paint = paint,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter), shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
lift = lift, lift = lift,
alpha = copyAlpha, alpha = copyAlpha,
title = title, title = title,
titleLines = drag.titleLines,
// Once for the whole event: repeated, it would name it per day. // Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled }, label = label.takeIf { index == labelled },
) )
@@ -692,14 +705,34 @@ private fun DragCopy(
topLeftInRoot: Offset, topLeftInRoot: Offset,
overlayOrigin: Offset, overlayOrigin: Offset,
sizePx: IntSize, sizePx: IntSize,
fill: Color, paint: EventPaint,
shape: RoundedCornerShape, shape: RoundedCornerShape,
cuts: ChipCuts,
lift: Float, lift: Float,
alpha: Float, alpha: Float,
title: String, title: String,
titleLines: Int,
label: String?, label: String?,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val width = with(density) { sizePx.width.toDp() }
val height = with(density) { sizePx.height.toDp() }
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val titleLineHeight = with(density) {
MaterialTheme.typography.labelMedium.lineHeight.toDp()
}
val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp()
}
// The block's size, spent the block's way, so nothing reflows under the
// finger (#267) — but the range is paid for first, since it may be showing
// where a block too short for it will land, and nothing clips the overrun.
val available = height - BLOCK_TEXT_INSET * 2
val reserved = if (label == null) 0.dp else timeLineHeight
val titleBudget = ((available - reserved) / titleLineHeight).toInt().coerceAtLeast(0)
val lines = titleLines.coerceAtMost(titleBudget)
val spare = available - titleLineHeight * lines - reserved
val timeMaxLines = if (label == null) 1 else blockTimeLines(label, textWidth, spare)
Box( Box(
modifier = Modifier modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware // Absolute: these are root coordinates, and the direction-aware
@@ -710,11 +743,8 @@ private fun DragCopy(
(topLeftInRoot.y - overlayOrigin.y).roundToInt(), (topLeftInRoot.y - overlayOrigin.y).roundToInt(),
) )
} }
.size( .size(width = width, height = height)
width = with(density) { sizePx.width.toDp() }, .padding(horizontal = BLOCK_OUTER_INSET)
height = with(density) { sizePx.height.toDp() },
)
.padding(horizontal = 1.dp)
.graphicsLayer { .graphicsLayer {
scaleX = 1f + 0.02f * lift scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift scaleY = 1f + 0.02f * lift
@@ -723,24 +753,30 @@ private fun DragCopy(
this.shape = shape this.shape = shape
clip = false clip = false
} }
.background(fill, shape) .eventSurface(paint, shape, cuts)
.padding(horizontal = 4.dp, vertical = 2.dp), .padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET),
) { ) {
Column { Column {
Text( if (lines > 0) {
text = title, BlockTitle(
style = MaterialTheme.typography.labelMedium, title = title,
maxLines = 1, maxLines = lines,
overflow = TextOverflow.Ellipsis, textWidth = textWidth,
color = eventInk(fill, alpha = 0.85f), color = paint.titleInk,
textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
) )
}
if (label != null) { if (label != null) {
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelSmall, // As the block it lifted off sets its own time (#219).
maxLines = 1, style = MaterialTheme.typography.labelSmall.asEventTime(),
overflow = TextOverflow.Ellipsis, maxLines = timeMaxLines,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = paint.secondaryInk,
) )
} }
} }

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
@@ -40,6 +39,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
@@ -53,10 +53,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 +63,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
@@ -73,6 +70,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
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.CalendarTitleButton import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
@@ -81,7 +81,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.BLOCK_TEXT_INSET
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.blockTimeLines
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
@@ -94,9 +99,11 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
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
@@ -106,18 +113,13 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -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,
@@ -385,16 +371,22 @@ internal fun DaySuccess(
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// All-day strip collapses to nothing when the day has no all-day events, // All-day strip collapses to nothing when the day has no all-day events,
// so the timeline sits directly under the app bar. // so the timeline sits directly under the app bar.
// This strip is painted on `surface`, so an outlined chip has to fill
// with that and not with the column's container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip( AllDayStrip(
state = state, state = state,
height = allDayHeight, height = allDayHeight,
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 }
// scrolling timeline below. // Breathing room between the top section and the scrolling timeline
// below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Timeline( Timeline(
state = state, state = state,
@@ -414,20 +406,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 +445,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,
) )
} }
@@ -516,23 +515,25 @@ private fun AllDayBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val soften = LocalSoftenColors.current val paint = eventPaint(event, dark)
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = modifier modifier = modifier
.background(fill, EventChipShape) .eventSurface(paint, EventChipShape)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
.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,
color = eventInk(fill), softWrap = titleOverflow.softWrap,
textDecoration = declinedDecoration(event.isDeclined), color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
) )
} }
} }
@@ -691,6 +692,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 +701,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 +718,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,
@@ -734,29 +737,42 @@ private fun EventBlock(
val timeLineHeight = with(density) { val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp() MaterialTheme.typography.labelSmall.lineHeight.toDp()
} }
// What's left for text once the 2.dp top/bottom padding is paid for. A block // A block that cannot afford both lines spends its space on the title, and
// that cannot afford both lines spends its space on the title, and one too // 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
val available = height - 4.dp // half-hour block the user has pinched open to three times the room it needs.
val showTime = block.endMin - block.startMin >= 45 && val available = height - BLOCK_TEXT_INSET * 2
available >= titleLineHeight + timeLineHeight val showTime = available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight val showTitle = available >= titleLineHeight
val soften = LocalSoftenColors.current val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2
val fill = eventFill(block.event.color, dark, soften) val titleMaxLines = if (showTime) 1 else 2
// On a day column — wide enough for "09:3011:00" several times over — the
// range never needs the second line, until lanes cut the column down.
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) else 1
val paint = eventPaint(block.event, dark)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event) val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands // The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253). // where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(block, date, zone) { val cuts = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) timedBlockCuts(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
} }
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val dragModifier = rememberEventDragSource( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> onPickUp = { pointer, blockRoot, _ ->
dragController.begin(block, clipOffset, pointer, blockRoot) dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
blockInRoot = blockRoot,
)
}, },
onMove = dragController::move, onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) }, onDrop = { dragController.finish()?.let(onDrop) },
@@ -768,12 +784,12 @@ private fun EventBlock(
modifier = modifier modifier = modifier
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape) .eventSurface(paint, shape, cuts)
.clickable(onClick = onClick) .clickable(onClick = onClick)
// 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 = BLOCK_TEXT_INSET)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -781,19 +797,20 @@ 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 = paint.titleInk,
color = eventInk(fill, alpha = 0.85f), textDecoration = paint.decoration,
textDecoration = declinedDecoration(block.event.isDeclined), fontWeight = paint.titleWeight,
) )
} }
if (showTime) { if (showTime) {
BlockTimeLabel( BlockTimeLabel(
label = timeLabel, label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), color = paint.secondaryInk,
maxLines = timeMaxLines,
) )
} }
} }
@@ -829,10 +846,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

@@ -0,0 +1,19 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.ui.unit.dp
// The month grid's chip geometry, together in one file: these derive from each
// other, and a top-level val reading one across a file boundary would resolve
// against whichever class the JVM happened to initialise first.
/** Gap between a day cell and its neighbours. */
internal val CELL_GAP = 2.dp
/** Padding between a month chip's edge and its text. */
internal val MONTH_CHIP_TEXT_PADDING = 4.dp
/** A chip's own inset inside its day cell, on top of the cell's gap. */
internal val MONTH_CHIP_INSET = CELL_GAP + 1.dp
/** Horizontal space a chip spends on chrome rather than on text, both sides. */
internal val MONTH_CHIP_CHROME = (MONTH_CHIP_INSET + MONTH_CHIP_TEXT_PADDING) * 2f

View File

@@ -0,0 +1,33 @@
package de.jeanlucmakiola.calendula.ui.month
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberInlineTimeWidth
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.util.Locale
/**
* The start time a chip shows before its title (#219), or null when it has none
* to show: an all-day chip never carries a time, and neither does a bar carried
* in from an earlier week, whose start is not in this segment.
*/
internal fun monthChipTime(
event: EventInstance,
continuesLeft: Boolean,
zone: TimeZone,
is24Hour: Boolean,
locale: Locale,
): String? {
if (event.isAllDay || continuesLeft) return null
val start = event.start.toLocalDateTime(zone).time
return formatTimeOfDay(start.hour, start.minute, is24Hour, locale)
}
/** The narrowest chip that may show a time — the text it needs, plus its chrome. */
@Composable
internal fun rememberMonthTimeChipWidth(): Dp =
rememberInlineTimeWidth(MaterialTheme.typography.labelSmall) + MONTH_CHIP_CHROME

View File

@@ -97,6 +97,13 @@ data class MonthChipDrag(
val targetDate: LocalDate?, val targetDate: LocalDate?,
val topLeftInRoot: Offset, val topLeftInRoot: Offset,
val sizePx: IntSize, val sizePx: IntSize,
/**
* The start time of the chip this lifted off, already formatted (#219).
* Carried rather than re-derived: the overlay has no row to ask whether the
* bar was carried in from the previous week, and no zone the grid laid out
* in. Null where the source chip had no time to show.
*/
val time: String? = null,
) )
/** Where a finished chip drag asks its event to go, as a whole-day shift. */ /** Where a finished chip drag asks its event to go, as a whole-day shift. */
@@ -172,6 +179,7 @@ class MonthDragController {
private var event: EventInstance? = null private var event: EventInstance? = null
private var grabDate: LocalDate? = null private var grabDate: LocalDate? = null
private var time: String? = null
private var grab = Offset.Zero private var grab = Offset.Zero
private var pointer = Offset.Zero private var pointer = Offset.Zero
private var sizePx = IntSize.Zero private var sizePx = IntSize.Zero
@@ -190,9 +198,11 @@ class MonthDragController {
pointerInRoot: Offset, pointerInRoot: Offset,
chipInRoot: Offset, chipInRoot: Offset,
size: IntSize, size: IntSize,
time: String? = null,
) { ) {
this.event = event this.event = event
this.grabDate = grabDate this.grabDate = grabDate
this.time = time
settling = null settling = null
isDragging = true isDragging = true
liftedInstanceId = event.instanceId liftedInstanceId = event.instanceId
@@ -210,6 +220,7 @@ class MonthDragController {
fun cancel() { fun cancel() {
event = null event = null
grabDate = null grabDate = null
time = null
isDragging = false isDragging = false
liftedInstanceId = null liftedInstanceId = null
drag = null drag = null
@@ -331,6 +342,7 @@ class MonthDragController {
targetDate = resolved ?: drag?.targetDate, targetDate = resolved ?: drag?.targetDate,
topLeftInRoot = pointer - grab, topLeftInRoot = pointer - grab,
sizePx = sizePx, sizePx = sizePx,
time = time,
) )
} }
} }

View File

@@ -88,9 +88,13 @@ import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
import de.jeanlucmakiola.calendula.ui.common.monthBarCuts
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 +117,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
@@ -123,6 +126,7 @@ 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.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
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
@@ -134,6 +138,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
@@ -142,14 +147,13 @@ import de.jeanlucmakiola.calendula.ui.common.CALENDAR_SWIPE_THRESHOLD
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.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.inlineTimeLabel
import de.jeanlucmakiola.calendula.ui.common.eventAccent import de.jeanlucmakiola.calendula.ui.common.eventAccent
import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.monthBarShape import de.jeanlucmakiola.calendula.ui.common.monthBarShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
@@ -212,7 +216,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 +301,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) {
val year = titleMonth.year.toString()
year to year
} else { } else {
formatMonthTitle(titleMonth, locale, currentYear = today.year) 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 +393,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 = {
@@ -494,6 +502,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
var origin by remember { mutableStateOf(Offset.Zero) } var origin by remember { mutableStateOf(Offset.Zero) }
val dark = isSystemInDarkTheme() val dark = isSystemInDarkTheme()
val density = LocalDensity.current val density = LocalDensity.current
val timeChipWidth = rememberMonthTimeChipWidth()
val reduceMotion = rememberReduceMotion() val reduceMotion = rememberReduceMotion()
val moveInFlight = moveInFlight() val moveInFlight = moveInFlight()
// Here rather than beside the controller: this reads [drag], which changes // Here rather than beside the controller: this reads [drag], which changes
@@ -551,6 +560,11 @@ private fun MonthDragOverlay(controller: MonthDragController) {
dark = dark, dark = dark,
continuesLeft = false, continuesLeft = false,
continuesRight = false, continuesRight = false,
// The time the source chip carried, drawn only if this one-column
// cut-out of it has the room — which a slice of a multi-day bar has
// not, however wide the bar was.
time = drag.time,
showTime = with(density) { drag.sizePx.width.toDp() } >= timeChipWidth,
modifier = Modifier modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware // Absolute: these are root coordinates, and the direction-aware
// offset would mirror them across the screen in an RTL layout. // offset would mirror them across the screen in an RTL layout.
@@ -563,7 +577,7 @@ private fun MonthDragOverlay(controller: MonthDragController) {
} }
.width(with(density) { drag.sizePx.width.toDp() }) .width(with(density) { drag.sizePx.width.toDp() })
.height(with(density) { drag.sizePx.height.toDp() }) .height(with(density) { drag.sizePx.height.toDp() })
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp) .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp)
.graphicsLayer { .graphicsLayer {
scaleX = 1f + 0.04f * lift scaleX = 1f + 0.04f * lift
scaleY = 1f + 0.04f * lift scaleY = 1f + 0.04f * lift
@@ -664,15 +678,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 +695,7 @@ private fun MonthTopBar(
title = title, title = title,
currentDate = titleDate, currentDate = titleDate,
onJumpToDate = onJumpToDate, onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
) )
}, },
navigationIcon = { navigationIcon = {
@@ -700,11 +716,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 +741,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))
@@ -745,7 +767,6 @@ private val DAY_NUMBER_HEIGHT = 22.dp
private val WEEK_NUMBER_GUTTER = 40.dp private val WEEK_NUMBER_GUTTER = 40.dp
private val DAY_NUMBER_GAP = 4.dp private val DAY_NUMBER_GAP = 4.dp
private val CELL_TOP_PADDING = 6.dp private val CELL_TOP_PADDING = 6.dp
private val CELL_GAP = 2.dp
/** Named separately because the split style's selection outline draws its own /** Named separately because the split style's selection outline draws its own
* rounded rect and has to match this radius exactly. */ * rounded rect and has to match this radius exactly. */
private val CELL_CORNER = 12.dp private val CELL_CORNER = 12.dp
@@ -787,17 +808,22 @@ 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
// Once per grid: the value depends on the typography, the density, the
// locale and the 24-hour setting, none of which vary by row (#219).
val timeChipWidth = rememberMonthTimeChipWidth()
state.weeks.forEach { week -> state.weeks.forEach { week ->
MonthWeekRow( MonthWeekRow(
week = week, week = week,
today = state.today, today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
inMonth = { it.month == month.month && it.year == month.year }, inMonth = { it.month == month.month && it.year == month.year },
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
@@ -837,6 +863,7 @@ internal fun ContinuousMonthGrid(
) { ) {
val monthCount = remember { continuousMonthCount() } val monthCount = remember { continuousMonthCount() }
val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) } val todayMonth = remember(state.today) { YearMonth(state.today.year, state.today.month) }
val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
@@ -858,6 +885,8 @@ internal fun ContinuousMonthGrid(
weeks = state.monthsByIndex[index], weeks = state.monthsByIndex[index],
weekStart = state.weekStart, weekStart = state.weekStart,
today = state.today, today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
showWeekNumbers = showWeekNumbers, showWeekNumbers = showWeekNumbers,
onOpenDay = onOpenDay, onOpenDay = onOpenDay,
onEventClick = onEventClick, onEventClick = onEventClick,
@@ -878,6 +907,8 @@ private fun ContinuousMonthBlock(
weeks: List<MonthWeek>?, weeks: List<MonthWeek>?,
weekStart: DayOfWeek, weekStart: DayOfWeek,
today: LocalDate, today: LocalDate,
zone: TimeZone,
timeChipWidth: Dp,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
onEventClick: (EventInstance) -> Unit, onEventClick: (EventInstance) -> Unit,
@@ -886,7 +917,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),
) { ) {
@@ -897,6 +928,8 @@ private fun ContinuousMonthBlock(
MonthWeekRow( MonthWeekRow(
week = week, week = week,
today = today, today = today,
zone = zone,
timeChipWidth = timeChipWidth,
inMonth = { it.month == month.month && it.year == month.year }, inMonth = { it.month == month.month && it.year == month.year },
// The block owns its month alone: a day from either // The block owns its month alone: a day from either
// neighbour is left out entirely rather than dimmed. // neighbour is left out entirely rather than dimmed.
@@ -966,6 +999,7 @@ internal fun DenseMonthGrid(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) } val weekCount = remember(state.weekStart) { continuousWeekCount(state.weekStart) }
val timeChipWidth = rememberMonthTimeChipWidth()
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier modifier = modifier
@@ -974,7 +1008,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.
@@ -988,6 +1022,8 @@ internal fun DenseMonthGrid(
MonthWeekRow( MonthWeekRow(
week = week, week = week,
today = state.today, today = state.today,
zone = state.zone,
timeChipWidth = timeChipWidth,
// Every day in the stream belongs to a month equally — there // Every day in the stream belongs to a month equally — there
// is no "other month" to recede here. // is no "other month" to recede here.
inMonth = { true }, inMonth = { true },
@@ -1441,7 +1477,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 +1842,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) {
@@ -1873,6 +1909,10 @@ private fun rememberSkeletonPulse(): Float {
private fun MonthWeekRow( private fun MonthWeekRow(
week: MonthWeek, week: MonthWeek,
today: LocalDate, today: LocalDate,
/** The zone the grid was laid out in — never re-read here (see [MonthUiState.Success.zone]). */
zone: TimeZone,
/** The narrowest chip that may carry a start time, measured once per grid (#219). */
timeChipWidth: Dp,
inMonth: (LocalDate) -> Boolean, inMonth: (LocalDate) -> Boolean,
showWeekNumbers: Boolean, showWeekNumbers: Boolean,
onOpenDay: (LocalDate) -> Unit, onOpenDay: (LocalDate) -> Unit,
@@ -1892,6 +1932,27 @@ private fun MonthWeekRow(
val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1 val laneCount = (week.spans.maxOfOrNull { it.lane } ?: -1) + 1
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS) val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
val morphing = morphInFlight() val morphing = morphInFlight()
// Every chip's start time for this row at once, and only when the row's own
// inputs change: formatting is a parsed pattern per call, and the dim cutoff
// ticks every minute while a drag recomposes every frame (#219).
val is24Hour = LocalUse24HourFormat.current
val locale = currentLocale()
val chipTimes = remember(week, zone, is24Hour, locale) {
buildMap {
week.spans.forEach { span ->
put(
span.event.instanceId,
monthChipTime(span.event, span.continuesLeft, zone, is24Hour, locale),
)
}
week.timedByDay.values.flatten().forEach { event ->
put(
event.instanceId,
monthChipTime(event, continuesLeft = false, zone, is24Hour, locale),
)
}
}
}
// Drag to reschedule (#68). The chips can take no pointer input of their own // Drag to reschedule (#68). The chips can take no pointer input of their own
// — the full-bleed tap layer sits on top of them — so one detector on the // — the full-bleed tap layer sits on top of them — so one detector on the
@@ -1966,9 +2027,12 @@ private fun MonthWeekRow(
band = bandCoordinates, band = bandCoordinates,
rowHeightPx = rowHeightPx, rowHeightPx = rowHeightPx,
isRtl = isRtl, isRtl = isRtl,
chipTimes = chipTimes,
), ),
), ),
) { ) {
// What a chip has to spend, against the [timeChipWidth] a start time
// costs it (#219).
val colW = maxWidth / 7 val colW = maxWidth / 7
// Per-day background pills — same surfaceContainer rounded surface the // Per-day background pills — same surfaceContainer rounded surface the
@@ -2049,6 +2113,8 @@ private fun MonthWeekRow(
continuesLeft = span.continuesLeft, continuesLeft = span.continuesLeft,
continuesRight = span.continuesRight, continuesRight = span.continuesRight,
days = week.days.subList(span.startCol, span.endCol + 1), days = week.days.subList(span.startCol, span.endCol + 1),
time = chipTimes[span.event.instanceId],
showTime = colW * cols >= timeChipWidth,
modifier = Modifier modifier = Modifier
.offset( .offset(
x = colW * span.startCol, x = colW * span.startCol,
@@ -2072,7 +2138,7 @@ private fun MonthWeekRow(
) )
.width(colW * cols) .width(colW * cols)
.height(EVENT_ROW_HEIGHT) .height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
) )
// One invisible slice of the bar per further column it // One invisible slice of the bar per further column it
// covers. A multi-day event has a dot on every day but // covers. A multi-day event has a dot on every day but
@@ -2096,7 +2162,7 @@ private fun MonthWeekRow(
) )
.width(colW) .width(colW)
.height(EVENT_ROW_HEIGHT) .height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
) )
} }
} }
@@ -2119,6 +2185,8 @@ private fun MonthWeekRow(
continuesLeft = false, continuesLeft = false,
continuesRight = false, continuesRight = false,
days = listOf(d), days = listOf(d),
time = chipTimes[ev.instanceId],
showTime = colW >= timeChipWidth,
modifier = Modifier modifier = Modifier
.offset( .offset(
x = colW * col, x = colW * col,
@@ -2127,7 +2195,7 @@ private fun MonthWeekRow(
.morphBounds(MonthMorphKey.Event(d, ev.instanceId)) .morphBounds(MonthMorphKey.Event(d, ev.instanceId))
.width(colW) .width(colW)
.height(EVENT_ROW_HEIGHT) .height(EVENT_ROW_HEIGHT)
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp), .padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp),
) )
} }
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
@@ -2279,6 +2347,9 @@ private fun monthChipDragModifier(
band: Array<LayoutCoordinates?>, band: Array<LayoutCoordinates?>,
rowHeightPx: Float, rowHeightPx: Float,
isRtl: Boolean, isRtl: Boolean,
/** The row's formatted chip times by instance id, so the copy carries the
* one its source chip had rather than deriving another (#219). */
chipTimes: Map<Long, String?>,
): Modifier = rememberDragSurface( ): Modifier = rememberDragSurface(
enabled = moveScope?.dragEnabled == true && controller != null, enabled = moveScope?.dragEnabled == true && controller != null,
key = week.days.first(), key = week.days.first(),
@@ -2307,6 +2378,7 @@ private fun monthChipDragModifier(
y = requireNotNull(bandTop) + lane * rowHeightPx, y = requireNotNull(bandTop) + lane * rowHeightPx,
), ),
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()), size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
time = chipTimes[event.instanceId],
) )
true true
} }
@@ -2414,7 +2486,10 @@ private fun shortMonthName(date: LocalDate): String {
} }
} }
/** A filled event pill/bar — softened (or raw) fill, title clipped to one line. */ /**
* A filled event pill/bar — softened (or raw) fill, title clipped to one line,
* with the start time before it where the chip is wide enough ([showTime], #219).
*/
@Composable @Composable
private fun MonthBar( private fun MonthBar(
event: de.jeanlucmakiola.calendula.domain.EventInstance, event: de.jeanlucmakiola.calendula.domain.EventInstance,
@@ -2428,12 +2503,26 @@ private fun MonthBar(
* the provider hands the re-read instance a new one. * the provider hands the re-read instance a new one.
*/ */
days: List<LocalDate>? = null, days: List<LocalDate>? = null,
/** The chip's start time, or null where it has none — see `monthChipTime` (#219). */
time: String? = null,
/** Whether this chip has the width to draw [time]; a screen reader gets it either way. */
showTime: Boolean = false,
) { ) {
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current val paint = eventPaint(event, dark)
val fill = eventFill(event.color, dark, soften) // The same title/secondary ink pairing the week and day blocks use, with
// the time on the quieter half.
val label = inlineTimeLabel(
time = time.takeIf { showTime },
title = title,
timeInk = paint.secondaryInk,
)
// Announced whether or not it is drawn, and comma-separated as the week and
// day blocks do it: what a screen reader hears shouldn't turn on how wide
// the chip happens to be (#219).
val description = if (time != null) "$title, $time" else title
val moveAction = eventMoveAction(event) val moveAction = eventMoveAction(event)
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels.
val monthDrag = LocalMonthDrag.current val monthDrag = LocalMonthDrag.current
@@ -2444,21 +2533,24 @@ private fun MonthBar(
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape) .eventSurface(paint, shape, monthBarCuts(continuesLeft, continuesRight))
.padding(horizontal = 4.dp) .padding(horizontal = MONTH_CHIP_TEXT_PADDING)
.semantics { .semantics {
contentDescription = title contentDescription = description
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
}, },
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
val titleOverflow = eventTitleOverflow()
Text( Text(
text = title, text = label,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = titleOverflow.overflow,
color = eventInk(fill), softWrap = titleOverflow.softWrap,
textDecoration = declinedDecoration(event.isDeclined), color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
) )
} }
} }
@@ -2514,7 +2606,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 +2632,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

@@ -74,7 +74,7 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
*/ */
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? { fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
if (col !in days.indices || lane !in 0 until laneCap) return null if (col !in days.indices || lane !in 0 until laneCap) return null
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event } spanAt(col, lane)?.let { return it.event }
val occupied = spans val occupied = spans
.filter { it.lane < laneCap && col in it.startCol..it.endCol } .filter { it.lane < laneCap && col in it.startCol..it.endCol }
.map { it.lane } .map { it.lane }
@@ -91,8 +91,11 @@ fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
* [col] for a single-day chip, and for an empty slot no one should be asking * [col] for a single-day chip, and for an empty slot no one should be asking
* about. * about.
*/ */
fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = fun MonthWeek.chipStartCol(col: Int, lane: Int): Int = spanAt(col, lane)?.startCol ?: col
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.startCol ?: col
/** The bar covering lane [lane] of column [col], or null where a pill sits there. */
fun MonthWeek.spanAt(col: Int, lane: Int): MonthSpan? =
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }
/** /**
* The events on [day] that [laneEvents] had no lane left for — its exact * The events on [day] that [laneEvents] had no lane left for — its exact
@@ -136,6 +139,8 @@ sealed interface ContinuousMonthUiState {
val monthsByIndex: Map<Int, List<MonthWeek>>, val monthsByIndex: Map<Int, List<MonthWeek>>,
val weeksByIndex: Map<Int, MonthWeek>, val weeksByIndex: Map<Int, MonthWeek>,
val weekStart: DayOfWeek, val weekStart: DayOfWeek,
/** As [MonthUiState.Success.zone], and for the same reason. */
val zone: TimeZone = TimeZone.currentSystemDefault(),
) : ContinuousMonthUiState ) : ContinuousMonthUiState
} }

View File

@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.ui.week.coversDay import de.jeanlucmakiola.calendula.ui.week.coversDay
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays import de.jeanlucmakiola.calendula.ui.week.spansMultipleDays
@@ -238,6 +239,7 @@ class MonthViewModel @Inject constructor(
monthsByIndex = months, monthsByIndex = months,
weeksByIndex = weeks, weeksByIndex = weeks,
weekStart = weekStart, weekStart = weekStart,
zone = zone,
) )
} }
@@ -438,14 +440,18 @@ internal fun layoutCalendarWeek(
days = days, days = days,
spans = spans, spans = spans,
timedByDay = days.associateWith { d -> timedByDay = days.associateWith { d ->
singles.filter { it.coversDay(d, zone) }.sortedBy { it.start } // Declined last, so a day that overflows drops the events you said
// no to before the ones you are actually going to (#230).
singles.filter { it.coversDay(d, zone) }
.sortedWith(compareBy<EventInstance> { it.isDeclined }.thenBy { it.start })
}, },
countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } }, countByDay = days.associateWith { d -> weekEvents.count { it.coversDay(d, zone) } },
) )
} }
/** /**
* Every event touching each of [days], all-day first then by start time. Unlike * Every event touching each of [days], declined ones last (#230), then all-day
* first and by start time within each group. Unlike
* [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date * [MonthWeek.timedByDay] this keeps multi-day and all-day events on every date
* they cover and applies no display cap, so the split style's day pane can list a * they cover and applies no display cap, so the split style's day pane can list a
* date in full without querying the provider again. * date in full without querying the provider again.
@@ -458,7 +464,11 @@ internal fun instancesByDay(
days.associateWith { day -> days.associateWith { day ->
instances instances
.filter { it.coversDay(day, zone) } .filter { it.coversDay(day, zone) }
.sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start }) .sortedWith(
compareBy<EventInstance> { it.isDeclined }
.thenByDescending { it.isAllDay }
.thenBy { it.start },
)
} }
/** /**

View File

@@ -82,6 +82,7 @@ import de.jeanlucmakiola.calendula.domain.MatchSpan
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.SearchHit import de.jeanlucmakiola.calendula.domain.SearchHit
import de.jeanlucmakiola.calendula.domain.SearchMonth import de.jeanlucmakiola.calendula.domain.SearchMonth
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack

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
@@ -81,6 +77,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
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.LocalChipGround
import de.jeanlucmakiola.calendula.ui.common.eventPaint
import de.jeanlucmakiola.calendula.ui.common.eventSurface
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.CalendarTitleButton import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
@@ -90,8 +89,13 @@ 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.EventDimAlpha import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
import de.jeanlucmakiola.calendula.ui.common.declinedDecoration import de.jeanlucmakiola.calendula.ui.common.BLOCK_OUTER_INSET
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_PADDING
import de.jeanlucmakiola.calendula.ui.common.BLOCK_TEXT_INSET
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.blockTextLines
import de.jeanlucmakiola.calendula.ui.common.blockTimeLines
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
@@ -103,23 +107,23 @@ import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes import de.jeanlucmakiola.calendula.ui.common.clipOffsetMinutes
import de.jeanlucmakiola.calendula.ui.common.continuesAfter import de.jeanlucmakiola.calendula.ui.common.continuesAfter
import de.jeanlucmakiola.calendula.ui.common.continuesBefore import de.jeanlucmakiola.calendula.ui.common.continuesBefore
import de.jeanlucmakiola.calendula.ui.common.timedBlockCuts
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
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.EventChipShape import de.jeanlucmakiola.calendula.ui.common.EventChipShape
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.ui.common.eventInk
import de.jeanlucmakiola.calendula.ui.common.NowLine import de.jeanlucmakiola.calendula.ui.common.NowLine
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe import de.jeanlucmakiola.calendula.ui.common.rememberCalendarPageSwipe
import de.jeanlucmakiola.calendula.ui.common.withTitleWeight
import de.jeanlucmakiola.floret.identity.rememberReduceMotion import de.jeanlucmakiola.floret.identity.rememberReduceMotion
import de.jeanlucmakiola.floret.locale.currentLocale import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -127,7 +131,6 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION import de.jeanlucmakiola.calendula.ui.common.MIN_EVENT_FRACTION
import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH import de.jeanlucmakiola.calendula.ui.common.MIN_TITLE_WRAP_WIDTH
import de.jeanlucmakiola.calendula.ui.common.SECONDARY_INK_ALPHA
import de.jeanlucmakiola.calendula.ui.common.hourHeight import de.jeanlucmakiola.calendula.ui.common.hourHeight
import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelinePinchZoom
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
@@ -190,21 +193,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 +256,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 +289,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 +308,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 +372,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 +398,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 +410,19 @@ 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)
// This strip is painted on `surface`, so an outlined chip has to
// fill with that and not with the columns' container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick) AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
} }
// Breathing room between the (colour-shifting) top section and the }
// scrolling timeline below. // Breathing room between the top section and the scrolling timeline
// below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Timeline( Timeline(
state = state, state = state,
@@ -449,20 +442,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 +481,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,
) )
} }
@@ -653,23 +651,25 @@ private fun AllDayBar(
val title = event.title.ifBlank { stringResource(R.string.event_untitled) } val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
val dimCutoff = LocalDimCutoff.current val dimCutoff = LocalDimCutoff.current
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff) val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
val soften = LocalSoftenColors.current val paint = eventPaint(event, dark)
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
.background(fill, EventChipShape) .eventSurface(paint, EventChipShape)
.clickable(onClick = onClick) .clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 2.dp) .padding(horizontal = 6.dp, vertical = 2.dp)
.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,
color = eventInk(fill), softWrap = titleOverflow.softWrap,
textDecoration = declinedDecoration(event.isDeclined), color = paint.titleInk,
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
) )
} }
} }
@@ -847,7 +847,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),
) )
} }
} }
@@ -883,48 +883,66 @@ private fun EventBlock(
val timeLineHeight = with(density) { val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp() MaterialTheme.typography.labelSmall.lineHeight.toDp()
} }
// What's left for text once the 2.dp top/bottom padding is paid for. val available = height - BLOCK_TEXT_INSET * 2
val available = height - 4.dp
// 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 paint = eventPaint(block.event, dark)
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,
// At the weight BlockTitle will set it in, or an invited block —
// drawn a weight lighter — is budgeted a line it never fills (#230).
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth,
max = titleBudget,
)
} }
val spare = available - titleLineHeight * titleMaxLines -
if (showTime) timeLineHeight else 0.dp
val timeMaxLines = if (showTime) blockTimeLines(timeLabel, textWidth, spare) 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)
val soften = LocalSoftenColors.current
val fill = eventFill(block.event.color, dark, soften)
val zone = remember { TimeZone.currentSystemDefault() } val zone = remember { TimeZone.currentSystemDefault() }
val moveAction = eventMoveAction(block.event) val moveAction = eventMoveAction(block.event)
val draggable = eventDragAllowed(block.event) val draggable = eventDragAllowed(block.event)
// The drop takes this offset back off, so a tail clipped at midnight lands // The drop takes this offset back off, so a tail clipped at midnight lands
// where the event's own start belongs (#253). // where the event's own start belongs (#253).
val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) } val clipOffset = remember(block, date, zone) { block.clipOffsetMinutes(date, zone) }
val shape = remember(block, date, zone) { val cuts = remember(block, date, zone) {
timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone)) timedBlockCuts(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
} }
val shape = remember(cuts) { timedBlockShape(cuts.top, cuts.bottom) }
val dragModifier = rememberEventDragSource( val dragModifier = rememberEventDragSource(
enabled = draggable, enabled = draggable,
key = block.event.instanceId, key = block.event.instanceId,
onPickUp = { pointer, blockRoot, _ -> onPickUp = { pointer, blockRoot, _ ->
dragController.begin(block, clipOffset, pointer, blockRoot) dragController.begin(
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
blockInRoot = blockRoot,
)
}, },
onMove = dragController::move, onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) }, onDrop = { dragController.finish()?.let(onDrop) },
@@ -936,12 +954,12 @@ private fun EventBlock(
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier) modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
// The source stays put as a ghost while its floating copy travels. // The source stays put as a ghost while its floating copy travels.
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier) .then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
.background(fill, shape) .eventSurface(paint, shape, cuts)
.clickable(onClick = onClick) .clickable(onClick = onClick)
// 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 = BLOCK_TEXT_INSET)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -949,19 +967,20 @@ 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 = paint.titleInk,
textDecoration = declinedDecoration(block.event.isDeclined), textDecoration = paint.decoration,
fontWeight = paint.titleWeight,
) )
} }
if (showTime) { if (showTime) {
BlockTimeLabel( BlockTimeLabel(
label = timeLabel, label = timeLabel,
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA), color = paint.secondaryInk,
maxLines = timeMaxLines,
) )
} }
} }
@@ -1033,13 +1052,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

@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.resolveFirstDay
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.isDeclined
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -144,7 +145,7 @@ class WeekViewModel @Inject constructor(
/** /**
* Lay out all-day events as connected horizontal spans across the visible week. * Lay out all-day events as connected horizontal spans across the visible week.
* Each event becomes one [AllDaySpan] from its first to its last covered column; * Each event becomes one [AllDaySpan] from its first to its last covered column;
* overlapping spans are stacked on separate lanes (greedy first-fit by start). * overlapping spans are stacked on separate lanes (greedy first-fit).
*/ */
internal fun layoutAllDay( internal fun layoutAllDay(
events: List<EventInstance>, events: List<EventInstance>,
@@ -158,21 +159,35 @@ internal fun layoutAllDay(
val covered = days.indices.filter { ev.coversDay(days[it], zone) } val covered = days.indices.filter { ev.coversDay(days[it], zone) }
if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last()) if (covered.isEmpty()) null else Raw(ev, covered.first(), covered.last())
} }
.sortedWith(compareBy({ it.startCol }, { it.endCol })) // Declined bars are packed after every other one, so on any day they
// cover they land in a lane below it (#230).
.sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol }))
val laneEnd = ArrayList<Int>() // last occupied column per lane // What each lane already holds, rather than just how far right it reaches.
// A single "last occupied column" only answers correctly while spans arrive
// in non-decreasing start order, which the declined-last rule above breaks:
// a Monday bar seated after a Wednesday one would be refused a lane it is
// nowhere near, and each wasted lane costs the all-day strip a whole row and
// pushes a bar closer to the month grid's MAX_EVENT_ROWS cap. Seven columns
// and a handful of bars, so the scan is cheaper than the sort above it.
val laneCols = ArrayList<MutableList<IntRange>>()
return raw.map { r -> return raw.map { r ->
var lane = laneEnd.indexOfFirst { it < r.startCol } val cols = r.startCol..r.endCol
var lane = laneCols.indexOfFirst { seated -> seated.none { it overlaps cols } }
if (lane == -1) { if (lane == -1) {
laneEnd.add(r.endCol) laneCols.add(mutableListOf(cols))
lane = laneEnd.size - 1 lane = laneCols.size - 1
} else { } else {
laneEnd[lane] = r.endCol laneCols[lane].add(cols)
} }
AllDaySpan(r.event, r.startCol, r.endCol, lane) AllDaySpan(r.event, r.startCol, r.endCol, lane)
} }
} }
/** Whether two column ranges share a column, so they cannot share a lane. */
private infix fun IntRange.overlaps(other: IntRange): Boolean =
first <= other.last && other.first <= last
/** Beginning of the week (at [weekStart]) that contains this date. */ /** Beginning of the week (at [weekStart]) that contains this date. */
internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate { internal fun LocalDate.startOfWeek(weekStart: DayOfWeek): LocalDate {
// DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering. // DayOfWeek.ordinal: MONDAY=0..SUNDAY=6 → identical to ISO ordering.

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

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.agenda package de.jeanlucmakiola.calendula.widget.agenda
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration

View File

@@ -1,5 +1,6 @@
package de.jeanlucmakiola.calendula.widget.month package de.jeanlucmakiola.calendula.widget.month
import de.jeanlucmakiola.calendula.domain.isDeclined
import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration import de.jeanlucmakiola.calendula.widget.glanceDeclinedDecoration
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration

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

@@ -2,6 +2,8 @@ package de.jeanlucmakiola.calendula.data.calendar
import android.provider.CalendarContract import android.provider.CalendarContract
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventResponse
import de.jeanlucmakiola.calendula.domain.isDeclined
import kotlin.time.Instant import kotlin.time.Instant
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -109,4 +111,22 @@ class InstanceMapperTest {
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse() assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.isDeclined).isFalse()
} }
} }
@Test
fun `an unanswered invitation reads as invited, an answered one does not`() {
assertThat(
reader(selfAttendeeStatus = CalendarContract.Attendees.ATTENDEE_STATUS_INVITED)
.toEventInstance()!!.response,
).isEqualTo(EventResponse.Invited)
mapOf(
CalendarContract.Attendees.ATTENDEE_STATUS_NONE to EventResponse.Going,
CalendarContract.Attendees.ATTENDEE_STATUS_ACCEPTED to EventResponse.Going,
// "Maybe" is still an answer, so it keeps the solid chip (#230).
CalendarContract.Attendees.ATTENDEE_STATUS_TENTATIVE to EventResponse.Going,
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED to EventResponse.Declined,
).forEach { (status, expected) ->
assertThat(reader(selfAttendeeStatus = status).toEventInstance()!!.response)
.isEqualTo(expected)
}
}
} }

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,61 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/** How an event's time is set against its title (#219). */
class EventTimeStyleTest {
@Test
fun `a time steps down to regular and closes its tracking up`() {
val label = TextStyle(fontWeight = FontWeight.Medium, letterSpacing = 0.5.sp)
val time = label.asEventTime()
assertThat(time.fontWeight).isEqualTo(FontWeight.Normal)
assertThat(time.letterSpacing).isEqualTo(0.sp)
}
@Test
fun `nothing else of the style is touched`() {
val label = TextStyle(
fontSize = 11.sp,
color = Color.Red,
textDecoration = TextDecoration.LineThrough,
)
val time = label.asEventTime()
assertThat(time.fontSize).isEqualTo(11.sp)
assertThat(time.color).isEqualTo(Color.Red)
assertThat(time.textDecoration).isEqualTo(TextDecoration.LineThrough)
}
@Test
fun `the title is inked above the time beside it`() {
assertThat(TITLE_INK_ALPHA).isGreaterThan(SECONDARY_INK_ALPHA)
}
@Test
fun `the time is set apart from the title it precedes`() {
val label = inlineTimeLabel("09:05", "Standup", Color.Black)
assertThat(label.text).isEqualTo("09:05 Standup")
// Only the time is restyled: the title keeps the surface's own label
// style, so the two read as separate things on one line (#219).
val spans = label.spanStyles
assertThat(spans).hasSize(1)
assertThat(spans.single().start).isEqualTo(0)
assertThat(spans.single().end).isEqualTo("09:05".length)
assertThat(spans.single().item.fontWeight).isEqualTo(FontWeight.Normal)
assertThat(spans.single().item.letterSpacing).isEqualTo(0.sp)
assertThat(spans.single().item.color).isEqualTo(Color.Black)
}
@Test
fun `a chip without a time is styled title and nothing else`() {
val label = inlineTimeLabel(null, "Standup", Color.Black)
assertThat(label.text).isEqualTo("Standup")
assertThat(label.spanStyles).isEmpty()
}
}

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

View File

@@ -0,0 +1,72 @@
package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atTime
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import org.junit.jupiter.api.Test
import java.util.Locale
/** Which month chips carry a start time, and how it reads (#219).
* How it is *set* against the title lives in `EventTimeStyleTest`. */
class MonthChipTimeTest {
private val zone = TimeZone.UTC
private val locale = Locale.UK
private val day = LocalDate(2026, Month.SEPTEMBER, 2)
private fun timed(hour: Int, minute: Int) = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 1L,
title = "Standup",
start = day.atTime(hour, minute).toInstant(zone),
end = day.atTime(hour + 1, minute).toInstant(zone),
isAllDay = false,
color = 0,
location = null,
)
private fun allDay() = EventInstance(
instanceId = 2L,
eventId = 2L,
calendarId = 1L,
title = "Holiday",
start = day.atTime(0, 0).toInstant(zone),
end = day.plus(1, DateTimeUnit.DAY).atTime(0, 0).toInstant(zone),
isAllDay = true,
color = 0,
location = null,
)
@Test
fun `a timed chip shows its start in the 24-hour convention`() {
val time = monthChipTime(timed(9, 5), continuesLeft = false, zone, is24Hour = true, locale)
assertThat(time).isEqualTo("09:05")
}
@Test
fun `the 12-hour setting is honoured`() {
val time = monthChipTime(timed(14, 30), continuesLeft = false, zone, is24Hour = false, locale)
assertThat(time).isEqualTo("2:30 pm")
}
@Test
fun `an all-day chip never shows a time`() {
val time = monthChipTime(allDay(), continuesLeft = false, zone, is24Hour = true, locale)
assertThat(time).isNull()
}
@Test
fun `a bar carried in from the previous week shows none either`() {
// Its start is not in this segment, so printing it would put a time on a
// row the event does not begin on.
val time = monthChipTime(timed(9, 5), continuesLeft = true, zone, is24Hour = true, locale)
assertThat(time).isNull()
}
}

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.month
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.EventResponse
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.DayOfWeek import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
@@ -267,4 +268,70 @@ class MonthLayoutTest {
assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th) assertThat(byDay.keys).containsExactlyElementsIn(weekOf8th)
assertThat(byDay.values.flatten()).isEmpty() assertThat(byDay.values.flatten()).isEmpty()
} }
@Test
fun `a declined event sorts behind every answered one on its day`() {
val day = LocalDate(2026, 6, 10)
// Earliest of the three, and all-day on top of that, so only the
// declined ordering can put it last.
val declined = allDay(day, id = 1L, title = "Declined")
.copy(response = EventResponse.Declined)
val morning = timed(day, 9, 10, id = 2L, title = "Morning")
val evening = timed(day, 18, 19, id = 3L, title = "Evening")
val events = listOf(declined, morning, evening)
assertThat(instancesByDay(listOf(day), events, zone).getValue(day).map { it.title })
.containsExactly("Morning", "Evening", "Declined").inOrder()
val timedOnly =
layoutCalendarWeek(weekOf8th, listOf(evening, morning, declinedTimed(day)), zone)
assertThat(timedOnly.timedByDay.getValue(day).map { it.title })
.containsExactly("Morning", "Evening", "Declined early").inOrder()
}
@Test
fun `a declined bar packed last still shares a lane it does not overlap`() {
// Sorting declined last means this bar is seated after one that reaches
// further right than it starts. A lane must still be offered on the
// columns it is actually free on, or the row grows a wasted rank.
val mon = weekOf8th[0]
val declined = allDay(mon, id = 1L, title = "Declined")
.copy(response = EventResponse.Declined)
val going = allDay(weekOf8th[2], weekOf8th[3], id = 2L, title = "Going")
val week = layoutCalendarWeek(weekOf8th, listOf(declined, going), zone)
assertThat(week.spans.map { it.lane }).containsExactly(0, 0)
}
@Test
fun `a declined all-day bar takes a lane below the ones you answered`() {
val day = LocalDate(2026, 6, 10)
val declined =
allDay(day, id = 1L, title = "Declined").copy(response = EventResponse.Declined)
val going = allDay(day, id = 2L, title = "Going")
// Declined listed first: only the sort, not the input order, may decide.
val week = layoutCalendarWeek(weekOf8th, listOf(declined, going), zone)
val lanes = week.spans.associate { it.event.title to it.lane }
assertThat(lanes.getValue("Going")).isLessThan(lanes.getValue("Declined"))
}
@Test
fun `overlapping bars never share a lane, whatever order they arrive in`() {
val a = allDay(weekOf8th[0], weekOf8th[3], id = 1L, title = "A")
val b = allDay(weekOf8th[2], weekOf8th[5], id = 2L, title = "B")
val c = allDay(weekOf8th[3], weekOf8th[4], id = 3L, title = "C")
.copy(response = EventResponse.Declined)
val lanes = layoutCalendarWeek(weekOf8th, listOf(c, b, a), zone)
.spans.associate { it.event.title to it.lane }
assertThat(lanes.getValue("A")).isNotEqualTo(lanes.getValue("B"))
assertThat(lanes.getValue("B")).isNotEqualTo(lanes.getValue("C"))
assertThat(lanes.getValue("A")).isNotEqualTo(lanes.getValue("C"))
}
/** A declined timed event starting before every other one in its test. */
private fun declinedTimed(date: LocalDate) =
timed(date, 7, 8, id = 4L, title = "Declined early")
.copy(response = EventResponse.Declined)
} }