Compare commits

..
1 Commits
Author SHA1 Message Date
renovate-bot e35f330492 fix(deps): update glance to v1.2.0 2026-09-07 05:02:47 +00:00
75 changed files with 481 additions and 2698 deletions
-6
View File
@@ -37,12 +37,6 @@ jobs:
- name: Reproducible-release invariant - name: Reproducible-release invariant
run: bash scripts/check_reproducible_release.sh run: bash scripts/check_reproducible_release.sh
# Play rejects a "What's New" over 500 characters, which would fail the
# upload after the release had already shipped everywhere else. Cheap, so
# it runs on every PR rather than only on the release merge.
- name: Changelog length invariant
run: bash scripts/check_changelog_lengths.sh
# Decide whether anything that affects the app build changed. Docs, store # Decide whether anything that affects the app build changed. Docs, store
# metadata, licence texts and forge housekeeping don't, so those PRs skip # metadata, licence texts and forge housekeeping don't, so those PRs skip
# the SDK + Gradle work below but still report a green `ci`. # the SDK + Gradle work below but still report a green `ci`.
+1 -102
View File
@@ -5,95 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.20.1] — 2026-09-07 ## [Unreleased]
### Fixed
- **An empty calendar now explains itself.** "No calendars configured" offered
an "Open system calendar settings" button that opened nothing — every screen
wired it to the same reload that had just produced the empty state. It now
opens the system's account chooser, filtered to the providers that sync
calendars ([#239]).
- **Calendars that are all switched off no longer look like an empty month.**
When every calendar on the device is hidden at system level there is nothing
to draw, but the app said nothing at all: it only ever checked whether the
device had *no* calendars, so a device that had them and hid them fell
through to a blank grid with no hint. That case now says so, and points at
Settings → Calendars — the one screen that can switch a calendar back on. The
navigation drawer's calendar list drew the same conflation and is fixed with
it ([#239]).
## [2.20.0] — 2026-09-07
### Added
- **Long-press to copy.** Holding down the title, the location or the
description on an event puts that whole field on the clipboard. Tapping still
does what it always did — the location opens maps, a link in the description
opens — and the times, calendar, repeat rule and guests stay uncopyable. Below
Android 13 a short confirmation appears; from Android 13 on the system shows
its own clipboard chip ([#195]).
- **The open-source projects Calendula ships** are now listed under Settings →
About, each with its copyright holder and licence, and each row tapping
through to that project's source ([#281]).
### Changed
- 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 in the month, week and day views — a few
more letters per event, which is often the difference between two events you
can tell apart and two you can't. Right-to-left languages keep the ellipsis,
where clipping would cut the beginning of a title rather than its end
([#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 hour lines in week and day view are now an hour grid.** The setting
that drew a faint separator line at each hour now seats every hour in its own
rounded cell, so an hour boundary reads as the seam between two surfaces
rather than a line drawn across one — the same negative space that separates
the month grid's days and the week's columns. An event that starts on the hour
fills its cell instead of overhanging the seam, and one that runs past
midnight still meets the edge of its column ([#113]).
- **An invitation you haven't answered now reads as an outline.** An event still
waiting on your reply is drawn with the calendar's colour on its border and
its title and nothing behind it, so it stands apart from the ones you have
accepted — on the month chips, the all-day bars and the week and day blocks
alike. Declined events keep their filled, struck-through chip and now sort
last in the month view as well, so a day with more events than fit drops the
ones you said no to first ([#230]).
- 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]).
- The jump-to-today button in the toolbar now shows today's date. It drew a
generic calendar icon, which told you nothing you didn't already know from
tapping it; it now carries the current day number in an outlined box, so the
bar says what day it is as well as taking you there. It rolls over at midnight
on its own ([#220]).
- The quick-switch pill disappears when there is nothing left to switch between,
and you can now turn calendar views off all the way down to a single one — the
setting used to hold two open purely to keep the pill fed ([#150]).
### Fixed
- **A long location no longer runs off the edge of its field.** The location on
the edit screen sat on one line, so a full postal address or a long meeting
link scrolled sideways out of sight as you typed it. It now wraps and the card
grows to fit, with the pin and the contacts button staying level with the first
line at any font size. A multi-line address you paste in is joined with commas
— the way one picked from your contacts always has been — instead of running
together into a single word ([#273]).
- **The event form capitalizes sentences again.** The title, description and
guest fields had stopped starting a sentence with a capital letter; they
follow the keyboard's usual behaviour once more. The location is deliberately
left alone, since it is as often a URL as an address ([#146]).
- **A block you pick up no longer loses its second line.** Dragging an event
whose time or title wrapped to two lines clipped the floating copy to one line
for as long as you held it, and it snapped back on drop. The copy now measures
its text exactly as the block does ([#267]).
## [2.19.4] — 2026-09-01 ## [2.19.4] — 2026-09-01
@@ -1648,8 +1560,6 @@ 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
@@ -1659,16 +1569,5 @@ 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
[#273]: https://codeberg.org/jlmakiola/calendula/issues/273
[#113]: https://codeberg.org/jlmakiola/calendula/issues/113
[#220]: https://codeberg.org/jlmakiola/calendula/issues/220
[#195]: https://codeberg.org/jlmakiola/calendula/issues/195
[#281]: https://codeberg.org/jlmakiola/calendula/issues/281
[#230]: https://codeberg.org/jlmakiola/calendula/issues/230
[#150]: https://codeberg.org/jlmakiola/calendula/issues/150
[#146]: https://codeberg.org/jlmakiola/calendula/issues/146
[#267]: https://codeberg.org/jlmakiola/calendula/issues/267
[#239]: https://codeberg.org/jlmakiola/calendula/issues/239
+2 -2
View File
@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 22001 versionCode = 21904
versionName = "2.20.1" versionName = "2.19.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -31,7 +31,7 @@ import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid 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.rememberTimelineZoom import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -171,7 +171,7 @@ class MainActivity : AppCompatActivity() {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
CompositionLocalProvider( CompositionLocalProvider(
LocalUse24HourFormat provides use24Hour, LocalUse24HourFormat provides use24Hour,
LocalShowHourGrid provides settings.showHourGrid, LocalShowHourLines provides settings.showHourLines,
LocalTimelineZoom provides timelineZoom, LocalTimelineZoom provides timelineZoom,
LocalSoftenColors provides settings.softenColors, LocalSoftenColors provides settings.softenColors,
) { ) {
@@ -4,7 +4,6 @@ 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"
@@ -40,17 +39,7 @@ 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),
response = mapEventResponse(getInt(InstanceProjection.IDX_SELF_ATTENDEE_STATUS)), isDeclined = 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
}
@@ -1,5 +1,6 @@
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
@@ -42,6 +43,7 @@ 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(),
response = mapEventResponse(getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS)), isDeclined = getInt(SearchProjection.IDX_SELF_ATTENDEE_STATUS) ==
CalendarContract.Attendees.ATTENDEE_STATUS_DECLINED,
) )
} }
@@ -212,15 +212,14 @@ class SettingsPrefs @Inject constructor(
} }
/** /**
* Whether the week/day timeline seats each hour in its own cell (v2.11, a * Whether the week/day timeline draws a faint separator line at each hour
* separator line until v2.20). Defaults to OFF — the historical flat column; * (v2.11). Defaults to OFF — the historical clean look; users opt in.
* users opt in. The stored key keeps its original name.
*/ */
val showHourGrid: Flow<Boolean> = store.data.map { prefs -> val showHourLines: Flow<Boolean> = store.data.map { prefs ->
prefs[SHOW_HOUR_LINES_KEY] ?: false prefs[SHOW_HOUR_LINES_KEY] ?: false
} }
suspend fun setShowHourGrid(enabled: Boolean) { suspend fun setShowHourLines(enabled: Boolean) {
store.edit { it[SHOW_HOUR_LINES_KEY] = enabled } store.edit { it[SHOW_HOUR_LINES_KEY] = enabled }
} }
@@ -46,19 +46,3 @@ fun hasSystemHiddenCalendars(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
pendingDisabledIds: Set<Long>, pendingDisabledIds: Set<Long>,
): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds } ): Boolean = calendars.any { !it.isVisibleInSystem && it.id !in pendingDisabledIds }
/**
* The failure a calendar list implies, or null when there is something to show.
* Distinguishes "the device has no calendars" from "every one of them is
* switched off" (#239) — both leave the grid empty, but only the second is
* fixed from Settings → Calendars.
*
* The filter sheet's own hidden set is deliberately not folded in: emptying it
* is a deliberate act taken moments earlier, in a sheet that is still one tap
* away.
*/
fun calendarListFailure(calendars: List<CalendarSource>): FailureReason? = when {
calendars.isEmpty() -> FailureReason.NoCalendarsConfigured
calendars.none { it.isVisibleInSystem } -> FailureReason.AllCalendarsHidden
else -> null
}
@@ -68,35 +68,14 @@ data class EventInstance(
*/ */
val isRecurring: Boolean = false, val isRecurring: Boolean = false,
/** /**
* This device user's own answer to the invitation, as far as the grids care * This device user answered "no" to the invitation
* (#180, #230). * (`Events.SELF_ATTENDEE_STATUS`). 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).
*/ */
val response: EventResponse = EventResponse.Going, val isDeclined: Boolean = false,
) )
/**
* 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)
@@ -252,7 +231,6 @@ enum class RecurringWriteScope {
enum class FailureReason { enum class FailureReason {
PermissionRevoked, PermissionRevoked,
NoCalendarsConfigured, NoCalendarsConfigured,
AllCalendarsHidden,
ProviderUnavailable, ProviderUnavailable,
EventNotFound, EventNotFound,
Unknown, Unknown,
@@ -31,7 +31,6 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
import de.jeanlucmakiola.calendula.ui.common.LocalManageCalendars
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
import de.jeanlucmakiola.calendula.ui.common.drillToDay import de.jeanlucmakiola.calendula.ui.common.drillToDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
@@ -345,12 +344,7 @@ fun CalendarHost(
// navigation, so it fades through rather than sliding — paging *within* a // navigation, so it fades through rather than sliding — paging *within* a
// view keeps the directional slide. AnimatedContent keyed on the view type. // view keeps the directional slide. AnimatedContent keyed on the view type.
val viewSwitch = fadeThrough() val viewSwitch = fadeThrough()
CompositionLocalProvider( CompositionLocalProvider(LocalEventMove provides moveScope) {
LocalEventMove provides moveScope,
// The failure state's way out when every calendar is switched off
// (#239); nothing else in the calendar surfaces navigates here.
LocalManageCalendars provides { showCalendars = true },
) {
AnimatedContent( AnimatedContent(
targetState = view, targetState = view,
transitionSpec = { viewSwitch }, transitionSpec = { viewSwitch },
@@ -29,7 +29,6 @@ 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
@@ -30,6 +30,7 @@ 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
@@ -39,9 +40,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
@@ -50,8 +51,6 @@ 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
@@ -96,6 +95,7 @@ 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, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
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,24 +159,13 @@ 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( .padding(start = 28.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
start = RANGE_BAR_TEXT_INSET,
end = selectorEnd,
top = 8.dp,
bottom = 8.dp,
),
) { ) {
AgendaRangeBanner( AgendaRangeBanner(
range = s.range, range = s.range,
@@ -256,9 +245,6 @@ 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
@@ -423,22 +409,17 @@ 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 = {
@@ -459,16 +440,14 @@ 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.surface, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
scrollBehavior = scrollBehavior,
) )
} }
@@ -11,7 +11,6 @@ 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.calendarListFailure
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
@@ -157,7 +156,9 @@ class AgendaViewModel @Inject constructor(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
): AgendaUiState { ): AgendaUiState {
calendarListFailure(calendars)?.let { return AgendaUiState.Failure(it) } if (calendars.isEmpty()) {
return AgendaUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val anchor = params.anchor val anchor = params.anchor
val rangeEnd = anchor.plus( val rangeEnd = anchor.plus(
params.range.dayCount(anchor, params.weekStart) - 1, params.range.dayCount(anchor, params.weekStart) - 1,
@@ -1,41 +0,0 @@
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
/** M3's icon slot inside an icon button; the today glyph fills the same box. */
internal 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
}
@@ -10,137 +10,24 @@ 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.text.style.TextOverflow
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.unit.Constraints
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], breaking at word boundaries.
*
* #164 clipped the last line mid-glyph so none of a narrow chip's few
* characters went on an ellipsis. On a block that can wrap, whole words read
* better than full lines do: "Farmers Market" over two lines beats "Farmer" /
* "s Marke". A single line still clips, having nowhere to wrap to.
*/
@Composable
fun BlockTitle(
title: String,
maxLines: Int,
color: Color,
modifier: Modifier = Modifier,
textDecoration: TextDecoration? = null,
fontWeight: FontWeight? = null,
) {
val overflow = eventTitleOverflow(singleLine = maxLines == 1)
Text(
text = title,
modifier = modifier,
style = MaterialTheme.typography.labelMedium
.let { if (fontWeight == null) it else it.copy(fontWeight = fontWeight) },
maxLines = maxLines,
overflow = overflow.overflow,
softWrap = overflow.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( fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
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,
@@ -149,11 +36,9 @@ fun BlockTimeLabel(
) { text -> ) { text ->
Text( Text(
text = text, text = text,
// Regular weight against the title's medium above it (#219). style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.labelSmall.asEventTime(), maxLines = 1,
maxLines = maxLines, overflow = TextOverflow.Ellipsis,
overflow = overflow.overflow,
softWrap = overflow.softWrap,
color = color, color = color,
) )
} }
@@ -1,9 +1,5 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import android.content.Context
import android.content.Intent
import android.provider.CalendarContract
import android.provider.Settings
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -14,10 +10,8 @@ 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.runtime.compositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.unit.dp import androidx.compose.ui.unit.dp
@@ -30,29 +24,18 @@ import de.jeanlucmakiola.calendula.domain.FailureReason
*/ */
@Composable @Composable
fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) { fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
val context = LocalContext.current
val manageCalendars = LocalManageCalendars.current
val titleRes = when (reason) { val titleRes = when (reason) {
FailureReason.PermissionRevoked -> R.string.state_failure_permission FailureReason.PermissionRevoked -> R.string.state_failure_permission
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
FailureReason.ProviderUnavailable -> R.string.state_failure_provider FailureReason.ProviderUnavailable -> R.string.state_failure_provider
FailureReason.Unknown, FailureReason.Unknown,
FailureReason.EventNotFound -> R.string.state_failure_unknown FailureReason.EventNotFound -> R.string.state_failure_unknown
} }
val actionRes = when (reason) { val actionRes = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars_action
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden_action
FailureReason.PermissionRevoked -> R.string.state_failure_permission_action FailureReason.PermissionRevoked -> R.string.state_failure_permission_action
else -> R.string.state_retry else -> R.string.state_retry
} }
val onAction: () -> Unit = when (reason) {
FailureReason.NoCalendarsConfigured -> {
{ context.startCalendarSetup() }
}
FailureReason.AllCalendarsHidden -> manageCalendars ?: onRetry
else -> onRetry
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -66,30 +49,8 @@ fun CalendarFailure(reason: FailureReason, onRetry: () -> Unit) {
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
FilledTonalButton(onClick = onAction) { FilledTonalButton(onClick = onRetry) {
Text(stringResource(actionRes)) Text(stringResource(actionRes))
} }
} }
} }
/**
* Opens Settings Calendars, the only screen that can switch a calendar back
* on. Null outside the calendar host screens without it never raise the
* reason that needs it, and fall back to their own retry.
*/
val LocalManageCalendars = compositionLocalOf<(() -> Unit)?> { null }
/**
* Open the system screen for putting a calendar on the device: the account
* chooser filtered to calendar providers, falling back to account settings
* where no chooser resolves. Both are system components, so nothing here needs
* a `<queries>` entry.
*/
private fun Context.startCalendarSetup() {
val intents = listOf(
Intent(Settings.ACTION_ADD_ACCOUNT)
.putExtra(Settings.EXTRA_AUTHORITIES, arrayOf(CalendarContract.AUTHORITY)),
Intent(Settings.ACTION_SYNC_SETTINGS),
)
intents.firstOrNull { runCatching { startActivity(it) }.isSuccess }
}
@@ -1,7 +1,6 @@
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
@@ -13,7 +12,6 @@ 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
@@ -21,8 +19,6 @@ 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
@@ -35,9 +31,10 @@ 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).
* *
* [shortTitle] replaces [title] when the full one does not fit the width the app * The row keeps its own 8.dp inset rather than shifting back onto the app bar's
* bar hands the title slot (#165). Either way the line clamps to one and * start alignment: M3 places the title flush against the navigation icon's
* ellipsises, so pass a [shortTitle] wherever there is something left to drop. * trailing edge, and the title is hit-tested on top of it, so a negative offset
* would swallow taps meant for the menu button.
*/ */
@Composable @Composable
fun CalendarTitleButton( fun CalendarTitleButton(
@@ -45,7 +42,6 @@ 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) }
@@ -57,27 +53,14 @@ 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 = AppBarSpacing.TitleInset), .padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val style = MaterialTheme.typography.titleLarge Text(
BoxWithConstraints(modifier = Modifier.weight(1f, fill = false)) { text = title,
val measurer = rememberTextMeasurer() style = MaterialTheme.typography.titleLarge,
val shown = remember(title, shortTitle, style, constraints.maxWidth, measurer) { modifier = Modifier.weight(1f, fill = false),
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,
@@ -96,11 +79,3 @@ fun CalendarTitleButton(
) )
} }
} }
/** Falls back to [shortTitle] when [title] is wider than [availableWidth] (#165). */
internal fun titleFor(
title: String,
shortTitle: String,
titleWidth: Int,
availableWidth: Int,
): String = if (titleWidth <= availableWidth) title else shortTitle
@@ -57,8 +57,7 @@ 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 including when [cycle] is emptied and * disabled here stays reachable there.
* the pill disappears altogether.
*/ */
data class QuickSwitchConfig( data class QuickSwitchConfig(
val order: List<CalendarView>, val order: List<CalendarView>,
@@ -68,15 +67,14 @@ 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
} }
} }
@@ -1,104 +0,0 @@
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
}
}
}
}
@@ -1,165 +0,0 @@
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),
)
}
@@ -1,77 +0,0 @@
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() }
}
}
@@ -1,37 +0,0 @@
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 instead mid-glyph on one line, at the last whole word it
* could fit on several.
*
* One case keeps the ellipsis: a single **[rtl]** line. 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. Wrapping needs `softWrap` on, which clips the right end either
* way, so more than one line needs no such exception.
*/
fun eventTitleOverflowFor(rtl: Boolean, singleLine: Boolean): EventTitleOverflow =
when {
!singleLine -> EventTitleOverflow(TextOverflow.Clip, softWrap = true)
rtl -> 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,
)
@@ -1,83 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
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.unit.Dp
import androidx.compose.ui.unit.dp
/**
* Whether the week/day timeline seats each hour in its own cell, from the
* `show_hour_lines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off the historical flat column.
*/
val LocalShowHourGrid = staticCompositionLocalOf { false }
/** Gap between two hour cells, matching the month grid's gap between day cells. */
val HOUR_CELL_GAP = 2.dp
/** Half a gap: what a cell — and a block seated in it — gives up at each edge. */
val HOUR_CELL_INSET = HOUR_CELL_GAP / 2
/**
* The half-gap a block seated in the grid gives up at one of its ends, so an
* on-the-hour event fills its cell instead of overhanging the seam into its
* neighbours'. Zero with the grid off, and zero at an end [cut] at midnight:
* that edge is squared off against the column's own, and stopping a half-gap
* short of it would leave the block floating there rather than running off.
*/
fun hourCellBlockInset(show: Boolean, cut: Boolean): Dp =
if (show && !cut) HOUR_CELL_INSET else 0.dp
/**
* Corner radius of an hour cell: the event chip's own, so the grid never rounds
* harder than the blocks it seats. The month grid's 12dp belongs to a cell many
* times the size on an hour cell it out-rounds its own content.
*/
private val HOUR_CELL_CORNER = EVENT_CHIP_CORNER
/**
* Radius an hour cell of [cellHeight] by [cellWidth] pixels may round to. The
* hour pitch runs from a fit-the-day sliver up to [MAX_PINCH_HOUR_HEIGHT] and a
* week column is a seventh of the screen, so the radius is held to half the
* shorter side the point past which the corners would meet and the cell turn
* into a lozenge.
*/
internal fun hourCellRadiusPx(cellHeight: Float, cellWidth: Float, maxRadius: Float): Float =
minOf(maxRadius, cellHeight / 2f, cellWidth / 2f).coerceAtLeast(0f)
/**
* Seat each of the day's 24 hours in its own rounded cell when [show] is true,
* so the hour boundary reads as a seam between two surfaces rather than a line
* drawn across one. Applied to a day column's content, so the cells sit over the
* column background but beneath the event blocks blocks stay in a continuous
* coordinate space and keep spanning cells. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourGridCells(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
if (!show) {
this
} else {
drawBehind {
val inset = HOUR_CELL_INSET.toPx()
val cellHeight = hourHeightPx - inset * 2f
if (cellHeight <= 0f) return@drawBehind
val radius = CornerRadius(
hourCellRadiusPx(cellHeight, size.width, HOUR_CELL_CORNER.toPx()),
)
val cellSize = Size(size.width, cellHeight)
for (hour in 0 until 24) {
drawRoundRect(
color = color,
topLeft = Offset(0f, hour * hourHeightPx + inset),
size = cellSize,
cornerRadius = radius,
)
}
}
}
@@ -21,7 +21,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -73,11 +72,6 @@ fun HourGutter(
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f, targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
label = "hourLabelAlpha", label = "hourLabelAlpha",
) )
// Each label straddles the boundary it names, so it lines up with the seam
// the hour grid leaves there instead of hanging below it. Derived from the
// label's own line height, so it holds at any font scale.
val labelStyle = MaterialTheme.typography.labelSmall
val labelLift = with(LocalDensity.current) { labelStyle.lineHeight.toDp() } / 2
Box( Box(
modifier = modifier modifier = modifier
@@ -96,12 +90,12 @@ fun HourGutter(
if (h > 0) { if (h > 0) {
Text( Text(
text = formatHourLabel(h, use24Hour, locale), text = formatHourLabel(h, use24Hour, locale),
style = labelStyle, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
.copy(alpha = hourAlpha), .copy(alpha = hourAlpha),
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.offset(y = -labelLift), .offset(y = (-6).dp),
) )
} }
} }
@@ -0,0 +1,38 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
/**
* Whether the week/day timeline draws an hour separator line, from the
* `showHourLines` preference. Provided once at the app root (like
* [LocalUse24HourFormat]) so the timeline reads it without ViewModel plumbing.
* Defaults to off the historical clean look.
*/
val LocalShowHourLines = staticCompositionLocalOf { false }
/**
* Draw a faint separator line at the top of each hour (1..23) when [show] is
* true. Applied to a day column's content so each line sits over the column's
* background but beneath the event blocks. [hourHeightPx] is one hour's pixel
* height; [color] is resolved by the caller from the theme.
*/
fun Modifier.hourSeparatorLines(show: Boolean, hourHeightPx: Float, color: Color): Modifier =
if (!show) {
this
} else {
drawBehind {
for (hour in 1 until 24) {
val y = hour * hourHeightPx
drawLine(
color = color,
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 1f,
)
}
}
}
@@ -12,7 +12,7 @@ import kotlin.time.Instant
* drawn dimmed in the month/week grids i.e. the current wall-clock minute when * drawn dimmed in the month/week grids i.e. the current wall-clock minute when
* the "dim completed events" setting is on, or `null` when it is off (nothing * the "dim completed events" setting is on, or `null` when it is off (nothing
* dims). Provided per grid screen so only the event chips that read it recompose * dims). Provided per grid screen so only the event chips that read it recompose
* as the minute ticks, mirroring [LocalShowHourGrid] / [LocalUse24HourFormat]. * as the minute ticks, mirroring [LocalShowHourLines] / [LocalUse24HourFormat].
*/ */
val LocalDimCutoff = compositionLocalOf<Instant?> { null } val LocalDimCutoff = compositionLocalOf<Instant?> { null }
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -25,9 +24,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.draw.clipToBounds
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin 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
@@ -36,6 +34,7 @@ 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
@@ -84,13 +83,6 @@ 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
@@ -200,32 +192,6 @@ internal fun dragFloorMin(clipOffsetMin: Int, eventSpanMin: Int): Int =
0 0
} }
/**
* Where one dragged piece's top edge sits below its column's top, and how tall
* it is drawn, at [hourPx] to the hour. [insetPx] is the half-gap a block seated
* in the hour grid gives up at each end, zero with the grid off taken off the
* copy exactly as the grid takes it off the block, or the copy is a gap taller
* than the block it lifted off and spends the difference on a time label that
* block had no room for (#267). An end cut at midnight keeps none of it, as the
* block's own squared-off edge does.
*/
internal fun dragPieceBounds(
slice: DragSlice,
hourPx: Float,
insetPx: Float,
): DragPieceBounds {
val top = if (slice.continuesBefore) 0f else insetPx
val bottom = if (slice.continuesAfter) 0f else insetPx
val full = maxOf(slice.spanMin / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
return DragPieceBounds(
top = slice.startMin / 60f * hourPx + top,
height = (full - top - bottom).coerceAtLeast(0f),
)
}
/** A piece's placement within its column — see [dragPieceBounds]. */
internal data class DragPieceBounds(val top: Float, val height: Float)
/** One day's share of a dragged event, before it is placed on screen. */ /** One day's share of a dragged event, before it is placed on screen. */
internal data class DragSlice( internal data class DragSlice(
val dayOffset: Int, val dayOffset: Int,
@@ -269,13 +235,6 @@ class TimelineGeometry {
var stepPx: Float = 0f var stepPx: Float = 0f
var days: List<LocalDate> = emptyList() var days: List<LocalDate> = emptyList()
/**
* The half-gap a block gives up at each uncut end when the hour grid seats
* it in a cell, zero with the grid off. The floating copy gives up the same,
* so it is the size of the block it lifted off (#267).
*/
var blockInsetPx: Float = 0f
/** /**
* Whether the columns are laid out right-to-left. Pointer coordinates are * Whether the columns are laid out right-to-left. Pointer coordinates are
* never mirrored but the grid is, so the mapping has to flip with it. * never mirrored but the grid is, so the mapping has to flip with it.
@@ -354,9 +313,6 @@ 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).
@@ -366,13 +322,11 @@ 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
@@ -391,7 +345,6 @@ 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
@@ -514,22 +467,23 @@ 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
.map { slice -> .map { slice ->
val day = dayIndex + slice.dayOffset val day = dayIndex + slice.dayOffset
val col = if (geometry.isRtl) days.lastIndex - day else day val col = if (geometry.isRtl) days.lastIndex - day else day
val bounds = dragPieceBounds(slice, hourPx, geometry.blockInsetPx)
TimelineDragPiece( TimelineDragPiece(
topLeftInRoot = Offset( topLeftInRoot = Offset(
x = origin.x + col * columnPx, x = origin.x + col * columnPx,
y = origin.y + bounds.top, y = origin.y + slice.startMin / 60f * hourPx,
), ),
sizePx = IntSize( sizePx = IntSize(
(columnPx - geometry.columnGapPx).roundToInt(), (columnPx - geometry.columnGapPx).roundToInt(),
bounds.height.roundToInt(), maxOf(
slice.spanMin / 60f * hourPx,
MIN_EVENT_FRACTION * hourPx,
).roundToInt(),
), ),
continuesBefore = slice.continuesBefore, continuesBefore = slice.continuesBefore,
continuesAfter = slice.continuesAfter, continuesAfter = slice.continuesAfter,
@@ -650,8 +604,8 @@ const val SETTLE_FADE_MILLIS: Int = 250
@Composable @Composable
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 density = LocalDensity.current
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()
@@ -701,7 +655,7 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
animationSpec = tween(SETTLE_FADE_MILLIS), animationSpec = tween(SETTLE_FADE_MILLIS),
label = "drag-handover", label = "drag-handover",
) )
val paint = eventPaint(drag.event, dark) val fill = eventFill(drag.event.color, dark, soften)
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
@@ -712,61 +666,22 @@ fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier =
val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd val endMin = if (rawEnd > MINUTES_PER_DAY) rawEnd % MINUTES_PER_DAY else rawEnd
val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}" + val label = "${formatMinuteOfDay(startMin, use24Hour, locale)}" +
formatMinuteOfDay(endMin, use24Hour, locale) formatMinuteOfDay(endMin, use24Hour, locale)
// The timeline's own bounds. The copy is drawn over the whole calendar // The tallest piece carries the range: on the smallest it would be
// so no column clip or rounded corner cuts it, but it still belongs to // clipped away, which is exactly the case when a short tail is held.
// the timeline: a piece a whole day tall reaches far past both ends of val labelled = drag.pieces.indices.maxByOrNull { drag.pieces[it].sizePx.height }
// the viewport, and unclipped it paints over the headers above (#267). drag.pieces.forEachIndexed { index, piece ->
val port = controller.geometry.viewport?.takeIf { it.isAttached }?.boundsInRoot() DragCopy(
// How much of each piece the viewport actually shows. A multi-day event topLeftInRoot = piece.topLeftInRoot,
// has a piece per day, and the widest one is a full 24 hours — taller overlayOrigin = origin,
// than the screen, its top at a midnight scrolled out of sight. Picking sizePx = piece.sizePx,
// the range's piece by raw height put it there, off the top of the fill = fill,
// timeline; picking by *visible* height puts it where it can be read. shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
val shown = drag.pieces.map { piece -> lift = lift,
if (port == null) { alpha = copyAlpha,
piece.sizePx.height.toFloat() title = title,
} else { // Once for the whole event: repeated, it would name it per day.
val top = maxOf(piece.topLeftInRoot.y, port.top) label = label.takeIf { index == labelled },
val bottom = minOf(piece.topLeftInRoot.y + piece.sizePx.height, port.bottom) )
bottom - top
}
}
val labelled = shown.indices.maxByOrNull { shown[it] }
Box(
modifier = if (port == null) {
Modifier
} else {
Modifier
.absoluteOffset {
IntOffset(
(port.left - origin.x).roundToInt(),
(port.top - origin.y).roundToInt(),
)
}
.size(
width = with(density) { port.width.toDp() },
height = with(density) { port.height.toDp() },
)
.clipToBounds()
},
) {
val pieceOrigin = port?.topLeft ?: origin
drag.pieces.forEachIndexed { index, piece ->
DragCopy(
topLeftInRoot = piece.topLeftInRoot,
overlayOrigin = pieceOrigin,
sizePx = piece.sizePx,
paint = paint,
shape = timedBlockShape(piece.continuesBefore, piece.continuesAfter),
cuts = timedBlockCuts(piece.continuesBefore, piece.continuesAfter),
lift = lift,
alpha = copyAlpha,
title = title,
titleLines = drag.titleLines,
// Once for the whole event: repeated, it would name it per day.
label = label.takeIf { index == labelled },
)
}
} }
} }
} }
@@ -777,54 +692,14 @@ private fun DragCopy(
topLeftInRoot: Offset, topLeftInRoot: Offset,
overlayOrigin: Offset, overlayOrigin: Offset,
sizePx: IntSize, sizePx: IntSize,
paint: EventPaint, fill: Color,
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 — text sits at the top as it does
// on the block, so the copy hands back to the grid without shifting (#267).
// The title is served in full first and the range lives off what is left:
// the hour gutter down the side still says where the copy sits, so the
// range is the half that can afford to go.
val available = height - BLOCK_TEXT_INSET * 2
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(0)
val allowed = titleLines.coerceAtMost(titleBudget)
// Re-measured at the copy's own width rather than spent on the source's
// count: a block sharing its column with another is a lane wide where the
// copy is a whole column, so the source's second line is one the copy never
// draws — and reserving it took the range away with it (#267).
val lines = if (allowed <= 0) {
0
} else {
blockTextLines(
text = title,
style = MaterialTheme.typography.labelMedium.withTitleWeight(paint),
textWidth = textWidth,
max = allowed,
)
}
val left = available - titleLineHeight * lines
val showTime = label != null && left >= timeLineHeight
val timeMaxLines = if (showTime) {
blockTimeLines(label!!, textWidth, left - timeLineHeight)
} else {
1
}
Box( Box(
modifier = Modifier modifier = Modifier
// Absolute: these are root coordinates, and the direction-aware // Absolute: these are root coordinates, and the direction-aware
@@ -835,20 +710,12 @@ private fun DragCopy(
(topLeftInRoot.y - overlayOrigin.y).roundToInt(), (topLeftInRoot.y - overlayOrigin.y).roundToInt(),
) )
} }
// Required: the parent is the viewport's size, and a plain `size` .size(
// would let it cap a piece a whole day tall at one screen — drawn width = with(density) { sizePx.width.toDp() },
// from a midnight scrolled off the top, that ended the column a height = with(density) { sizePx.height.toDp() },
// scroll offset short of its own bottom (#267). The parent clips it. )
.requiredSize(width = width, height = height) .padding(horizontal = 1.dp)
.padding(horizontal = BLOCK_OUTER_INSET)
.graphicsLayer { .graphicsLayer {
// Anchored at the top edge, not the middle: scaled about the
// centre, the 2% lift raises the top by 1% of the height — a few
// pixels on an hour, but a steady upward creep of the text as a
// dragged multi-day piece grows, and a shear against the
// timeline's clip. It snapped back when the lift animated out
// on drop (#267).
transformOrigin = TransformOrigin(0.5f, 0f)
scaleX = 1f + 0.02f * lift scaleX = 1f + 0.02f * lift
scaleY = 1f + 0.02f * lift scaleY = 1f + 0.02f * lift
shadowElevation = 8.dp.toPx() * lift shadowElevation = 8.dp.toPx() * lift
@@ -856,29 +723,24 @@ private fun DragCopy(
this.shape = shape this.shape = shape
clip = false clip = false
} }
.eventSurface(paint, shape, cuts) .background(fill, shape)
.padding(horizontal = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET), .padding(horizontal = 4.dp, vertical = 2.dp),
) { ) {
Column { Column {
if (lines > 0) { Text(
BlockTitle( text = title,
title = title, style = MaterialTheme.typography.labelMedium,
maxLines = lines, maxLines = 1,
color = paint.titleInk, overflow = TextOverflow.Ellipsis,
textDecoration = paint.decoration, color = eventInk(fill, alpha = 0.85f),
fontWeight = paint.titleWeight, )
) if (label != null) {
}
if (showTime) {
val overflow = eventTitleOverflow(singleLine = timeMaxLines == 1)
Text( Text(
text = label, text = label,
// As the block it lifted off sets its own time (#219). style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.labelSmall.asEventTime(), maxLines = 1,
maxLines = timeMaxLines, overflow = TextOverflow.Ellipsis,
overflow = overflow.overflow, color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
softWrap = overflow.softWrap,
color = paint.secondaryInk,
) )
} }
} }
@@ -96,6 +96,17 @@ fun tappedMinuteOfDay(offsetY: Float, hourPx: Float): Int =
*/ */
const val MIN_EVENT_FRACTION = 26f / 60f const val MIN_EVENT_FRACTION = 26f / 60f
/**
* Narrowest an event block may be and still wrap its title over several lines.
*
* Wrapping is driven by the block's height, so a tall block on a lane-split
* column would otherwise stack two or three characters per line "Da/ily",
* "Fa/rmer/s…" which reads worse than one ellipsised line. A full week column
* clears this on any phone; a split one never does, while the day view's much
* wider columns keep wrapping even several lanes deep.
*/
val MIN_TITLE_WRAP_WIDTH = 36.dp
/** Smallest hour height [TimelineScale.FitDay] will resolve to. */ /** Smallest hour height [TimelineScale.FitDay] will resolve to. */
val FIT_DAY_MIN = 24.dp val FIT_DAY_MIN = 24.dp
@@ -1,81 +1,26 @@
package de.jeanlucmakiola.calendula.ui.common package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.foundation.border import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.Box import androidx.compose.material.icons.filled.Today
import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
private val GlyphShape = RoundedCornerShape(7.dp)
private val GlyphBorder = 1.5.dp
/**
* Height of the day number. Expressed in dp and converted to sp at draw time so
* it does not follow the font scale: this is an icon glyph sharing the bar with
* stroke icons, and a two-digit day that grew with the text would push the bar
* past the width #165 measured it to. The button's content description carries
* the meaning for screen readers, so nothing is lost by holding it still.
*/
private val GlyphTextHeight = 12.dp
/** /**
* The top-bar "jump to today" icon button, shared by every calendar view's app * The top-bar "jump to today" icon button, shared by every calendar view's app
* bar (#60). Shown in place of the fade-in FAB pill when the user moves the * bar (#60). Shown in place of the fade-in FAB pill when the user moves the
* today control into the toolbar; renders nothing when [show] is false, so it * today control into the toolbar; renders nothing when [show] is false, so it
* drops straight into an app bar's `actions` slot without a wrapping condition. * drops straight into an app bar's `actions` slot without a wrapping condition.
*
* The glyph is the current day number in an outlined box rather than a generic
* calendar icon (#220), so the bar also says what day it is. Outlined rather
* than the month grid's filled circle, which reads as "this cell is today" and
* would sit heavier here than the stroke icons beside it.
*/ */
@Composable @Composable
fun TodayAction(show: Boolean, onToday: () -> Unit) { fun TodayAction(show: Boolean, onToday: () -> Unit) {
if (!show) return if (!show) return
val now = rememberCurrentMinute() IconButton(onClick = onToday) {
// Only the day number is read, so the bar recomposes at midnight rather Icon(
// than on every minute tick. imageVector = Icons.Default.Today,
val day by remember { contentDescription = stringResource(R.string.today_jump_action),
derivedStateOf { now.value.toLocalDateTime(TimeZone.currentSystemDefault()).date.day } )
}
val description = stringResource(R.string.today_jump_action)
IconButton(
onClick = onToday,
modifier = Modifier.semantics { contentDescription = description },
) {
val textSize = with(LocalDensity.current) { GlyphTextHeight.toSp() }
Box(
modifier = Modifier
.size(AppBarSpacing.IconSize)
.border(GlyphBorder, LocalContentColor.current, GlyphShape),
contentAlignment = Alignment.Center,
) {
Text(
text = day.toString(),
fontSize = textSize,
lineHeight = textSize,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = LocalContentColor.current,
)
}
} }
} }
@@ -1,39 +1,26 @@
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.padding(end = trailingInset), modifier = modifier,
) { ) {
Text(stringResource(current.labelRes)) Text(stringResource(current.labelRes))
} }
@@ -1,6 +1,7 @@
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
@@ -39,7 +40,6 @@ 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,8 +53,10 @@ 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
@@ -63,6 +65,7 @@ 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
@@ -70,9 +73,6 @@ 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,12 +81,7 @@ 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
@@ -99,12 +94,9 @@ 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.ChipCuts
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
@@ -114,21 +106,25 @@ 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.LocalShowHourGrid 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
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.week.TimedBlock import de.jeanlucmakiola.calendula.ui.week.TimedBlock
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -174,9 +170,21 @@ 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
@@ -234,19 +242,19 @@ fun DayScreen(
}, },
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
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 = {
@@ -261,6 +269,7 @@ 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,
@@ -278,6 +287,7 @@ 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,
@@ -338,6 +348,7 @@ 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,
@@ -363,6 +374,7 @@ 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,
@@ -373,22 +385,16 @@ 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 AllDayStrip(
// with that and not with the column's container (#230). state = state,
CompositionLocalProvider( height = allDayHeight,
LocalChipGround provides MaterialTheme.colorScheme.surface, onEventClick = onEventClick,
) { modifier = Modifier
AllDayStrip( .fillMaxWidth()
state = state, .background(topSectionColor),
height = allDayHeight, )
onEventClick = onEventClick, // Breathing room between the (colour-shifting) top section and the
modifier = Modifier // scrolling timeline below.
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface),
)
}
// 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,
@@ -408,25 +414,20 @@ 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 = title, title = formatDayTitle(date, locale, currentYear),
currentDate = date, currentDate = date,
onJumpToDate = onJumpToDate, onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
) )
}, },
navigationIcon = { navigationIcon = {
@@ -447,17 +448,15 @@ 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.surface, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
scrollBehavior = scrollBehavior,
) )
} }
@@ -517,25 +516,23 @@ 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 paint = eventPaint(event, dark) val soften = LocalSoftenColors.current
val fill = eventFill(event.color, dark, soften)
Box( Box(
modifier = modifier modifier = modifier
.eventSurface(paint, EventChipShape) .background(fill, 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 = titleOverflow.overflow, overflow = TextOverflow.Ellipsis,
softWrap = titleOverflow.softWrap, color = eventInk(fill),
color = paint.titleInk, textDecoration = declinedDecoration(event.isDeclined),
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
) )
} }
} }
@@ -553,12 +550,6 @@ private fun Timeline(
val zoom = LocalTimelineZoom.current val zoom = LocalTimelineZoom.current
val density = LocalDensity.current val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// What the hour grid takes off a block's ends, for the floating copy to
// take off its own. Per-end, a cut edge keeps it — that much the drag
// geometry decides itself, from the slice it is drawing.
val blockInsetPx = with(density) {
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
}
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top // timeline's own viewport height, which is only known here — below the top
@@ -612,7 +603,6 @@ private fun Timeline(
it.grid = coords it.grid = coords
it.scroll = scrollState it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() } it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = 0f it.columnGapPx = 0f
it.columnWidthPx = coords.size.width.toFloat() it.columnWidthPx = coords.size.width.toFloat()
it.days = listOf(state.date) it.days = listOf(state.date)
@@ -639,8 +629,8 @@ private fun DayColumnCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hourPx = with(LocalDensity.current) { hourHeight.toPx() } val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourGrid = LocalShowHourGrid.current val showHourLines = LocalShowHourLines.current
val zone = remember { TimeZone.currentSystemDefault() } val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// Tells a settled drop when this column has caught up with it. // Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) { LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks) dragController.noteGrid(date, blocks)
@@ -656,23 +646,16 @@ private fun DayColumnCard(
// rounded scroll viewport, so inner rounding would look odd at the edges. // rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape, shape = RectangleShape,
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
// With the grid on, the container colour moves onto the hour cells containerColor = MaterialTheme.colorScheme.surfaceContainer,
// and the column behind them recedes to surface, so an hour boundary
// reads as negative space between two surfaces.
containerColor = if (showHourGrid) {
MaterialTheme.colorScheme.surface
} else {
MaterialTheme.colorScheme.surfaceContainer
},
), ),
modifier = modifier, modifier = modifier,
) { ) {
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// The hour cells sit over the column background but under the // Faint hour separators sit over the column background but under
// event blocks (drawBehind paints before the children). // the event blocks (drawBehind paints before the children).
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer) .hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// Tap an empty slot to create an event there. Taps on event // Tap an empty slot to create an event there. Taps on event
// blocks are consumed by their own click handler first, so this // blocks are consumed by their own click handler first, so this
// only fires on the column background. Snaps to the tapped hour. // only fires on the column background. Snaps to the tapped hour.
@@ -704,34 +687,19 @@ private fun DayColumnCard(
width = laneWidth, width = laneWidth,
height = height, height = height,
) )
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter(date, zone),
)
}
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset)
.coerceAtLeast(0.dp)
EventBlock( EventBlock(
block = block, block = block,
dark = dark, dark = dark,
height = blockHeight, height = place.height,
width = place.width,
date = date, date = date,
cuts = cuts,
topInset = topInset,
dragController = dragController, dragController = dragController,
onClick = { onEventClick(block.event) }, onClick = { onEventClick(block.event) },
onDrop = onDrop, onDrop = onDrop,
modifier = Modifier modifier = Modifier
.offset(x = place.x, y = place.y + topInset) .offset(x = place.x, y = place.y)
.width(place.width) .width(place.width)
.height(blockHeight) .height(place.height)
.padding(horizontal = BLOCK_OUTER_INSET), .padding(horizontal = 1.dp),
) )
} }
} }
@@ -748,10 +716,7 @@ private fun EventBlock(
block: TimedBlock, block: TimedBlock,
dark: Boolean, dark: Boolean,
height: Dp, height: Dp,
width: Dp,
date: LocalDate, date: LocalDate,
cuts: ChipCuts,
topInset: Dp,
dragController: TimelineDragController, dragController: TimelineDragController,
onClick: () -> Unit, onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit, onDrop: (TimelineDrop) -> Unit,
@@ -769,48 +734,29 @@ private fun EventBlock(
val timeLineHeight = with(density) { val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp() MaterialTheme.typography.labelSmall.lineHeight.toDp()
} }
// A block that cannot afford both lines spends its space on the title, and // What's left for text once the 2.dp top/bottom padding is paid for. A block
// one too short even for that drops the title rather than serving a sliced one. // that cannot afford both lines spends its space on the title, and one too
// Height alone decides: a duration threshold would keep hiding the time on a // short even for that drops the title rather than serving a sliced one.
// half-hour block the user has pinched open to three times the room it needs. val available = height - 4.dp
val available = height - BLOCK_TEXT_INSET * 2 val showTime = block.endMin - block.startMin >= 45 &&
val showTime = available >= titleLineHeight + timeLineHeight available >= titleLineHeight + timeLineHeight
val showTitle = available >= titleLineHeight val showTitle = available >= titleLineHeight
val textWidth = width - (BLOCK_OUTER_INSET + BLOCK_TEXT_PADDING) * 2 val soften = LocalSoftenColors.current
// Only lines the block can actually draw: a block too short for the time is val fill = eventFill(block.event.color, dark, soften)
// too short for a second title line too, and asking for one served a sliced
// one — as well as handing the drag copy a count it couldn't honour, so the
// title re-wrapped the moment the block was lifted (#267).
val titleBudget = (available / titleLineHeight).toInt().coerceAtLeast(1)
val titleMaxLines = if (showTime) 1 else titleBudget.coerceAtMost(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(cuts) { timedBlockShape(cuts.top, cuts.bottom) } val shape = remember(block, date, zone) {
val topInsetPx = with(density) { topInset.toPx() } timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
}
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( dragController.begin(block, clipOffset, pointer, blockRoot)
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
}, },
onMove = dragController::move, onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) }, onDrop = { dragController.finish()?.let(onDrop) },
@@ -822,12 +768,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)
.eventSurface(paint, shape, cuts) .background(fill, shape)
.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 = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET) .padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -835,19 +781,19 @@ private fun EventBlock(
) { ) {
Column { Column {
if (showTitle) { if (showTitle) {
BlockTitle( Text(
title = title, text = title,
maxLines = titleMaxLines, style = MaterialTheme.typography.labelMedium,
color = paint.titleInk, maxLines = if (showTime) 1 else 2,
textDecoration = paint.decoration, overflow = TextOverflow.Ellipsis,
fontWeight = paint.titleWeight, color = eventInk(fill, alpha = 0.85f),
textDecoration = declinedDecoration(block.event.isDeclined),
) )
} }
if (showTime) { if (showTime) {
BlockTimeLabel( BlockTimeLabel(
label = timeLabel, label = timeLabel,
color = paint.secondaryInk, color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
maxLines = timeMaxLines,
) )
} }
} }
@@ -861,12 +807,7 @@ private fun DayLoading() {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's column // Same scale resolution as the loaded timeline, so the skeleton's column
// doesn't resize the moment the real day arrives. // doesn't resize the moment the real day arrives.
val hourHeight = scale.hourHeight(maxHeight) val totalHeight = scale.hourHeight(maxHeight) * 24
val totalHeight = hourHeight * 24
// The skeleton wears the loaded column's own ground, so the arrival of
// the real day doesn't flash a solid block into a gapped grid.
val showHourGrid = LocalShowHourGrid.current
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -879,11 +820,7 @@ private fun DayLoading() {
.weight(1f) .weight(1f)
.height(totalHeight) .height(totalHeight)
.padding(horizontal = 2.dp) .padding(horizontal = 2.dp)
.background( .background(MaterialTheme.colorScheme.surfaceContainer),
if (showHourGrid) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceContainer,
)
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer),
) )
} }
} }
@@ -892,16 +829,10 @@ 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)
/** [abbreviated] drops the weekday, leaving the date itself (#165). */ private fun formatDayTitle(date: LocalDate, locale: Locale, currentYear: Int): String =
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 = if (abbreviated) "dMMM" else "EEEdMMM", skeleton = "EEEdMMM",
) )
@@ -8,7 +8,6 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
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.calendarListFailure
import de.jeanlucmakiola.calendula.ui.week.layoutAllDay import de.jeanlucmakiola.calendula.ui.week.layoutAllDay
import de.jeanlucmakiola.calendula.ui.week.layoutDay import de.jeanlucmakiola.calendula.ui.week.layoutDay
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@@ -89,7 +88,9 @@ class DayViewModel @Inject constructor(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
): DayUiState { ): DayUiState {
calendarListFailure(calendars)?.let { return DayUiState.Failure(it) } if (calendars.isEmpty()) {
return DayUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val days = listOf(day) val days = listOf(day)
val allDay = instances.filter { it.isAllDay } val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay } val timed = instances.filterNot { it.isAllDay }
@@ -1,6 +1,7 @@
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
@@ -40,6 +41,7 @@ internal fun DayViewPreview(
ScaledViewPreview(height = height, modifier = modifier) { ScaledViewPreview(height = height, modifier = modifier) {
DaySuccess( DaySuccess(
state = state, state = state,
topSectionColor = MaterialTheme.colorScheme.surface,
scrollState = scrollState, scrollState = scrollState,
allDayHeight = state.allDayStripHeight(), allDayHeight = state.allDayStripHeight(),
dragController = rememberTimelineDragController(), dragController = rememberTimelineDragController(),
@@ -9,6 +9,7 @@ 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
@@ -97,9 +98,6 @@ 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
@@ -152,7 +150,6 @@ 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.
@@ -303,7 +300,7 @@ fun EventDetailScreen(
reason = s.reason, reason = s.reason,
onRetry = viewModel::retry, onRetry = viewModel::retry,
) )
is EventDetailUiState.Success -> EventDetailContent(s, copyField, contentModifier) is EventDetailUiState.Success -> EventDetailContent(s, contentModifier)
} }
} }
@@ -385,11 +382,7 @@ private fun DeleteEventDialog(
@Composable @Composable
private fun EventDetailContent( private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modifier = Modifier) {
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()
@@ -406,7 +399,6 @@ private fun EventDetailContent(
// 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,
@@ -416,17 +408,7 @@ private fun EventDetailContent(
} else { } else {
null null
}, },
modifier = Modifier modifier = Modifier.weight(1f),
.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))
@@ -534,11 +516,10 @@ private fun EventDetailContent(
// 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 = locationLabel, iconContentDescription = stringResource(R.string.event_detail_location),
) { ) {
Text( Text(
text = location, text = location,
@@ -546,12 +527,7 @@ private fun EventDetailContent(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.copyOnLongPress( .clickable { openInMaps(context, location) }
label = locationLabel,
text = location,
copy = copyField,
onTap = { openInMaps(context, location) },
)
.padding(vertical = 2.dp), .padding(vertical = 2.dp),
) )
} }
@@ -559,15 +535,10 @@ private fun EventDetailContent(
// 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 = descriptionLabel, iconContentDescription = stringResource(R.string.event_detail_description),
// 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),
@@ -640,14 +611,13 @@ private fun EventDetailContent(
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),
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -91,7 +90,6 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
@@ -103,7 +101,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -791,59 +788,38 @@ private fun EventEditContent(
EditCard( EditCard(
icon = Icons.Default.Place, icon = Icons.Default.Place,
iconContentDescription = stringResource(R.string.event_detail_location), iconContentDescription = stringResource(R.string.event_detail_location),
iconAtTop = true,
) { ) {
Row(verticalAlignment = Alignment.Top) { Row(verticalAlignment = Alignment.CenterVertically) {
InlineField( InlineField(
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,
// Multi-line so a long address or meeting URL wraps and
// the card grows instead of scrolling off one line
// (#273). The field holds no newlines of its own, so
// the IME's action key closes the keyboard rather than
// inserting a break setLocation would only fold away.
singleLine = false,
imeAction = ImeAction.Done,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.animateContentSizeMotion()
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
) )
// The box is exactly one text line tall, so the button IconButton(
// centres on the first line without making a single-line onClick = {
// card taller than its text; requiredSize keeps the runCatching {
// button's own 40dp ripple. pickContactAddress.launch(
Box( Intent(
modifier = Modifier.height(firstLineHeight()), Intent.ACTION_PICK,
contentAlignment = Alignment.Center, ContactsContract.CommonDataKinds.StructuredPostal
.CONTENT_URI,
),
)
}
},
modifier = Modifier.size(40.dp),
) { ) {
IconButton( Icon(
onClick = { imageVector = Icons.Default.Contacts,
runCatching { contentDescription = stringResource(
pickContactAddress.launch( R.string.event_edit_location_from_contacts,
Intent( ),
Intent.ACTION_PICK, tint = MaterialTheme.colorScheme.onSurfaceVariant,
ContactsContract.CommonDataKinds.StructuredPostal modifier = Modifier.size(22.dp),
.CONTENT_URI, )
),
)
}
},
modifier = Modifier.requiredSize(40.dp),
) {
Icon(
imageVector = Icons.Default.Contacts,
contentDescription = stringResource(
R.string.event_edit_location_from_contacts,
),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
)
}
} }
} }
} }
@@ -2013,8 +1989,8 @@ private fun readContactEmail(context: Context, uri: Uri): Pair<String, String>?
/** /**
* Read the formatted postal address from a contact-pick result URI. No * Read the formatted postal address from a contact-pick result URI. No
* READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access. * READ_CONTACTS needed: ACTION_PICK grants this URI temporary read access.
* The provider's formatted address is multi-line; collapse it to one comma- * The provider's formatted address is multi-line; collapse it to one line so
* separated line, which is what the location field holds. * it sits cleanly in the single-line location field.
*/ */
private fun readContactAddress(context: Context, uri: Uri): String? = private fun readContactAddress(context: Context, uri: Uri): String? =
context.contentResolver.query( context.contentResolver.query(
@@ -2198,19 +2174,14 @@ private fun EditCard(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically, verticalAlignment = if (iconAtTop) Alignment.Top else Alignment.CenterVertically,
) { ) {
// A top-aligned icon centres on the first text line, whatever
// that line measures at the user's font scale.
val iconOffset = if (iconAtTop) {
((firstLineHeight() - 24.dp) / 2).coerceAtLeast(0.dp)
} else {
0.dp
}
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = iconContentDescription, contentDescription = iconContentDescription,
tint = iconTint, tint = iconTint,
// 4dp mirrors InlineField's vertical padding, so a
// top-aligned icon (24dp) centres on the ~24sp first line.
modifier = Modifier modifier = Modifier
.padding(top = iconOffset) .padding(top = if (iconAtTop) 4.dp else 0.dp)
.size(24.dp), .size(24.dp),
) )
Spacer(Modifier.width(16.dp)) Spacer(Modifier.width(16.dp))
@@ -2252,9 +2223,7 @@ 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. Sentence-case by default, as floret-kit is; * the app share one input style.
* a field holding an identifier rather than prose passes
* [KeyboardCapitalization.None].
*/ */
@Composable @Composable
private fun InlineField( private fun InlineField(
@@ -2266,8 +2235,6 @@ 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,
imeAction: ImeAction = ImeAction.Default,
modifier: Modifier = Modifier modifier: Modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
@@ -2282,25 +2249,10 @@ private fun InlineField(
minLines = minLines, minLines = minLines,
enabled = enabled, enabled = enabled,
keyboardType = keyboardType, keyboardType = keyboardType,
capitalization = capitalization, capitalization = KeyboardCapitalization.None,
imeAction = imeAction,
) )
} }
/**
* Height of one [InlineField] line: the resolved [TextStyle.lineHeight] plus the
* field's 4dp vertical padding. In dp, so it tracks the user's font scale
* instead of assuming the 24sp line of a 1.0 scale (#165).
*/
@Composable
private fun firstLineHeight(
textStyle: TextStyle = MaterialTheme.typography.titleMedium,
): Dp {
val lineHeight = textStyle.lineHeight
if (!lineHeight.isSp) return 32.dp
return with(LocalDensity.current) { lineHeight.toDp() } + 8.dp
}
/** One schedule row: label, then tappable date and (unless all-day) time. */ /** One schedule row: label, then tappable date and (unless all-day) time. */
@Composable @Composable
private fun ScheduleRow( private fun ScheduleRow(
@@ -59,9 +59,6 @@ import javax.inject.Inject
private const val TAG = "EventEdit" private const val TAG = "EventEdit"
/** Any line break, with the whitespace around it, as one match. */
private val LINE_BREAK = Regex("""[ \t]*\R[ \t]*""")
/** /**
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources * Where a prefilled [EventEditViewModel.openImported] form came from. The sources
* want different reminder handling (#49), and differ in whether they own the * want different reminder handling (#49), and differ in whether they own the
@@ -535,11 +532,7 @@ class EventEditViewModel @Inject constructor(
// paste would introduce, so it never reaches the provider's TITLE column. // paste would introduce, so it never reaches the provider's TITLE column.
fun setTitle(value: String) = fun setTitle(value: String) =
update { it.copy(title = value.replace("\n", "").replace("\r", "")) } update { it.copy(title = value.replace("\n", "").replace("\r", "")) }
// The location wraps too (#273) but is likewise one logical line. A pasted fun setLocation(value: String) = update { it.copy(location = value) }
// multi-line address joins the way the contact picker's does (#146), so it
// never runs together into "12 Main St10115 Berlin".
fun setLocation(value: String) =
update { it.copy(location = value.replace(LINE_BREAK, ", ")) }
fun setDescription(value: String) = update { it.copy(description = value) } fun setDescription(value: String) = update { it.copy(description = value) }
fun setAllDay(value: Boolean) { fun setAllDay(value: Boolean) {
// Going all-day drops any pinned zone: the times become bare dates that // Going all-day drops any pinned zone: the times become bare dates that
@@ -125,7 +125,6 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) { private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier) {
val msg = when (reason) { val msg = when (reason) {
FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars FailureReason.NoCalendarsConfigured -> R.string.state_failure_no_calendars
FailureReason.AllCalendarsHidden -> R.string.state_failure_all_hidden
FailureReason.PermissionRevoked -> R.string.state_failure_permission FailureReason.PermissionRevoked -> R.string.state_failure_permission
else -> R.string.state_failure_provider else -> R.string.state_failure_provider
} }
@@ -8,7 +8,6 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.FailureReason import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.calendarListFailure
import de.jeanlucmakiola.calendula.ui.common.groupByAccount import de.jeanlucmakiola.calendula.ui.common.groupByAccount
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -36,9 +35,8 @@ class FilterViewModel @Inject constructor(
// Calendars switched off device-wide don't belong in the drawer's // Calendars switched off device-wide don't belong in the drawer's
// hide/show list; they live only in Settings → Calendars. // hide/show list; they live only in Settings → Calendars.
val enabled = calendars.filter { it.isVisibleInSystem } val enabled = calendars.filter { it.isVisibleInSystem }
val failure = calendarListFailure(calendars) if (enabled.isEmpty()) {
if (failure != null) { FilterUiState.Failure(FailureReason.NoCalendarsConfigured)
FilterUiState.Failure(failure)
} else { } else {
FilterUiState.Success(groupCalendarsForFilter(enabled, hidden)) FilterUiState.Success(groupCalendarsForFilter(enabled, hidden))
} }
@@ -1,60 +0,0 @@
package de.jeanlucmakiola.calendula.ui.licences
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.positionOf
/**
* Third-party attribution.
*
* Reachable from Settings rather than buried, because Apache-2.0 §4(a) is about
* what the *recipient of the binary* is told. Each row opens the project's source.
*/
@Composable
internal fun LicencesScreen(onBack: () -> Unit) {
val uriHandler = LocalUriHandler.current
CollapsingScaffold(
title = stringResource(R.string.licences_title),
onBack = onBack,
predictiveBack = true,
) {
Text(
stringResource(R.string.licences_intro),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(16.dp))
OpenSourceLicences.ALL.forEachIndexed { index, attribution ->
GroupedRow(
title = attribution.name,
summary = "${attribution.copyright} · ${attribution.licence.spdxId}",
position = positionOf(index, OpenSourceLicences.ALL.size),
onClick = { uriHandler.openUri(attribution.sourceUrl) },
)
}
Spacer(Modifier.height(24.dp))
Text(
stringResource(R.string.licences_footer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = GroupedListInset),
)
Spacer(Modifier.height(24.dp))
}
}
@@ -1,68 +0,0 @@
package de.jeanlucmakiola.calendula.ui.licences
/**
* The third-party code Calendula ships, and what each licence obliges us to say.
*
* This screen is a **licence obligation, not an about-page nicety**:
* **Apache-2.0 §4(a)** requires the licence to travel with the work and **§4(d)**
* propagates any `NOTICE` file. What discharges that is what the recipient of the
* APK is shown a notice only visible to someone reading the repository does not
* count. Nearly everything below is Apache-2.0.
*
* Hand-maintained on purpose. Generators read POM metadata, and POM metadata is
* routinely wrong the licence name is often not an SPDX id and the licence URL
* frequently 404s, which produces confidently incomplete output. A short honest
* list beats a long generated one that omits the entry that mattered.
*
* **When adding a dependency, add it here.** There is no automated check that can
* tell you that you forgot. Test-only dependencies (JUnit, Truth, Turbine) are not
* shipped in the APK and stay off the list.
*/
data class Attribution(
val name: String,
val copyright: String,
val licence: Licence,
/** Where the source can actually be obtained; what each row links to. */
val sourceUrl: String,
)
enum class Licence(val spdxId: String, val url: String) {
APACHE_2("Apache-2.0", "https://www.apache.org/licenses/LICENSE-2.0"),
MIT("MIT", "https://opensource.org/license/mit"),
}
object OpenSourceLicences {
val ALL: List<Attribution> = listOf(
Attribution(
name = "AndroidX, Jetpack Compose, Glance, WorkManager, DataStore, DocumentFile",
copyright = "© The Android Open Source Project",
licence = Licence.APACHE_2,
sourceUrl = "https://cs.android.com/androidx/platform/frameworks/support",
),
Attribution(
name = "Kotlin, kotlinx.coroutines, kotlinx.datetime",
copyright = "© JetBrains s.r.o. and Kotlin Programming Language contributors",
licence = Licence.APACHE_2,
sourceUrl = "https://github.com/JetBrains/kotlin",
),
Attribution(
name = "Dagger and Hilt",
copyright = "© Google LLC and The Dagger Authors",
licence = Licence.APACHE_2,
sourceUrl = "https://github.com/google/dagger",
),
Attribution(
name = "Material Design icons",
copyright = "© Google LLC",
licence = Licence.APACHE_2,
sourceUrl = "https://github.com/google/material-design-icons",
),
Attribution(
name = "floret-kit",
copyright = "© Jean-Luc Makiola",
licence = Licence.MIT,
sourceUrl = "https://codeberg.org/jlmakiola/floret-kit",
),
)
}
@@ -1,19 +0,0 @@
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
@@ -1,33 +0,0 @@
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
@@ -97,13 +97,6 @@ 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. */
@@ -179,7 +172,6 @@ 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
@@ -198,11 +190,9 @@ 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
@@ -220,7 +210,6 @@ 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
@@ -342,7 +331,6 @@ class MonthDragController {
targetDate = resolved ?: drag?.targetDate, targetDate = resolved ?: drag?.targetDate,
topLeftInRoot = pointer - grab, topLeftInRoot = pointer - grab,
sizePx = sizePx, sizePx = sizePx,
time = time,
) )
} }
} }
@@ -88,13 +88,9 @@ 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
@@ -117,6 +113,7 @@ 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
@@ -126,7 +123,6 @@ 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
@@ -138,7 +134,6 @@ 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
@@ -147,13 +142,14 @@ 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
@@ -216,6 +212,7 @@ 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()
@@ -301,14 +298,10 @@ 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, topBarShortTitle) = remember(viewStyle, titleMonth, locale, today.year) { val topBarTitle = if (viewStyle == MonthViewStyle.Continuous) {
if (viewStyle == MonthViewStyle.Continuous) { titleMonth.year.toString()
val year = titleMonth.year.toString() } else {
year to year formatMonthTitle(titleMonth, locale, currentYear = today.year)
} else {
formatMonthTitle(titleMonth, locale, currentYear = today.year) to
formatMonthTitle(titleMonth, locale, currentYear = today.year, abbreviated = true)
}
} }
// Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide). // Slide direction for the grid transition: +1 = next, -1 = prev, 0 = jump (no slide).
@@ -393,20 +386,19 @@ fun MonthScreen(
}, },
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
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 = {
@@ -502,7 +494,6 @@ 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
@@ -560,11 +551,6 @@ 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.
@@ -577,7 +563,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 = MONTH_CHIP_INSET, vertical = 1.dp) .padding(horizontal = CELL_GAP + 1.dp, 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
@@ -678,16 +664,15 @@ 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 = {
@@ -695,7 +680,6 @@ private fun MonthTopBar(
title = title, title = title,
currentDate = titleDate, currentDate = titleDate,
onJumpToDate = onJumpToDate, onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
) )
}, },
navigationIcon = { navigationIcon = {
@@ -716,17 +700,11 @@ private fun MonthTopBar(
} }
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 scrollBehavior = scrollBehavior,
// 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,
),
) )
} }
@@ -741,7 +719,7 @@ internal fun WeekdayHeader(weekStart: DayOfWeek, showWeekNumbers: Boolean) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = 8.dp, 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))
@@ -767,6 +745,7 @@ 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
@@ -808,22 +787,17 @@ internal fun MonthGrid(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// Match the weekday header's inset so day cells sit under their // Match the weekday header's 8dp 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 = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = 8.dp, 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,
@@ -863,7 +837,6 @@ 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(),
@@ -885,8 +858,6 @@ 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,
@@ -907,8 +878,6 @@ 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,
@@ -917,7 +886,7 @@ private fun ContinuousMonthBlock(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = AppBarSpacing.Inset) .padding(horizontal = 8.dp)
.padding(bottom = CONTINUOUS_MONTH_GAP), .padding(bottom = CONTINUOUS_MONTH_GAP),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
@@ -928,8 +897,6 @@ 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.
@@ -999,7 +966,6 @@ 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
@@ -1008,7 +974,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 = AppBarSpacing.Inset) .padding(horizontal = 8.dp)
.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.
@@ -1022,8 +988,6 @@ 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 },
@@ -1477,7 +1441,7 @@ internal fun SplitMonthGrid(
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
state.weeks.forEach { week -> state.weeks.forEach { week ->
@@ -1842,7 +1806,7 @@ private fun ContinuousMonthSkeleton(dense: Boolean) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = AppBarSpacing.Inset) .padding(horizontal = 8.dp)
.clipToBounds(), .clipToBounds(),
) { ) {
if (!dense) { if (!dense) {
@@ -1909,10 +1873,6 @@ 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,
@@ -1932,27 +1892,6 @@ 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
@@ -2027,12 +1966,9 @@ 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
@@ -2113,8 +2049,6 @@ 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,
@@ -2138,7 +2072,7 @@ private fun MonthWeekRow(
) )
.width(colW * cols) .width(colW * cols)
.height(EVENT_ROW_HEIGHT) .height(EVENT_ROW_HEIGHT)
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), .padding(horizontal = CELL_GAP + 1.dp, 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
@@ -2162,7 +2096,7 @@ private fun MonthWeekRow(
) )
.width(colW) .width(colW)
.height(EVENT_ROW_HEIGHT) .height(EVENT_ROW_HEIGHT)
.padding(horizontal = MONTH_CHIP_INSET, vertical = 1.dp), .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
) )
} }
} }
@@ -2185,8 +2119,6 @@ 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,
@@ -2195,7 +2127,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 = MONTH_CHIP_INSET, vertical = 1.dp), .padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp),
) )
} }
val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size val hidden = (week.countByDay[d] ?: 0) - occupied.size - pillsShown.size
@@ -2347,9 +2279,6 @@ 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(),
@@ -2378,7 +2307,6 @@ 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
} }
@@ -2486,10 +2414,7 @@ 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,
@@ -2503,26 +2428,12 @@ 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 paint = eventPaint(event, dark) val soften = LocalSoftenColors.current
// The same title/secondary ink pairing the week and day blocks use, with val fill = eventFill(event.color, dark, soften)
// 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
@@ -2533,24 +2444,21 @@ 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)
.eventSurface(paint, shape, monthBarCuts(continuesLeft, continuesRight)) .background(fill, shape)
.padding(horizontal = MONTH_CHIP_TEXT_PADDING) .padding(horizontal = 4.dp)
.semantics { .semantics {
contentDescription = description contentDescription = title
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
}, },
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
val titleOverflow = eventTitleOverflow()
Text( Text(
text = label, text = title,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
maxLines = 1, maxLines = 1,
overflow = titleOverflow.overflow, overflow = TextOverflow.Ellipsis,
softWrap = titleOverflow.softWrap, color = eventInk(fill),
color = paint.titleInk, textDecoration = declinedDecoration(event.isDeclined),
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
) )
} }
} }
@@ -2606,7 +2514,7 @@ private fun MonthGridLoading() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = AppBarSpacing.Inset, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
repeat(6) { repeat(6) {
@@ -2632,16 +2540,10 @@ private fun MonthGridLoading() {
} }
} }
/** [abbreviated] swaps the full month name for its three-letter form (#165). */ private fun formatMonthTitle(ym: YearMonth, locale: Locale, currentYear: Int): String =
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 = if (abbreviated) "LLL" else "LLLL", skeleton = "LLLL",
) )
@@ -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
spanAt(col, lane)?.let { return it.event } spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.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,11 +91,8 @@ 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 = spanAt(col, lane)?.startCol ?: col fun MonthWeek.chipStartCol(col: Int, lane: Int): Int =
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
@@ -139,8 +136,6 @@ 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
} }
@@ -10,8 +10,6 @@ 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.calendarListFailure
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
@@ -209,7 +207,9 @@ class MonthViewModel @Inject constructor(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
): ContinuousMonthUiState { ): ContinuousMonthUiState {
calendarListFailure(calendars)?.let { return ContinuousMonthUiState.Failure(it) } if (calendars.isEmpty()) {
return ContinuousMonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
// Bucketed once for the whole window: a row then takes the handful of // Bucketed once for the whole window: a row then takes the handful of
// events on its seven days instead of re-scanning a year of them. // events on its seven days instead of re-scanning a year of them.
val byDay = DayIndex(instances, zone) val byDay = DayIndex(instances, zone)
@@ -238,7 +238,6 @@ class MonthViewModel @Inject constructor(
monthsByIndex = months, monthsByIndex = months,
weeksByIndex = weeks, weeksByIndex = weeks,
weekStart = weekStart, weekStart = weekStart,
zone = zone,
) )
} }
@@ -313,7 +312,9 @@ class MonthViewModel @Inject constructor(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
): MonthUiState { ): MonthUiState {
calendarListFailure(calendars)?.let { return MonthUiState.Failure(it) } if (calendars.isEmpty()) {
return MonthUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val weeks = layoutMonthWeeks(ym, weekStart, instances, zone) val weeks = layoutMonthWeeks(ym, weekStart, instances, zone)
return MonthUiState.Success( return MonthUiState.Success(
month = ym, month = ym,
@@ -437,18 +438,14 @@ internal fun layoutCalendarWeek(
days = days, days = days,
spans = spans, spans = spans,
timedByDay = days.associateWith { d -> timedByDay = days.associateWith { d ->
// Declined last, so a day that overflows drops the events you said singles.filter { it.coversDay(d, zone) }.sortedBy { it.start }
// 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], declined ones last (#230), then all-day * Every event touching each of [days], all-day first then by start time. Unlike
* 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.
@@ -461,11 +458,7 @@ internal fun instancesByDay(
days.associateWith { day -> days.associateWith { day ->
instances instances
.filter { it.coversDay(day, zone) } .filter { it.coversDay(day, zone) }
.sortedWith( .sortedWith(compareByDescending<EventInstance> { it.isAllDay }.thenBy { it.start })
compareBy<EventInstance> { it.isDeclined }
.thenByDescending { it.isAllDay }
.thenBy { it.start },
)
} }
/** /**
@@ -82,7 +82,6 @@ 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
@@ -21,7 +21,6 @@ import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.Cake import androidx.compose.material.icons.filled.Cake
import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Gavel import androidx.compose.material.icons.filled.Gavel
import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Language
@@ -55,7 +54,6 @@ 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.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.licences.LicencesScreen
import de.jeanlucmakiola.floret.components.AboutCard import de.jeanlucmakiola.floret.components.AboutCard
import de.jeanlucmakiola.floret.components.CollapsingScaffold import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedListInset import de.jeanlucmakiola.floret.components.GroupedListInset
@@ -69,9 +67,7 @@ import de.jeanlucmakiola.floret.crash.submitCrashReport
import de.jeanlucmakiola.floret.locale.AppLanguage import de.jeanlucmakiola.floret.locale.AppLanguage
/** The settings sub-screens reached from the hub's category rows. */ /** The settings sub-screens reached from the hub's category rows. */
private enum class SettingsSection { private enum class SettingsSection { Appearance, Views, EventForm, Notifications, SpecialDates, Widgets }
Appearance, Views, EventForm, Notifications, SpecialDates, Widgets, Licences
}
/** /**
* Settings hub: three labelled groups of category rows, each opening a * Settings hub: three labelled groups of category rows, each opening a
@@ -150,13 +146,6 @@ fun SettingsScreen(
) { ) {
WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null }) WidgetsScreen(state = state, viewModel = viewModel, onBack = { section = null })
} }
AnimatedVisibility(
visible = section == SettingsSection.Licences,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
LicencesScreen(onBack = { section = null })
}
} }
} }
@@ -264,13 +253,6 @@ private fun SettingsHub(
icon = Icons.Default.Gavel, icon = Icons.Default.Gavel,
position = Position.Middle, position = Position.Middle,
) )
GroupedRow(
title = stringResource(R.string.settings_licences),
summary = stringResource(R.string.settings_licences_subtitle),
position = Position.Middle,
leading = { CategoryIcon(Icons.Default.Code, ChipAccent.Neutral) },
onClick = { onOpenSection(SettingsSection.Licences) },
)
AboutLinkRow( AboutLinkRow(
label = stringResource(R.string.settings_about_privacy), label = stringResource(R.string.settings_about_privacy),
url = stringResource(R.string.about_privacy_url), url = stringResource(R.string.about_privacy_url),
@@ -32,8 +32,8 @@ data class SettingsUiState(
val weekStart: WeekStartPref = WeekStartPref.Auto, val weekStart: WeekStartPref = WeekStartPref.Auto,
/** Clock convention for time labels (v2.11). AUTO follows the system setting. */ /** Clock convention for time labels (v2.11). AUTO follows the system setting. */
val timeFormat: TimeFormatPref = TimeFormatPref.AUTO, val timeFormat: TimeFormatPref = TimeFormatPref.AUTO,
/** Whether the week/day timeline seats each hour in its own cell (v2.11). */ /** Whether the week/day timeline draws an hour separator line (v2.11). */
val showHourGrid: Boolean = false, val showHourLines: Boolean = false,
/** How the Agenda screen treats events that already ended today. */ /** How the Agenda screen treats events that already ended today. */
val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW, val pastEventDisplay: PastEventDisplay = PastEventDisplay.SHOW,
/** Whether the month/week grids fade events that have already finished. */ /** Whether the month/week grids fade events that have already finished. */
@@ -141,7 +141,7 @@ class SettingsViewModel @Inject constructor(
// Display toggles folded into one flow so they fit this group — // Display toggles folded into one flow so they fit this group —
// the outer combine is already at its five-arg limit. // the outer combine is already at its five-arg limit.
combine( combine(
prefs.showHourGrid, prefs.showHourLines,
prefs.showWeekNumbers, prefs.showWeekNumbers,
prefs.agendaShowToday, prefs.agendaShowToday,
prefs.softenCalendarColors, prefs.softenCalendarColors,
@@ -158,7 +158,7 @@ class SettingsViewModel @Inject constructor(
) { view, screenRange, widgetRange, timeFormat, toggles -> ) { view, screenRange, widgetRange, timeFormat, toggles ->
ViewSettings( ViewSettings(
view, screenRange, widgetRange, timeFormat, view, screenRange, widgetRange, timeFormat,
showHourGrid = toggles.showHourGrid, showHourLines = toggles.showHourLines,
showWeekNumbers = toggles.showWeekNumbers, showWeekNumbers = toggles.showWeekNumbers,
agendaShowToday = toggles.agendaShowToday, agendaShowToday = toggles.agendaShowToday,
softenColors = toggles.softenColors, softenColors = toggles.softenColors,
@@ -191,7 +191,7 @@ class SettingsViewModel @Inject constructor(
agendaScreenRange = views.agendaScreenRange, agendaScreenRange = views.agendaScreenRange,
agendaWidgetRange = views.agendaWidgetRange, agendaWidgetRange = views.agendaWidgetRange,
timeFormat = views.timeFormat, timeFormat = views.timeFormat,
showHourGrid = views.showHourGrid, showHourLines = views.showHourLines,
showWeekNumbers = views.showWeekNumbers, showWeekNumbers = views.showWeekNumbers,
agendaShowToday = views.agendaShowToday, agendaShowToday = views.agendaShowToday,
softenColors = views.softenColors, softenColors = views.softenColors,
@@ -292,7 +292,7 @@ class SettingsViewModel @Inject constructor(
val agendaScreenRange: AgendaRange, val agendaScreenRange: AgendaRange,
val agendaWidgetRange: AgendaRange, val agendaWidgetRange: AgendaRange,
val timeFormat: TimeFormatPref, val timeFormat: TimeFormatPref,
val showHourGrid: Boolean, val showHourLines: Boolean,
val showWeekNumbers: Boolean, val showWeekNumbers: Boolean,
val agendaShowToday: Boolean, val agendaShowToday: Boolean,
val softenColors: Boolean, val softenColors: Boolean,
@@ -301,7 +301,7 @@ class SettingsViewModel @Inject constructor(
) )
private data class DisplayToggles( private data class DisplayToggles(
val showHourGrid: Boolean, val showHourLines: Boolean,
val showWeekNumbers: Boolean, val showWeekNumbers: Boolean,
val agendaShowToday: Boolean, val agendaShowToday: Boolean,
val softenColors: Boolean, val softenColors: Boolean,
@@ -532,8 +532,8 @@ class SettingsViewModel @Inject constructor(
viewModelScope.launch { prefs.setTimeFormat(pref) } viewModelScope.launch { prefs.setTimeFormat(pref) }
} }
fun setShowHourGrid(enabled: Boolean) { fun setShowHourLines(enabled: Boolean) {
viewModelScope.launch { prefs.setShowHourGrid(enabled) } viewModelScope.launch { prefs.setShowHourLines(enabled) }
} }
fun setShowWeekNumbers(enabled: Boolean) { fun setShowWeekNumbers(enabled: Boolean) {
@@ -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. Any number of * read-modify-write so a concurrent reorder can't clobber it. The
* views may be turned off; below two the pill hides itself (#150). * MIN_ENABLED floor is re-checked inside the transform, because the screen's
* 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 ->
config.copy( val next = if (enabled) config.enabled + view else config.enabled - view
enabled = if (enabled) config.enabled + view else config.enabled - view, if (next.size < QuickSwitchConfig.MIN_ENABLED) config else config.copy(enabled = next)
)
} }
} }
} }
@@ -32,6 +32,7 @@ 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
@@ -54,8 +55,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, including when the cycle * off in the cycle is still reachable from the drawer. The cycle needs at least
* is emptied and the pill disappears (#150). * [QuickSwitchConfig.MIN_ENABLED] targets.
*/ */
@Composable @Composable
internal fun ViewsScreen( internal fun ViewsScreen(
@@ -170,11 +171,11 @@ internal fun ViewsScreen(
position = Position.Bottom, position = Position.Bottom,
trailing = { trailing = {
Switch( Switch(
checked = state.showHourGrid, checked = state.showHourLines,
onCheckedChange = viewModel::setShowHourGrid, onCheckedChange = viewModel::setShowHourLines,
) )
}, },
onClick = { viewModel.setShowHourGrid(!state.showHourGrid) }, onClick = { viewModel.setShowHourLines(!state.showHourLines) },
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -220,6 +221,8 @@ 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 },
@@ -235,6 +238,8 @@ internal fun ViewsScreen(
trailing = { trailing = {
Switch( Switch(
checked = checked, checked = checked,
// Keep the last two on: with fewer, the pill can't switch.
enabled = !checked || canDisable,
onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) }, onCheckedChange = { on -> viewModel.setQuickSwitchViewEnabled(view, on) },
) )
}, },
@@ -1,6 +1,7 @@
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
@@ -59,7 +60,9 @@ 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
@@ -69,6 +72,7 @@ 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
@@ -77,9 +81,6 @@ 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
@@ -89,13 +90,8 @@ 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.BLOCK_OUTER_INSET import de.jeanlucmakiola.calendula.ui.common.declinedDecoration
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
@@ -107,30 +103,31 @@ 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.ChipCuts
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
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourGrid 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.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
@@ -138,8 +135,7 @@ import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
import de.jeanlucmakiola.calendula.ui.common.HourGutter import de.jeanlucmakiola.calendula.ui.common.HourGutter
import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET import de.jeanlucmakiola.calendula.ui.common.TIMELINE_CONTENT_END_INSET
import de.jeanlucmakiola.calendula.ui.common.hourCellBlockInset import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
import de.jeanlucmakiola.calendula.ui.common.hourGridCells
import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay import de.jeanlucmakiola.calendula.ui.common.tappedMinuteOfDay
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
import de.jeanlucmakiola.calendula.ui.common.next import de.jeanlucmakiola.calendula.ui.common.next
@@ -194,9 +190,21 @@ 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.
@@ -257,19 +265,19 @@ fun WeekScreen(
}, },
) { ) {
Scaffold( Scaffold(
modifier = modifier, modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
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 = {
@@ -290,6 +298,7 @@ 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,
@@ -309,6 +318,7 @@ 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,
@@ -373,6 +383,7 @@ 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,
@@ -399,6 +410,7 @@ 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,
@@ -411,19 +423,13 @@ internal fun WeekSuccess(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface), .background(topSectionColor),
) { ) {
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 AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
// fill with that and not with the columns' container (#230).
CompositionLocalProvider(
LocalChipGround provides MaterialTheme.colorScheme.surface,
) {
AllDayStrip(state = state, height = allDayHeight, onEventClick = onEventClick)
}
} }
// Breathing room between the top section and the scrolling timeline // Breathing room between the (colour-shifting) top section and the
// below. // scrolling timeline below.
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Timeline( Timeline(
state = state, state = state,
@@ -443,25 +449,20 @@ 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 = title, title = formatWeekTitle(weekStart, locale, currentYear),
currentDate = weekStart, currentDate = weekStart,
onJumpToDate = onJumpToDate, onJumpToDate = onJumpToDate,
shortTitle = shortTitle,
) )
}, },
navigationIcon = { navigationIcon = {
@@ -482,17 +483,17 @@ private fun WeekTopBar(
} }
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 // Match the static top section exactly: plain surface, lifting to
// it, but here the bar meets the header on the same surface and the // surfaceContainer once content scrolls under the bar.
// 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.surface, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
scrollBehavior = scrollBehavior,
) )
} }
@@ -652,25 +653,23 @@ 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 paint = eventPaint(event, dark) val soften = LocalSoftenColors.current
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)
.eventSurface(paint, EventChipShape) .background(fill, 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 = titleOverflow.overflow, overflow = TextOverflow.Ellipsis,
softWrap = titleOverflow.softWrap, color = eventInk(fill),
color = paint.titleInk, textDecoration = declinedDecoration(event.isDeclined),
fontWeight = paint.titleWeight,
textDecoration = paint.decoration,
) )
} }
} }
@@ -688,12 +687,6 @@ private fun Timeline(
val zoom = LocalTimelineZoom.current val zoom = LocalTimelineZoom.current
val density = LocalDensity.current val density = LocalDensity.current
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
// What the hour grid takes off a block's ends, for the floating copy to
// take off its own. Per-end, a cut edge keeps it — that much the drag
// geometry decides itself, from the slice it is drawing.
val blockInsetPx = with(density) {
hourCellBlockInset(LocalShowHourGrid.current, cut = false).toPx()
}
// BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the // BoxWithConstraints rather than Box: the fit-the-whole-day scale needs the
// timeline's own viewport height, which is only known here — below the top // timeline's own viewport height, which is only known here — below the top
@@ -740,7 +733,6 @@ private fun Timeline(
it.grid = coords it.grid = coords
it.scroll = scrollState it.scroll = scrollState
it.hourPx = with(density) { hourHeight.toPx() } it.hourPx = with(density) { hourHeight.toPx() }
it.blockInsetPx = blockInsetPx
it.columnGapPx = gap it.columnGapPx = gap
it.columnWidthPx = (coords.size.width + gap) / state.days.size it.columnWidthPx = (coords.size.width + gap) / state.days.size
it.days = state.days it.days = state.days
@@ -785,8 +777,8 @@ private fun DayColumnCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val hourPx = with(LocalDensity.current) { hourHeight.toPx() } val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val showHourGrid = LocalShowHourGrid.current val showHourLines = LocalShowHourLines.current
val zone = remember { TimeZone.currentSystemDefault() } val hourLineColor = MaterialTheme.colorScheme.outlineVariant
// Tells a settled drop when this column has caught up with it. // Tells a settled drop when this column has caught up with it.
LaunchedEffect(blocks, dragController.settling) { LaunchedEffect(blocks, dragController.settling) {
dragController.noteGrid(date, blocks) dragController.noteGrid(date, blocks)
@@ -802,24 +794,16 @@ private fun DayColumnCard(
// rounded scroll viewport, so inner rounding would look odd at the edges. // rounded scroll viewport, so inner rounding would look odd at the edges.
shape = RectangleShape, shape = RectangleShape,
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
// With the grid on, the container colour moves onto the hour cells containerColor = MaterialTheme.colorScheme.surfaceContainer,
// and the column behind them recedes to surface, so the gap between
// two cells reads as the same negative space that separates the
// week's columns.
containerColor = if (showHourGrid) {
MaterialTheme.colorScheme.surface
} else {
MaterialTheme.colorScheme.surfaceContainer
},
), ),
modifier = modifier, modifier = modifier,
) { ) {
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
// The hour cells sit over the column background but under the // Faint hour separators sit over the column background but under
// event blocks (drawBehind paints before the children). // the event blocks (drawBehind paints before the children).
.hourGridCells(showHourGrid, hourPx, MaterialTheme.colorScheme.surfaceContainer) .hourSeparatorLines(showHourLines, hourPx, hourLineColor)
// Tap an empty slot to create an event there; taps on event // Tap an empty slot to create an event there; taps on event
// blocks are consumed by their own handler first. Snaps to hour. // blocks are consumed by their own handler first. Snaps to hour.
.pointerInput(date) { .pointerInput(date) {
@@ -850,34 +834,20 @@ private fun DayColumnCard(
width = laneWidth, width = laneWidth,
height = height, height = height,
) )
// Where this block is cut at midnight, which decides both
// its shape and the half-gap it gives up to the hour cells.
val cuts = remember(block, date, zone) {
timedBlockCuts(
block.continuesBefore(date, zone),
block.continuesAfter(date, zone),
)
}
val topInset = hourCellBlockInset(showHourGrid, cuts.top)
val bottomInset = hourCellBlockInset(showHourGrid, cuts.bottom)
val blockHeight = (place.height - topInset - bottomInset)
.coerceAtLeast(0.dp)
EventBlock( EventBlock(
block = block, block = block,
dark = dark, dark = dark,
height = blockHeight, height = place.height,
width = place.width, width = place.width,
date = date, date = date,
cuts = cuts,
topInset = topInset,
dragController = dragController, dragController = dragController,
onClick = { onEventClick(block.event) }, onClick = { onEventClick(block.event) },
onDrop = onDrop, onDrop = onDrop,
modifier = Modifier modifier = Modifier
.offset(x = place.x, y = place.y + topInset) .offset(x = place.x, y = place.y)
.width(place.width) .width(place.width)
.height(blockHeight) .height(place.height)
.padding(horizontal = BLOCK_OUTER_INSET), .padding(horizontal = 1.dp),
) )
} }
} }
@@ -896,8 +866,6 @@ private fun EventBlock(
height: Dp, height: Dp,
width: Dp, width: Dp,
date: LocalDate, date: LocalDate,
cuts: ChipCuts,
topInset: Dp,
dragController: TimelineDragController, dragController: TimelineDragController,
onClick: () -> Unit, onClick: () -> Unit,
onDrop: (TimelineDrop) -> Unit, onDrop: (TimelineDrop) -> Unit,
@@ -915,66 +883,48 @@ private fun EventBlock(
val timeLineHeight = with(density) { val timeLineHeight = with(density) {
MaterialTheme.typography.labelSmall.lineHeight.toDp() MaterialTheme.typography.labelSmall.lineHeight.toDp()
} }
val available = height - BLOCK_TEXT_INSET * 2 // What's left for text once the 2.dp top/bottom padding is paid for.
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. Height decides that on // afford both lines spends its space on the title.
// its own: a duration threshold would keep hiding the time on a half-hour val showTime = block.endMin - block.startMin >= 45 &&
// block the user has pinched open to three times the room it needs. block.laneCount == 1 &&
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
// The title is served first, out of everything the block has left once the // Wrap the title across as many lines as the block can fit — but only once a
// time is down to one line — but only takes the lines it will actually use, // line is wide enough to hold more than a syllable. Below that the extra
// and only wraps at all once a line is wide enough to hold more than a // lines just stack fragments of the word, and one ellipsised line reads
// syllable. Below that the extra lines just stack fragments of the word. // better.
val contentHeight = available - if (showTime) timeLineHeight else 0.dp val contentHeight = available - if (showTime) timeLineHeight else 0.dp
val titleBudget = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1) val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
val paint = eventPaint(block.event, dark) 1
// Every line the height affords, however narrow the lane: two events side by } else {
// side leave columns well under a word wide, and cutting the title to one (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
// line there lost it outright where the block had the room to wrap it. }
val titleMaxLines = 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(cuts) { timedBlockShape(cuts.top, cuts.bottom) } val shape = remember(block, date, zone) {
val topInsetPx = with(density) { topInset.toPx() } timedBlockShape(block.continuesBefore(date, zone), block.continuesAfter(date, zone))
}
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( dragController.begin(block, clipOffset, pointer, blockRoot)
block = block,
clipOffsetMin = clipOffset,
titleLines = if (showTitle) titleMaxLines else 0,
pointerInRoot = pointer,
// Measured off the block's placement rather than off where the
// hour grid seats it: the copy is drawn from that placement and
// re-seated the same half-gap down (#267).
blockInRoot = blockRoot.copy(y = blockRoot.y - topInsetPx),
)
}, },
onMove = dragController::move, onMove = dragController::move,
onDrop = { dragController.finish()?.let(onDrop) }, onDrop = { dragController.finish()?.let(onDrop) },
@@ -986,12 +936,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)
.eventSurface(paint, shape, cuts) .background(fill, shape)
.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 = BLOCK_TEXT_PADDING, vertical = BLOCK_TEXT_INSET) .padding(horizontal = 4.dp, vertical = 2.dp)
.semantics { .semantics {
contentDescription = "$title, $timeLabel" contentDescription = "$title, $timeLabel"
if (moveAction != null) customActions = listOf(moveAction) if (moveAction != null) customActions = listOf(moveAction)
@@ -999,19 +949,19 @@ private fun EventBlock(
) { ) {
Column { Column {
if (showTitle) { if (showTitle) {
BlockTitle( Text(
title = title, text = title,
style = MaterialTheme.typography.labelMedium,
maxLines = titleMaxLines, maxLines = titleMaxLines,
color = paint.titleInk, overflow = TextOverflow.Ellipsis,
textDecoration = paint.decoration, color = eventInk(fill, alpha = 0.85f),
fontWeight = paint.titleWeight, textDecoration = declinedDecoration(block.event.isDeclined),
) )
} }
if (showTime) { if (showTime) {
BlockTimeLabel( BlockTimeLabel(
label = timeLabel, label = timeLabel,
color = paint.secondaryInk, color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
maxLines = timeMaxLines,
) )
} }
} }
@@ -1042,14 +992,7 @@ private fun WeekLoading() {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
// Same scale resolution as the loaded timeline, so the skeleton's // Same scale resolution as the loaded timeline, so the skeleton's
// columns don't resize the moment the real week arrives. // columns don't resize the moment the real week arrives.
val hourHeight = scale.hourHeight(maxHeight) val totalHeight = scale.hourHeight(maxHeight) * 24
val totalHeight = hourHeight * 24
// The skeleton wears the loaded columns' own ground, so the arrival
// of the real week doesn't flash solid blocks into a gapped grid.
val showHourGrid = LocalShowHourGrid.current
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
val columnGround = if (showHourGrid) MaterialTheme.colorScheme.surface
else MaterialTheme.colorScheme.surfaceContainer
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -1063,12 +1006,7 @@ private fun WeekLoading() {
.weight(1f) .weight(1f)
.height(totalHeight) .height(totalHeight)
.padding(horizontal = 2.dp) .padding(horizontal = 2.dp)
.background(columnGround) .background(MaterialTheme.colorScheme.surfaceContainer),
.hourGridCells(
showHourGrid,
hourPx,
MaterialTheme.colorScheme.surfaceContainer,
),
) )
} }
} }
@@ -1095,18 +1033,13 @@ 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( private fun formatWeekTitle(weekStart: LocalDate, locale: Locale, currentYear: Int): String {
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 = if (abbreviated) "LLL" else "LLLL", skeleton = "LLLL",
forceYear = weekEnd.year != weekStart.year, forceYear = weekEnd.year != weekStart.year,
) )
} }
@@ -10,8 +10,6 @@ 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.calendarListFailure
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
@@ -127,7 +125,9 @@ class WeekViewModel @Inject constructor(
calendars: List<CalendarSource>, calendars: List<CalendarSource>,
instances: List<EventInstance>, instances: List<EventInstance>,
): WeekUiState { ): WeekUiState {
calendarListFailure(calendars)?.let { return WeekUiState.Failure(it) } if (calendars.isEmpty()) {
return WeekUiState.Failure(FailureReason.NoCalendarsConfigured)
}
val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) } val days = (0 until 7).map { start.plus(it, DateTimeUnit.DAY) }
val allDay = instances.filter { it.isAllDay } val allDay = instances.filter { it.isAllDay }
val timed = instances.filterNot { it.isAllDay } val timed = instances.filterNot { it.isAllDay }
@@ -144,7 +144,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). * overlapping spans are stacked on separate lanes (greedy first-fit by start).
*/ */
internal fun layoutAllDay( internal fun layoutAllDay(
events: List<EventInstance>, events: List<EventInstance>,
@@ -158,35 +158,21 @@ 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())
} }
// Declined bars are packed after every other one, so on any day they .sortedWith(compareBy({ it.startCol }, { it.endCol }))
// cover they land in a lane below it (#230).
.sortedWith(compareBy({ it.event.isDeclined }, { it.startCol }, { it.endCol }))
// What each lane already holds, rather than just how far right it reaches. val laneEnd = ArrayList<Int>() // last occupied column per lane
// 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 ->
val cols = r.startCol..r.endCol var lane = laneEnd.indexOfFirst { it < r.startCol }
var lane = laneCols.indexOfFirst { seated -> seated.none { it overlaps cols } }
if (lane == -1) { if (lane == -1) {
laneCols.add(mutableListOf(cols)) laneEnd.add(r.endCol)
lane = laneCols.size - 1 lane = laneEnd.size - 1
} else { } else {
laneCols[lane].add(cols) laneEnd[lane] = r.endCol
} }
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.
@@ -1,6 +1,7 @@
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
@@ -46,6 +47,7 @@ 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(),
@@ -1,6 +1,5 @@
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
@@ -1,6 +1,5 @@
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
+3 -22
View File
@@ -14,15 +14,8 @@
<string name="state_failure_permission_action">Grant access</string> <string name="state_failure_permission_action">Grant access</string>
<string name="state_failure_no_calendars">No calendars configured.</string> <string name="state_failure_no_calendars">No calendars configured.</string>
<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_all_hidden">All your calendars are switched off.</string>
<string name="state_failure_all_hidden_action">Manage calendars</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>
@@ -195,7 +188,6 @@
<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>
@@ -433,12 +425,8 @@
<string name="settings_time_format_24h">24-hour (14:00)</string> <string name="settings_time_format_24h">24-hour (14:00)</string>
<!-- %1$s is a sample time written the way the system currently writes it. --> <!-- %1$s is a sample time written the way the system currently writes it. -->
<string name="settings_time_format_auto_summary">Following the system: %1$s</string> <string name="settings_time_format_auto_summary">Following the system: %1$s</string>
<!-- Reworded in v2.20 (#113): this setting no longer draws a separator line <string name="settings_hour_lines">Hour lines</string>
at each hour, it seats each hour in its own cell. The key is kept, so any <string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</string>
translation of the old wording ("Hour lines" / "show a separator line at
each hour") describes a feature that no longer exists and needs redoing. -->
<string name="settings_hour_lines">Hour grid</string>
<string name="settings_hour_lines_summary">Seat each hour in its own cell in week and day view</string>
<string name="settings_timeline_scale">Hour height</string> <string name="settings_timeline_scale">Hour height</string>
<string name="settings_timeline_scale_hint">How much vertical space one hour takes in week and day view. Both views share this setting. You can also pinch the timeline with two fingers to set any height in between.</string> <string name="settings_timeline_scale_hint">How much vertical space one hour takes in week and day view. Both views share this setting. You can also pinch the timeline with two fingers to set any height in between.</string>
<string name="timeline_scale_fit_day">Fit whole day</string> <string name="timeline_scale_fit_day">Fit whole day</string>
@@ -510,7 +498,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. With fewer than two views turned on the button is hidden. 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. 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>
@@ -622,8 +610,6 @@
<string name="settings_license_value">MIT</string> <string name="settings_license_value">MIT</string>
<string name="settings_about_author">by Jean-Luc Makiola</string> <string name="settings_about_author">by Jean-Luc Makiola</string>
<string name="settings_about_source">Source</string> <string name="settings_about_source">Source</string>
<string name="settings_licences">Open source licenses</string>
<string name="settings_licences_subtitle">The projects Calendula is built on</string>
<string name="settings_about_privacy">Privacy policy</string> <string name="settings_about_privacy">Privacy policy</string>
<string name="settings_about_support">Support development</string> <string name="settings_about_support">Support development</string>
<string name="settings_about_version">Version %1$s</string> <string name="settings_about_version">Version %1$s</string>
@@ -631,11 +617,6 @@
<string name="settings_report_problem">Report a problem</string> <string name="settings_report_problem">Report a problem</string>
<string name="settings_report_problem_hint">Send a crash report or open the issue tracker</string> <string name="settings_report_problem_hint">Send a crash report or open the issue tracker</string>
<!-- Open source licences -->
<string name="licences_title">Open source licenses</string>
<string name="licences_intro">Calendula includes the work below. Tap any entry to get its source code.</string>
<string name="licences_footer">Every project here is included unmodified, under the license shown.</string>
<!-- Calendar manager --> <!-- Calendar manager -->
<string name="calendars_title">Calendars</string> <string name="calendars_title">Calendars</string>
<string name="calendars_local_header">Your calendars</string> <string name="calendars_local_header">Your calendars</string>
@@ -2,8 +2,6 @@ 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
@@ -111,22 +109,4 @@ 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)
}
}
} }
@@ -95,9 +95,9 @@ class SettingsPrefsTest {
@Test @Test
fun `hour lines default off and round-trips`(@TempDir tempDir: Path) = runTest { fun `hour lines default off and round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(newDataStore(tempDir)) val prefs = SettingsPrefs(newDataStore(tempDir))
assertThat(prefs.showHourGrid.first()).isFalse() assertThat(prefs.showHourLines.first()).isFalse()
prefs.setShowHourGrid(true) prefs.setShowHourLines(true)
assertThat(prefs.showHourGrid.first()).isTrue() assertThat(prefs.showHourLines.first()).isTrue()
} }
@Test @Test
@@ -121,24 +121,4 @@ class CalendarVisibilityPlanTest {
), ),
).isTrue() ).isTrue()
} }
@Test
fun `no calendars on the device is its own failure`() {
assertThat(calendarListFailure(emptyList()))
.isEqualTo(FailureReason.NoCalendarsConfigured)
}
@Test
fun `calendars that are all switched off read as hidden, not missing`() {
// The remedy differs: one needs a calendar made, the other needs one
// switched back on (#239).
assertThat(calendarListFailure(listOf(cal(1L, visible = false), cal(2L, visible = false))))
.isEqualTo(FailureReason.AllCalendarsHidden)
}
@Test
fun `one visible calendar is enough to show something`() {
assertThat(calendarListFailure(listOf(cal(1L, visible = false), cal(2L, visible = true))))
.isNull()
}
} }
@@ -36,12 +36,11 @@ class ModelsTest {
} }
@Test @Test
fun `FailureReason enum has all six variants`() { fun `FailureReason enum has all five variants`() {
assertThat(FailureReason.values().toSet()).isEqualTo( assertThat(FailureReason.values().toSet()).isEqualTo(
setOf( setOf(
FailureReason.PermissionRevoked, FailureReason.PermissionRevoked,
FailureReason.NoCalendarsConfigured, FailureReason.NoCalendarsConfigured,
FailureReason.AllCalendarsHidden,
FailureReason.ProviderUnavailable, FailureReason.ProviderUnavailable,
FailureReason.EventNotFound, FailureReason.EventNotFound,
FailureReason.Unknown, FailureReason.Unknown,
@@ -1,28 +0,0 @@
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)
}
}
@@ -1,31 +0,0 @@
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")
}
}
@@ -1,80 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The floating copy is the size of the block it lifted off, hour grid or not
* a copy a half-gap taller at each end has room for a time label the block
* itself had to drop, so the label appears on long-press and goes on drop
* (#267).
*/
class DragPieceBoundsTest {
/** A Regular 56dp hour on a 3x screen. */
private val hourPx = 168f
/** The grid's half-gap, 1dp at 3x. */
private val insetPx = 3f
private fun slice(
startMin: Int,
spanMin: Int,
continuesBefore: Boolean = false,
continuesAfter: Boolean = false,
) = DragSlice(
dayOffset = 0,
startMin = startMin,
spanMin = spanMin,
continuesBefore = continuesBefore,
continuesAfter = continuesAfter,
)
@Test
fun `with the grid off a piece keeps its raw placement`() {
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 60), hourPx, insetPx = 0f)
assertThat(bounds.top).isEqualTo(9 * hourPx)
assertThat(bounds.height).isEqualTo(hourPx)
}
@Test
fun `with the grid on a piece gives up a half-gap at each end`() {
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 60), hourPx, insetPx)
assertThat(bounds.top).isEqualTo(9 * hourPx + insetPx)
assertThat(bounds.height).isEqualTo(hourPx - insetPx * 2)
}
@Test
fun `an end cut at midnight keeps none of the inset`() {
val head = dragPieceBounds(slice(startMin = 1200, spanMin = 240, continuesAfter = true), hourPx, insetPx)
assertThat(head.top).isEqualTo(20 * hourPx + insetPx)
assertThat(head.height).isEqualTo(4 * hourPx - insetPx)
val tail = dragPieceBounds(slice(startMin = 0, spanMin = 480, continuesBefore = true), hourPx, insetPx)
assertThat(tail.top).isEqualTo(0f)
assertThat(tail.height).isEqualTo(8 * hourPx - insetPx)
}
@Test
fun `a whole day cut at both ends fills its column`() {
val bounds = dragPieceBounds(
slice(startMin = 0, spanMin = 1440, continuesBefore = true, continuesAfter = true),
hourPx,
insetPx,
)
assertThat(bounds.top).isEqualTo(0f)
assertThat(bounds.height).isEqualTo(24 * hourPx)
}
@Test
fun `a very short piece is floored before the inset comes off`() {
val bounds = dragPieceBounds(slice(startMin = 540, spanMin = 5), hourPx, insetPx)
assertThat(bounds.height).isEqualTo(MIN_EVENT_FRACTION * hourPx - insetPx * 2)
}
@Test
fun `an inset taller than the piece leaves no height at all`() {
val bounds = dragPieceBounds(slice(startMin = 0, spanMin = 60), hourPx = 4f, insetPx = insetPx)
assertThat(bounds.height).isEqualTo(0f)
}
}
@@ -1,61 +0,0 @@
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()
}
}
@@ -1,40 +0,0 @@
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 wraps whole words and clips, spending none on a dot`() {
val result = eventTitleOverflowFor(rtl = false, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
assertThat(result.softWrap).isTrue()
}
@Test
fun `multi-line in RTL clips too, since softWrap cuts the logical end`() {
val result = eventTitleOverflowFor(rtl = true, singleLine = false)
assertThat(result.overflow).isEqualTo(TextOverflow.Clip)
assertThat(result.softWrap).isTrue()
}
}
@@ -1,53 +0,0 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.compose.ui.unit.dp
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class HourGridTest {
/** The hour cell's 4dp corner at a 3x density. */
private val maxRadius = 12f
@Test
fun `a tall cell rounds to the full radius`() {
// A Regular 56dp hour on a 3x screen, in a phone-width day column.
assertThat(hourCellRadiusPx(cellHeight = 162f, cellWidth = 900f, maxRadius = maxRadius))
.isEqualTo(maxRadius)
}
@Test
fun `a short cell rounds to half its height, never past a lozenge`() {
// Fit-the-day on a small viewport: the hour is shorter than the radius.
val radius = hourCellRadiusPx(cellHeight = 18f, cellWidth = 900f, maxRadius = maxRadius)
assertThat(radius).isEqualTo(9f)
}
@Test
fun `a narrow cell rounds to half its width`() {
// Seven columns on a small phone, so the cell is narrower than it is tall.
assertThat(hourCellRadiusPx(cellHeight = 162f, cellWidth = 18f, maxRadius = maxRadius))
.isEqualTo(9f)
}
@Test
fun `a degenerate cell asks for no radius at all`() {
assertThat(hourCellRadiusPx(cellHeight = -4f, cellWidth = 900f, maxRadius = maxRadius))
.isEqualTo(0f)
}
@Test
fun `a seated block gives up the half-gap at an end of its own`() {
assertThat(hourCellBlockInset(show = true, cut = false)).isEqualTo(HOUR_CELL_INSET)
}
@Test
fun `an end cut at midnight stays against the column edge`() {
assertThat(hourCellBlockInset(show = true, cut = true)).isEqualTo(0.dp)
}
@Test
fun `with the grid off nothing is given up`() {
assertThat(hourCellBlockInset(show = false, cut = false)).isEqualTo(0.dp)
}
}
@@ -120,30 +120,4 @@ 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)
}
} }
@@ -1,72 +0,0 @@
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()
}
}
@@ -2,7 +2,6 @@ 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
@@ -268,70 +267,4 @@ 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)
} }
-9
View File
@@ -54,15 +54,6 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
solely when the file is missing, so the self-hosted pipeline always has solely when the file is missing, so the self-hosted pipeline always has
something to publish. The pipeline runs the same script, so forgetting to something to publish. The pipeline runs the same script, so forgetting to
write the file yields a long auto-generated changelog rather than none. write the file yields a long auto-generated changelog rather than none.
To check every locale rather than just en-US, run
```bash
scripts/check_changelog_lengths.sh
```
It fails on any file for the current `versionCode` that is missing, empty or
over the limit, and only warns about older ones (already published; Play
never re-reads them). CI runs it on every pull request. `--strict` fails on
the older files too.
4. **Verify the release build on a real device** — the mandatory gate. The 4. **Verify the release build on a real device** — the mandatory gate. The
shipped APK is R8-shrunk/obfuscated, and bugs that only appear there, or shipped APK is R8-shrunk/obfuscated, and bugs that only appear there, or
only on first run, never show up in the debug build or on a device that only on first run, never show up in the debug build or on a device that
@@ -1,14 +0,0 @@
New
• Long-press an event's title, location or description to copy.
• Settings → About lists the open-source projects we ship.
Changed
• Week & day: each hour sits in its own cell, not a line.
• Event titles run to the chip's edge — no more "…".
• Month events show their start time on wide chips.
• The today button shows today's date.
• Unanswered invitations are drawn as outlines.
Fixed
• A long location wraps instead of scrolling out of view.
• The event form capitalizes sentences again.
@@ -1,11 +0,0 @@
New
• Long-press an event's title, location or description to copy.
• Settings → About lists the open-source projects we ship.
Changed
• Week & day: each hour sits in its own cell, not a line.
• Unanswered invitations are drawn as outlines.
Fixed
• "No calendars configured" now really opens the system calendar setup.
• When every calendar is switched off, the app says so instead of a blank month.
+1 -1
View File
@@ -25,7 +25,7 @@ hiltNavigationCompose = "1.4.0"
lifecycleCompose = "2.10.0" lifecycleCompose = "2.10.0"
androidxTestRules = "1.7.0" androidxTestRules = "1.7.0"
# Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha). # Glance: 1.1.1 is the latest stable (1.2.0 is still rc, 1.3.0 alpha).
glance = "1.1.1" glance = "1.2.0"
work = "2.11.2" work = "2.11.2"
documentfile = "1.1.0" documentfile = "1.1.0"
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env bash
# Length guard for the per-version "What's New" files under
# fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt.
#
# Google Play rejects an upload whose "What's New" exceeds 500 characters, and
# F-Droid truncates a long entry in-client. Play reads only the version being
# uploaded, so the file for the CURRENT versionCode is release-blocking in every
# locale it exists in — that is a hard failure here. Older files are already
# published and Play never looks at them again; they are reported as warnings so
# a legacy overrun (the pipeline used to auto-generate these from CHANGELOG.md)
# does not turn every PR red.
#
# scripts/check_changelog_lengths.sh current version fails, rest warn
# scripts/check_changelog_lengths.sh --strict every file must fit
#
# The companion scripts/sync_changelog_to_fastlane.sh checks only en-US for the
# current version, and only as a side effect of ensuring the file exists.
set -euo pipefail
cd "$(dirname "$0")/.." # repo root
# wc -m counts bytes, not characters, under a C locale — force UTF-8 so "•" and
# "—" count as the single characters Play sees.
if locale -a 2>/dev/null | grep -qix 'C.utf8\|C.UTF-8'; then
export LC_ALL=C.UTF-8
fi
LIMIT=500
STRICT=0
[ "${1:-}" = "--strict" ] && STRICT=1
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
[ -n "$VERSION" ] || { echo "No versionName in app/build.gradle.kts" >&2; exit 1; }
MAJOR=${VERSION%%.*}; rest=${VERSION#*.}; MINOR=${rest%%.*}; PATCH=${rest##*.}
VERSION_CODE=$(( ${MAJOR:-0} * 10000 + ${MINOR:-0} * 100 + ${PATCH:-0} ))
fail=0
warned=0
current=0
shopt -s nullglob
for f in fastlane/metadata/android/*/changelogs/*.txt; do
locale_dir=$(basename "$(dirname "$(dirname "$f")")")
code=$(basename "$f" .txt)
chars=$(wc -m < "$f" | tr -d ' ')
if [ "$code" = "$VERSION_CODE" ]; then
current=$((current + 1))
if [ ! -s "$f" ]; then
echo "ERROR: $f is empty." >&2
fail=1
elif [ "$chars" -gt "$LIMIT" ]; then
echo "ERROR: $f is $chars chars (limit $LIMIT) — Play would reject this release." >&2
fail=1
else
printf 'OK: %-6s %s — %s chars\n' "$locale_dir" "$code" "$chars"
fi
elif [ "$chars" -gt "$LIMIT" ]; then
if [ "$STRICT" -eq 1 ]; then
echo "ERROR: $f is $chars chars (limit $LIMIT)." >&2
fail=1
else
echo "warning: $f is $chars chars (limit $LIMIT) — already published, left as is." >&2
warned=$((warned + 1))
fi
fi
done
if [ "$current" -eq 0 ]; then
echo "ERROR: no changelog for version $VERSION (code $VERSION_CODE)." >&2
echo " Write fastlane/metadata/android/en-US/changelogs/$VERSION_CODE.txt" >&2
echo " before releasing — see docs/RELEASING.md step 3." >&2
fail=1
fi
if [ "$fail" -ne 0 ]; then
echo >&2
echo "Fix the changelog(s) above before releasing." >&2
exit 1
fi
[ "$warned" -gt 0 ] && echo "$warned older changelog(s) over the limit — warnings only."
echo "All release-blocking changelogs fit in $LIMIT characters."