Compare commits
21 Commits
v2.17.1
...
5b4c32aff9
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b4c32aff9 | |||
| e13b631e10 | |||
| 74780cc212 | |||
| 1285bbbf47 | |||
| 4412038caa | |||
| 1c9a4f7f50 | |||
| b7e624877d | |||
| 7a86014461 | |||
| 5eb7d76f8a | |||
| 19b86936e6 | |||
| 0bcbd2751c | |||
| cf0540fce4 | |||
| aa24de443d | |||
|
|
5764bd889b | ||
|
|
a8a83a39d0 | ||
|
|
9557ca73ed | ||
|
|
9767fbbbaf | ||
|
|
9054742503 | ||
|
|
3d62036d79 | ||
|
|
34a8e91ea5 | ||
|
|
0097a9c534 |
@@ -264,9 +264,10 @@ jobs:
|
||||
cp app/build/outputs/apk/release/app-release.apk "fdroid/repo/calendula_v${VERSION}.apk"
|
||||
|
||||
# Per-version "What's New": ensure this version's changelog exists in the
|
||||
# fastlane tree (committed at release-cut time for the official repo; this
|
||||
# regenerates it from CHANGELOG.md so the self-hosted repo never depends on
|
||||
# the commit having happened). The transform below then carries it across.
|
||||
# fastlane tree. The committed hand-written summary (kept under Play's
|
||||
# 500-char cap) is used as-is; only if it is missing does the script fall
|
||||
# back to CHANGELOG.md, so the self-hosted repo never depends on the
|
||||
# commit having happened. The transform below then carries it across.
|
||||
- name: Ensure this version's changelog is in the fastlane tree
|
||||
if: env.IS_RELEASE == 'true'
|
||||
run: bash scripts/sync_changelog_to_fastlane.sh
|
||||
@@ -635,9 +636,13 @@ jobs:
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f "dist/app-release.aab"
|
||||
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
|
||||
# Absolute, because a lane body runs from fastlane/, not the
|
||||
# workspace root — a relative path resolves against the wrong
|
||||
# directory there and 2.17.1 died on exactly that.
|
||||
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
||||
bundle exec fastlane deploy \
|
||||
aab:"dist/app-release.aab" \
|
||||
aab:"$AAB" \
|
||||
track:"$PLAY_TRACK" \
|
||||
release_status:"$PLAY_RELEASE_STATUS" \
|
||||
dry_run:"$PLAY_DRY_RUN"
|
||||
|
||||
70
CHANGELOG.md
70
CHANGELOG.md
@@ -7,6 +7,71 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.18.0] — 2026-07-31
|
||||
|
||||
### Added
|
||||
- A new event no longer always lasts an hour. **Settings → New event form →
|
||||
Default duration** sets how long one opens, and each calendar may keep its own
|
||||
length underneath — 8 hours for the calendar you keep work shifts in, 30
|
||||
minutes for the one you book calls in. A calendar without its own length
|
||||
follows the default, switching calendars mid-form re-stretches the event, and
|
||||
setting an end time by hand keeps it. An event another app hands over with only
|
||||
a start gets the default too; one that names its own end — an `.ics` file, a
|
||||
duplicate — keeps that length. All-day events are unaffected ([#54]).
|
||||
- Week and day view can be set to show more or less of the day at once, under
|
||||
Settings → Views → Week & day → **Hour height**. **Fit whole day** sizes an
|
||||
hour to your screen so all 24 hours are visible without scrolling — on a tall
|
||||
phone the old fixed spacing showed only about half a day, so appointments
|
||||
could sit below the fold all week. Compact and Comfortable are fixed steps
|
||||
either side of the previous spacing, which stays the default. Both views share
|
||||
the setting ([#56]).
|
||||
- Week and day view can be **pinched** with two fingers to set the hour height
|
||||
directly, anywhere between and beyond the named steps. The time under your
|
||||
fingers stays put as it zooms, so you keep your place in the day. A pinched
|
||||
height is remembered and appears as **Custom** in the Hour height setting, so
|
||||
tapping a named step there takes you back to it ([#56]).
|
||||
|
||||
### Changed
|
||||
- The date in the top bar is now the way to jump: tapping the month, week or day
|
||||
title opens the same date picker the sidebar's **Jump to date** offers, seeded
|
||||
on whatever the bar is naming. A drop-down caret marks it as tappable. The
|
||||
sidebar entry stays where it is ([#57]).
|
||||
- A reminder in the status bar is now Calendula's own mark — the calendar with
|
||||
the bloom — instead of the generic calendar glyph it shared with the system's
|
||||
date surfaces and every other calendar app. The status bar draws that icon as
|
||||
a plain silhouette, so its shape is the only thing that could tell them apart
|
||||
([#83]).
|
||||
- Event blocks now only draw text they can draw whole. One too short for a full
|
||||
line shows no title rather than a sliced one, a block that cannot fit both its
|
||||
title and its time keeps the title, and a block too narrow to hold more than a
|
||||
syllable stays on one ellipsised line instead of stacking letters down the
|
||||
block. Tapping and the spoken description are unchanged ([#56]).
|
||||
- Calendar colours are reworked so text on an event is always readable. They
|
||||
were shaped by a brightness setting that does not match what the eye sees, so
|
||||
whether a block got dark or light text depended on which hue you happened to
|
||||
pick — an orange calendar took dark text while a red one beside it took light
|
||||
— and colours landing in between were hard to read either way. Each colour now
|
||||
keeps its hue and is moved clear of that middle: most become deep blocks with
|
||||
light text, while naturally pale colours such as yellow stay pale and take
|
||||
dark text, rather than being forced into a muddy brown. Dots, stripes and
|
||||
icons are tuned separately from blocks, so they stay visible against the
|
||||
background instead of sharing a colour meant to sit behind text. The setting
|
||||
is now called **Harmonise calendar colours**; turning it off still shows the
|
||||
raw colours from your calendar source ([#21], [#36]).
|
||||
|
||||
### Fixed
|
||||
- The back gesture closes the sidebar instead of the app. With the drawer open,
|
||||
swiping back left Calendula altogether rather than putting the sidebar away
|
||||
([#114]).
|
||||
- The sidebar lines up. "Calendula", "View" and "Calendars" now share the left
|
||||
edge of the rows under them, and the view, jump-to-date, Settings and calendar
|
||||
rows sit on one vertical axis instead of each icon finding its own — the same
|
||||
alignment the Settings screens use ([#114]).
|
||||
- The status- and navigation-bar icons follow Calendula's own light/dark choice.
|
||||
Setting the app dark while the system stayed light — or the other way round —
|
||||
left the clock and battery drawn for the system's theme, so they could sit
|
||||
near-invisible against the app's own bar ([#70]).
|
||||
|
||||
## [2.17.1] — 2026-07-30
|
||||
|
||||
### Added
|
||||
@@ -1249,7 +1314,12 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#79]: https://codeberg.org/jlmakiola/calendula/issues/79
|
||||
[#81]: https://codeberg.org/jlmakiola/calendula/issues/81
|
||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||
[#83]: https://codeberg.org/jlmakiola/calendula/issues/83
|
||||
[#87]: https://codeberg.org/jlmakiola/calendula/issues/87
|
||||
[#89]: https://codeberg.org/jlmakiola/calendula/issues/89
|
||||
[#103]: https://codeberg.org/jlmakiola/calendula/issues/103
|
||||
[#69]: https://codeberg.org/jlmakiola/calendula/issues/69
|
||||
[#54]: https://codeberg.org/jlmakiola/calendula/issues/54
|
||||
[#57]: https://codeberg.org/jlmakiola/calendula/issues/57
|
||||
[#56]: https://codeberg.org/jlmakiola/calendula/issues/56
|
||||
[#114]: https://codeberg.org/jlmakiola/calendula/issues/114
|
||||
|
||||
@@ -28,8 +28,8 @@ android {
|
||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
||||
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
|
||||
versionCode = 21701
|
||||
versionName = "2.17.1"
|
||||
versionCode = 21800
|
||||
versionName = "2.18.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package de.jeanlucmakiola.calendula
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.CalendarContract
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
@@ -12,6 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -29,11 +32,14 @@ import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
|
||||
import de.jeanlucmakiola.calendula.ui.RootScreen
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||
import de.jeanlucmakiola.calendula.ui.edit.ImportSource
|
||||
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
@@ -50,9 +56,17 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** A prefilled create form from an external launch, with the source it came from. */
|
||||
private data class InsertRequest(val form: EventForm, val source: ImportSource)
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
// Which of light/dark the system bars are drawn for. The styles installed
|
||||
// in onCreate read this field live, so androidx's config-change replay
|
||||
// picks up the in-app override instead of the night resource qualifier.
|
||||
private var systemBarsDark = false
|
||||
|
||||
// The occurrence a reminder notification was tapped for (eventId, begin,
|
||||
// end — the detail screen's key shape). singleTop + onNewIntent route a
|
||||
// tap into the running activity; CalendarHost consumes and clears it.
|
||||
@@ -69,7 +83,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// A prefilled new-event form from an external ACTION_INSERT launch (another
|
||||
// app/widget asking us to create an event, issue #30). Consumed once by
|
||||
// CalendarHost, which opens it in the create form for review.
|
||||
private var requestedInsertForm by mutableStateOf<EventForm?>(null)
|
||||
private var requestedInsert by mutableStateOf<InsertRequest?>(null)
|
||||
|
||||
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
|
||||
// opens the occurrence in the edit form. Same occurrence-key shape as the
|
||||
@@ -96,11 +110,13 @@ class MainActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
systemBarsDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
|
||||
Configuration.UI_MODE_NIGHT_YES
|
||||
applyEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsertForm = intent.insertFormOrNull()
|
||||
requestedInsert = intent.insertRequestOrNull()
|
||||
requestedEditKey = intent.editEventKeyOrNull()
|
||||
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
|
||||
setContent {
|
||||
@@ -113,6 +129,14 @@ class MainActivity : AppCompatActivity() {
|
||||
ThemeMode.LIGHT -> false
|
||||
ThemeMode.DARK -> true
|
||||
}
|
||||
// onCreate can only see the night resource qualifier, not the in-app
|
||||
// override — re-apply from the resolved theme so the bar icons follow
|
||||
// the app's light/dark choice.
|
||||
DisposableEffect(darkTheme) {
|
||||
systemBarsDark = darkTheme
|
||||
applyEdgeToEdge()
|
||||
onDispose {}
|
||||
}
|
||||
// The app-wide clock convention: the time-format preference resolved
|
||||
// against the device's 24-hour system setting, provided once here so
|
||||
// every time label reads it via LocalUse24HourFormat.
|
||||
@@ -125,6 +149,13 @@ class MainActivity : AppCompatActivity() {
|
||||
// re-import stamp AppFontSettings carries, so replacing the file
|
||||
// behind an active "custom" token still refreshes — changes;
|
||||
// "system for both" returns the default scale untouched.
|
||||
// The timeline scale plus the pinch in flight over it (#56). Held
|
||||
// here, above the calendar views, so a zoom survives paging between
|
||||
// weeks and switching between the week and day view.
|
||||
val timelineZoom = rememberTimelineZoom(
|
||||
stored = settings.timelineScale,
|
||||
onPersist = settingsViewModel::setTimelineScale,
|
||||
)
|
||||
val fonts by settingsViewModel.fontState.collectAsStateWithLifecycle()
|
||||
val typography = remember(fonts, context) {
|
||||
calendulaTypography(
|
||||
@@ -141,6 +172,7 @@ class MainActivity : AppCompatActivity() {
|
||||
CompositionLocalProvider(
|
||||
LocalUse24HourFormat provides use24Hour,
|
||||
LocalShowHourLines provides settings.showHourLines,
|
||||
LocalTimelineZoom provides timelineZoom,
|
||||
LocalSoftenColors provides settings.softenColors,
|
||||
) {
|
||||
RootScreen(
|
||||
@@ -151,8 +183,9 @@ class MainActivity : AppCompatActivity() {
|
||||
onWidgetNavConsumed = { requestedNav = null },
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = { requestedImportUri = null },
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
onInsertConsumed = { requestedInsertForm = null },
|
||||
requestedInsertForm = requestedInsert?.form,
|
||||
requestedInsertSource = requestedInsert?.source ?: ImportSource.Insert,
|
||||
onInsertConsumed = { requestedInsert = null },
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = { requestedEditKey = null },
|
||||
)
|
||||
@@ -181,6 +214,19 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the transparent edge-to-edge bars for the current [systemBarsDark].
|
||||
* Both scrims are transparent because API 29+ enforces its own contrast and
|
||||
* ignores them anyway.
|
||||
*/
|
||||
private fun applyEdgeToEdge() {
|
||||
val transparent = android.graphics.Color.TRANSPARENT
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||
navigationBarStyle = SystemBarStyle.auto(transparent, transparent) { systemBarsDark },
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Reaching a running UI means startup succeeded; reset the loop trail.
|
||||
@@ -192,7 +238,7 @@ class MainActivity : AppCompatActivity() {
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||
intent.insertRequestOrNull()?.let { requestedInsert = it }
|
||||
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
|
||||
}
|
||||
|
||||
@@ -219,9 +265,10 @@ class MainActivity : AppCompatActivity() {
|
||||
* (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
|
||||
* event", i.e. create). The new event's fields ride as CalendarContract
|
||||
* extras; anything omitted falls back to the in-app "new event" defaults in
|
||||
* [buildInsertEventForm].
|
||||
* [buildInsertEventForm]. An intent that names no end time leaves the length
|
||||
* to the default-duration setting ([ImportSource.InsertOpenEnded], #54).
|
||||
*/
|
||||
private fun Intent.insertFormOrNull(): EventForm? {
|
||||
private fun Intent.insertRequestOrNull(): InsertRequest? {
|
||||
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
|
||||
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
|
||||
// as is any plain ACTION_INSERT.
|
||||
@@ -229,9 +276,11 @@ class MainActivity : AppCompatActivity() {
|
||||
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
|
||||
editEventKeyOrNull() == null)
|
||||
if (!isCreate) return null
|
||||
return buildInsertEventForm(
|
||||
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
|
||||
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
|
||||
val beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME)
|
||||
val endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME)
|
||||
val form = buildInsertEventForm(
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false),
|
||||
title = getStringExtra(CalendarContract.Events.TITLE),
|
||||
description = getStringExtra(CalendarContract.Events.DESCRIPTION),
|
||||
@@ -240,6 +289,10 @@ class MainActivity : AppCompatActivity() {
|
||||
zone = TimeZone.currentSystemDefault(),
|
||||
now = Clock.System.now(),
|
||||
)
|
||||
// An end the intent didn't name — or one [buildInsertEventForm] drops for
|
||||
// landing before the start — leaves the length to the setting.
|
||||
val namesEnd = beginMillis != null && endMillis != null && endMillis >= beginMillis
|
||||
return InsertRequest(form, if (namesEnd) ImportSource.Insert else ImportSource.InsertOpenEnded)
|
||||
}
|
||||
|
||||
/** A Long extra's value, or null when the extra is absent. */
|
||||
|
||||
@@ -125,6 +125,7 @@ internal fun ColumnReader.toEventDetailCore(
|
||||
selfStatus = mapAttendeeStatus(getInt(EventDetailProjection.IDX_SELF_ATTENDEE_STATUS)),
|
||||
eventColor = eventColor,
|
||||
eventColorKey = eventColorKey,
|
||||
isException = !isNull(EventDetailProjection.IDX_ORIGINAL_ID),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,11 @@ internal object EventDetailProjection {
|
||||
// Recurring rows carry DURATION instead of DTEND; the detail screen
|
||||
// needs it to render a series opened without a named occurrence.
|
||||
CalendarContract.Events.DURATION,
|
||||
// Non-null on a modified-occurrence exception row (it points at the
|
||||
// series). "No RRULE" alone can't tell an exception from a master — a
|
||||
// sync adapter that leaves the rule on the exception would otherwise
|
||||
// get an exception written against an exception (#68).
|
||||
CalendarContract.Events.ORIGINAL_ID,
|
||||
)
|
||||
|
||||
const val IDX_EVENT_ID = 0
|
||||
@@ -110,6 +115,7 @@ internal object EventDetailProjection {
|
||||
const val IDX_SELF_ATTENDEE_STATUS = 16
|
||||
const val IDX_EVENT_COLOR_KEY = 17
|
||||
const val IDX_DURATION = 18
|
||||
const val IDX_ORIGINAL_ID = 19
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,9 @@ import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.parseTimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_SYSTEM_TOKEN
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
@@ -270,6 +273,19 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[MONTH_VIEW_STYLE_KEY] = style.name }
|
||||
}
|
||||
|
||||
/**
|
||||
* How tall an hour is drawn in the week and day timelines (#56). Defaults to
|
||||
* [TimelineScale.Regular] — the historical 56dp scale. Holds either a preset
|
||||
* or the height a pinch on the timeline settled at.
|
||||
*/
|
||||
val timelineScale: Flow<TimelineScale> = store.data.map { prefs ->
|
||||
parseTimelineScale(prefs[TIMELINE_SCALE_KEY])
|
||||
}
|
||||
|
||||
suspend fun setTimelineScale(scale: TimelineScale) {
|
||||
store.edit { it[TIMELINE_SCALE_KEY] = scale.storageValue() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the jump-to-today control lives (issue #60). Default OFF — the
|
||||
* historical layout, where it's an extended FAB that fades in above the "+"
|
||||
@@ -457,6 +473,46 @@ class SettingsPrefs @Inject constructor(
|
||||
store.edit { it[AUTOFOCUS_EVENT_TITLE_KEY] = enabled }
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a new **timed** event lasts, in minutes (#54). Defaults to
|
||||
* [DEFAULT_EVENT_DURATION] — the historical fixed hour. Per-calendar
|
||||
* overrides in [perCalendarEventDuration] take precedence; all-day events are
|
||||
* date-anchored and ignore it. Resolve with [resolveDefaultEventDuration].
|
||||
*/
|
||||
val defaultEventDurationMinutes: Flow<Int> = store.data.map { prefs ->
|
||||
(prefs[DEFAULT_EVENT_DURATION_KEY] ?: DEFAULT_EVENT_DURATION)
|
||||
.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
|
||||
suspend fun setDefaultEventDurationMinutes(minutes: Int) {
|
||||
store.edit {
|
||||
it[DEFAULT_EVENT_DURATION_KEY] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-calendar overrides of [defaultEventDurationMinutes], keyed by calendar
|
||||
* id: a calendar **present** in the map gives its new events that length, one
|
||||
* **absent** inherits the global default (there is no "no duration", so this
|
||||
* needs no `none` sentinel). Serialised as `id=minutes;id=minutes`.
|
||||
*/
|
||||
val perCalendarEventDuration: Flow<Map<Long, Int>> = store.data.map { prefs ->
|
||||
parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY])
|
||||
}
|
||||
|
||||
/** [minutes] null drops the override, so the calendar inherits the global default. */
|
||||
suspend fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
||||
store.edit { prefs ->
|
||||
val current = parseDurationOverrides(prefs[CALENDAR_EVENT_DURATION_KEY]).toMutableMap()
|
||||
if (minutes == null) {
|
||||
current.remove(calendarId)
|
||||
} else {
|
||||
current[calendarId] = minutes.coerceIn(MIN_EVENT_DURATION, MAX_EVENT_DURATION)
|
||||
}
|
||||
prefs[CALENDAR_EVENT_DURATION_KEY] = current.toStoredDurations()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula posts reminder notifications (v1.4). Defaults to ON —
|
||||
* for users whose only calendar app this is, reminders are essential; the
|
||||
@@ -808,12 +864,22 @@ class SettingsPrefs @Inject constructor(
|
||||
internal val DIM_COMPLETED_EVENTS_KEY = booleanPreferencesKey("dim_completed_events")
|
||||
internal val SHOW_WEEK_NUMBERS_KEY = booleanPreferencesKey("show_week_numbers")
|
||||
internal val MONTH_VIEW_STYLE_KEY = stringPreferencesKey("month_view_style")
|
||||
internal val TIMELINE_SCALE_KEY = stringPreferencesKey("timeline_scale")
|
||||
internal val TODAY_BUTTON_IN_TOOLBAR_KEY = booleanPreferencesKey("today_button_in_toolbar")
|
||||
internal val DEFAULT_VIEW_KEY = stringPreferencesKey("default_view")
|
||||
internal val QUICK_SWITCH_VIEWS_KEY = stringPreferencesKey("quick_switch_views")
|
||||
internal val DRAWER_VIEW_ORDER_KEY = stringPreferencesKey("drawer_view_order")
|
||||
internal val FORM_FIELDS_KEY = stringPreferencesKey("event_form_default_fields")
|
||||
internal val AUTOFOCUS_EVENT_TITLE_KEY = booleanPreferencesKey("autofocus_event_title")
|
||||
internal val DEFAULT_EVENT_DURATION_KEY = intPreferencesKey("default_event_duration_minutes")
|
||||
internal val CALENDAR_EVENT_DURATION_KEY =
|
||||
stringPreferencesKey("per_calendar_event_duration")
|
||||
|
||||
/** A new timed event's length until the user changes it: one hour. */
|
||||
const val DEFAULT_EVENT_DURATION = 60
|
||||
internal const val MIN_EVENT_DURATION = 1
|
||||
/** A day — past that the form is describing a multi-day event, not a default. */
|
||||
internal const val MAX_EVENT_DURATION = 1_440
|
||||
internal val REMINDERS_ENABLED_KEY = booleanPreferencesKey("reminders_enabled")
|
||||
internal val REMINDER_ONBOARDING_KEY = booleanPreferencesKey("reminder_onboarding_done")
|
||||
internal val ALLOW_COLOR_UNSUPPORTED_KEY =
|
||||
@@ -905,6 +971,17 @@ fun resolveDefaultReminder(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The length a new timed event on [calendarId] opens with: that calendar's
|
||||
* override if it has one, otherwise the [global] default. Pure so it can be
|
||||
* unit-tested.
|
||||
*/
|
||||
fun resolveDefaultEventDuration(
|
||||
global: Int,
|
||||
overrides: Map<Long, Int>,
|
||||
calendarId: Long?,
|
||||
): Int = calendarId?.let { overrides[it] } ?: global
|
||||
|
||||
/** Sentinel stored for [WeekStartPref.Auto]; days store their [DayOfWeek.name]. */
|
||||
private const val WEEK_START_AUTO = "AUTO"
|
||||
|
||||
@@ -952,6 +1029,27 @@ private fun String?.toReminderList(): List<Int> = when {
|
||||
private fun List<Int>.toStoredReminders(): String =
|
||||
if (isEmpty()) NONE else normalizeReminders().joinToString(LIST_SEP) { it.toString() }
|
||||
|
||||
/**
|
||||
* Parse the per-calendar duration map (`id=minutes` entries joined by `;`).
|
||||
* Malformed entries and out-of-range lengths are dropped, so a garbled value
|
||||
* degrades to "inherits the global default" instead of throwing.
|
||||
*/
|
||||
private fun parseDurationOverrides(stored: String?): Map<Long, Int> =
|
||||
stored?.split(ENTRY_SEP).orEmpty().mapNotNull { entry ->
|
||||
val parts = entry.split(KEY_VALUE_SEP)
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
val calendarId = parts[0].trim().toLongOrNull() ?: return@mapNotNull null
|
||||
val minutes = parts[1].trim().toIntOrNull()
|
||||
?.takeIf { it in SettingsPrefs.MIN_EVENT_DURATION..SettingsPrefs.MAX_EVENT_DURATION }
|
||||
?: return@mapNotNull null
|
||||
calendarId to minutes
|
||||
}.toMap()
|
||||
|
||||
private fun Map<Long, Int>.toStoredDurations(): String =
|
||||
entries.sortedBy { it.key }.joinToString(ENTRY_SEP) { (id, minutes) ->
|
||||
"$id$KEY_VALUE_SEP$minutes"
|
||||
}
|
||||
|
||||
|
||||
private inline fun <reified E : Enum<E>> String?.toEnum(default: E): E =
|
||||
this?.let { stored -> enumValues<E>().firstOrNull { it.name == stored } } ?: default
|
||||
|
||||
@@ -44,6 +44,16 @@ val CalendarSource.hasVisibilitySwitch: Boolean
|
||||
val CalendarSource.isEventTarget: Boolean
|
||||
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
|
||||
|
||||
/**
|
||||
* Whether this calendar's events may have their times rewritten by a drag (#68).
|
||||
* Deliberately not [isEventTarget]: an event living in a switched-off or
|
||||
* non-syncing calendar isn't rendered anyway, while a *managed* event is
|
||||
* editable (reminders, notes) yet must never move — the next contacts sync would
|
||||
* put it back.
|
||||
*/
|
||||
val CalendarSource.allowsEventMove: Boolean
|
||||
get() = canModifyContents && !isManaged
|
||||
|
||||
/** Every state worth naming on this calendar's row, in reading order. */
|
||||
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
|
||||
if (isManaged) add(CalendarStateLabel.MANAGED)
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cbrt
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sqrt
|
||||
import de.jeanlucmakiola.calendula.domain.color.Oklch
|
||||
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||
|
||||
/**
|
||||
* Curates an account's published event palette for the colour picker.
|
||||
@@ -18,24 +14,25 @@ import kotlin.math.sqrt
|
||||
* (#22).
|
||||
*
|
||||
* Crucially, curation runs against the colour the picker actually *paints*, not
|
||||
* the raw provider value. The picker softens every swatch through [pastelArgb]:
|
||||
* it pins lightness to a constant and caps saturation, so the raw palette's
|
||||
* lightness axis is invisible on screen. Two raw colours that look different —
|
||||
* a navy and a mid blue — paint as one swatch, and every neutral (black, the
|
||||
* grays, white) paints as the same pale tint. Judging distinctness in raw
|
||||
* space, as before, left near-identical painted swatches and stranded the
|
||||
* neutrals as a run of look-alike "pinks" at the end of the grid.
|
||||
* the raw provider value — and it gets that colour from the same [eventTone]
|
||||
* the picker calls, rather than from a copy of its shaping kept in step by hand.
|
||||
* Because a harmonised container pins lightness, the raw palette's lightness
|
||||
* axis is invisible on screen: two raw colours that look different — a navy and
|
||||
* a mid blue — paint as one swatch, and every neutral (black, the grays, white)
|
||||
* paints as the same grey. Judging distinctness in raw space, as before, left
|
||||
* near-identical painted swatches and stranded the neutrals as a run of
|
||||
* look-alike tints at the end of the grid.
|
||||
*
|
||||
* Three steps, all in painted space:
|
||||
* 1. Collapse swatches that paint identically to one (alphabetically-first key
|
||||
* wins, deterministically) — this folds aliases, dark/light shades of a
|
||||
* hue, and all the neutrals together.
|
||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the washed-out
|
||||
* neutral-origin tints (painted chroma < [PASTEL_CHROMA_FLOOR]) and are
|
||||
* then thinned to visually distinct colours: most vivid first, a colour is
|
||||
* kept only when at least [MIN_DELTA_E] (CIE76, painted Lab) from every
|
||||
* colour already kept. Small palettes are already curated by their adapter
|
||||
* and pass through whole.
|
||||
* 2. Oversized palettes (> [CURATION_TRIGGER_SIZE]) drop the neutral-origin
|
||||
* swatches — with lightness pinned, a grey source paints as plain grey, so
|
||||
* "has no hue left" is simply zero chroma — and are then thinned to visually
|
||||
* distinct colours: most vivid first, a colour is kept only when at least
|
||||
* [MIN_DISTANCE] away in Oklab from every colour already kept. Small
|
||||
* palettes are already curated by their adapter and pass through whole.
|
||||
* 3. The survivors are ordered like a rainbow — continuously by painted hue —
|
||||
* with the wheel cut at its single widest empty gap so the one unavoidable
|
||||
* seam lands in dead space and no hue family is torn across both ends.
|
||||
@@ -45,12 +42,12 @@ import kotlin.math.sqrt
|
||||
*/
|
||||
fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
val painted = sortedBy { it.key }
|
||||
.distinctBy { pastelArgb(it.argb) }
|
||||
.map { it to Lab.of(pastelArgb(it.argb)) }
|
||||
.distinctBy { paintedArgb(it.argb) }
|
||||
.map { it to oklchOf(paintedArgb(it.argb)) }
|
||||
val kept = if (painted.size <= CURATION_TRIGGER_SIZE) {
|
||||
painted
|
||||
} else {
|
||||
thin(painted.filter { (_, lab) -> lab.chroma >= PASTEL_CHROMA_FLOOR })
|
||||
thin(painted.filter { (_, painted) -> painted.chroma > 0f })
|
||||
}
|
||||
return orderAroundWheel(kept).map { (option, _) -> option }
|
||||
}
|
||||
@@ -61,17 +58,17 @@ fun List<EventColorOption>.curatedForPicker(): List<EventColorOption> {
|
||||
* instead of mid-family. Saturation breaks ties, vivid first.
|
||||
*/
|
||||
private fun orderAroundWheel(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||
): List<Pair<EventColorOption, Oklch>> {
|
||||
if (swatches.size < 2) return swatches
|
||||
val byHue = swatches.sortedWith(
|
||||
compareBy({ (_, lab) -> lab.hue }, { (_, lab) -> -lab.chroma }),
|
||||
compareBy({ (_, painted) -> painted.hue }, { (_, painted) -> -painted.chroma }),
|
||||
)
|
||||
// Split the wheel after the largest empty arc between neighbouring hues;
|
||||
// the default is the wrap gap (last hue back round to the first), i.e. the
|
||||
// familiar 0→360 order, and we only rotate away from it for a wider void.
|
||||
var cutAfter = byHue.lastIndex
|
||||
var widestGap = 360.0 - byHue.last().second.hue + byHue.first().second.hue
|
||||
var widestGap = 360f - byHue.last().second.hue + byHue.first().second.hue
|
||||
for (i in 0 until byHue.lastIndex) {
|
||||
val gap = byHue[i + 1].second.hue - byHue[i].second.hue
|
||||
if (gap > widestGap) {
|
||||
@@ -84,102 +81,32 @@ private fun orderAroundWheel(
|
||||
|
||||
/** Greedy max-distance filter: vivid colours stake out clusters first. */
|
||||
private fun thin(
|
||||
swatches: List<Pair<EventColorOption, Lab>>,
|
||||
): List<Pair<EventColorOption, Lab>> {
|
||||
swatches: List<Pair<EventColorOption, Oklch>>,
|
||||
): List<Pair<EventColorOption, Oklch>> {
|
||||
val byVividness = swatches
|
||||
.sortedWith(compareByDescending<Pair<EventColorOption, Lab>> { it.second.chroma }.thenBy { it.first.key })
|
||||
val kept = mutableListOf<Pair<EventColorOption, Lab>>()
|
||||
.sortedWith(
|
||||
compareByDescending<Pair<EventColorOption, Oklch>> { it.second.chroma }
|
||||
.thenBy { it.first.key },
|
||||
)
|
||||
val kept = mutableListOf<Pair<EventColorOption, Oklch>>()
|
||||
for (candidate in byVividness) {
|
||||
if (kept.none { it.second.deltaE(candidate.second) < MIN_DELTA_E }) kept += candidate
|
||||
if (kept.none { it.second.distanceTo(candidate.second) < MIN_DISTANCE }) kept += candidate
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
/**
|
||||
* The softening the colour picker paints over every swatch: keep the hue, scale
|
||||
* and clamp saturation into a gentle band, and pin value to a constant so
|
||||
* nothing screams and everything reads on the surface. Value is fixed here so
|
||||
* curation is theme-independent — only hue and saturation distinguish painted
|
||||
* swatches.
|
||||
*
|
||||
* This is a self-contained mirror of floret-kit's `pastelize` hue/saturation
|
||||
* shaping (`de.jeanlucmakiola.floret.components.pastelize`), with value pinned
|
||||
* rather than theme-picked. Curation must reason about the colour the picker
|
||||
* paints, so the two shapings have to agree: if floret's saturation band or
|
||||
* curve changes, update this in step.
|
||||
*/
|
||||
fun pastelArgb(rawArgb: Int): Int {
|
||||
val r = ((rawArgb shr 16) and 0xFF) / 255f
|
||||
val g = ((rawArgb shr 8) and 0xFF) / 255f
|
||||
val b = (rawArgb and 0xFF) / 255f
|
||||
val max = maxOf(r, g, b)
|
||||
val min = minOf(r, g, b)
|
||||
val delta = max - min
|
||||
val hue = when {
|
||||
delta == 0f -> 0f
|
||||
max == r -> 60f * (((g - b) / delta) % 6f)
|
||||
max == g -> 60f * (((b - r) / delta) + 2f)
|
||||
else -> 60f * (((r - g) / delta) + 4f)
|
||||
}.let { if (it < 0f) it + 360f else it }
|
||||
val sat = (if (max == 0f) 0f else delta / max) * 0.6f
|
||||
val s = sat.coerceIn(0.25f, 0.65f)
|
||||
val v = PASTEL_VALUE
|
||||
val c = v * s
|
||||
val x = c * (1f - abs((hue / 60f) % 2f - 1f))
|
||||
val m = v - c
|
||||
val (rr, gg, bb) = when {
|
||||
hue < 60f -> Triple(c, x, 0f)
|
||||
hue < 120f -> Triple(x, c, 0f)
|
||||
hue < 180f -> Triple(0f, c, x)
|
||||
hue < 240f -> Triple(0f, x, c)
|
||||
hue < 300f -> Triple(x, 0f, c)
|
||||
else -> Triple(c, 0f, x)
|
||||
}
|
||||
fun channel(value: Float) = ((value + m) * 255f).roundToInt().coerceIn(0, 255)
|
||||
return (0xFF shl 24) or (channel(rr) shl 16) or (channel(gg) shl 8) or channel(bb)
|
||||
}
|
||||
|
||||
/** Reference lightness for curation; the picker paints at this on dark surfaces. */
|
||||
private const val PASTEL_VALUE = 0.82f
|
||||
/** The colour the picker paints for [argb]; the light theme stands in as the
|
||||
* reference, since a harmonised container differs only in lightness by theme
|
||||
* and curation compares hue and chroma. */
|
||||
private fun paintedArgb(argb: Int): Int =
|
||||
eventTone(argb, dark = false, harmonise = true).container
|
||||
|
||||
/** Palettes at most this big skip the thinning (Google's ~26 pass through). */
|
||||
private const val CURATION_TRIGGER_SIZE = 36
|
||||
|
||||
/** Minimum CIE76 ΔE between surviving painted swatches. */
|
||||
private const val MIN_DELTA_E = 13.0
|
||||
|
||||
/**
|
||||
* Painted-chroma floor for oversized palettes: below this a swatch is a washed-
|
||||
* out tint — the neutrals and near-whites the saturation clamp muddies — so it
|
||||
* is dropped rather than shown as pale filler.
|
||||
* Minimum Oklab distance between surviving painted swatches. Painted colours all
|
||||
* share one lightness, so this is really a hue/chroma separation — far enough
|
||||
* apart that two swatches never read as the same colour in the grid.
|
||||
*/
|
||||
private const val PASTEL_CHROMA_FLOOR = 22.0
|
||||
|
||||
/** CIE Lab (D65) — the space where Euclidean distance ≈ perceived difference. */
|
||||
private class Lab(val l: Double, val a: Double, val b: Double) {
|
||||
val chroma: Double get() = hypot(a, b)
|
||||
|
||||
/** Hue angle in degrees, 0–360, around the Lab a-b plane. */
|
||||
val hue: Double get() = (Math.toDegrees(atan2(b, a)) + 360.0) % 360.0
|
||||
|
||||
fun deltaE(other: Lab): Double =
|
||||
sqrt((l - other.l).pow(2) + (a - other.a).pow(2) + (b - other.b).pow(2))
|
||||
|
||||
companion object {
|
||||
fun of(argb: Int): Lab {
|
||||
fun linear(shift: Int): Double {
|
||||
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||
return if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||
}
|
||||
val r = linear(16)
|
||||
val g = linear(8)
|
||||
val b = linear(0)
|
||||
val x = (0.4124 * r + 0.3576 * g + 0.1805 * b) / 0.95047
|
||||
val y = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
val z = (0.0193 * r + 0.1192 * g + 0.9505 * b) / 1.08883
|
||||
fun f(t: Double) = if (t > 0.008856) cbrt(t) else 7.787 * t + 16.0 / 116.0
|
||||
val fy = f(y)
|
||||
return Lab(116 * fy - 16, 500 * (f(x) - fy), 200 * (fy - f(z)))
|
||||
}
|
||||
}
|
||||
}
|
||||
private const val MIN_DISTANCE = 0.025f
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The zone this form's wall-clock times mean, matching what the data layer
|
||||
* resolves them in at write time: the form's own pinned zone, else [deviceZone].
|
||||
* An unparseable pinned id falls back to the device, like the write path does.
|
||||
*/
|
||||
fun EventForm.resolvedZone(deviceZone: TimeZone): TimeZone =
|
||||
timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: deviceZone
|
||||
|
||||
/**
|
||||
* The form moved so it starts at [newStart], keeping its length. The wall-clock
|
||||
* values are re-derived in the event's own zone, not the device's, so dragging a
|
||||
* pinned event still means what the event means.
|
||||
*
|
||||
* The **instant** duration is preserved, not the wall-clock span: a recurring
|
||||
* event's length travels to the provider as `DURATION`, so preserving wall clock
|
||||
* would rewrite a whole series' length whenever a shift crosses a DST boundary.
|
||||
*
|
||||
* All-day events carry placeholder times and are date-anchored — move them with
|
||||
* [shiftedByDays] instead; this returns them untouched.
|
||||
*/
|
||||
fun EventForm.shiftedTo(newStart: Instant, deviceZone: TimeZone): EventForm {
|
||||
if (isAllDay) return this
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(
|
||||
start = newStart.toLocalDateTime(zone),
|
||||
end = (newStart + span).toLocalDateTime(zone),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The form moved [days] calendar days, keeping its time of day and its length.
|
||||
* A multi-day event keeps its span rather than collapsing to a single day.
|
||||
*
|
||||
* An all-day event is pure date arithmetic — both ends move, and the placeholder
|
||||
* times ride along untouched. A timed one re-derives its end from the preserved
|
||||
* **instant** duration, for the same reason [shiftedTo] does: the length travels
|
||||
* to a recurring row as `DURATION`, so a move onto a DST changeover that kept
|
||||
* wall clock would silently rewrite the whole series' length.
|
||||
*/
|
||||
fun EventForm.shiftedByDays(days: Int, deviceZone: TimeZone): EventForm {
|
||||
if (days == 0) return this
|
||||
val newStart = LocalDateTime(start.date.plus(days, DateTimeUnit.DAY), start.time)
|
||||
if (isAllDay) {
|
||||
return copy(
|
||||
start = newStart,
|
||||
end = LocalDateTime(end.date.plus(days, DateTimeUnit.DAY), end.time),
|
||||
)
|
||||
}
|
||||
val zone = resolvedZone(deviceZone)
|
||||
val span = end.toInstant(zone) - start.toInstant(zone)
|
||||
return copy(start = newStart, end = (newStart.toInstant(zone) + span).toLocalDateTime(zone))
|
||||
}
|
||||
@@ -15,7 +15,9 @@ import kotlin.time.Instant
|
||||
* calendar this way to create a new event, passing the fields as
|
||||
* [android.provider.CalendarContract] extras. Any field the intent omits falls
|
||||
* back to the same defaults the in-app "new event" uses — a timed start at the
|
||||
* next full hour and a one-hour duration. [EventForm.calendarId] is left null so
|
||||
* next full hour, and a placeholder hour for a missing end that the form then
|
||||
* stretches to the default-duration setting (the intent is opened as
|
||||
* `ImportSource.InsertOpenEnded`, #54). [EventForm.calendarId] is left null so
|
||||
* it resolves to the last-used / first-writable calendar, exactly like the
|
||||
* `.ics` single-event and plain new-event paths.
|
||||
*
|
||||
|
||||
@@ -124,6 +124,13 @@ data class EventDetail(
|
||||
val eventColor: Int? = null,
|
||||
/** The event's `Events.EVENT_COLOR_KEY` (a calendar-palette key), or null. */
|
||||
val eventColorKey: String? = null,
|
||||
/**
|
||||
* True when this row is a modified occurrence of a series (`ORIGINAL_ID` is
|
||||
* set), not a master. Such a row stands alone — writing an exception against
|
||||
* it would nest one exception inside another — so a reschedule always takes
|
||||
* the plain whole-row path, whatever [rrule] a sync adapter left on it.
|
||||
*/
|
||||
val isException: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,7 +138,7 @@ fun SimpleRecurrence.toRRule(zone: TimeZone = TimeZone.currentSystemDefault()):
|
||||
}
|
||||
}
|
||||
|
||||
private val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||
internal val RRULE_DAY_CODES: Map<DayOfWeek, String> = mapOf(
|
||||
DayOfWeek.MONDAY to "MO",
|
||||
DayOfWeek.TUESDAY to "TU",
|
||||
DayOfWeek.WEDNESDAY to "WE",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* [rrule] re-anchored from an occurrence on [oldStart] to one on [newStart].
|
||||
*
|
||||
* `Events.RRULE` is written verbatim while DTSTART moves, so a rule that names
|
||||
* its own day — `FREQ=WEEKLY;BYDAY=MO`, what Google and CalDAV write for nearly
|
||||
* every weekly series — would keep pointing at Monday after the anchor became a
|
||||
* Wednesday, and the series would not move at all. `BYDAY` is therefore
|
||||
* re-derived from [newStart].
|
||||
*
|
||||
* **Only weekly `BYDAY` is realigned, and only for a whole-day move.** The rule
|
||||
* has to agree with the series *anchor*, which is not the occurrence being
|
||||
* dragged: the anchor moves by the same wall-clock shift, and only weekday
|
||||
* arithmetic survives that unchanged, because it is uniform mod 7 and every
|
||||
* anchor time-of-day crosses the same number of midnights. Day-of-month does not
|
||||
* — `BYMONTHDAY=28` on a January anchor dragged from Feb 28 to Mar 1 gives an
|
||||
* anchor of Jan 29 under a `BYMONTHDAY=1` rule, which is not an instance of its
|
||||
* own rule and materialises a phantom occurrence on any client that trusts
|
||||
* DTSTART. Those rules return null instead. The caller must also refuse when the
|
||||
* shift is not a whole number of days, for the same reason (see
|
||||
* `RescheduleViewModel`).
|
||||
*
|
||||
* Null also covers rules one moved occurrence cannot resolve at all
|
||||
* (`BYDAY=MO,WE`, an ordinal `2TH`, `BYSETPOS`, …) — dropping the extra days
|
||||
* would delete occurrences. The accepted parts are deliberately a subset of what
|
||||
* [parseSimpleRecurrence] understands, so anything realignable is also a rule
|
||||
* [problems] can check the `UNTIL` of.
|
||||
*/
|
||||
fun realignRecurrence(rrule: String, oldStart: LocalDate, newStart: LocalDate): String? {
|
||||
if (oldStart == newStart) return rrule
|
||||
val prefix = if (rrule.startsWith("RRULE:")) "RRULE:" else ""
|
||||
val parts = rrule.removePrefix("RRULE:").split(';').filter { it.isNotBlank() }
|
||||
if (parts.isEmpty()) return null
|
||||
var weekly = false
|
||||
val rebuilt = parts.map { part ->
|
||||
val eq = part.indexOf('=')
|
||||
if (eq <= 0) return null
|
||||
val key = part.substring(0, eq).uppercase()
|
||||
val value = part.substring(eq + 1).trim()
|
||||
when (key) {
|
||||
"FREQ" -> {
|
||||
weekly = value.equals("WEEKLY", ignoreCase = true)
|
||||
part
|
||||
}
|
||||
"BYDAY" -> {
|
||||
val old = RRULE_DAY_CODES[oldStart.dayOfWeek] ?: return null
|
||||
if (!value.equals(old, ignoreCase = true)) return null
|
||||
"BYDAY=${RRULE_DAY_CODES.getValue(newStart.dayOfWeek)}"
|
||||
}
|
||||
"INTERVAL", "COUNT", "UNTIL", "WKST" -> part
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
// BYDAY is only simple on a weekly rule (matching parseSimpleRecurrence);
|
||||
// "every Monday of the month" is a shape this has not been reasoned about.
|
||||
if (!weekly && parts.any { it.substringBefore('=').trim().uppercase() == "BYDAY" }) return null
|
||||
if (parts.none { it.substringBefore('=').trim().uppercase() == "FREQ" }) return null
|
||||
return prefix + rebuilt.joinToString(";")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package de.jeanlucmakiola.calendula.domain.color
|
||||
|
||||
/**
|
||||
* The colours one calendar's identity resolves to in one theme.
|
||||
*
|
||||
* The app paints a calendar's colour in two structurally different places, and
|
||||
* they want opposite things:
|
||||
*
|
||||
* - a **[container]** sits behind text (week and day blocks, month bars, widget
|
||||
* rows, picker swatches), so it has to contrast with its own ink;
|
||||
* - an **[accent]** is a mark on an ordinary app surface (day dots, agenda and
|
||||
* search stripes, calendar icon tints, the detail header), so it has to
|
||||
* contrast with the *surface* instead.
|
||||
*
|
||||
* Painting both from one colour is what made this hard to get right: deep enough
|
||||
* to carry white text is too dark to see as a dot on a dark surface, and light
|
||||
* enough to show up there is too pale to carry white text. Splitting them lets
|
||||
* each be pinned to the lightness its job needs, while hue and chroma — the parts
|
||||
* that actually say *which calendar this is* — stay shared, so the two roles
|
||||
* still read as the same colour.
|
||||
*/
|
||||
data class EventTone(
|
||||
val container: Int,
|
||||
val onContainer: Int,
|
||||
val accent: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolve [rawArgb] for the current theme.
|
||||
*
|
||||
* With [harmonise] on, hue and chroma are kept and lightness is re-pinned per
|
||||
* role, which is what makes the ink predictable: every container lands at the
|
||||
* same perceptual lightness, so one ink colour serves every hue at ≥ 6:1. With
|
||||
* it off the provider's colour is painted verbatim — the sync source's own look,
|
||||
* as DAVx5/CalDAV users expect — and the ink is then chosen per colour, since
|
||||
* nothing constrains what the provider sends.
|
||||
*/
|
||||
fun eventTone(rawArgb: Int, dark: Boolean, harmonise: Boolean): EventTone {
|
||||
val opaque = rawArgb or 0xFF000000.toInt()
|
||||
val raw = oklchOf(opaque)
|
||||
if (!harmonise) {
|
||||
return EventTone(
|
||||
container = opaque,
|
||||
onContainer = inkFor(raw.lightness),
|
||||
accent = opaque,
|
||||
)
|
||||
}
|
||||
// A near-grey source has no hue worth keeping, so it stays grey rather than
|
||||
// being pushed to an arbitrary one; everything else is held inside a band
|
||||
// that keeps calendars apart without going neon.
|
||||
val chroma = if (raw.chroma < GREY_CHROMA) 0f else raw.chroma.coerceIn(MIN_CHROMA, MAX_CHROMA)
|
||||
// Two poles rather than one. Forcing every hue deep is what turned the warm,
|
||||
// naturally light ones muddy — a dark orange is brown and a dark yellow is
|
||||
// olive, which is a fact about those hues, not a tuning miss. So a colour is
|
||||
// sent to whichever pole it already sits nearer: most land deep and carry
|
||||
// white ink, while genuinely light sources (yellows, creams, pale tints)
|
||||
// stay light and carry dark ink, keeping the character that made them
|
||||
// recognisable. Either way the colour is pulled clear of the middle, where
|
||||
// neither ink reads well — which was the original fault.
|
||||
val staysLight = raw.lightness >= LIGHT_POLE_THRESHOLD
|
||||
val containerLightness = when {
|
||||
staysLight && dark -> LIGHT_CONTAINER_LIGHTNESS_DARK
|
||||
staysLight -> LIGHT_CONTAINER_LIGHTNESS_LIGHT
|
||||
dark -> CONTAINER_LIGHTNESS_DARK
|
||||
else -> CONTAINER_LIGHTNESS_LIGHT
|
||||
}
|
||||
// The accent takes no pole: it is a mark on the app's surface, so it has to
|
||||
// contrast with that surface whichever way its container went.
|
||||
val accentLightness = if (dark) ACCENT_LIGHTNESS_DARK else ACCENT_LIGHTNESS_LIGHT
|
||||
return EventTone(
|
||||
container = Oklch(containerLightness, chroma, raw.hue).toArgb(),
|
||||
onContainer = inkFor(containerLightness),
|
||||
accent = Oklch(accentLightness, chroma, raw.hue).toArgb(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* White or black ink for a background of the given perceptual [lightness].
|
||||
*
|
||||
* Derived rather than hardcoded so it stays right for raw provider colours,
|
||||
* where lightness is whatever the sync source sent. For harmonised containers it
|
||||
* is constant by construction — that is the point of pinning the lightness.
|
||||
*/
|
||||
fun inkFor(lightness: Float): Int =
|
||||
if (lightness < INK_FLIP_LIGHTNESS) 0xFFFFFFFF.toInt() else 0xFF000000.toInt()
|
||||
|
||||
/**
|
||||
* Lightness at which white ink overtakes black. Sits above the midpoint because
|
||||
* lightness is perceptual: a colour has to be distinctly light before black wins.
|
||||
*/
|
||||
const val INK_FLIP_LIGHTNESS = 0.62f
|
||||
|
||||
/**
|
||||
* Raw lightness at or above which a colour keeps its light character instead of
|
||||
* being pushed deep. Set high enough that oranges and warm reds still go deep —
|
||||
* a burnt orange reads as orange, where a dark yellow does not read as yellow —
|
||||
* so only the genuinely pale sources take the light pole.
|
||||
*/
|
||||
const val LIGHT_POLE_THRESHOLD = 0.72f
|
||||
|
||||
/** Container lightness: deep enough that white ink clears 5.5:1 on every hue. */
|
||||
const val CONTAINER_LIGHTNESS_LIGHT = 0.45f
|
||||
|
||||
/** Same in dark mode, a shade lighter so a block separates from the surface. */
|
||||
const val CONTAINER_LIGHTNESS_DARK = 0.48f
|
||||
|
||||
/** Light-pole container: pale enough that dark ink clears 11:1 on every hue. */
|
||||
const val LIGHT_CONTAINER_LIGHTNESS_LIGHT = 0.88f
|
||||
|
||||
/** Same in dark mode, held down a little so a pale block doesn't glare. */
|
||||
const val LIGHT_CONTAINER_LIGHTNESS_DARK = 0.84f
|
||||
|
||||
/** Accent lightness: dark enough to read as a mark on a pale surface. */
|
||||
const val ACCENT_LIGHTNESS_LIGHT = 0.55f
|
||||
|
||||
/** Same against a dark surface, where the mark has to be the light one. */
|
||||
const val ACCENT_LIGHTNESS_DARK = 0.78f
|
||||
|
||||
/** Below this chroma a colour counts as grey and keeps no hue. */
|
||||
const val GREY_CHROMA = 0.02f
|
||||
|
||||
/** Chroma band for harmonised colours: distinct, but never electric. */
|
||||
const val MIN_CHROMA = 0.07f
|
||||
const val MAX_CHROMA = 0.16f
|
||||
@@ -0,0 +1,119 @@
|
||||
package de.jeanlucmakiola.calendula.domain.color
|
||||
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cbrt
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.hypot
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* A colour in Oklch — Oklab's cylindrical form: perceptual [lightness], [chroma]
|
||||
* (colourfulness) and [hue] in degrees.
|
||||
*
|
||||
* The point of using it over HSV is that its lightness axis matches what the eye
|
||||
* calls brightness. HSV's "value" does not: at a single pinned value the hues
|
||||
* spread across relative luminance 0.10 (indigo) to 0.45 (yellow), which is why
|
||||
* pinning value produced fills that needed different ink per hue. Pin Oklch
|
||||
* lightness instead and every hue lands at the same apparent brightness, so one
|
||||
* ink serves all of them.
|
||||
*
|
||||
* [lightness] runs 0 (black) to 1 (white); [chroma] is 0 (grey) to about 0.32 at
|
||||
* the sRGB limit.
|
||||
*/
|
||||
data class Oklch(val lightness: Float, val chroma: Float, val hue: Float) {
|
||||
|
||||
/**
|
||||
* Perceptual distance to [other] — plain Euclidean in Oklab, which is what
|
||||
* Oklab is built for (unlike CIE Lab, where CIE76 is known to misjudge
|
||||
* saturated blues).
|
||||
*/
|
||||
fun distanceTo(other: Oklch): Float {
|
||||
val (a1, b1) = chroma * cosDeg(hue) to chroma * sinDeg(hue)
|
||||
val (a2, b2) = other.chroma * cosDeg(other.hue) to other.chroma * sinDeg(other.hue)
|
||||
val dl = (lightness - other.lightness).toDouble()
|
||||
return sqrt(dl * dl + (a1 - a2).pow(2) + (b1 - b2).pow(2)).toFloat()
|
||||
}
|
||||
}
|
||||
|
||||
/** Read [argb]'s opaque colour as Oklch. */
|
||||
fun oklchOf(argb: Int): Oklch {
|
||||
val r = toLinear(((argb shr 16) and 0xFF) / 255.0)
|
||||
val g = toLinear(((argb shr 8) and 0xFF) / 255.0)
|
||||
val b = toLinear((argb and 0xFF) / 255.0)
|
||||
|
||||
val l = cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
|
||||
val m = cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
|
||||
val s = cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
|
||||
|
||||
val lightness = 0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s
|
||||
val aAxis = 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s
|
||||
val bAxis = 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
|
||||
|
||||
val hue = (Math.toDegrees(atan2(bAxis, aAxis)) + 360.0) % 360.0
|
||||
return Oklch(lightness.toFloat(), hypot(aAxis, bAxis).toFloat(), hue.toFloat())
|
||||
}
|
||||
|
||||
/**
|
||||
* The opaque sRGB colour for this Oklch, gamut-mapped: most of the Oklch cylinder
|
||||
* falls outside sRGB, so a colour that does not fit keeps its lightness and hue
|
||||
* and gives up chroma until it does. Holding lightness is what matters here —
|
||||
* it's the axis the contrast guarantees rest on.
|
||||
*/
|
||||
fun Oklch.toArgb(): Int {
|
||||
val fitted = if (inSrgb(lightness, chroma, hue)) {
|
||||
chroma
|
||||
} else {
|
||||
var low = 0f
|
||||
var high = chroma
|
||||
repeat(GAMUT_STEPS) {
|
||||
val mid = (low + high) / 2f
|
||||
if (inSrgb(lightness, mid, hue)) low = mid else high = mid
|
||||
}
|
||||
low
|
||||
}
|
||||
val (r, g, b) = linearSrgbOf(lightness, fitted, hue)
|
||||
return 0xFF shl 24 or
|
||||
(channel(r) shl 16) or
|
||||
(channel(g) shl 8) or
|
||||
channel(b)
|
||||
}
|
||||
|
||||
private fun linearSrgbOf(lightness: Float, chroma: Float, hue: Float): Triple<Double, Double, Double> {
|
||||
val a = chroma * cosDeg(hue)
|
||||
val b = chroma * sinDeg(hue)
|
||||
val l = (lightness + 0.3963377774 * a + 0.2158037573 * b).pow(3)
|
||||
val m = (lightness - 0.1055613458 * a - 0.0638541728 * b).pow(3)
|
||||
val s = (lightness - 0.0894841775 * a - 1.2914855480 * b).pow(3)
|
||||
return Triple(
|
||||
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||
)
|
||||
}
|
||||
|
||||
private fun inSrgb(lightness: Float, chroma: Float, hue: Float): Boolean {
|
||||
val (r, g, b) = linearSrgbOf(lightness, chroma, hue)
|
||||
return r in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
||||
g in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON) &&
|
||||
b in -GAMUT_EPSILON..(1.0 + GAMUT_EPSILON)
|
||||
}
|
||||
|
||||
private fun channel(linear: Double): Int =
|
||||
(toSrgb(linear.coerceIn(0.0, 1.0)) * 255.0).toInt().coerceIn(0, 255)
|
||||
|
||||
private fun toLinear(c: Double): Double =
|
||||
if (c <= 0.04045) c / 12.92 else ((c + 0.055) / 1.055).pow(2.4)
|
||||
|
||||
private fun toSrgb(c: Double): Double =
|
||||
if (c <= 0.0031308) 12.92 * c else 1.055 * c.pow(1.0 / 2.4) - 0.055
|
||||
|
||||
private fun cosDeg(deg: Float) = cos(Math.toRadians(deg.toDouble()))
|
||||
private fun sinDeg(deg: Float) = sin(Math.toRadians(deg.toDouble()))
|
||||
|
||||
/** Bisection steps when pulling an out-of-gamut colour back into sRGB. */
|
||||
private const val GAMUT_STEPS = 24
|
||||
|
||||
/** Slack for the gamut test, so rounding at the boundary doesn't reject a fit. */
|
||||
private const val GAMUT_EPSILON = 1e-4
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -27,6 +28,10 @@ import de.jeanlucmakiola.calendula.ui.calendars.BackupScreen
|
||||
import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
|
||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveHost
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.RescheduleViewModel
|
||||
import de.jeanlucmakiola.calendula.ui.common.drillToDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.selectView
|
||||
@@ -75,6 +80,7 @@ fun CalendarHost(
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: EventForm? = null,
|
||||
requestedInsertSource: ImportSource = ImportSource.Insert,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
@@ -203,7 +209,7 @@ fun CalendarHost(
|
||||
// reveals on top of whatever was open without extra dismissal.
|
||||
LaunchedEffect(requestedInsertForm) {
|
||||
if (requestedInsertForm != null) {
|
||||
importFormSource = ImportSource.Insert
|
||||
importFormSource = requestedInsertSource
|
||||
importForm = requestedInsertForm
|
||||
onInsertConsumed()
|
||||
}
|
||||
@@ -293,6 +299,29 @@ fun CalendarHost(
|
||||
}
|
||||
}
|
||||
|
||||
// Drag to reschedule (#68). Hosted here so one instance serves every view and
|
||||
// survives view switches; the calendar surfaces read it out of LocalEventMove.
|
||||
val reschedule: RescheduleViewModel = hiltViewModel()
|
||||
val movableCalendarIds by reschedule.movableCalendarIds.collectAsStateWithLifecycle()
|
||||
val onEditEvent: (EventInstance) -> Unit = { event ->
|
||||
val key = longArrayOf(
|
||||
event.eventId,
|
||||
event.start.toEpochMilliseconds(),
|
||||
event.end.toEpochMilliseconds(),
|
||||
)
|
||||
heldEditKey = key
|
||||
editKey = key
|
||||
}
|
||||
val moveScope = remember(movableCalendarIds, reschedule) {
|
||||
EventMoveScope(
|
||||
movableCalendarIds = movableCalendarIds,
|
||||
move = reschedule::move,
|
||||
inFlight = reschedule.inFlight,
|
||||
undoStarted = reschedule.undoStarted,
|
||||
edit = onEditEvent,
|
||||
)
|
||||
}
|
||||
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
|
||||
// Base-level back: pop the view stack while no overlay covers it (each overlay
|
||||
@@ -310,6 +339,7 @@ fun CalendarHost(
|
||||
// navigation, so it fades through rather than sliding — paging *within* a
|
||||
// view keeps the directional slide. AnimatedContent keyed on the view type.
|
||||
val viewSwitch = fadeThrough()
|
||||
CompositionLocalProvider(LocalEventMove provides moveScope) {
|
||||
AnimatedContent(
|
||||
targetState = view,
|
||||
transitionSpec = { viewSwitch },
|
||||
@@ -366,6 +396,12 @@ fun CalendarHost(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scope prompt + confirmation/undo snackbar for a dropped event. Declared
|
||||
// right after the calendar views, so any overlay opened afterwards covers
|
||||
// the snackbar rather than the other way round.
|
||||
EventMoveHost(reschedule, modifier = Modifier.fillMaxSize())
|
||||
|
||||
// Search overlay — below detail/edit in the Box so a tapped result's
|
||||
// detail screen draws on top, and closing it returns to the results.
|
||||
|
||||
@@ -39,6 +39,8 @@ fun RootScreen(
|
||||
requestedImportUri: android.net.Uri? = null,
|
||||
onImportConsumed: () -> Unit = {},
|
||||
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
|
||||
requestedInsertSource: de.jeanlucmakiola.calendula.ui.edit.ImportSource =
|
||||
de.jeanlucmakiola.calendula.ui.edit.ImportSource.Insert,
|
||||
onInsertConsumed: () -> Unit = {},
|
||||
requestedEditKey: LongArray? = null,
|
||||
onEditKeyConsumed: () -> Unit = {},
|
||||
@@ -109,6 +111,7 @@ fun RootScreen(
|
||||
requestedImportUri = requestedImportUri,
|
||||
onImportConsumed = onImportConsumed,
|
||||
requestedInsertForm = requestedInsertForm,
|
||||
requestedInsertSource = requestedInsertSource,
|
||||
onInsertConsumed = onInsertConsumed,
|
||||
requestedEditKey = requestedEditKey,
|
||||
onEditKeyConsumed = onEditKeyConsumed,
|
||||
|
||||
@@ -31,6 +31,7 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
@@ -137,7 +138,7 @@ internal fun AgendaEventRow(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
.background(eventAccent(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
|
||||
@@ -112,6 +112,7 @@ fun AgendaScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = anchor,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
|
||||
@@ -88,6 +88,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.accountGroupTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.groupByAccount
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.LeadingAvatar
|
||||
import de.jeanlucmakiola.calendula.ui.common.SourceLogo
|
||||
@@ -442,7 +443,7 @@ private fun CalendarEditor(
|
||||
)
|
||||
}
|
||||
}
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventAccent(color, dark, soften)) {
|
||||
InlineTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.FiniteAnimationSpec
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
|
||||
/**
|
||||
* A timed block's own time label, crossfaded rather than replaced. The block
|
||||
* keeps its identity across a move and slides to the new slot; the label is the
|
||||
* one thing on it that would otherwise change in a single frame.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun BlockTimeLabel(label: String, color: Color, modifier: Modifier = Modifier) {
|
||||
val spec: FiniteAnimationSpec<Float> = if (rememberReduceMotion()) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastEffectsSpec()
|
||||
}
|
||||
Crossfade(
|
||||
targetState = label,
|
||||
animationSpec = spec,
|
||||
label = "block-time",
|
||||
modifier = modifier,
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = color,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a timed block sits in its column, after tweening. */
|
||||
@Immutable
|
||||
data class BlockPlacement(val x: Dp, val y: Dp, val width: Dp, val height: Dp)
|
||||
|
||||
/**
|
||||
* A timed block's placement, tweened rather than jumped. Every bound a block
|
||||
* has changes for a reason the user just caused — a drop landing at a new time,
|
||||
* an undo putting it back, a neighbour arriving and halving both lanes — and all
|
||||
* of them read better as motion than as a new layout appearing.
|
||||
*
|
||||
* Continuity comes from the caller keying each block by identity; a block
|
||||
* composed for the first time starts *at* its target, so nothing flies in from
|
||||
* the corner on the first frame.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
@Composable
|
||||
fun animatedBlockPlacement(x: Dp, y: Dp, width: Dp, height: Dp): BlockPlacement {
|
||||
val spec: FiniteAnimationSpec<Dp> = if (rememberReduceMotion()) {
|
||||
snap()
|
||||
} else {
|
||||
MaterialTheme.motionScheme.fastSpatialSpec()
|
||||
}
|
||||
val animatedX by animateDpAsState(x, spec, label = "block-x")
|
||||
val animatedY by animateDpAsState(y, spec, label = "block-y")
|
||||
val animatedWidth by animateDpAsState(width, spec, label = "block-width")
|
||||
val animatedHeight by animateDpAsState(height, spec, label = "block-height")
|
||||
return BlockPlacement(animatedX, animatedY, animatedWidth, animatedHeight)
|
||||
}
|
||||
@@ -35,7 +35,7 @@ fun CalendarColorChip(color: Int, modifier: Modifier = Modifier) {
|
||||
Icon(
|
||||
Icons.Filled.CalendarMonth,
|
||||
contentDescription = null,
|
||||
tint = eventFill(color, dark, soften),
|
||||
tint = eventAccent(color, dark, soften),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -19,6 +20,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.DrawerState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
@@ -27,19 +29,23 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
@@ -50,7 +56,8 @@ import kotlinx.datetime.LocalDate
|
||||
* a jump-to-date action, the per-calendar visibility filter (M3) inline, and a
|
||||
* pinned Settings row. The "View" section mirrors the top-bar switcher pill —
|
||||
* tapping a view here selects it (and closes the drawer) rather than cycling.
|
||||
* The host screen owns the drawer state.
|
||||
* The host screen owns the drawer state; the sheet reads it only to dismiss
|
||||
* itself on back.
|
||||
*
|
||||
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
|
||||
* anchor); [onJumpToDate] navigates the active view to the chosen day.
|
||||
@@ -59,12 +66,17 @@ import kotlinx.datetime.LocalDate
|
||||
fun CalendarDrawer(
|
||||
currentView: CalendarView,
|
||||
currentDate: LocalDate,
|
||||
drawerState: DrawerState,
|
||||
onSelectView: (CalendarView) -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// Registered in the sheet so it takes precedence over the host's back handler.
|
||||
BackHandler(enabled = drawerState.isOpen) { scope.launch { drawerState.close() } }
|
||||
|
||||
ModalDrawerSheet {
|
||||
// The whole sidebar scrolls as one — header, views, the calendar filter
|
||||
@@ -83,7 +95,7 @@ fun CalendarDrawer(
|
||||
position = positionOf(index, viewOrder.size),
|
||||
selected = view == currentView,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(view.icon, contentDescription = null) },
|
||||
leading = { DrawerLeadingIcon(view.icon) },
|
||||
onClick = { onSelectView(view) },
|
||||
)
|
||||
}
|
||||
@@ -93,7 +105,7 @@ fun CalendarDrawer(
|
||||
title = stringResource(R.string.drawer_jump_to_date),
|
||||
position = Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(Icons.Filled.DateRange, contentDescription = null) },
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
|
||||
onClick = { showDatePicker = true },
|
||||
)
|
||||
|
||||
@@ -107,7 +119,7 @@ fun CalendarDrawer(
|
||||
title = stringResource(R.string.month_action_settings),
|
||||
position = Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
leading = { Icon(Icons.Filled.Settings, contentDescription = null) },
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.Settings) },
|
||||
onClick = onSettings,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
@@ -126,13 +138,27 @@ fun CalendarDrawer(
|
||||
}
|
||||
}
|
||||
|
||||
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
|
||||
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
|
||||
@Composable
|
||||
private fun DrawerLeadingIcon(icon: ImageVector) {
|
||||
Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) {
|
||||
Icon(icon, contentDescription = null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Branded header: the app-icon chip beside the app name. */
|
||||
@Composable
|
||||
private fun DrawerHeader() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 28.dp, end = 28.dp, top = 24.dp, bottom = 16.dp),
|
||||
.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 24.dp,
|
||||
bottom = 16.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
@@ -164,6 +190,11 @@ private fun DrawerSectionHeader(text: String) {
|
||||
text = text,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 16.dp, bottom = 8.dp),
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 16.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import kotlinx.datetime.LocalDate
|
||||
|
||||
/**
|
||||
* Top-bar title that doubles as the jump-to-date entry point: the formatted
|
||||
* [title] with a drop-down caret, opening the same [CalendarDatePickerDialog] the
|
||||
* drawer uses and reporting the chosen day through [onJumpToDate].
|
||||
*
|
||||
* [currentDate] seeds the picker with whatever the bar is currently naming (the
|
||||
* visible day, week start or month anchor).
|
||||
*
|
||||
* The row keeps its own 8.dp inset rather than shifting back onto the app bar's
|
||||
* start alignment: M3 places the title flush against the navigation icon's
|
||||
* trailing edge, and the title is hit-tested on top of it, so a negative offset
|
||||
* would swallow taps meant for the menu button.
|
||||
*/
|
||||
@Composable
|
||||
fun CalendarTitleButton(
|
||||
title: String,
|
||||
currentDate: LocalDate,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showDatePicker by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.heightIn(min = 48.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.clickable(
|
||||
onClickLabel = stringResource(R.string.drawer_jump_to_date),
|
||||
role = Role.Button,
|
||||
) { showDatePicker = true }
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDropDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (showDatePicker) {
|
||||
CalendarDatePickerDialog(
|
||||
initial = currentDate,
|
||||
onConfirm = {
|
||||
showDatePicker = false
|
||||
onJumpToDate(it)
|
||||
},
|
||||
onDismiss = { showDatePicker = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,45 +2,53 @@ package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import de.jeanlucmakiola.floret.components.pastelize
|
||||
import de.jeanlucmakiola.calendula.domain.color.eventTone
|
||||
import de.jeanlucmakiola.calendula.domain.color.inkFor
|
||||
import de.jeanlucmakiola.calendula.domain.color.oklchOf
|
||||
|
||||
/**
|
||||
* Whether calendar/event colours are softened toward theme-fitting pastels
|
||||
* before display (issue #36). Provided app-wide from the "soften colours"
|
||||
* setting; the default `true` keeps the historical look. When off, the raw
|
||||
* provider colour is painted verbatim — matching the sync source (DAVx5/CalDAV)
|
||||
* and other calendar apps. Widgets live outside this composition and read the
|
||||
* preference directly, then pass the flag to [eventFill] / [eventInk].
|
||||
* Whether calendar/event colours are harmonised — hue kept, lightness re-pinned
|
||||
* per role — before display (issue #36). Provided app-wide from the setting; the
|
||||
* default `true` is the app's own look. When off, the raw provider colour is
|
||||
* painted verbatim, matching the sync source (DAVx5/CalDAV) and other calendar
|
||||
* apps. Widgets live outside this composition and read the preference directly,
|
||||
* then pass the flag through.
|
||||
*/
|
||||
val LocalSoftenColors = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* Display fill for an event chip/bar or a calendar tint: the [pastelize]d colour
|
||||
* when [soften] is on, else the raw provider ARGB verbatim (forced opaque, since
|
||||
* pastelize also returns an opaque colour).
|
||||
* Fill for a surface that carries text on it — a week/day block, a month bar, a
|
||||
* widget row, a picker swatch. Pair it with [eventInk].
|
||||
*/
|
||||
fun eventFill(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||
if (soften) pastelize(rawArgb, dark) else Color(rawArgb or 0xFF000000.toInt())
|
||||
Color(eventTone(rawArgb, dark, soften).container)
|
||||
|
||||
/**
|
||||
* Contrast ink (title text / glyph) for a filled chip painted with [eventFill]:
|
||||
* white on a dark fill, near-black on a light one (issue #21). Applies to both
|
||||
* softened and raw fills — a saturated hue (deep blue, purple, red) is
|
||||
* perceptually dark even after softening pins its HSV value, so black text on it
|
||||
* reads poorly. The choice is objective, not a tuned threshold: white wins only
|
||||
* when it out-contrasts black against the fill, which (by the WCAG contrast
|
||||
* ratio) is at relative luminance ≈ 0.18 — so mid and light colours keep
|
||||
* near-black text. [alpha] carries the caller's soft emphasis.
|
||||
* Colour for a mark on an ordinary app surface — a day dot, an agenda or search
|
||||
* stripe, a calendar icon tint, the detail header. Same identity as [eventFill],
|
||||
* pinned to contrast with the surface instead of with ink.
|
||||
*/
|
||||
fun eventInk(fill: Color, alpha: Float = 0.8f): Color {
|
||||
val useWhite = fill.luminance() < INK_LUMINANCE_CROSSOVER
|
||||
return (if (useWhite) Color.White else Color.Black).copy(alpha = alpha)
|
||||
}
|
||||
fun eventAccent(rawArgb: Int, dark: Boolean, soften: Boolean): Color =
|
||||
Color(eventTone(rawArgb, dark, soften).accent)
|
||||
|
||||
/**
|
||||
* Relative-luminance crossover where white text starts to out-contrast black.
|
||||
* Solving `contrast(white, L) = contrast(black, L)` on the WCAG ratio gives
|
||||
* `L = sqrt(1.05 * 0.05) - 0.05 ≈ 0.179`.
|
||||
* Ink for text drawn on an [eventFill]. [alpha] carries the caller's emphasis.
|
||||
*
|
||||
* Takes the fill rather than the raw colour so it stays correct for the raw
|
||||
* (un-harmonised) path too, where the provider decides the lightness.
|
||||
*/
|
||||
private const val INK_LUMINANCE_CROSSOVER = 0.179f
|
||||
fun eventInk(fill: Color, alpha: Float = 0.8f): Color =
|
||||
Color(inkFor(oklchOf(fill.toArgbInt()).lightness)).copy(alpha = alpha)
|
||||
|
||||
/**
|
||||
* Ink alpha for a block's secondary line (the time under the title). Held above
|
||||
* a fainter value because harmonised containers are deep: the small time text
|
||||
* needs to stay clear of the 4.5:1 WCAG asks of body text.
|
||||
*/
|
||||
const val SECONDARY_INK_ALPHA = 0.8f
|
||||
|
||||
private fun Color.toArgbInt(): Int =
|
||||
(0xFF shl 24) or
|
||||
((red * 255f).toInt().coerceIn(0, 255) shl 16) or
|
||||
((green * 255f).toInt().coerceIn(0, 255) shl 8) or
|
||||
(blue * 255f).toInt().coerceIn(0, 255)
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* How far the finger may wander during the hold. Deliberately well under touch
|
||||
* slop (~18dp): past that an ancestor — the vertical scroll, the page swipe —
|
||||
* claims the gesture, and a hold that survived to there would have opened a dead
|
||||
* zone where neither the drag nor the page turn happens.
|
||||
*/
|
||||
private val PICKUP_TOLERANCE = 6.dp
|
||||
|
||||
/**
|
||||
* Pick an event block up with a long press and drag it, without
|
||||
* `detectDragGesturesAfterLongPress`.
|
||||
*
|
||||
* The stock detector cancels the press as soon as an ancestor consumes — and
|
||||
* during the hold the block itself consumes nothing, so the scroll and the page
|
||||
* swipe are free to claim at their own slop — and it also cancels when the finger
|
||||
* leaves the block, which a `MIN_EVENT_FRACTION`-tall block loses immediately.
|
||||
* This one keeps its own timeout and its own (much smaller) tolerance, and never
|
||||
* cancels on leaving the bounds.
|
||||
*
|
||||
* Movement before the timeout is deliberately **not** consumed: consuming it
|
||||
* would kill the ancestor's gesture outright, so a scroll that happens to start
|
||||
* on top of a block would die. Fast movement means the user meant to scroll —
|
||||
* abandon quietly. A second finger means a pinch, which owns the gesture.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberEventDragSource(
|
||||
enabled: Boolean,
|
||||
key: Any?,
|
||||
onPickUp: (pointerInRoot: Offset, blockInRoot: Offset, size: IntSize) -> Unit,
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
): Modifier = rememberDragSurface(
|
||||
enabled = enabled,
|
||||
key = key,
|
||||
onPickUp = { _, pointerInRoot, nodeInRoot, size ->
|
||||
onPickUp(pointerInRoot, nodeInRoot, size)
|
||||
true
|
||||
},
|
||||
onMove = onMove,
|
||||
onDrop = onDrop,
|
||||
onCancel = onCancel,
|
||||
)
|
||||
|
||||
/**
|
||||
* The same pickup, for a surface that carries many draggable pieces rather than
|
||||
* being one itself — the month grid, whose chips are covered by a full-bleed tap
|
||||
* layer and so can never take pointer input of their own. [onPickUp] receives the
|
||||
* press position local to this node and answers whether anything is there;
|
||||
* returning false abandons the gesture as if the hold had never completed.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberDragSurface(
|
||||
enabled: Boolean,
|
||||
key: Any?,
|
||||
onPickUp: (local: Offset, pointerInRoot: Offset, nodeInRoot: Offset, size: IntSize) -> Boolean,
|
||||
onMove: (pointerInRoot: Offset) -> Unit,
|
||||
onDrop: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
): Modifier {
|
||||
if (!enabled) return Modifier
|
||||
val holdMillis = LocalViewConfiguration.current.longPressTimeoutMillis
|
||||
val tolerance = with(LocalDensity.current) { PICKUP_TOLERANCE.toPx() }
|
||||
val coordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||
val currentPickUp by rememberUpdatedState(onPickUp)
|
||||
val currentMove by rememberUpdatedState(onMove)
|
||||
val currentDrop by rememberUpdatedState(onDrop)
|
||||
val currentCancel by rememberUpdatedState(onCancel)
|
||||
|
||||
return Modifier
|
||||
.onGloballyPositioned { coordinates[0] = it }
|
||||
.pointerInput(key) {
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
val origin = down.position
|
||||
val heldStill = withTimeoutOrNull(holdMillis) {
|
||||
while (true) {
|
||||
val main = awaitPointerEvent()
|
||||
val change = main.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) break
|
||||
if (main.changes.count { it.pressed } > 1) break
|
||||
if ((change.position - origin).getDistance() > tolerance) break
|
||||
// An ancestor's claim only becomes visible once the whole
|
||||
// main pass has run, so look again on the final pass.
|
||||
val final = awaitPointerEvent(PointerEventPass.Final)
|
||||
if (final.changes.any { it.isConsumed }) break
|
||||
}
|
||||
} == null
|
||||
if (!heldStill) return@awaitEachGesture
|
||||
|
||||
val layout = coordinates[0]?.takeIf { it.isAttached } ?: return@awaitEachGesture
|
||||
val took = currentPickUp(
|
||||
down.position,
|
||||
layout.localToRoot(down.position),
|
||||
layout.positionInRoot(),
|
||||
layout.size,
|
||||
)
|
||||
if (!took) return@awaitEachGesture
|
||||
var dropped = false
|
||||
try {
|
||||
// Once the block is lifted the gesture is ours, so it is
|
||||
// driven on the initial pass and consumed there. That pass
|
||||
// runs parent → child, which gets both halves right: an
|
||||
// ancestor that outranks us (the pinch, which claims the
|
||||
// moment a second finger lands anywhere in the timeline) has
|
||||
// already consumed by the time we look, and everything below
|
||||
// us — the month grid's full-bleed tap layer, which is a
|
||||
// descendant and would otherwise open the day on lift — sees
|
||||
// ours. Consumption persists across passes, so the scroll and
|
||||
// the page swipe stand down on main as well.
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (change.isConsumed) break
|
||||
if (!change.pressed) {
|
||||
change.consume()
|
||||
dropped = true
|
||||
break
|
||||
}
|
||||
if (event.changes.count { it.pressed } > 1) break
|
||||
change.consume()
|
||||
coordinates[0]?.takeIf { it.isAttached }
|
||||
?.let { currentMove(it.localToRoot(change.position)) }
|
||||
}
|
||||
} finally {
|
||||
// Also reached when the pointer node is disposed mid-drag (the
|
||||
// page swapping out under the finger) — that is a cancel, not a
|
||||
// drop, and must never write.
|
||||
if (dropped) currentDrop() else currentCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's first haptics: a lift on pickup, then a tick every time the drop
|
||||
* target snaps to a different slot, so the granularity is felt rather than read.
|
||||
*/
|
||||
@Composable
|
||||
fun DragSnapHaptics(slot: Any?) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val previous = remember { arrayOfNulls<Any>(1) }
|
||||
LaunchedEffect(slot) {
|
||||
val had = previous[0]
|
||||
previous[0] = slot
|
||||
when {
|
||||
slot == null -> Unit
|
||||
had == null -> haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
had != slot -> haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.floret.components.SnackChip
|
||||
import de.jeanlucmakiola.floret.components.SnackChipHeight
|
||||
import de.jeanlucmakiola.floret.components.SnackChipMargin
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import kotlinx.coroutines.delay
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.util.Locale
|
||||
|
||||
/** How long the confirmation chip stays up, matching a short snackbar. */
|
||||
private const val CHIP_MILLIS = 4_000L
|
||||
|
||||
/**
|
||||
* How long an *undone* move stays up. Shorter than everything else the chip
|
||||
* says: it offers nothing to act on, and it confirms a change the user has just
|
||||
* asked for and can see on the grid behind it, so the full dwell is only the
|
||||
* chip outstaying what it had to say.
|
||||
*/
|
||||
private const val UNDONE_CHIP_MILLIS = 1_600L
|
||||
|
||||
/** What the chip currently reads, kept past the outcome it was built from. */
|
||||
private data class ChipContent(val message: String, val undo: MoveUndo?)
|
||||
|
||||
/** The FAB's own band at the bottom end, which the chip must not run into. */
|
||||
private val FAB_BAND = 88.dp
|
||||
|
||||
/**
|
||||
* The two surfaces a drag-and-drop reschedule needs on top of the calendar: the
|
||||
* recurring-scope prompt, and the confirmation chip carrying Undo.
|
||||
*
|
||||
* None of the four calendar screens sets a `snackbarHost` on its Scaffold, so
|
||||
* this hosts its own confirmation — a pill on the FAB's own band at the bottom
|
||||
* start rather than a full-width bar, so the calendar it confirms a change to
|
||||
* stays visible behind it.
|
||||
*/
|
||||
@Composable
|
||||
fun EventMoveHost(viewModel: RescheduleViewModel, modifier: Modifier = Modifier) {
|
||||
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
|
||||
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
|
||||
val locale = currentLocale()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
|
||||
prompt?.let { pending ->
|
||||
RecurringScopeDialog(
|
||||
title = stringResource(R.string.event_move_recurring_title),
|
||||
onSelect = viewModel::moveWithScope,
|
||||
onDismiss = viewModel::cancelScope,
|
||||
allowSeries = !pending.occurrenceOnly,
|
||||
reason = stringResource(R.string.event_move_occurrence_only)
|
||||
.takeIf { pending.occurrenceOnly },
|
||||
)
|
||||
}
|
||||
|
||||
val moved = outcome as? MoveOutcome.Moved
|
||||
val movedLabel = moved?.let { formatMovedTo(it.startMillis, it.isAllDay, use24Hour, locale) }
|
||||
val message = when (outcome) {
|
||||
null -> null
|
||||
is MoveOutcome.Moved -> stringResource(R.string.event_move_done, movedLabel.orEmpty())
|
||||
MoveOutcome.Undone -> stringResource(R.string.event_move_undone)
|
||||
MoveOutcome.WriteDenied -> stringResource(R.string.event_move_write_denied)
|
||||
MoveOutcome.Gone -> stringResource(R.string.event_move_gone)
|
||||
MoveOutcome.BlockedSeriesEnd -> stringResource(R.string.event_move_blocked_series_end)
|
||||
MoveOutcome.Failed -> stringResource(R.string.event_move_failed)
|
||||
}
|
||||
|
||||
// Held past the outcome being consumed so the chip has something to draw
|
||||
// while it springs back out. Updated *in composition* rather than from an
|
||||
// effect: an effect lands a frame late, so the chip would open carrying the
|
||||
// previous message and grow into this one while it was still animating in.
|
||||
val shown = remember { mutableStateOf(ChipContent("", null)) }
|
||||
if (message != null && (shown.value.message != message || shown.value.undo != moved?.undo)) {
|
||||
shown.value = ChipContent(message, moved?.undo)
|
||||
}
|
||||
val content = shown.value
|
||||
LaunchedEffect(outcome) {
|
||||
if (outcome == null) return@LaunchedEffect
|
||||
delay(if (outcome == MoveOutcome.Undone) UNDONE_CHIP_MILLIS else CHIP_MILLIS)
|
||||
viewModel.consumeOutcome()
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val chipMaxWidth = maxWidth - FAB_BAND
|
||||
// A FAB-height band anchored at the bottom start with the FAB's own
|
||||
// margin; centring the chip in it lines it up beside the bottom-end FAB
|
||||
// at exactly its height, rather than sitting a touch above it.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.navigationBarsPadding()
|
||||
.padding(start = SnackChipMargin, bottom = SnackChipMargin)
|
||||
.height(SnackChipHeight),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
SnackChip(
|
||||
visible = outcome != null,
|
||||
message = content.message,
|
||||
maxWidth = chipMaxWidth,
|
||||
actionLabel = stringResource(R.string.event_move_undo)
|
||||
.takeIf { content.undo != null },
|
||||
onAction = content.undo?.let { undo -> { viewModel.undo(undo) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Fri, 7 Aug, 09:00" — the day, plus the time for a timed event. All-day events
|
||||
* are read back on the UTC calendar day they are anchored to (#65, #82).
|
||||
*/
|
||||
private fun formatMovedTo(
|
||||
startMillis: Long,
|
||||
isAllDay: Boolean,
|
||||
use24Hour: Boolean,
|
||||
locale: Locale,
|
||||
): String {
|
||||
val zone: ZoneId = if (isAllDay) ZoneOffset.UTC else ZoneId.systemDefault()
|
||||
val skeleton = when {
|
||||
isAllDay -> "EEEdMMM"
|
||||
use24Hour -> "EEEdMMMHm"
|
||||
else -> "EEEdMMMhm"
|
||||
}
|
||||
return localizedDateFormatter(locale, skeleton)
|
||||
.format(Instant.ofEpochMilli(startMillis).atZone(zone))
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Drag-to-reschedule wiring (#68), provided once at `CalendarHost` and read by
|
||||
* whichever event block is being composed. A composition local rather than six
|
||||
* layers of parameters: every calendar surface needs the same three things, and
|
||||
* the blocks that need them sit deep inside private composables.
|
||||
*
|
||||
* Null means moving is off entirely (no host provided it) — a block then
|
||||
* registers no drag gesture at all, so a long press keeps its old meaning of
|
||||
* nothing happening.
|
||||
*/
|
||||
@Immutable
|
||||
class EventMoveScope(
|
||||
/**
|
||||
* Calendars whose events may be moved. Nothing below the UI enforces this —
|
||||
* the repository writes whatever it is handed — so this gate is load-bearing.
|
||||
*/
|
||||
val movableCalendarIds: Set<Long>,
|
||||
val move: (MoveRequest) -> Unit,
|
||||
/**
|
||||
* True while a dropped event is being written — including the time its scope
|
||||
* dialog is up. A flow rather than a value so this scope stays the same
|
||||
* object across a move: it is a composition local every visible block reads,
|
||||
* and replacing it would recompose all of them twice per drop.
|
||||
*/
|
||||
val inFlight: StateFlow<Boolean>,
|
||||
/** Ticks when an undo write begins — see `RescheduleViewModel.undoStarted`. */
|
||||
val undoStarted: StateFlow<Int>,
|
||||
/** Open an event in the edit form — the pointer-free route to the same change. */
|
||||
val edit: (EventInstance) -> Unit,
|
||||
) {
|
||||
fun allows(event: EventInstance): Boolean = event.calendarId in movableCalendarIds
|
||||
}
|
||||
|
||||
val LocalEventMove = compositionLocalOf<EventMoveScope?> { null }
|
||||
|
||||
private val NEVER_IN_FLIGHT = MutableStateFlow(false)
|
||||
|
||||
private val NEVER_UNDONE = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Whether a dropped event is still being written — false wherever moving is off.
|
||||
* The drag overlays hold a landed block on its target for this window.
|
||||
*/
|
||||
@Composable
|
||||
fun moveInFlight(): Boolean {
|
||||
val flow = LocalEventMove.current?.inFlight ?: NEVER_IN_FLIGHT
|
||||
return flow.collectAsStateWithLifecycle().value
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [onUndo] when an undo write begins, and never for one that began before
|
||||
* this composable came on screen — a view switched to *after* an undo has
|
||||
* nothing left to carry back.
|
||||
*/
|
||||
@Composable
|
||||
fun OnUndoStarted(onUndo: () -> Unit) {
|
||||
val flow = LocalEventMove.current?.undoStarted ?: NEVER_UNDONE
|
||||
val tick by flow.collectAsStateWithLifecycle()
|
||||
var seen by remember { mutableIntStateOf(tick) }
|
||||
LaunchedEffect(tick) {
|
||||
if (tick == seen) return@LaunchedEffect
|
||||
seen = tick
|
||||
onUndo()
|
||||
}
|
||||
}
|
||||
|
||||
/** Opacity the source block keeps while its floating copy travels. */
|
||||
const val GHOST_ALPHA: Float = 0.3f
|
||||
|
||||
/**
|
||||
* Opacity for a block whose copy is in flight: ghosted from the lift until the
|
||||
* copy is handed back, then animated up rather than switched. The ghost keeps
|
||||
* its place through the write and *travels* to the new slot when the grid
|
||||
* re-reads it, arriving under the copy as that fades — so what the eye follows
|
||||
* is one block moving, not one vanishing and another appearing.
|
||||
*/
|
||||
@Composable
|
||||
fun ghostAlpha(lifted: Boolean): Float = animateFloatAsState(
|
||||
targetValue = if (lifted) GHOST_ALPHA else 1f,
|
||||
label = "ghost-alpha",
|
||||
).value
|
||||
|
||||
/**
|
||||
* A TalkBack action that opens [event] in the edit form, so rescheduling isn't
|
||||
* pointer-only. Null when this event can't be moved — the block then carries no
|
||||
* action and registers no drag either.
|
||||
*/
|
||||
@Composable
|
||||
fun eventMoveAction(event: EventInstance): CustomAccessibilityAction? {
|
||||
val move = LocalEventMove.current ?: return null
|
||||
if (!move.allows(event)) return null
|
||||
val label = stringResource(R.string.event_move_action)
|
||||
return remember(event.instanceId, label, move) {
|
||||
CustomAccessibilityAction(label) {
|
||||
move.edit(event)
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
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.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
|
||||
/** Width of the hour gutter down the start edge of the day and week timelines. */
|
||||
val GUTTER_WIDTH = 48.dp
|
||||
|
||||
/**
|
||||
* Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp,
|
||||
* matching the icon button's centre.
|
||||
*/
|
||||
val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
|
||||
private val BADGE_HEIGHT = 20.dp
|
||||
|
||||
/** How far the fixed hour labels recede while a block is being dragged. */
|
||||
private const val DIMMED_HOUR_ALPHA = 0.3f
|
||||
|
||||
/**
|
||||
* The timeline's hour gutter. Scrolls in sync with the day columns through the
|
||||
* shared [scrollState], and while [dragController] holds a lifted block it dims
|
||||
* the hour labels and floats a badge with the drag's current start time at the
|
||||
* row the block would land on.
|
||||
*/
|
||||
@Composable
|
||||
fun HourGutter(
|
||||
scrollState: ScrollState,
|
||||
hourHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
// Derived, not read directly: the drag is rewritten every frame, while its
|
||||
// snapped start only changes once per slot — which is all the gutter shows.
|
||||
val dragStartMin by remember(dragController) {
|
||||
derivedStateOf { dragController.drag?.startMin }
|
||||
}
|
||||
val hourAlpha by animateFloatAsState(
|
||||
targetValue = if (dragStartMin != null) DIMMED_HOUR_ALPHA else 1f,
|
||||
label = "hourLabelAlpha",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Column {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(hourHeight),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
.copy(alpha = hourAlpha),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dragStartMin?.let { startMin ->
|
||||
val top = (hourHeight * (startMin / 60f) - BADGE_HEIGHT / 2).coerceAtLeast(0.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = top)
|
||||
.height(BADGE_HEIGHT)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||
.padding(horizontal = 4.dp)
|
||||
// The dragged block behind it already carries this time; a
|
||||
// second copy would only duplicate the announcement.
|
||||
.clearAndSetSemantics { },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = formatGutterTime(startMin, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -354,35 +354,66 @@ private fun CustomDaysEditor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Snooze-duration picker, full-screen and **single-select**: the [presets]
|
||||
* (whole-minute delays) each sit as a checkmark row, with a "Custom" row that
|
||||
* expands an inline amount field plus a Minutes/Hours unit toggle to enter an
|
||||
* arbitrary delay. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking
|
||||
* a preset or confirming a custom value applies via [onSelect] and closes.
|
||||
* [label] renders a delay in minutes as a duration ("10 minutes", "1 hour") and
|
||||
* is reused for both the rows and the custom preview.
|
||||
* Duration picker, full-screen and **single-select**: the [presets] (whole-minute
|
||||
* lengths) each sit as a checkmark row, with a "Custom" row that expands an
|
||||
* inline amount field plus a Minutes/Hours unit toggle to enter an arbitrary
|
||||
* one. Mirrors [AgendaRangePicker]'s custom-expand pattern; picking a preset or
|
||||
* confirming a custom value applies via [onSelect] and closes. [label] renders a
|
||||
* length in minutes ("10 minutes", "1 hour") and is reused for both the rows and
|
||||
* the custom preview.
|
||||
*
|
||||
* [inheritLabel] adds an exclusive "use the default" row on top, for the
|
||||
* per-calendar pickers; picking it reports null. [selected] is null exactly when
|
||||
* that row is the current choice. [description] explains the setting above the
|
||||
* rows when it needs it. [maxMinutes] caps what the custom editor will confirm,
|
||||
* for settings that clamp their stored value — offering a length the store would
|
||||
* silently shorten is worse than refusing it.
|
||||
*/
|
||||
@Composable
|
||||
fun SnoozeDurationPicker(
|
||||
fun DurationPicker(
|
||||
title: String,
|
||||
presets: List<Int>,
|
||||
selected: Int,
|
||||
selected: Int?,
|
||||
label: @Composable (Int) -> String,
|
||||
onSelect: (Int) -> Unit,
|
||||
onSelect: (Int?) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
description: String? = null,
|
||||
inheritLabel: String? = null,
|
||||
maxMinutes: Int = Int.MAX_VALUE,
|
||||
) {
|
||||
val customSelected = selected !in presets
|
||||
val inherits = selected == null
|
||||
// The current choice when it isn't one of the presets — the Custom row then
|
||||
// names it and the editor opens pre-filled with it.
|
||||
val custom = selected?.takeIf { it !in presets }
|
||||
val rowCount = presets.size + 1 // + the custom row
|
||||
|
||||
var customExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var amountText by rememberSaveable {
|
||||
mutableStateOf(if (customSelected) snoozeCustomAmount(selected).toString() else "")
|
||||
mutableStateOf(custom?.let { durationCustomAmount(it).toString() }.orEmpty())
|
||||
}
|
||||
var unit by rememberSaveable {
|
||||
mutableStateOf(if (customSelected) snoozeCustomUnit(selected) else ReminderUnit.Minutes)
|
||||
mutableStateOf(custom?.let { durationCustomUnit(it) } ?: ReminderUnit.Minutes)
|
||||
}
|
||||
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = true) {
|
||||
if (description != null) PickerDescription(description)
|
||||
if (inheritLabel != null) {
|
||||
GroupedRow(
|
||||
title = inheritLabel,
|
||||
position = Position.Alone,
|
||||
selected = inherits,
|
||||
trailing = if (inherits) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(null)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
presets.forEachIndexed { index, minute ->
|
||||
val isSelected = minute == selected
|
||||
GroupedRow(
|
||||
@@ -403,10 +434,10 @@ fun SnoozeDurationPicker(
|
||||
// The Custom row connects downward into the editor card when expanded, so
|
||||
// the two read as one grouped container (the shared custom-expand pattern).
|
||||
GroupedRow(
|
||||
title = if (customSelected) label(selected) else stringResource(R.string.event_edit_reminder_custom),
|
||||
title = custom?.let { label(it) } ?: stringResource(R.string.event_edit_reminder_custom),
|
||||
position = if (customExpanded) Position.Top else positionOf(presets.size, rowCount),
|
||||
selected = customSelected,
|
||||
trailing = if (customSelected) {
|
||||
selected = custom != null,
|
||||
trailing = if (custom != null) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
@@ -418,12 +449,13 @@ fun SnoozeDurationPicker(
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
CustomSnoozeEditor(
|
||||
CustomDurationEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = { amountText = it },
|
||||
unit = unit,
|
||||
onUnitChange = { unit = it },
|
||||
label = label,
|
||||
maxMinutes = maxMinutes,
|
||||
onConfirm = { minutes ->
|
||||
onSelect(minutes)
|
||||
onDismiss()
|
||||
@@ -433,41 +465,48 @@ fun SnoozeDurationPicker(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole hours if the delay divides evenly, else minutes. */
|
||||
private fun snoozeCustomUnit(minutes: Int): ReminderUnit =
|
||||
/** Whole hours if the length divides evenly, else minutes. */
|
||||
private fun durationCustomUnit(minutes: Int): ReminderUnit =
|
||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) ReminderUnit.Hours else ReminderUnit.Minutes
|
||||
|
||||
private fun snoozeCustomAmount(minutes: Int): Int =
|
||||
private fun durationCustomAmount(minutes: Int): Int =
|
||||
if (minutes % ReminderUnit.Hours.minutesFactor == 0) minutes / ReminderUnit.Hours.minutesFactor else minutes
|
||||
|
||||
/**
|
||||
* The expanded "Custom" snooze editor: a tonal card connected to the Custom row
|
||||
* The expanded "Custom" duration editor: a tonal card connected to the Custom row
|
||||
* above it. A Minutes/Hours unit toggle, an amount field with a live preview of
|
||||
* the delay it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||
* amount. [onConfirm] receives the final delay in minutes.
|
||||
* the length it resolves to, and a tonal confirm enabled only for a valid 1–999
|
||||
* amount that stays within [maxMinutes]. Over the cap, the preview names the
|
||||
* limit instead of a length the setting could not keep. [onConfirm] receives the
|
||||
* final length in minutes.
|
||||
*/
|
||||
@Composable
|
||||
private fun CustomSnoozeEditor(
|
||||
private fun CustomDurationEditor(
|
||||
amountText: String,
|
||||
onAmountChange: (String) -> Unit,
|
||||
unit: ReminderUnit,
|
||||
onUnitChange: (ReminderUnit) -> Unit,
|
||||
label: @Composable (Int) -> String,
|
||||
maxMinutes: Int,
|
||||
onConfirm: (Int) -> Unit,
|
||||
) {
|
||||
val units = remember { listOf(ReminderUnit.Minutes, ReminderUnit.Hours) }
|
||||
val amount = amountText.toIntOrNull()?.takeIf { it in 1..999 }
|
||||
val entered = amountText.toIntOrNull()?.takeIf { it in 1..999 }?.times(unit.minutesFactor)
|
||||
val amount = entered?.takeIf { it <= maxMinutes }
|
||||
CustomAmountEditor(
|
||||
amountText = amountText,
|
||||
onAmountChange = onAmountChange,
|
||||
unitLabels = units.map { stringResource(reminderUnitLabel(it)) },
|
||||
selectedUnit = units.indexOf(unit).coerceAtLeast(0),
|
||||
onUnitChange = { onUnitChange(units[it]) },
|
||||
preview = amount?.let { label(it * unit.minutesFactor) }
|
||||
?: stringResource(R.string.reminder_custom_amount),
|
||||
preview = when {
|
||||
amount != null -> label(amount)
|
||||
entered != null -> stringResource(R.string.duration_custom_max, label(maxMinutes))
|
||||
else -> stringResource(R.string.reminder_custom_amount)
|
||||
},
|
||||
setLabel = stringResource(R.string.reminder_custom_set),
|
||||
confirmEnabled = amount != null,
|
||||
onConfirm = { amount?.let { onConfirm(it * unit.minutesFactor) } },
|
||||
onConfirm = { amount?.let(onConfirm) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
|
||||
/**
|
||||
* How far a write to a recurring event should reach: this occurrence, it and
|
||||
* everything after (a series split), or the whole series.
|
||||
*
|
||||
* One of the two carve-outs from the full-screen picker rule — a two-or-three
|
||||
* option decision taken mid-action reads better as a popup than as a near-empty
|
||||
* screen. Shared by the edit screen's save and a drag-and-drop reschedule, which
|
||||
* want the same three options for the same reasons.
|
||||
*
|
||||
* [allowOccurrence] drops "only this event" (an exception row can't carry its own
|
||||
* rule, so a changed recurrence rules it out); [allowSeries] drops the two wider
|
||||
* options, for a rule whose days can't be recalculated from one moved
|
||||
* occurrence — [reason] then says why.
|
||||
*/
|
||||
@Composable
|
||||
fun RecurringScopeDialog(
|
||||
title: String,
|
||||
onSelect: (RecurringWriteScope) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
allowOccurrence: Boolean = true,
|
||||
allowSeries: Boolean = true,
|
||||
reason: String? = null,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (reason != null) {
|
||||
Text(
|
||||
text = reason,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (allowOccurrence) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_occurrence),
|
||||
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
||||
)
|
||||
}
|
||||
if (allowSeries) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_following),
|
||||
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_series),
|
||||
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -37,3 +37,21 @@ fun reminderLeadTimeLabel(minutes: Int): String = when {
|
||||
pluralStringResource(R.plurals.reminder_hours, minutes / 60, minutes / 60)
|
||||
else -> pluralStringResource(R.plurals.reminder_minutes, minutes, minutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Humanise a plain duration — no "before": "45 minutes", "8 hours", or both
|
||||
* parts for a mixed length ("1 hour 30 minutes"). Shared by the snooze delay and
|
||||
* the default event duration (#54).
|
||||
*/
|
||||
@Composable
|
||||
fun durationLabel(minutes: Int): String {
|
||||
val hours = minutes / 60
|
||||
val rest = minutes % 60
|
||||
val hoursLabel = pluralStringResource(R.plurals.duration_hours, hours, hours)
|
||||
val minutesLabel = pluralStringResource(R.plurals.duration_minutes, rest, rest)
|
||||
return when {
|
||||
hours == 0 -> minutesLabel
|
||||
rest == 0 -> hoursLabel
|
||||
else -> stringResource(R.string.duration_hours_minutes, hoursLabel, minutesLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
|
||||
import de.jeanlucmakiola.calendula.data.calendar.NoSuchEventException
|
||||
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormProblem
|
||||
import de.jeanlucmakiola.calendula.domain.RecurrenceEnd
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.allowsEventMove
|
||||
import de.jeanlucmakiola.calendula.domain.parseSimpleRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
||||
import de.jeanlucmakiola.calendula.domain.resolvedZone
|
||||
import de.jeanlucmakiola.calendula.domain.shiftedByDays
|
||||
import de.jeanlucmakiola.calendula.domain.shiftedTo
|
||||
import de.jeanlucmakiola.calendula.domain.toEditForm
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.atStartOfDayIn
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/** Where a dragged event should land. */
|
||||
sealed interface MoveTarget {
|
||||
/** A new start instant — a timeline drag, which moves time and day at once. */
|
||||
data class Start(val instant: Instant) : MoveTarget
|
||||
|
||||
/**
|
||||
* A whole-day shift, keeping the time of day — a month-grid or all-day drag.
|
||||
* A delta rather than a target date on purpose: the grid already knows how
|
||||
* many columns the finger crossed, and re-deriving that from a date would
|
||||
* mean the screen and this view model each resolving the event's first day
|
||||
* in their own zone, which can disagree by a day.
|
||||
*/
|
||||
data class ByDays(val days: Int) : MoveTarget
|
||||
}
|
||||
|
||||
/**
|
||||
* One dropped event. [beginMillis]/[endMillis] are the dragged *occurrence's*
|
||||
* own times (`Instances.BEGIN`/`END`), exactly as the detail and edit screens
|
||||
* pass them, so a recurring series resolves the right occurrence.
|
||||
*/
|
||||
data class MoveRequest(
|
||||
val eventId: Long,
|
||||
val beginMillis: Long,
|
||||
val endMillis: Long,
|
||||
val target: MoveTarget,
|
||||
)
|
||||
|
||||
/**
|
||||
* A recurring drop waiting for the user to pick how far it reaches.
|
||||
* [occurrenceOnly] means the rule names days that a single moved occurrence
|
||||
* can't re-derive (`BYDAY=MO,WE`, `2TH`, …), so the only honest option left is
|
||||
* this one occurrence — see [realignRecurrence].
|
||||
*/
|
||||
data class MoveScopePrompt(val occurrenceOnly: Boolean)
|
||||
|
||||
/** The inverse of a completed move, for the undo action. */
|
||||
data class MoveUndo(
|
||||
val eventId: Long,
|
||||
/** The form as it now stands (the moved state). */
|
||||
val moved: EventForm,
|
||||
/** The form as it stood before the move. */
|
||||
val restored: EventForm,
|
||||
)
|
||||
|
||||
sealed interface MoveOutcome {
|
||||
/**
|
||||
* Written. [startMillis] is where the dragged occurrence now begins, for the
|
||||
* confirmation message; [undo] is null when the write has no clean inverse.
|
||||
*/
|
||||
data class Moved(
|
||||
val startMillis: Long,
|
||||
val isAllDay: Boolean,
|
||||
val undo: MoveUndo?,
|
||||
) : MoveOutcome
|
||||
|
||||
data object Undone : MoveOutcome
|
||||
|
||||
/** `WRITE_CALENDAR` was revoked between the drop and the provider call. */
|
||||
data object WriteDenied : MoveOutcome
|
||||
|
||||
/** The event vanished (sync, another device) between the read and the write. */
|
||||
data object Gone : MoveOutcome
|
||||
|
||||
/** The drop would push the series past its own `UNTIL`, generating nothing. */
|
||||
data object BlockedSeriesEnd : MoveOutcome
|
||||
|
||||
data object Failed : MoveOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a drag-and-drop reschedule (#68) through the same repository calls the
|
||||
* edit screen's save uses, so recurring writes, reminder reconciliation and
|
||||
* attendee preservation behave identically. Hosted at `CalendarHost` so one
|
||||
* instance serves every calendar view and survives view switches.
|
||||
*
|
||||
* The full prefilled form is carried through the write — never a stripped one —
|
||||
* because the occurrence-exception path reconciles reminders and attendees onto
|
||||
* the new row, and a partial form would wipe them.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RescheduleViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _scopePrompt = MutableStateFlow<MoveScopePrompt?>(null)
|
||||
val scopePrompt: StateFlow<MoveScopePrompt?> = _scopePrompt.asStateFlow()
|
||||
|
||||
private val _outcome = MutableStateFlow<MoveOutcome?>(null)
|
||||
val outcome: StateFlow<MoveOutcome?> = _outcome.asStateFlow()
|
||||
|
||||
private var pending: PreparedMove? = null
|
||||
|
||||
private val _inFlight = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True from the moment a drop is accepted until its write settles — the
|
||||
* window the dropped block holds its landing position for, rather than
|
||||
* snapping back to where it came from until the grid re-reads it.
|
||||
*/
|
||||
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
|
||||
|
||||
private val _undoStarted = MutableStateFlow(0)
|
||||
|
||||
/**
|
||||
* Ticks the moment an undo write begins — before the provider has anything to
|
||||
* re-read. An undo moves an event exactly as a drop does, so the view that
|
||||
* drew the drop takes this as its cue to carry the chip back rather than let
|
||||
* it reappear on the old day. A counter rather than the undo itself: what a
|
||||
* view needs is the *timing*, and it already knows what it moved.
|
||||
*/
|
||||
val undoStarted: StateFlow<Int> = _undoStarted.asStateFlow()
|
||||
|
||||
/**
|
||||
* Set from the moment a drop is accepted until its write settles. Two drops
|
||||
* of the same recurring event landing inside that window would each compute
|
||||
* their shift from the same pre-move occurrence, and the data layer applies
|
||||
* both to the re-read anchor — so the shifts would compound.
|
||||
*/
|
||||
private var busy = false
|
||||
set(value) {
|
||||
field = value
|
||||
_inFlight.value = value
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendars whose events may be dragged. Nothing below the UI guards
|
||||
* this — the repository and data source attempt any write handed to them —
|
||||
* so a block outside this set registers no drag gesture at all.
|
||||
*/
|
||||
val movableCalendarIds: StateFlow<Set<Long>> = repository.calendars()
|
||||
.map { calendars -> calendars.filter { it.allowsEventMove }.map { it.id }.toSet() }
|
||||
.catch { emit(emptySet<Long>()) }
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
/** Everything a drop needs, resolved before anything is written. */
|
||||
private data class PreparedMove(
|
||||
val request: MoveRequest,
|
||||
val original: EventForm,
|
||||
val updated: EventForm,
|
||||
/** True for a series master with a rule — an exception row is not one. */
|
||||
val isRecurring: Boolean,
|
||||
/**
|
||||
* False when the rule names days this move can't re-derive, so anything
|
||||
* wider than the single occurrence would leave rule and anchor disagreeing.
|
||||
*/
|
||||
val canRealign: Boolean,
|
||||
/** The series row's DTSTART date after the move — what its `UNTIL` must clear. */
|
||||
val newAnchorDate: LocalDate,
|
||||
)
|
||||
|
||||
fun move(request: MoveRequest) {
|
||||
if (busy || _scopePrompt.value != null) return
|
||||
busy = true
|
||||
viewModelScope.launch {
|
||||
val prepared = prepare(request)
|
||||
if (prepared == null) {
|
||||
busy = false
|
||||
return@launch
|
||||
}
|
||||
if (!prepared.isRecurring) {
|
||||
write(prepared, RecurringWriteScope.AllEvents)
|
||||
busy = false
|
||||
} else {
|
||||
// Still busy: the scope dialog is now the thing in flight.
|
||||
pending = prepared
|
||||
_scopePrompt.value = MoveScopePrompt(occurrenceOnly = !prepared.canRealign)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Answer the scope dialog. */
|
||||
fun moveWithScope(scope: RecurringWriteScope) {
|
||||
val prepared = pending ?: return
|
||||
// Belt and braces against the dialog ever offering a scope the rule
|
||||
// can't carry: writing it would leave anchor and rule disagreeing.
|
||||
if (!prepared.canRealign && scope != RecurringWriteScope.ThisEvent) return
|
||||
pending = null
|
||||
_scopePrompt.value = null
|
||||
viewModelScope.launch {
|
||||
write(prepared, scope)
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the scope dialog without writing. */
|
||||
fun cancelScope() {
|
||||
pending = null
|
||||
busy = false
|
||||
_scopePrompt.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a completed move back where it came from. The outcome deliberately
|
||||
* stands until the inverse write reports back: clearing it first would drop
|
||||
* the confirmation chip and open a second one a moment later, rather than
|
||||
* letting the one chip change what it says.
|
||||
*/
|
||||
fun undo(undo: MoveUndo) {
|
||||
if (busy) return
|
||||
busy = true
|
||||
_undoStarted.value += 1
|
||||
viewModelScope.launch {
|
||||
_outcome.value = try {
|
||||
repository.updateEvent(undo.eventId, undo.moved, undo.restored)
|
||||
MoveOutcome.Undone
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
MoveOutcome.WriteDenied
|
||||
} catch (e: NoSuchEventException) {
|
||||
MoveOutcome.Gone
|
||||
} catch (e: Exception) {
|
||||
MoveOutcome.Failed
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the outcome once the screen has shown it. */
|
||||
fun consumeOutcome() {
|
||||
_outcome.value = null
|
||||
}
|
||||
|
||||
private suspend fun prepare(request: MoveRequest): PreparedMove? {
|
||||
val detail = try {
|
||||
repository.eventDetail(request.eventId)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: NoSuchEventException) {
|
||||
_outcome.value = MoveOutcome.Gone
|
||||
return null
|
||||
} catch (e: Exception) {
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
return null
|
||||
}
|
||||
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
val original = detail.toEditForm(request.beginMillis, request.endMillis, zone)
|
||||
val shifted = when (val target = request.target) {
|
||||
is MoveTarget.Start -> original.shiftedTo(target.instant, zone)
|
||||
is MoveTarget.ByDays -> original.shiftedByDays(target.days, zone)
|
||||
}
|
||||
// A zero-distance drop is not a write. Matches the edit form's own
|
||||
// pristine-form no-op, and keeps a mis-aimed long press harmless.
|
||||
if (shifted == original) return null
|
||||
|
||||
// The UNTIL check is deferred to the write: how far the move reaches
|
||||
// decides which date has to clear it, and "only this event" writes an
|
||||
// exception row that no UNTIL constrains at all.
|
||||
if ((shifted.problems() - EventFormProblem.RecurrenceEndsBeforeStart).isNotEmpty()) {
|
||||
_outcome.value = MoveOutcome.Failed
|
||||
return null
|
||||
}
|
||||
|
||||
// An exception row stands alone whatever rule a sync adapter left on it.
|
||||
val isRecurring = original.rrule != null && !detail.isException
|
||||
val movedDay = shifted.start.date != original.start.date
|
||||
// The series anchor moves by the same *wall-clock* shift as the dragged
|
||||
// occurrence, so only a whole-day shift moves it by a predictable number
|
||||
// of days — a drag that also changes the time of day would carry some
|
||||
// anchor times of day across an extra midnight and leave the rule naming
|
||||
// the wrong weekday. All-day forms shift as bare dates, so they always
|
||||
// qualify.
|
||||
val wholeDayShift = original.isAllDay || shifted.start.time == original.start.time
|
||||
val realigned = if (isRecurring && movedDay) {
|
||||
if (wholeDayShift) {
|
||||
realignRecurrence(
|
||||
requireNotNull(original.rrule),
|
||||
original.start.date,
|
||||
shifted.start.date,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
original.rrule
|
||||
}
|
||||
return PreparedMove(
|
||||
request = request,
|
||||
original = original,
|
||||
// A rule we can't re-derive stays verbatim: the only scope offered
|
||||
// then is the single occurrence, whose exception row carries no rule.
|
||||
updated = shifted.copy(rrule = realigned ?: original.rrule),
|
||||
isRecurring = isRecurring,
|
||||
canRealign = !isRecurring || !movedDay || realigned != null,
|
||||
// Where the series row's own DTSTART lands, given the anchor moves by
|
||||
// the same shift. Only meaningful under [wholeDayShift], which is the
|
||||
// only case a wider-than-one-occurrence write is offered in.
|
||||
newAnchorDate = anchorDate(detail, original, zone)
|
||||
.plus(shifted.start.date.toEpochDays() - original.start.date.toEpochDays(), DateTimeUnit.DAY),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun write(prepared: PreparedMove, scope: RecurringWriteScope) {
|
||||
val request = prepared.request
|
||||
if (endsBeforeItStarts(prepared, scope)) {
|
||||
_outcome.value = MoveOutcome.BlockedSeriesEnd
|
||||
return
|
||||
}
|
||||
_outcome.value = try {
|
||||
when {
|
||||
!prepared.isRecurring || scope == RecurringWriteScope.AllEvents ->
|
||||
repository.updateEvent(request.eventId, prepared.original, prepared.updated)
|
||||
|
||||
scope == RecurringWriteScope.ThisEvent ->
|
||||
repository.updateOccurrence(
|
||||
request.eventId,
|
||||
request.beginMillis,
|
||||
prepared.updated,
|
||||
)
|
||||
|
||||
else -> repository.updateEventFromOccurrence(
|
||||
eventId = request.eventId,
|
||||
beginMillis = request.beginMillis,
|
||||
original = prepared.original,
|
||||
updated = prepared.updated,
|
||||
)
|
||||
}
|
||||
MoveOutcome.Moved(
|
||||
startMillis = movedStartMillis(prepared),
|
||||
isAllDay = prepared.updated.isAllDay,
|
||||
undo = undoFor(prepared, scope),
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: SecurityException) {
|
||||
MoveOutcome.WriteDenied
|
||||
} catch (e: NoSuchEventException) {
|
||||
MoveOutcome.Gone
|
||||
} catch (e: Exception) {
|
||||
MoveOutcome.Failed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this write would leave a rule whose `UNTIL` precedes the first day
|
||||
* it now applies to — the provider then generates nothing and the event
|
||||
* silently disappears from every view.
|
||||
*
|
||||
* Which date has to clear `UNTIL` depends on how far the write reaches: a
|
||||
* whole-series move carries the series *anchor*, a split starts a new series
|
||||
* at the moved occurrence, and a single occurrence becomes an exception row
|
||||
* that no `UNTIL` constrains. Testing the occurrence in every case would
|
||||
* refuse the perfectly ordinary drag of a bounded series' last occurrence.
|
||||
*/
|
||||
private fun endsBeforeItStarts(prepared: PreparedMove, scope: RecurringWriteScope): Boolean {
|
||||
if (!prepared.isRecurring || scope == RecurringWriteScope.ThisEvent) return false
|
||||
val rule = prepared.updated.rrule ?: return false
|
||||
val end = parseSimpleRecurrence(rule)?.end as? RecurrenceEnd.Until ?: return false
|
||||
val firstDay = if (scope == RecurringWriteScope.AllEvents) {
|
||||
prepared.newAnchorDate
|
||||
} else {
|
||||
prepared.updated.start.date
|
||||
}
|
||||
return end.date < firstDay
|
||||
}
|
||||
|
||||
/**
|
||||
* The series row's own start date — for a recurring master, `EventDetail`
|
||||
* carries the row's DTSTART rather than the tapped occurrence's. Read in the
|
||||
* same anchoring the write path uses: UTC for an all-day series.
|
||||
*/
|
||||
private fun anchorDate(detail: EventDetail, original: EventForm, zone: TimeZone): LocalDate =
|
||||
detail.instance.start
|
||||
.toLocalDateTime(if (original.isAllDay) TimeZone.UTC else original.resolvedZone(zone))
|
||||
.date
|
||||
|
||||
/**
|
||||
* Undo is offered only where the inverse is one symmetric write: a
|
||||
* non-recurring event (absolute DTSTART/DTEND) and a whole-series move (the
|
||||
* −Δ wall-clock shift lands on the re-read anchor). "This event" leaves an
|
||||
* exception row behind and "this and following" splits the series with an
|
||||
* UNTIL truncation — neither is undone by shifting back.
|
||||
*/
|
||||
private fun undoFor(prepared: PreparedMove, scope: RecurringWriteScope): MoveUndo? =
|
||||
if (!prepared.isRecurring || scope == RecurringWriteScope.AllEvents) {
|
||||
MoveUndo(
|
||||
eventId = prepared.request.eventId,
|
||||
moved = prepared.updated,
|
||||
restored = prepared.original,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the dragged occurrence now begins, in the same anchoring the views
|
||||
* read dates back in: a UTC midnight for an all-day event, the real instant
|
||||
* for a timed one.
|
||||
*/
|
||||
private fun movedStartMillis(prepared: PreparedMove): Long {
|
||||
val form = prepared.updated
|
||||
return if (form.isAllDay) {
|
||||
form.start.date.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds()
|
||||
} else {
|
||||
form.start.toInstant(form.resolvedZone(TimeZone.currentSystemDefault()))
|
||||
.toEpochMilliseconds()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,7 @@ val LocalUse24HourFormat = staticCompositionLocalOf { true }
|
||||
private const val PATTERN_24 = "HH:mm"
|
||||
private const val PATTERN_12 = "h:mm a"
|
||||
private const val HOUR_PATTERN_12 = "h a"
|
||||
private const val HOUR_MINUTE_PATTERN_12 = "h:mm"
|
||||
|
||||
/** A time-of-day [DateTimeFormatter] for the resolved convention and [locale]. */
|
||||
fun timeOfDayFormatter(is24Hour: Boolean, locale: Locale): DateTimeFormatter =
|
||||
@@ -42,6 +43,18 @@ fun formatMinuteOfDay(minutes: Int, is24Hour: Boolean, locale: Locale): String =
|
||||
else -> formatTimeOfDay(minutes / 60, minutes % 60, is24Hour, locale)
|
||||
}
|
||||
|
||||
/**
|
||||
* The time shown in the timeline gutter while a block is dragged: 24h →
|
||||
* "09:15", 12h → "9:15". The meridiem is dropped on purpose — the hour labels
|
||||
* around it already carry it, and the gutter is too narrow to hold it.
|
||||
*/
|
||||
fun formatGutterTime(minutes: Int, is24Hour: Boolean, locale: Locale): String {
|
||||
val clamped = minutes.coerceIn(0, MINUTES_PER_DAY - 1)
|
||||
val pattern = if (is24Hour) PATTERN_24 else HOUR_MINUTE_PATTERN_12
|
||||
return LocalTime.of(clamped / 60, clamped % 60)
|
||||
.format(DateTimeFormatter.ofPattern(pattern, locale))
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact hour-only label for a timeline gutter: 24h → "13" (zero-padded,
|
||||
* the prior look); 12h → "1 PM".
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.week.MINUTES_PER_DAY
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** Start granularity a dragged block snaps to, matching the big calendar apps. */
|
||||
const val DRAG_SNAP_MINUTES: Int = 15
|
||||
|
||||
/** How close to a timeline edge the finger must get before the view scrolls. */
|
||||
private val AUTO_SCROLL_EDGE = 64.dp
|
||||
|
||||
/** Fastest auto-scroll step, per frame, right at the edge. */
|
||||
private val AUTO_SCROLL_STEP = 16.dp
|
||||
|
||||
/** A block being dragged, in root coordinates so it can be drawn in an overlay. */
|
||||
data class TimelineDrag(
|
||||
val event: EventInstance,
|
||||
val date: LocalDate,
|
||||
val startMin: Int,
|
||||
val endMin: Int,
|
||||
val topLeftInRoot: Offset,
|
||||
val sizePx: IntSize,
|
||||
) {
|
||||
/** What the snap haptics key off: one tick per changed slot, not per frame. */
|
||||
val slot: Pair<LocalDate, Int> get() = date to startMin
|
||||
}
|
||||
|
||||
/** Where a finished drag asks its event to go. */
|
||||
data class TimelineDrop(val event: EventInstance, val date: LocalDate, val startMin: Int)
|
||||
|
||||
/**
|
||||
* The timeline's live geometry, republished on every layout. Held in plain
|
||||
* fields rather than snapshot state on purpose: it changes on every scroll
|
||||
* frame, and recomposing the screen that often would cost far more than the
|
||||
* drag loop's own per-frame read of it.
|
||||
*/
|
||||
class TimelineGeometry {
|
||||
/** The day-columns row — scrolling *content*, so its root position folds in the scroll. */
|
||||
var grid: LayoutCoordinates? = null
|
||||
|
||||
/** The scroll viewport, for the edge that triggers auto-scrolling. */
|
||||
var viewport: LayoutCoordinates? = null
|
||||
var scroll: ScrollState? = null
|
||||
var hourPx: Float = 0f
|
||||
|
||||
/** Column pitch — one column plus the gap after it. */
|
||||
var columnWidthPx: Float = 0f
|
||||
var columnGapPx: Float = 0f
|
||||
var edgePx: Float = 0f
|
||||
var stepPx: Float = 0f
|
||||
var days: List<LocalDate> = emptyList()
|
||||
|
||||
/**
|
||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||
* never mirrored, but the grid is, so the leftmost column is the *last* day
|
||||
* in Arabic — the mapping has to flip with it.
|
||||
*/
|
||||
var isRtl: Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoisted drag state for one timeline (#68). It lives above the per-page
|
||||
* `AnimatedContent` — a page change mid-drag would otherwise strand a ghost —
|
||||
* and the block it renders is drawn in an overlay: a `Card` column ends its
|
||||
* modifier chain with a clip, so a block offset toward the neighbouring column
|
||||
* would simply be cut off in place.
|
||||
*/
|
||||
@Stable
|
||||
class TimelineDragController {
|
||||
val geometry = TimelineGeometry()
|
||||
|
||||
var drag: TimelineDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* A dropped block, held at the slot it landed on while the write runs. The
|
||||
* grid behind it still shows the old time until the provider notifies and
|
||||
* the query re-reads, so releasing the copy at drop time would snap the
|
||||
* event back to where it came from for the length of the write.
|
||||
*/
|
||||
var settling: TimelineDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Which block is lifted, and whether anything is. Separate snapshot state
|
||||
* from [drag] on purpose: [drag] changes on every frame, and the blocks that
|
||||
* only need to know "am I the ghost" must not recompose that often. Stays
|
||||
* set through [settling], so the source never reappears under the copy.
|
||||
*/
|
||||
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Where the settled drop came from, and which event row it belongs to. The
|
||||
* instance id alone can't identify the source block for the length of the
|
||||
* write: the provider regenerates `Instances` rows, so a re-read that still
|
||||
* carries the *old* time can arrive under a new instance id — and a source
|
||||
* matched by instance id would stop being the ghost and flash back to full
|
||||
* opacity in the slot the event is about to leave.
|
||||
*/
|
||||
private var settledOrigin: Triple<Long, LocalDate, Int>? by mutableStateOf(null)
|
||||
|
||||
/** Whether a finger is on a block right now — [settling] is not dragging. */
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether the grid itself now draws the settled drop at its landing slot.
|
||||
* The copy may only be handed back once this is true: releasing on a timer
|
||||
* puts the source ghost back at full opacity in its *old* slot for whatever
|
||||
* is left of the re-read — a flicker of the event where it no longer is.
|
||||
*/
|
||||
var settledOnGrid: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private var source: TimedBlock? = null
|
||||
private var grab = Offset.Zero
|
||||
private var pointer = Offset.Zero
|
||||
|
||||
/**
|
||||
* The slot the block already occupied when it was picked up. A long press
|
||||
* that never moves must write nothing — and because the target snaps to the
|
||||
* grid, "nothing moved" is not the same as "the start is unchanged": an
|
||||
* event at 09:07 resolves to 09:00 the instant it lifts.
|
||||
*/
|
||||
private var originSlot: Pair<LocalDate, Int>? = null
|
||||
|
||||
fun begin(block: TimedBlock, pointerInRoot: Offset, blockInRoot: Offset) {
|
||||
source = block
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = block.event.instanceId
|
||||
grab = pointerInRoot - blockInRoot
|
||||
pointer = pointerInRoot
|
||||
originSlot = null
|
||||
recompute()
|
||||
originSlot = drag?.slot
|
||||
}
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
recompute()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
source = null
|
||||
isDragging = false
|
||||
liftedInstanceId = null
|
||||
originSlot = null
|
||||
drag = null
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
settledOrigin = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [block] is the one whose copy is in flight, and so must stay a
|
||||
* ghost. While the finger holds it that is the instance it picked up; once
|
||||
* dropped it is also whatever now sits in the slot it left, however the
|
||||
* provider has renumbered it in the meantime.
|
||||
*/
|
||||
fun ghosts(block: TimedBlock, date: LocalDate): Boolean {
|
||||
if (liftedInstanceId == null) return false
|
||||
if (block.event.instanceId == liftedInstanceId) return true
|
||||
val (eventId, originDate, originMin) = settledOrigin ?: return false
|
||||
return block.event.eventId == eventId &&
|
||||
date == originDate &&
|
||||
block.startMin == originMin
|
||||
}
|
||||
|
||||
/**
|
||||
* What a day column now holds, so a settled drop can tell when the grid has
|
||||
* caught up with it. Matched on the landing slot plus either the event row
|
||||
* or its title: a single-occurrence move writes an exception row with a new
|
||||
* `eventId`, which nothing else about the drop can predict.
|
||||
*/
|
||||
fun noteGrid(date: LocalDate, blocks: List<TimedBlock>) {
|
||||
val landed = settling ?: return
|
||||
if (settledOnGrid || landed.date != date) return
|
||||
settledOnGrid = blocks.any { block ->
|
||||
block.startMin == landed.startMin &&
|
||||
(
|
||||
block.event.eventId == landed.event.eventId ||
|
||||
block.event.title == landed.event.title
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* End the drag, handing back where it landed — null when it never resolved,
|
||||
* or when it landed back on the slot it started from. A real drop keeps its
|
||||
* copy on the target as [settling] until [release].
|
||||
*/
|
||||
fun finish(): TimelineDrop? {
|
||||
val landed = drag
|
||||
val origin = originSlot
|
||||
cancel()
|
||||
if (landed == null || landed.slot == origin) return null
|
||||
settling = landed
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledOrigin = origin?.let { (date, min) -> Triple(landed.event.eventId, date, min) }
|
||||
return TimelineDrop(landed.event, landed.date, landed.startMin)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ghosting the source — the grid draws the drop itself by now — while
|
||||
* the copy is still on screen dissolving. Un-ghosting only at [release]
|
||||
* would leave the block under the fading copy dim, and brighten it once the
|
||||
* copy was gone: a dip the eye reads as the event flickering.
|
||||
*/
|
||||
fun handOver() {
|
||||
liftedInstanceId = null
|
||||
settledOrigin = null
|
||||
}
|
||||
|
||||
/** Drop the copy, once it has faded into the grid's own block. */
|
||||
fun release() {
|
||||
settling = null
|
||||
settledOnGrid = false
|
||||
handOver()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the target from the pointer's *root* position. Recomputed rather
|
||||
* than accumulated from `positionChange()`, because a stationary finger emits
|
||||
* no move events while auto-scroll walks the content underneath it.
|
||||
*/
|
||||
fun recompute() {
|
||||
val block = source ?: return
|
||||
val grid = geometry.grid?.takeIf { it.isAttached } ?: return
|
||||
val hourPx = geometry.hourPx
|
||||
val columnPx = geometry.columnWidthPx
|
||||
val days = geometry.days
|
||||
if (hourPx <= 0f || columnPx <= 0f || days.isEmpty()) return
|
||||
|
||||
val origin = grid.positionInRoot()
|
||||
val rawMinutes = (pointer.y - grab.y - origin.y) / hourPx * 60f
|
||||
val snapped = (rawMinutes / DRAG_SNAP_MINUTES).roundToInt() * DRAG_SNAP_MINUTES
|
||||
// Clamp the start into the target day; a tail running past midnight is
|
||||
// fine and stays visible on the next day.
|
||||
val startMin = snapped.coerceIn(0, MINUTES_PER_DAY - DRAG_SNAP_MINUTES)
|
||||
val span = block.endMin - block.startMin
|
||||
// The column the finger is over on screen, and the day that column shows.
|
||||
val column = ((pointer.x - origin.x) / columnPx).toInt().coerceIn(0, days.lastIndex)
|
||||
val dayIndex = if (geometry.isRtl) days.lastIndex - column else column
|
||||
val height = maxOf(span / 60f * hourPx, MIN_EVENT_FRACTION * hourPx)
|
||||
drag = TimelineDrag(
|
||||
event = block.event,
|
||||
date = days[dayIndex],
|
||||
startMin = startMin,
|
||||
endMin = startMin + span,
|
||||
topLeftInRoot = Offset(
|
||||
x = origin.x + column * columnPx,
|
||||
y = origin.y + startMin / 60f * hourPx,
|
||||
),
|
||||
sizePx = IntSize(
|
||||
(columnPx - geometry.columnGapPx).roundToInt(),
|
||||
height.roundToInt(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold the scroll for the whole drag and nudge it once per frame while the
|
||||
* finger sits near an edge. One `scroll` call, not a loop of
|
||||
* `animateScrollBy`: each of those re-acquires the mutex and cancels the last.
|
||||
*/
|
||||
suspend fun autoScroll() {
|
||||
val scroll = geometry.scroll ?: return
|
||||
scroll.scroll(MutatePriority.UserInput) {
|
||||
while (true) {
|
||||
withFrameNanos { }
|
||||
recompute()
|
||||
val step = edgeStep()
|
||||
if (step != 0f) scrollBy(step)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun edgeStep(): Float {
|
||||
val viewport = geometry.viewport?.takeIf { it.isAttached } ?: return 0f
|
||||
val edge = geometry.edgePx
|
||||
if (edge <= 0f) return 0f
|
||||
val bounds = viewport.boundsInRoot()
|
||||
val fromTop = pointer.y - bounds.top
|
||||
val fromBottom = bounds.bottom - pointer.y
|
||||
return when {
|
||||
fromTop < edge -> -(edge - fromTop) / edge * geometry.stepPx
|
||||
fromBottom < edge -> (edge - fromBottom) / edge * geometry.stepPx
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberTimelineDragController(): TimelineDragController {
|
||||
val controller = remember { TimelineDragController() }
|
||||
val density = LocalDensity.current
|
||||
controller.geometry.edgePx = with(density) { AUTO_SCROLL_EDGE.toPx() }
|
||||
controller.geometry.stepPx = with(density) { AUTO_SCROLL_STEP.toPx() }
|
||||
return controller
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [block] actually begins on [day] rather than being the tail of an
|
||||
* event that started earlier. A clipped block's top edge is midnight, not the
|
||||
* event's start, so dragging it would move the event to a time it never had.
|
||||
*/
|
||||
fun TimedBlock.beginsOn(day: LocalDate, zone: TimeZone): Boolean =
|
||||
event.start.toLocalDateTime(zone).date == day
|
||||
|
||||
/**
|
||||
* The instant a drop asks for, read in the zone the timeline is drawn in. A drop
|
||||
* into a spring-forward gap has no instant of its own; `toInstant` resolves it
|
||||
* forward by the missing hour, and the block visibly settles there.
|
||||
*/
|
||||
fun TimelineDrop.startInstant(zone: TimeZone): Instant =
|
||||
LocalDateTime(date, LocalTime(startMin / 60, startMin % 60)).toInstant(zone)
|
||||
|
||||
/**
|
||||
* A beat after the grid draws the drop, so its own block is under way before the
|
||||
* copy starts dissolving.
|
||||
*/
|
||||
const val SETTLE_GRACE_MILLIS: Long = 60L
|
||||
|
||||
/**
|
||||
* How long to wait for a grid that never confirms the drop — the event landed on
|
||||
* a day this timeline doesn't show, or the write failed and nothing changed. The
|
||||
* copy has to go either way.
|
||||
*/
|
||||
private const val SETTLE_TIMEOUT_MILLIS = 900L
|
||||
|
||||
/**
|
||||
* The hand-over: the copy dissolves over this while the grid's own block slides
|
||||
* in under it, so the two overlap rather than one replacing the other. Kept
|
||||
* short, because a full-width copy sits over any neighbour it now shares a lane
|
||||
* with until it is gone.
|
||||
*/
|
||||
const val SETTLE_FADE_MILLIS: Int = 250
|
||||
|
||||
/**
|
||||
* The floating block, drawn over the whole calendar so it is free of the day
|
||||
* column's clip and of the scroll viewport's rounded corners.
|
||||
*/
|
||||
@Composable
|
||||
fun TimelineDragOverlay(controller: TimelineDragController, modifier: Modifier = Modifier) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val soften = LocalSoftenColors.current
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val density = LocalDensity.current
|
||||
val moveInFlight = moveInFlight()
|
||||
|
||||
// Both live here rather than beside the controller: they read [drag], which
|
||||
// changes every frame, and this composable is the one that is meant to.
|
||||
DragSnapHaptics(controller.drag?.slot)
|
||||
LaunchedEffect(controller.isDragging) {
|
||||
if (controller.isDragging) controller.autoScroll()
|
||||
}
|
||||
// Hold the landed copy until the write is done — including the whole time a
|
||||
// recurring drop's scope dialog is up — and then until the grid draws the
|
||||
// drop itself, so the copy dissolves onto a block that is already there.
|
||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||
LaunchedEffect(controller.settling, moveInFlight, controller.settledOnGrid) {
|
||||
if (controller.settling == null || moveInFlight) return@LaunchedEffect
|
||||
delay(if (controller.settledOnGrid) SETTLE_GRACE_MILLIS else SETTLE_TIMEOUT_MILLIS)
|
||||
handingOver = true
|
||||
controller.handOver()
|
||||
delay(SETTLE_FADE_MILLIS.toLong())
|
||||
controller.release()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
// A copy of a block that is still in the tree behind it; announcing
|
||||
// it again would just duplicate the event for the drag's duration.
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write
|
||||
// runs, so the release reads as the block settling rather than vanishing.
|
||||
val landed = controller.drag == null
|
||||
val lift by animateFloatAsState(
|
||||
targetValue = if (landed || reduceMotion) 0f else 1f,
|
||||
label = "drag-lift",
|
||||
)
|
||||
val copyAlpha by animateFloatAsState(
|
||||
targetValue = if (handingOver) 0f else 1f,
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "drag-handover",
|
||||
)
|
||||
val fill = eventFill(drag.event.color, dark, soften)
|
||||
val title = drag.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val label = "${formatMinuteOfDay(drag.startMin, use24Hour, locale)}–" +
|
||||
formatMinuteOfDay(drag.endMin.coerceAtMost(MINUTES_PER_DAY), use24Hour, locale)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
IntOffset(
|
||||
(drag.topLeftInRoot.x - origin.x).roundToInt(),
|
||||
(drag.topLeftInRoot.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.size(
|
||||
width = with(density) { drag.sizePx.width.toDp() },
|
||||
height = with(density) { drag.sizePx.height.toDp() },
|
||||
)
|
||||
.padding(horizontal = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.02f * lift
|
||||
scaleY = 1f + 0.02f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
alpha = copyAlpha
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
clip = false
|
||||
}
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
|
||||
/**
|
||||
* How tall one hour is drawn in the week and day timelines (#56).
|
||||
*
|
||||
* One shared setting for both views: they are the same grid at different widths,
|
||||
* and a scale that only applied to one of them would read as a bug.
|
||||
*
|
||||
* [FitDay] is the answer to the actual complaint behind the issue — on a tall
|
||||
* phone the default scale shows about half a day, so a whole week can hide
|
||||
* appointments below the fold. It derives the hour height from the timeline's
|
||||
* own viewport instead of a fixed value.
|
||||
*
|
||||
* [Custom] is what a pinch on the timeline leaves behind. The presets are the
|
||||
* settings-screen vocabulary; a pinch is a direct manipulation and should be
|
||||
* able to land anywhere between them, so it gets its own case rather than
|
||||
* snapping to the nearest named step.
|
||||
*/
|
||||
sealed interface TimelineScale {
|
||||
|
||||
/** Whole day in one screen: the hour height follows the viewport. */
|
||||
data object FitDay : TimelineScale
|
||||
|
||||
/** Denser than the default, still a fixed height. */
|
||||
data object Compact : TimelineScale
|
||||
|
||||
/** The historical 56dp scale. */
|
||||
data object Regular : TimelineScale
|
||||
|
||||
/** Roomier blocks, more scrolling. */
|
||||
data object Comfortable : TimelineScale
|
||||
|
||||
/** A height the user pinched to. Build it through [custom], which clamps. */
|
||||
data class Custom(val hourHeight: Dp) : TimelineScale
|
||||
|
||||
companion object {
|
||||
/** The named steps the settings picker offers, coarse to roomy. */
|
||||
val presets: List<TimelineScale> = listOf(FitDay, Compact, Regular, Comfortable)
|
||||
|
||||
/**
|
||||
* A pinched hour height. The floor a pinch actually stops at depends on
|
||||
* the viewport and is applied when the height is resolved (see
|
||||
* [hourHeight]); this only holds a stored value to something sane.
|
||||
*/
|
||||
fun custom(hourHeight: Dp): Custom =
|
||||
Custom(hourHeight.coerceIn(MIN_STORED_HOUR_HEIGHT, MAX_PINCH_HOUR_HEIGHT))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hour height for this scale. [viewportHeight] is the visible height of the
|
||||
* scrolling timeline and is only consulted by [TimelineScale.FitDay].
|
||||
*
|
||||
* The fit-day result is clamped: below [FIT_DAY_MIN] the 24 gutter labels stop
|
||||
* being legible, and above [FIT_DAY_MAX] a short landscape day would stretch its
|
||||
* blocks absurdly. On a screen too short for the whole day the clamp wins and
|
||||
* the timeline still scrolls a little — honest, rather than unreadable.
|
||||
*
|
||||
* A pinched height is held to [fillHourHeight] here and not only in the gesture,
|
||||
* because the viewport can change under a height that was already stored: pinch
|
||||
* all the way out in landscape and the same value would leave portrait with dead
|
||||
* space under midnight.
|
||||
*/
|
||||
fun TimelineScale.hourHeight(viewportHeight: Dp): Dp = when (this) {
|
||||
TimelineScale.FitDay -> (viewportHeight / 24f).coerceIn(FIT_DAY_MIN, FIT_DAY_MAX)
|
||||
TimelineScale.Compact -> 40.dp
|
||||
TimelineScale.Regular -> 56.dp
|
||||
TimelineScale.Comfortable -> 80.dp
|
||||
is TimelineScale.Custom -> this.hourHeight
|
||||
.coerceAtMost(MAX_PINCH_HOUR_HEIGHT)
|
||||
.coerceAtLeast(fillHourHeight(viewportHeight))
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortest an event block may render, as a fraction of an hour. Blocks keep a
|
||||
* floor so a 15-minute event stays tappable, but the floor scales with the hour
|
||||
* height — a fixed 24dp would swallow half an hour once zoomed out and make
|
||||
* short events overlap their neighbours.
|
||||
*/
|
||||
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. */
|
||||
val FIT_DAY_MIN = 24.dp
|
||||
|
||||
/** Largest hour height [TimelineScale.FitDay] will resolve to. */
|
||||
val FIT_DAY_MAX = 96.dp
|
||||
|
||||
/**
|
||||
* The hour height at which all 24 hours exactly fill [viewportHeight] — how far
|
||||
* out a pinch can zoom.
|
||||
*
|
||||
* Below it there is no more day left to uncover, so the only thing shrinking
|
||||
* further buys is empty space under midnight. Zooming out means "show me more of
|
||||
* the day", and once the whole day is on screen that request is answered.
|
||||
*/
|
||||
fun fillHourHeight(viewportHeight: Dp): Dp = viewportHeight / 24f
|
||||
|
||||
/**
|
||||
* Ceiling for a pinch, deliberately far above [TimelineScale.Comfortable]: a
|
||||
* pinch is a deliberate act, so someone zooming in on a single busy afternoon
|
||||
* should be allowed to go further than any preset offers.
|
||||
*/
|
||||
val MAX_PINCH_HOUR_HEIGHT = 240.dp
|
||||
|
||||
/**
|
||||
* Floor for a *stored* pinched height, which only has to stay sane enough to be
|
||||
* re-clamped against whatever viewport it is later shown in. The floor a pinch
|
||||
* stops at is [fillHourHeight].
|
||||
*/
|
||||
private val MIN_STORED_HOUR_HEIGHT = 1.dp
|
||||
|
||||
@get:StringRes
|
||||
val TimelineScale.labelRes: Int
|
||||
get() = when (this) {
|
||||
TimelineScale.FitDay -> R.string.timeline_scale_fit_day
|
||||
TimelineScale.Compact -> R.string.timeline_scale_compact
|
||||
TimelineScale.Regular -> R.string.timeline_scale_regular
|
||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable
|
||||
is TimelineScale.Custom -> R.string.timeline_scale_custom
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
val TimelineScale.descriptionRes: Int
|
||||
get() = when (this) {
|
||||
TimelineScale.FitDay -> R.string.timeline_scale_fit_day_summary
|
||||
TimelineScale.Compact -> R.string.timeline_scale_compact_summary
|
||||
TimelineScale.Regular -> R.string.timeline_scale_regular_summary
|
||||
TimelineScale.Comfortable -> R.string.timeline_scale_comfortable_summary
|
||||
is TimelineScale.Custom -> R.string.timeline_scale_custom_summary
|
||||
}
|
||||
|
||||
/** Marks a stored custom height; the rest of the value is its dp. */
|
||||
private const val CUSTOM_PREFIX = "custom:"
|
||||
|
||||
/**
|
||||
* Stored names for the presets. Spelled out rather than taken from `toString()`
|
||||
* so R8 can't rename them out from under an existing install — and they match
|
||||
* the enum names this used to be, so a value written before the pinch existed
|
||||
* still reads back as the same preset.
|
||||
*/
|
||||
private val PRESET_NAMES: Map<TimelineScale, String> = mapOf(
|
||||
TimelineScale.FitDay to "FitDay",
|
||||
TimelineScale.Compact to "Compact",
|
||||
TimelineScale.Regular to "Regular",
|
||||
TimelineScale.Comfortable to "Comfortable",
|
||||
)
|
||||
|
||||
/**
|
||||
* Serialise for the `timeline_scale` preference; see [parseTimelineScale].
|
||||
*
|
||||
* Spelled out rather than an `else` into [PRESET_NAMES], so a preset added later
|
||||
* without a stored name is a compile error here instead of a crash the first
|
||||
* time someone picks it.
|
||||
*/
|
||||
fun TimelineScale.storageValue(): String = when (this) {
|
||||
is TimelineScale.Custom -> CUSTOM_PREFIX + hourHeight.value
|
||||
TimelineScale.FitDay,
|
||||
TimelineScale.Compact,
|
||||
TimelineScale.Regular,
|
||||
TimelineScale.Comfortable,
|
||||
-> PRESET_NAMES.getValue(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stored scale. Anything unrecognised — a null key, a name from a
|
||||
* future release, a truncated custom height — falls back to
|
||||
* [TimelineScale.Regular] rather than throwing, like the other enum prefs.
|
||||
*/
|
||||
fun parseTimelineScale(stored: String?): TimelineScale = when {
|
||||
stored == null -> TimelineScale.Regular
|
||||
stored.startsWith(CUSTOM_PREFIX) -> stored.removePrefix(CUSTOM_PREFIX).toFloatOrNull()
|
||||
?.let { TimelineScale.custom(it.dp) }
|
||||
?: TimelineScale.Regular
|
||||
else -> PRESET_NAMES.entries.firstOrNull { it.value == stored }?.key ?: TimelineScale.Regular
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.calculateCentroid
|
||||
import androidx.compose.foundation.gestures.calculateZoom
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The scale the timelines draw at right now: the stored preference, or whatever
|
||||
* a pinch is currently doing to it (#56).
|
||||
*
|
||||
* A pinch changes the scale on every pointer frame, which the preference alone
|
||||
* cannot carry — a DataStore round trip per frame would lag the gesture badly
|
||||
* behind the fingers. So the live value lives here in composition state and only
|
||||
* the settled result is written back, once, when the fingers lift.
|
||||
*/
|
||||
@Stable
|
||||
class TimelineZoom(
|
||||
initial: TimelineScale,
|
||||
private val persist: (TimelineScale) -> Unit,
|
||||
) {
|
||||
/** What the week and day timelines should draw at. */
|
||||
var scale: TimelineScale by mutableStateOf(initial)
|
||||
private set
|
||||
|
||||
private var pinching = false
|
||||
|
||||
/**
|
||||
* Take a value that came from the preference. Ignored mid-pinch: the stored
|
||||
* value is a frame or two behind the fingers there, and letting it land would
|
||||
* snap the timeline back while the user is still pinching.
|
||||
*/
|
||||
fun adopt(stored: TimelineScale) {
|
||||
if (!pinching) scale = stored
|
||||
}
|
||||
|
||||
fun beginPinch() {
|
||||
pinching = true
|
||||
}
|
||||
|
||||
fun pinchTo(hourHeight: Dp) {
|
||||
scale = TimelineScale.custom(hourHeight)
|
||||
}
|
||||
|
||||
fun endPinch() {
|
||||
pinching = false
|
||||
persist(scale)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The zoom the timelines read. Falls back to a detached instance — one shared
|
||||
* lazy value, not one per read, or each read would hand out a fresh state — so a
|
||||
* preview or test can render a timeline without the activity's provider.
|
||||
*/
|
||||
private val DetachedTimelineZoom by lazy { TimelineZoom(TimelineScale.Regular) {} }
|
||||
|
||||
val LocalTimelineZoom = staticCompositionLocalOf { DetachedTimelineZoom }
|
||||
|
||||
/**
|
||||
* The app-wide [TimelineZoom], seeded from [stored] and writing settled pinches
|
||||
* back through [onPersist].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTimelineZoom(
|
||||
stored: TimelineScale,
|
||||
onPersist: (TimelineScale) -> Unit,
|
||||
): TimelineZoom {
|
||||
val persist by rememberUpdatedState(onPersist)
|
||||
val zoom = remember { TimelineZoom(stored) { persist(it) } }
|
||||
// Picking a preset in Settings has to reach the timelines, and so does the
|
||||
// stored value arriving after the first frame.
|
||||
LaunchedEffect(stored) { zoom.adopt(stored) }
|
||||
return zoom
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-finger pinch that rescales a 24-hour timeline (#56) — the gesture the
|
||||
* issue actually asked for; the Settings presets stay as the accessible route to
|
||||
* the same thing.
|
||||
*
|
||||
* Three gestures share this area, so the pinch is deliberately the fussiest
|
||||
* about claiming it: nothing happens until a *second* finger is down and the
|
||||
* spread has grown past [PINCH_SLOP], which leaves one-finger vertical scrolling
|
||||
* and the horizontal week swipe untouched, and lets a two-finger drag still
|
||||
* scroll. It watches on [PointerEventPass.Initial] because the scroll it has to
|
||||
* outrank is a descendant — on the main pass the scroll would have consumed the
|
||||
* drag before this ever saw it.
|
||||
*
|
||||
* [hourHeight] is the *resolved* height, so a pinch that starts from
|
||||
* `FitDay` picks up where the viewport left it rather than jumping.
|
||||
* [viewportHeight] sets how far out it can go — see [fillHourHeight].
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTimelinePinchZoom(
|
||||
scrollState: ScrollState,
|
||||
viewportHeight: Dp,
|
||||
hourHeight: Dp,
|
||||
zoom: TimelineZoom,
|
||||
): Modifier {
|
||||
// The gesture loop outlives any single composition, so it reads these
|
||||
// through state handles rather than capturing what they were when it started.
|
||||
val currentHourHeight = rememberUpdatedState(hourHeight)
|
||||
val currentViewport = rememberUpdatedState(viewportHeight)
|
||||
return Modifier.pointerInput(scrollState, zoom) {
|
||||
awaitEachGesture {
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
var claimed = false
|
||||
var slop = 1f
|
||||
// Scroll the layout could not give us yet, carried to the next frame
|
||||
// (see anchoredScroll).
|
||||
var pending = 0f
|
||||
try {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
if (event.changes.none { it.pressed }) break
|
||||
if (event.changes.count { it.pressed } >= 2) {
|
||||
val step = event.calculateZoom()
|
||||
if (!claimed) {
|
||||
slop *= step
|
||||
if (abs(slop - 1f) >= PINCH_SLOP) {
|
||||
claimed = true
|
||||
zoom.beginPinch()
|
||||
}
|
||||
}
|
||||
if (claimed) {
|
||||
val old = currentHourHeight.value.toPx()
|
||||
val new = pinchedHourHeightPx(
|
||||
target = old * step,
|
||||
fillPx = fillHourHeight(currentViewport.value).toPx(),
|
||||
maxPx = MAX_PINCH_HOUR_HEIGHT.toPx(),
|
||||
)
|
||||
// Half a pixel, not equality: the height round-trips
|
||||
// through dp and back, and an exact test would read
|
||||
// the float noise that comes back as a scale change
|
||||
// and feed the scroll a delta on every frame of a
|
||||
// held pinch.
|
||||
if (abs(new - old) >= 0.5f) {
|
||||
val centroidY = event.calculateCentroid(useCurrent = true).y
|
||||
pending += anchoredScroll(scrollState.value, centroidY, old, new) -
|
||||
scrollState.value
|
||||
zoom.pinchTo(new.toDp())
|
||||
}
|
||||
pending -= scrollState.dispatchRawDelta(pending)
|
||||
}
|
||||
}
|
||||
// Hold the gesture to the end once it has become a pinch:
|
||||
// letting go the moment a finger lifts would turn the tail of
|
||||
// a zoom into a scroll, and the taps underneath into a new event.
|
||||
if (claimed) event.changes.forEach { if (it.pressed) it.consume() }
|
||||
}
|
||||
} finally {
|
||||
// In a finally because the gesture can also end by having its
|
||||
// pointer node disposed mid-pinch — the timeline swapping out
|
||||
// under the fingers. The zoom outlives that node, and a pinch
|
||||
// left open would make it ignore every later Settings change.
|
||||
if (claimed) zoom.endPinch()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a pinch aiming at [target] px per hour actually lands: clamped between
|
||||
* "the whole day fills the screen" ([fillPx]) and [maxPx], then rounded to a
|
||||
* whole pixel.
|
||||
*
|
||||
* The rounding is not cosmetic. The hour gutter is 24 stacked boxes one hour
|
||||
* tall, and each rounds its own height to whole pixels, while the hour lines and
|
||||
* event blocks are drawn at the fractional height — so a height of 56.4px lays
|
||||
* the labels out at 56px and leaves the 23:00 label ~9px above the line it
|
||||
* names, jumping the whole column as a pinch drifts across each half pixel.
|
||||
* Pinning the hour to whole pixels keeps every part of the timeline on one grid.
|
||||
*
|
||||
* The bounds themselves are pulled onto that grid too, each in the direction
|
||||
* that keeps its own promise — up for the fill floor, so no dead space opens
|
||||
* under midnight, down for the ceiling. A fractional bound would be a height the
|
||||
* pinch can be held against but never actually land on, and the difference feeds
|
||||
* the focal anchor a scroll correction on every frame the fingers sit still.
|
||||
*/
|
||||
internal fun pinchedHourHeightPx(target: Float, fillPx: Float, maxPx: Float): Float =
|
||||
// Filling the viewport wins over the ceiling: on a screen tall enough for
|
||||
// the two to disagree, dead space is the worse of the two failures.
|
||||
target.roundToInt().toFloat()
|
||||
.coerceAtMost(floor(maxPx))
|
||||
.coerceAtLeast(ceil(fillPx))
|
||||
|
||||
/**
|
||||
* The scroll offset that keeps the moment under [centroidY] under it after the
|
||||
* hour height changes from [oldHourPx] to [newHourPx].
|
||||
*
|
||||
* Anchoring on the fingers is what makes a zoom feel like the day is being
|
||||
* stretched rather than replaced: without it, zooming in on the evening walks
|
||||
* the evening off the bottom of the screen.
|
||||
*
|
||||
* The caller may not get all of this at once — the content only grows on the
|
||||
* next layout pass, so scrolling further down than the *current* content allows
|
||||
* is refused — which is why the shortfall is carried over and re-offered.
|
||||
*/
|
||||
internal fun anchoredScroll(
|
||||
scroll: Int,
|
||||
centroidY: Float,
|
||||
oldHourPx: Float,
|
||||
newHourPx: Float,
|
||||
): Float = ((scroll + centroidY) / oldHourPx) * newHourPx - centroidY
|
||||
|
||||
/**
|
||||
* How far the fingers must spread or close before the pinch takes over. Small
|
||||
* enough to feel immediate, wide enough that the two-finger scroll a user meant
|
||||
* as a scroll stays one.
|
||||
*/
|
||||
private const val PINCH_SLOP = 0.08f
|
||||
@@ -42,6 +42,7 @@ import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -53,14 +54,19 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -68,12 +74,27 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.NowLine
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.ViewSwitcherPill
|
||||
import de.jeanlucmakiola.calendula.ui.common.calendarSlideTransition
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarFadeSpec
|
||||
@@ -87,8 +108,14 @@ import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||
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.rememberTimelinePinchZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.week.TimedBlock
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -100,13 +127,6 @@ import kotlin.time.Clock
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val HOUR_HEIGHT = 56.dp
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
/** Start inset for the gutter's hour labels so they centre on the top bar's
|
||||
* hamburger: with a 48dp gutter, 8dp lands the centre at 28dp (the app bar's
|
||||
* 4dp inset + 24dp half icon button), matching the week view. */
|
||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
private val MIN_EVENT_HEIGHT = 24.dp
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
|
||||
@@ -195,6 +215,7 @@ fun DayScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = date,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
@@ -221,6 +242,7 @@ fun DayScreen(
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
onJumpToDate = jumpToDate,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
@@ -264,7 +286,6 @@ private fun DayContent(
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -275,11 +296,9 @@ private fun DayContent(
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
val maxV = scrollState.maxValue
|
||||
val target = with(density) {
|
||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
||||
}.coerceIn(0, maxV)
|
||||
scrollState.scrollTo(target)
|
||||
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||
// centring the range centres midday at whatever hour height is in force.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
@@ -295,31 +314,51 @@ private fun DayContent(
|
||||
// drag is consumed by the inner scroll first — the two gestures coexist.
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = modifier.then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
// Above the AnimatedContent: a page change mid-drag would strand the
|
||||
// floating block inside the outgoing page.
|
||||
val dragController = rememberTimelineDragController()
|
||||
val move = LocalEventMove.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is DayUiState.Success -> "success-${s.date}"
|
||||
is DayUiState.Failure -> "failure-${s.reason}"
|
||||
DayUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "day-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
is DayUiState.Success -> "success-${s.date}"
|
||||
is DayUiState.Failure -> "failure-${s.reason}"
|
||||
DayUiState.Loading -> "loading"
|
||||
DayUiState.Loading -> DayLoading()
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = { drop ->
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "day-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
DayUiState.Loading -> DayLoading()
|
||||
is DayUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is DayUiState.Success -> DaySuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
)
|
||||
}
|
||||
TimelineDragOverlay(dragController)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,8 +368,10 @@ private fun DaySuccess(
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// All-day strip collapses to nothing when the day has no all-day events,
|
||||
@@ -349,8 +390,10 @@ private fun DaySuccess(
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -364,6 +407,7 @@ private fun DayTopBar(
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
@@ -371,9 +415,10 @@ private fun DayTopBar(
|
||||
val locale = currentLocale()
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatDayTitle(date, locale, currentYear),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
CalendarTitleButton(
|
||||
title = formatDayTitle(date, locale, currentYear),
|
||||
currentDate = date,
|
||||
onJumpToDate = onJumpToDate,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -482,66 +527,73 @@ private fun AllDayBar(
|
||||
private fun Timeline(
|
||||
state: DayUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val density = LocalDensity.current
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 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
|
||||
// bar, date header and all-day strip.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||
val totalHeight = hourHeight * 24
|
||||
// The pinch sits on the Row, above both scroll viewports: it has to
|
||||
// outrank the vertical scroll, and it does that by watching the initial
|
||||
// pass, which only reaches it if it is their ancestor.
|
||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||
// Gutter and day column are two scroll viewports that SHARE one scroll
|
||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||
// soft corners are permanent at any scroll position.
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
// Hour gutter (scrolls in sync with the day column). Start inset so the
|
||||
// labels centre on the top bar hamburger, matching the week view.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(HOUR_HEIGHT),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
)
|
||||
// Day column: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState),
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||
) {
|
||||
DayColumnCard(
|
||||
blocks = state.timed,
|
||||
dark = dark,
|
||||
date = state.date,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight),
|
||||
.height(totalHeight)
|
||||
// The scrolling content itself, so its root position
|
||||
// already folds in the scroll offset.
|
||||
.onGloballyPositioned { coords ->
|
||||
dragController.geometry.let {
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.columnGapPx = 0f
|
||||
it.columnWidthPx = coords.size.width.toFloat()
|
||||
it.days = listOf(state.date)
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -554,13 +606,20 @@ private fun DayColumnCard(
|
||||
dark: Boolean,
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
hourHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
// Tells a settled drop when this column has caught up with it.
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
}
|
||||
Card(
|
||||
// Plain rectangular column — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -587,25 +646,44 @@ private fun DayColumnCard(
|
||||
},
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
// Keyed by event, so a block that changes time or lane is the *same*
|
||||
// composable afterwards and tweens there. The ordinal disambiguates
|
||||
// the rare column holding two occurrences of one series, which would
|
||||
// otherwise be two blocks under one key.
|
||||
val ordinals = mutableMapOf<Long, Int>()
|
||||
blocks.forEach { block ->
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
.height(height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
val place = animatedBlockPlacement(
|
||||
x = laneWidth * block.lane,
|
||||
y = top,
|
||||
width = laneWidth,
|
||||
height = height,
|
||||
)
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = place.height,
|
||||
date = date,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = place.x, y = place.y)
|
||||
.width(place.width)
|
||||
.height(place.height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Current-time line, on top of the events, only on today's column.
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||
NowLine(date = date, hourHeight = hourHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -615,7 +693,11 @@ private fun DayColumnCard(
|
||||
private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
date: LocalDate,
|
||||
dragController: TimelineDragController,
|
||||
onClick: () -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
@@ -623,31 +705,67 @@ private fun EventBlock(
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
val showTime = block.endMin - block.startMin >= 45
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
}
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// What's left for text once the 2.dp top/bottom padding is paid for. A block
|
||||
// that cannot afford both lines spends its space on the title, and one too
|
||||
// short even for that drops the title rather than serving a sliced one.
|
||||
val available = height - 4.dp
|
||||
val showTime = block.endMin - block.startMin >= 45 &&
|
||||
available >= titleLineHeight + timeLineHeight
|
||||
val showTitle = available >= titleLineHeight
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(block.event.color, dark, soften)
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
val moveAction = eventMoveAction(block.event)
|
||||
// A block clipped at the top continues from the previous day: its top edge is
|
||||
// midnight, not the event's start, so dragging it would invent a time.
|
||||
val draggable = moveAction != null && block.beginsOn(date, zone)
|
||||
val dragModifier = rememberEventDragSource(
|
||||
enabled = draggable,
|
||||
key = block.event.instanceId,
|
||||
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
||||
onMove = dragController::move,
|
||||
onDrop = { dragController.finish()?.let(onDrop) },
|
||||
onCancel = dragController::cancel,
|
||||
)
|
||||
val lifted = draggable && dragController.ghosts(block, date)
|
||||
val ghost = ghostAlpha(lifted)
|
||||
Box(
|
||||
modifier = modifier
|
||||
// The source stays put as a ghost while its floating copy travels,
|
||||
// then fades out as the copy settles on its new slot.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = if (showTime) 1 else 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.6f),
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -656,17 +774,22 @@ private fun EventBlock(
|
||||
|
||||
@Composable
|
||||
private fun DayLoading() {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val scale = LocalTimelineZoom.current.scale
|
||||
val scrollState = rememberScrollState()
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
// Same scale resolution as the loaded timeline, so the skeleton's column
|
||||
// doesn't resize the moment the real day arrives.
|
||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.OptionCard
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
@@ -386,7 +387,7 @@ private fun EventDetailContent(state: EventDetailUiState.Success, modifier: Modi
|
||||
val instance = detail.instance
|
||||
val dark = isSystemInDarkTheme()
|
||||
val locale = currentDetailLocale()
|
||||
val accent = eventFill(instance.color, dark, LocalSoftenColors.current)
|
||||
val accent = eventAccent(instance.color, dark, LocalSoftenColors.current)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
|
||||
@@ -127,12 +127,14 @@ import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.RecurringScopeDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarPickerGroups
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDatePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.ColorSwatchRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimeZonePickerDialog
|
||||
import de.jeanlucmakiola.calendula.ui.common.icuTimeZoneRegion
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.floret.components.DialogAmountField
|
||||
import de.jeanlucmakiola.floret.components.DialogUnitDropdown
|
||||
@@ -451,30 +453,11 @@ private fun SaveScopeDialog(
|
||||
onSelect: (RecurringWriteScope) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.event_edit_recurring_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (!recurrenceChanged) {
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_occurrence),
|
||||
onClick = { onSelect(RecurringWriteScope.ThisEvent) },
|
||||
)
|
||||
}
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_following),
|
||||
onClick = { onSelect(RecurringWriteScope.ThisAndFollowing) },
|
||||
)
|
||||
OptionCard(
|
||||
label = stringResource(R.string.event_delete_option_series),
|
||||
onClick = { onSelect(RecurringWriteScope.AllEvents) },
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.dialog_cancel)) }
|
||||
},
|
||||
RecurringScopeDialog(
|
||||
title = stringResource(R.string.event_edit_recurring_title),
|
||||
onSelect = onSelect,
|
||||
onDismiss = onDismiss,
|
||||
allowOccurrence = !recurrenceChanged,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -563,7 +546,7 @@ private fun EventEditContent(
|
||||
// The accent ties the form to the detail screen's design language: the
|
||||
// bar under the title takes the target calendar's colour.
|
||||
val soften = LocalSoftenColors.current
|
||||
val accent = selectedCalendar?.let { eventFill(it.color, dark, soften) }
|
||||
val accent = selectedCalendar?.let { eventAccent(it.color, dark, soften) }
|
||||
?: MaterialTheme.colorScheme.primary
|
||||
val gap = 12.dp
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.firstDayOfWeek
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultEventDuration
|
||||
import de.jeanlucmakiola.calendula.data.prefs.resolveDefaultReminder
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
@@ -51,28 +52,38 @@ import kotlinx.datetime.toInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Instant
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Where a prefilled [EventEditViewModel.openImported] form came from — the two
|
||||
* sources want different reminder handling (#49).
|
||||
* Where a prefilled [EventEditViewModel.openImported] form came from. The sources
|
||||
* want different reminder handling (#49), and differ in whether they own the
|
||||
* event's length ([ownsDuration], #54) — one that does is never restretched to the
|
||||
* default-duration setting.
|
||||
*/
|
||||
enum class ImportSource {
|
||||
enum class ImportSource(internal val ownsDuration: Boolean) {
|
||||
/**
|
||||
* An external `ACTION_INSERT` intent (another app/widget, e.g. Google Maps).
|
||||
* It carries no reminders of its own, so the settings default is applied
|
||||
* automatically, exactly like an in-app new event.
|
||||
* An external `ACTION_INSERT` intent that named both ends (another app/widget,
|
||||
* e.g. Google Maps), or an in-app duplicate. It carries no reminders of its
|
||||
* own, so the settings default is applied automatically, exactly like an
|
||||
* in-app new event; its span, though, is the caller's.
|
||||
*/
|
||||
Insert,
|
||||
Insert(ownsDuration = true),
|
||||
|
||||
/**
|
||||
* An `ACTION_INSERT` that named only a start. Reminders behave as for
|
||||
* [Insert], but the length is ours to pick, so the default-duration setting
|
||||
* applies just like an in-app new event.
|
||||
*/
|
||||
InsertOpenEnded(ownsDuration = false),
|
||||
|
||||
/**
|
||||
* A parsed single-event `.ics` file. Its own reminders are respected; the
|
||||
* settings default is offered through [EventEditViewModel.importReminderPrompt]
|
||||
* rather than silently applied or suppressed.
|
||||
* rather than silently applied or suppressed. Its span is the file's.
|
||||
*/
|
||||
File,
|
||||
File(ownsDuration = true),
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,6 +124,9 @@ class EventEditViewModel @Inject constructor(
|
||||
// freezes the auto-applied default: switching calendars no longer overwrites
|
||||
// their choice. Reset with the form.
|
||||
private val _remindersTouched = MutableStateFlow(false)
|
||||
// Same freeze for the default duration (#54): once the user has set an end
|
||||
// time by hand, switching calendars no longer stretches the event.
|
||||
private val _durationTouched = MutableStateFlow(false)
|
||||
// A one-time offer, raised when a .ics import opens, to replace the file's
|
||||
// reminders with the settings default (#49). Null while there's nothing to ask.
|
||||
private val _importReminderPrompt = MutableStateFlow<ImportReminderPrompt?>(null)
|
||||
@@ -282,8 +296,10 @@ class EventEditViewModel @Inject constructor(
|
||||
* Initialise a fresh form for a new event on [date]. [startMinutes] (minutes
|
||||
* from midnight) anchors the start when the form is opened by tapping a slot
|
||||
* in the day/week grid; without it the default is the next full hour (today)
|
||||
* or 09:00 (any other day). No-op when a form is already open, so user input
|
||||
* survives configuration changes; [reset] clears it when the screen closes.
|
||||
* or 09:00 (any other day). The event opens an hour long and is stretched to
|
||||
* the configured default by [applyDefaultDuration], like the reminders.
|
||||
* No-op when a form is already open, so user input survives configuration
|
||||
* changes; [reset] clears it when the screen closes.
|
||||
*/
|
||||
fun openNew(date: LocalDate, startMinutes: Int? = null) {
|
||||
if (_form.value != null) return
|
||||
@@ -300,9 +316,11 @@ class EventEditViewModel @Inject constructor(
|
||||
}
|
||||
else -> LocalDateTime(date, LocalTime(9, 0))
|
||||
}
|
||||
val end = (start.toInstant(zone) + 1.hours).toLocalDateTime(zone)
|
||||
val end = (start.toInstant(zone) + SettingsPrefs.DEFAULT_EVENT_DURATION.minutes)
|
||||
.toLocalDateTime(zone)
|
||||
_form.value = EventForm(calendarId = null, start = start, end = end)
|
||||
applyDefaultReminder()
|
||||
applyDefaultDuration()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -321,6 +339,12 @@ class EventEditViewModel @Inject constructor(
|
||||
* settings default is configured and differs, [importReminderPrompt] offers
|
||||
* to swap it in rather than silently deciding for the user.
|
||||
*
|
||||
* The length is frozen the same way: a source that named the event's end
|
||||
* ([ImportSource.ownsDuration]) keeps it, so the default-duration setting
|
||||
* never silently rewrites a span someone else chose; only an
|
||||
* [ImportSource.InsertOpenEnded] intent — a start with no end — gets the
|
||||
* default applied.
|
||||
*
|
||||
* No-op when a form is already open, so the prefill survives configuration
|
||||
* changes.
|
||||
*/
|
||||
@@ -328,8 +352,9 @@ class EventEditViewModel @Inject constructor(
|
||||
if (_form.value != null || _editTarget.value != null) return
|
||||
_revealed.value = form.populatedFields()
|
||||
_form.value = form
|
||||
if (source.ownsDuration) _durationTouched.value = true else applyDefaultDuration()
|
||||
when (source) {
|
||||
ImportSource.Insert ->
|
||||
ImportSource.Insert, ImportSource.InsertOpenEnded ->
|
||||
if (form.reminders.isNotEmpty()) _remindersTouched.value = true
|
||||
else applyDefaultReminder()
|
||||
|
||||
@@ -358,7 +383,9 @@ class EventEditViewModel @Inject constructor(
|
||||
val form = _form.value ?: return@launch
|
||||
if (_editTarget.value != null || _remindersTouched.value) return@launch
|
||||
val reminders = defaults.resolveFor(targetId, form.isAllDay)
|
||||
_form.value = form.copy(reminders = reminders)
|
||||
// Write through update(): the duration default resolves in parallel,
|
||||
// so both must compose onto the current form, not a pre-suspend copy.
|
||||
update { it.copy(reminders = reminders) }
|
||||
// Surface the section so an auto-applied default is visible and
|
||||
// removable, even when Reminders isn't a default-shown field.
|
||||
if (reminders.isNotEmpty()) {
|
||||
@@ -377,6 +404,39 @@ class EventEditViewModel @Inject constructor(
|
||||
ReminderDefaults(timed, allDay, timedOv, allDayOv)
|
||||
}.first()
|
||||
|
||||
/**
|
||||
* Stretch a new timed event to the configured default length (#54) — the
|
||||
* resolved calendar's per-calendar duration, otherwise the global default —
|
||||
* keeping its start put. No-op while editing an existing event, on an all-day
|
||||
* event (which is date-anchored), or once the user has set an end time by
|
||||
* hand. [calendarId] short-circuits the resolution after a calendar switch;
|
||||
* null resolves it as the form does.
|
||||
*/
|
||||
private fun applyDefaultDuration(calendarId: Long? = null) {
|
||||
if (_editTarget.value != null || _durationTouched.value) return
|
||||
viewModelScope.launch {
|
||||
val global = settingsPrefs.defaultEventDurationMinutes.first()
|
||||
val overrides = settingsPrefs.perCalendarEventDuration.first()
|
||||
// Only resolve the calendar when an override could actually apply —
|
||||
// that read waits on the provider query, and the stretch would then
|
||||
// land after the user can already see (and accept) the end time.
|
||||
val targetId = when {
|
||||
overrides.isEmpty() -> null
|
||||
else -> calendarId ?: resolvedCalendarId.first()
|
||||
}
|
||||
// Re-check after suspending: bail if the form closed or the user edited.
|
||||
if (_editTarget.value != null || _durationTouched.value) return@launch
|
||||
if (_form.value?.isAllDay != false) return@launch
|
||||
val duration = resolveDefaultEventDuration(global, overrides, targetId)
|
||||
val zone = TimeZone.currentSystemDefault()
|
||||
update { form ->
|
||||
form.copy(
|
||||
end = (form.start.toInstant(zone) + duration.minutes).toLocalDateTime(zone),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A `.ics` import respects the file's reminders, but an event opened from a
|
||||
* file often has none while the user still expects their configured default.
|
||||
@@ -455,6 +515,7 @@ class EventEditViewModel @Inject constructor(
|
||||
_editTarget.value = null
|
||||
_loadFailed.value = false
|
||||
_remindersTouched.value = false
|
||||
_durationTouched.value = false
|
||||
_importReminderPrompt.value = null
|
||||
}
|
||||
|
||||
@@ -479,6 +540,9 @@ class EventEditViewModel @Inject constructor(
|
||||
// The default reminder differs for all-day vs timed; re-apply the
|
||||
// type-appropriate default unless the user has hand-edited it (guarded).
|
||||
applyDefaultReminder()
|
||||
// Coming back out of all-day re-applies the default length (guarded);
|
||||
// going all-day is a no-op, since dates carry no duration.
|
||||
applyDefaultDuration()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -500,9 +564,10 @@ class EventEditViewModel @Inject constructor(
|
||||
*/
|
||||
fun setCalendar(id: Long) {
|
||||
update { it.copy(calendarId = id, colorKey = null, color = null) }
|
||||
// A fresh event re-inherits the new calendar's default reminder unless
|
||||
// the user has already hand-edited it (guarded inside).
|
||||
// A fresh event re-inherits the new calendar's default reminder and
|
||||
// length unless the user has already hand-edited them (guarded inside).
|
||||
applyDefaultReminder(id)
|
||||
applyDefaultDuration(id)
|
||||
}
|
||||
fun setAvailability(value: Availability) = update { it.copy(availability = value) }
|
||||
fun setAccessLevel(value: AccessLevel) = update { it.copy(accessLevel = value) }
|
||||
@@ -559,8 +624,18 @@ class EventEditViewModel @Inject constructor(
|
||||
/** Moving the start drags the end along, preserving the duration. */
|
||||
fun setStartDate(date: LocalDate) = moveStart { LocalDateTime(date, it.time) }
|
||||
fun setStartTime(time: LocalTime) = moveStart { LocalDateTime(it.date, time) }
|
||||
fun setEndDate(date: LocalDate) = update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||
fun setEndTime(time: LocalTime) = update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||
|
||||
// Setting an end by hand is the user picking a length: it freezes the
|
||||
// default duration, so a later calendar switch keeps their span (#54).
|
||||
fun setEndDate(date: LocalDate) {
|
||||
_durationTouched.value = true
|
||||
update { it.copy(end = LocalDateTime(date, it.end.time)) }
|
||||
}
|
||||
|
||||
fun setEndTime(time: LocalTime) {
|
||||
_durationTouched.value = true
|
||||
update { it.copy(end = LocalDateTime(it.end.date, time)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and write. Saving a dirty recurring event pauses in
|
||||
|
||||
@@ -22,6 +22,7 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.sourceAppName
|
||||
import de.jeanlucmakiola.floret.components.GroupedListInset
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
|
||||
@@ -73,7 +74,12 @@ private fun FilterList(
|
||||
},
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 28.dp, end = 28.dp, top = 12.dp, bottom = 4.dp),
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 12.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
group.calendars.forEachIndexed { index, cal ->
|
||||
GroupedRow(
|
||||
@@ -103,7 +109,7 @@ private fun FilterLoading(modifier: Modifier = Modifier) {
|
||||
repeat(4) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 28.dp)
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.fillMaxWidth()
|
||||
.height(36.dp)
|
||||
.background(
|
||||
@@ -129,6 +135,6 @@ private fun FilterMessage(reason: FailureReason, modifier: Modifier = Modifier)
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 28.dp, vertical = 24.dp),
|
||||
.padding(horizontal = GroupedListInset, vertical = 24.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
package de.jeanlucmakiola.calendula.ui.month
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlin.math.abs
|
||||
|
||||
/** One week row's live geometry, republished on every layout while it is on screen. */
|
||||
class MonthRowGeometry(
|
||||
val days: List<LocalDate>,
|
||||
/** The row's day-column box — the space chip offsets are measured in. */
|
||||
val cell: LayoutCoordinates,
|
||||
/** The lane band inside that box, where the chips themselves are seated. */
|
||||
val band: LayoutCoordinates?,
|
||||
val columnWidthPx: Float,
|
||||
val laneHeightPx: Float,
|
||||
val laneCount: Int,
|
||||
/**
|
||||
* Whether the columns are laid out right-to-left. Pointer coordinates are
|
||||
* never mirrored, but the grid is, so the leftmost column is the *last* day
|
||||
* in Arabic.
|
||||
*/
|
||||
val isRtl: Boolean,
|
||||
/** What this row currently draws in [lane] of column `col`, or null. */
|
||||
val chipAt: (col: Int, lane: Int) -> EventInstance?,
|
||||
) {
|
||||
/** Root top-left of the chip seated in [lane] of column [col]. */
|
||||
fun seat(col: Int, lane: Int): Offset? {
|
||||
val band = band?.takeIf { it.isAttached } ?: return null
|
||||
val origin = band.positionInRoot()
|
||||
val column = if (isRtl) days.lastIndex - col else col
|
||||
return Offset(origin.x + column * columnWidthPx, origin.y + lane * laneHeightPx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this row seats [event] on [date], or null when it doesn't hold it —
|
||||
* the day isn't in this week, or the chip went into the day's "+N" overflow.
|
||||
*/
|
||||
fun seatOf(event: EventInstance, date: LocalDate): Offset? {
|
||||
val col = days.indexOf(date).takeIf { it >= 0 } ?: return null
|
||||
val lane = (0 until laneCount).firstOrNull { lane ->
|
||||
chipAt(col, lane)?.let { isSameEvent(it, event) } == true
|
||||
} ?: return null
|
||||
return seat(col, lane)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [chip] is the moved event [moved] as the grid now holds it. Neither
|
||||
* id alone will do: the provider hands re-read instances new instance ids, and a
|
||||
* single-occurrence move writes an exception row with a new event id — the title
|
||||
* is what survives both.
|
||||
*/
|
||||
private fun isSameEvent(chip: EventInstance, moved: EventInstance): Boolean =
|
||||
chip.eventId == moved.eventId || chip.title == moved.title
|
||||
|
||||
/** A chip in flight, in root coordinates so it can be drawn in an overlay. */
|
||||
data class MonthChipDrag(
|
||||
val event: EventInstance,
|
||||
/** The day whose column the chip was grabbed in — the drag is a delta from it. */
|
||||
val grabDate: LocalDate,
|
||||
val targetDate: LocalDate?,
|
||||
val topLeftInRoot: Offset,
|
||||
val sizePx: IntSize,
|
||||
)
|
||||
|
||||
/** Where a finished chip drag asks its event to go, as a whole-day shift. */
|
||||
data class MonthChipDrop(
|
||||
val event: EventInstance,
|
||||
val grabDate: LocalDate,
|
||||
val targetDate: LocalDate,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hoisted drag state for the month grid (#68).
|
||||
*
|
||||
* Every visible week row registers itself, so a chip can be carried across rows:
|
||||
* the pointer node that took the press keeps receiving events after the finger
|
||||
* has left its own bounds, and the target is then resolved against whichever
|
||||
* registered row the finger is actually over. Rows are keyed by an identity token
|
||||
* rather than by their date, because the continuous style can show the same week
|
||||
* twice — once in each adjoining month.
|
||||
*/
|
||||
@Stable
|
||||
class MonthDragController {
|
||||
private val rows = LinkedHashMap<Any, MonthRowGeometry>()
|
||||
|
||||
var drag: MonthChipDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* A dropped chip, held on the day it landed on while the write runs. The grid
|
||||
* behind it still shows the old day until the provider notifies and the query
|
||||
* re-reads, so releasing at drop time would snap the event back for the
|
||||
* length of the write.
|
||||
*/
|
||||
var settling: MonthChipDrag? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Which chip is lifted, and whether anything is. Separate snapshot state from
|
||||
* [drag] on purpose: [drag] changes on every frame, while the chips and rows
|
||||
* that only need "am I the ghost" / "must I stop clipping" must not. Stays
|
||||
* set through [settling], so the source never reappears under the copy.
|
||||
*/
|
||||
var liftedInstanceId: Long? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Where the grid itself now draws the drop, in root coordinates — published
|
||||
* by whichever week row ends up holding it. The copy is dropped wherever the
|
||||
* finger was, but the grid seats it in a lane; without this the copy waits
|
||||
* out a flat hold beside the chip the grid has already drawn and then
|
||||
* cross-fades across the gap, which reads as the event taking its time to
|
||||
* arrive. With it the copy glides onto its seat and hands over there.
|
||||
*/
|
||||
var settledInRoot: Offset? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Whether the drop's own chip is still standing in for the copy. Cleared at
|
||||
* [handOver] rather than at [release], for the same reason [liftedInstanceId]
|
||||
* is: held to the end it would keep the chip dim under a copy that has
|
||||
* already faded, and brighten it afterwards — a dip the eye reads as the
|
||||
* event flickering.
|
||||
*/
|
||||
private var settledGhost: Boolean by mutableStateOf(false)
|
||||
|
||||
/** Whether a finger is on a chip right now — [settling] is not dragging. */
|
||||
var isDragging: Boolean by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* The last drop this controller made, kept past [release] for [beginUndo] —
|
||||
* the confirmation chip carries Undo for four seconds after the copy is long
|
||||
* gone. Not snapshot state: nothing draws from it.
|
||||
*/
|
||||
private var undoable: MonthChipDrag? = null
|
||||
|
||||
private var event: EventInstance? = null
|
||||
private var grabDate: LocalDate? = null
|
||||
private var grab = Offset.Zero
|
||||
private var pointer = Offset.Zero
|
||||
private var sizePx = IntSize.Zero
|
||||
|
||||
fun putRow(token: Any, geometry: MonthRowGeometry) {
|
||||
rows[token] = geometry
|
||||
}
|
||||
|
||||
fun removeRow(token: Any) {
|
||||
rows.remove(token)
|
||||
}
|
||||
|
||||
fun begin(
|
||||
event: EventInstance,
|
||||
grabDate: LocalDate,
|
||||
pointerInRoot: Offset,
|
||||
chipInRoot: Offset,
|
||||
size: IntSize,
|
||||
) {
|
||||
this.event = event
|
||||
this.grabDate = grabDate
|
||||
settling = null
|
||||
isDragging = true
|
||||
liftedInstanceId = event.instanceId
|
||||
grab = pointerInRoot - chipInRoot
|
||||
pointer = pointerInRoot
|
||||
sizePx = size
|
||||
recompute()
|
||||
}
|
||||
|
||||
fun move(pointerInRoot: Offset) {
|
||||
pointer = pointerInRoot
|
||||
recompute()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
event = null
|
||||
grabDate = null
|
||||
isDragging = false
|
||||
liftedInstanceId = null
|
||||
drag = null
|
||||
settling = null
|
||||
settledInRoot = null
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether [days] holds the chip the grid has drawn for the settled drop —
|
||||
* the seat the copy is on its way to, which must ghost until it gets there.
|
||||
* Matched on the landing day plus either the event row or its title: a
|
||||
* single-occurrence move writes an exception row with a new `eventId`, and
|
||||
* the provider hands re-read instances new ids, so neither alone identifies
|
||||
* the chip the drop became.
|
||||
*/
|
||||
fun isSettledChip(event: EventInstance, days: List<LocalDate>?): Boolean {
|
||||
val landed = settling?.takeIf { settledGhost } ?: return false
|
||||
if (days == null || landed.targetDate !in days) return false
|
||||
return isSameEvent(event, landed.event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the row keyed [token] whether it now seats the settled chip, and take
|
||||
* its answer. The continuous style can show the same week twice, once in each
|
||||
* adjoining month, so both copies of the landing row will answer: the seat
|
||||
* nearest where the chip was let go is the one the finger was over.
|
||||
*/
|
||||
fun noteSettled(token: Any) {
|
||||
val landed = settling ?: return
|
||||
val target = landed.targetDate ?: return
|
||||
val at = rows[token]?.seatOf(landed.event, target) ?: return
|
||||
val current = settledInRoot
|
||||
val closer = current == null ||
|
||||
abs(at.y - landed.topLeftInRoot.y) < abs(current.y - landed.topLeftInRoot.y)
|
||||
if (closer) settledInRoot = at
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a copy back for an undo, which moves the event exactly as the drop did
|
||||
* and so should read the same way rather than teleporting the chip. Nothing
|
||||
* here writes anything: it puts a chip on the journey the inverse write is
|
||||
* about to make, and the settle that follows is the drop's own.
|
||||
*
|
||||
* False when there is nothing to carry — no drop of this controller's to undo
|
||||
* (it happened in another view, or has already been undone), or the grid does
|
||||
* not seat the moved event where it would have to start from. The chip then
|
||||
* simply reappears on the day it came from, as it always did.
|
||||
*/
|
||||
fun beginUndo(): Boolean {
|
||||
val last = undoable ?: return false
|
||||
undoable = null
|
||||
val from = last.targetDate ?: return false
|
||||
val at = rows.values.firstNotNullOfOrNull { it.seatOf(last.event, from) } ?: return false
|
||||
settling = last.copy(grabDate = from, targetDate = last.grabDate, topLeftInRoot = at)
|
||||
liftedInstanceId = last.event.instanceId
|
||||
settledGhost = true
|
||||
settledInRoot = null
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* End the drag. A real drop keeps its chip on the target day as [settling]
|
||||
* until [release] — slid over to that day's column, so it lands where the
|
||||
* grid is about to draw it rather than wherever the finger happened to be.
|
||||
*/
|
||||
fun finish(): MonthChipDrop? {
|
||||
val landed = drag
|
||||
val left = landed?.targetDate?.let(::columnLeft)
|
||||
cancel()
|
||||
val target = landed?.targetDate ?: return null
|
||||
settling = landed.copy(
|
||||
topLeftInRoot = Offset(left ?: landed.topLeftInRoot.x, landed.topLeftInRoot.y),
|
||||
)
|
||||
liftedInstanceId = landed.event.instanceId
|
||||
settledGhost = true
|
||||
undoable = settling
|
||||
return MonthChipDrop(landed.event, landed.grabDate, target)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ghosting the source while the copy is still dissolving — un-ghosting
|
||||
* only at [release] leaves the chip under it dim and then brightens it, a
|
||||
* dip the eye reads as the event flickering.
|
||||
*/
|
||||
fun handOver() {
|
||||
liftedInstanceId = null
|
||||
settledGhost = false
|
||||
}
|
||||
|
||||
/** Drop the copy, once it has faded into the grid's own chip. */
|
||||
fun release() {
|
||||
settling = null
|
||||
settledInRoot = null
|
||||
handOver()
|
||||
}
|
||||
|
||||
/** Root x of [date]'s column, in whichever visible row shows that day. */
|
||||
private fun columnLeft(date: LocalDate): Float? = rows.values.firstNotNullOfOrNull { row ->
|
||||
val index = row.days.indexOf(date).takeIf { it >= 0 } ?: return@firstNotNullOfOrNull null
|
||||
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
||||
?: return@firstNotNullOfOrNull null
|
||||
val column = if (row.isRtl) row.days.lastIndex - index else index
|
||||
bounds.left + column * row.columnWidthPx
|
||||
}
|
||||
|
||||
private fun recompute() {
|
||||
val event = event ?: return
|
||||
val grabbed = grabDate ?: return
|
||||
val resolved = rows.values.firstNotNullOfOrNull { row ->
|
||||
val bounds = row.cell.takeIf { it.isAttached }?.boundsInRoot()
|
||||
?: return@firstNotNullOfOrNull null
|
||||
if (!bounds.contains(pointer) || row.columnWidthPx <= 0f) {
|
||||
null
|
||||
} else {
|
||||
val column = ((pointer.x - bounds.left) / row.columnWidthPx)
|
||||
.toInt()
|
||||
.coerceIn(0, row.days.lastIndex)
|
||||
row.days[if (row.isRtl) row.days.lastIndex - column else column]
|
||||
}
|
||||
}
|
||||
drag = MonthChipDrag(
|
||||
event = event,
|
||||
grabDate = grabbed,
|
||||
// Off the grid entirely (the gutter, the header): keep the last cell
|
||||
// it was over, so a wobble past the edge doesn't drop the target.
|
||||
targetDate = resolved ?: drag?.targetDate,
|
||||
topLeftInRoot = pointer - grab,
|
||||
sizePx = sizePx,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberMonthDragController(): MonthDragController = remember { MonthDragController() }
|
||||
|
||||
val LocalMonthDrag = compositionLocalOf<MonthDragController?> { null }
|
||||
@@ -4,7 +4,9 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
@@ -59,9 +61,11 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@@ -77,7 +81,32 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.foundation.layout.absoluteOffset
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import kotlin.math.roundToInt
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberDragSurface
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.moveInFlight
|
||||
import de.jeanlucmakiola.calendula.ui.common.OnUndoStarted
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.DragSnapHaptics
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_FADE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.SETTLE_GRACE_MILLIS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventMoveScope
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.LayoutCoordinates
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
@@ -101,6 +130,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaEmptyDayRow
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.AgendaEventRow
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
@@ -110,6 +140,7 @@ import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventInk
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCurrentMinute
|
||||
@@ -124,6 +155,7 @@ import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
import de.jeanlucmakiola.floret.time.isoWeekNumber
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -329,6 +361,7 @@ fun MonthScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
@@ -350,10 +383,12 @@ fun MonthScreen(
|
||||
topBar = {
|
||||
MonthTopBar(
|
||||
title = topBarTitle,
|
||||
titleDate = LocalDate(titleMonth.year, titleMonth.month, 1),
|
||||
selectedView = selectedView,
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
onJumpToDate = jumpToDate,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
@@ -379,13 +414,25 @@ fun MonthScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
// Hoisted above every style's grid: a dragged chip is drawn in an
|
||||
// overlay so it is free of the week row's clip and, in the scrolling
|
||||
// styles, of the list viewport's.
|
||||
val chipDrag = rememberMonthDragController()
|
||||
// Undo moves the event back, so the chip travels back too rather than
|
||||
// reappearing on its old day. The signal comes from the write, not
|
||||
// from the chip that offers it: that lives above every view.
|
||||
OnUndoStarted { chipDrag.beginUndo() }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
WeekdayHeader(weekStart = weekStart, showWeekNumbers = showWeekNumbers)
|
||||
CompositionLocalProvider(LocalDimCutoff provides dimCutoff) {
|
||||
CompositionLocalProvider(
|
||||
LocalDimCutoff provides dimCutoff,
|
||||
LocalMonthDrag provides chipDrag,
|
||||
) {
|
||||
if (scrolling) {
|
||||
ContinuousMonthContent(
|
||||
state = continuousState,
|
||||
@@ -422,10 +469,106 @@ fun MonthScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
MonthDragOverlay(chipDrag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long to wait for a grid that never seats the drop — it landed in a day's
|
||||
* "+N" overflow, or on a day this month doesn't show, or the write failed and
|
||||
* nothing changed. The copy has to go either way.
|
||||
*/
|
||||
private const val MONTH_SETTLE_TIMEOUT_MILLIS = 450L
|
||||
|
||||
/** The chip in flight, drawn over the grid and following the finger. */
|
||||
@Composable
|
||||
private fun MonthDragOverlay(controller: MonthDragController) {
|
||||
var origin by remember { mutableStateOf(Offset.Zero) }
|
||||
val dark = isSystemInDarkTheme()
|
||||
val density = LocalDensity.current
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
val moveInFlight = moveInFlight()
|
||||
// Here rather than beside the controller: this reads [drag], which changes
|
||||
// every frame, and this composable is the one that is meant to.
|
||||
DragSnapHaptics(controller.drag?.targetDate)
|
||||
// Hold the landed chip until the write is done — including the whole time a
|
||||
// recurring drop's scope dialog is up — and then, unlike the timeline, carry
|
||||
// it to its seat: a chip is dropped at whatever height the finger was at,
|
||||
// while the grid seats it in a lane, so the copy has a gap to close before
|
||||
// the two are the same chip and the hand-over can be invisible.
|
||||
var handingOver by remember(controller.settling) { mutableStateOf(false) }
|
||||
var gliding by remember(controller.settling) { mutableStateOf(false) }
|
||||
val glide = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
val glideSpec = MaterialTheme.motionScheme.fastSpatialSpec<Offset>()
|
||||
val seat = controller.settledInRoot
|
||||
LaunchedEffect(controller.settling, moveInFlight, seat) {
|
||||
val landed = controller.settling
|
||||
if (landed == null || moveInFlight) return@LaunchedEffect
|
||||
if (seat == null) {
|
||||
delay(MONTH_SETTLE_TIMEOUT_MILLIS)
|
||||
} else {
|
||||
if (!gliding) glide.snapTo(landed.topLeftInRoot)
|
||||
gliding = true
|
||||
if (reduceMotion) glide.snapTo(seat) else glide.animateTo(seat, glideSpec)
|
||||
delay(SETTLE_GRACE_MILLIS)
|
||||
}
|
||||
handingOver = true
|
||||
controller.handOver()
|
||||
delay(SETTLE_FADE_MILLIS.toLong())
|
||||
controller.release()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// A copy of a chip that is still in the tree behind it; announcing it
|
||||
// again would just duplicate the event for the drag's duration.
|
||||
.clearAndSetSemantics { }
|
||||
.onGloballyPositioned { origin = it.positionInRoot() },
|
||||
) {
|
||||
val drag = controller.drag ?: controller.settling ?: return@Box
|
||||
// Landed: the copy sinks back to the grid's own plane while the write
|
||||
// runs, so the release reads as the chip settling rather than vanishing.
|
||||
val lift by animateFloatAsState(
|
||||
targetValue = if (controller.drag == null || reduceMotion) 0f else 1f,
|
||||
label = "chip-lift",
|
||||
)
|
||||
val copyAlpha by animateFloatAsState(
|
||||
targetValue = if (handingOver) 0f else 1f,
|
||||
animationSpec = tween(SETTLE_FADE_MILLIS),
|
||||
label = "chip-handover",
|
||||
)
|
||||
MonthBar(
|
||||
event = drag.event,
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
modifier = Modifier
|
||||
// Absolute: these are root coordinates, and the direction-aware
|
||||
// offset would mirror them across the screen in an RTL layout.
|
||||
.absoluteOffset {
|
||||
val at = if (gliding) glide.value else drag.topLeftInRoot
|
||||
IntOffset(
|
||||
(at.x - origin.x).roundToInt(),
|
||||
(at.y - origin.y).roundToInt(),
|
||||
)
|
||||
}
|
||||
.width(with(density) { drag.sizePx.width.toDp() })
|
||||
.height(with(density) { drag.sizePx.height.toDp() })
|
||||
.padding(horizontal = CELL_GAP + 1.dp, vertical = 1.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = 1f + 0.04f * lift
|
||||
scaleY = 1f + 0.04f * lift
|
||||
shadowElevation = 8.dp.toPx() * lift
|
||||
alpha = copyAlpha
|
||||
shape = RoundedCornerShape(4.dp)
|
||||
clip = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonthContent(
|
||||
state: MonthUiState,
|
||||
@@ -509,19 +652,22 @@ private fun ContinuousMonthContent(
|
||||
@Composable
|
||||
private fun MonthTopBar(
|
||||
title: String,
|
||||
titleDate: LocalDate,
|
||||
selectedView: CalendarView,
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
CalendarTitleButton(
|
||||
title = title,
|
||||
currentDate = titleDate,
|
||||
onJumpToDate = onJumpToDate,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -1473,7 +1619,7 @@ private fun SplitDots(
|
||||
.morphBounds(MonthMorphKey.Event(date, event.instanceId))
|
||||
.size(SPLIT_DOT_SIZE)
|
||||
.alpha(if (dimCutoff != null && event.hasEnded(dimCutoff)) EventDimAlpha else 1f)
|
||||
.background(eventFill(event.color, dark, soften), CircleShape),
|
||||
.background(eventAccent(event.color, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
if (hidden.isNotEmpty()) {
|
||||
@@ -1712,6 +1858,53 @@ private fun MonthWeekRow(
|
||||
val shownLanes = laneCount.coerceAtMost(MAX_EVENT_ROWS)
|
||||
val morphing = morphInFlight()
|
||||
|
||||
// Drag to reschedule (#68). The chips can take no pointer input of their own —
|
||||
// the full-bleed tap layer below sits on top of them, and Compose stops
|
||||
// sibling hit-testing at the topmost hit — so one detector on the row's day-
|
||||
// column box hit-tests them geometrically instead.
|
||||
val moveScope = LocalEventMove.current
|
||||
val dragController = LocalMonthDrag.current
|
||||
val rowToken = remember { Any() }
|
||||
val bandCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||
val cellCoordinates = remember { arrayOfNulls<LayoutCoordinates>(1) }
|
||||
val density = LocalDensity.current
|
||||
val rowHeightPx = with(density) { EVENT_ROW_HEIGHT.toPx() }
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val dragging = dragController?.isDragging == true
|
||||
DisposableEffect(rowToken, dragController) {
|
||||
onDispose { dragController?.removeRow(rowToken) }
|
||||
}
|
||||
// Republished on layout *and* on every recomposition: the coordinates only
|
||||
// change on the former, but what the row draws — which the controller reads
|
||||
// to find a moved chip's seat — changes on the latter, and a re-read that
|
||||
// moves an event doesn't move the row it lands in.
|
||||
val publish = {
|
||||
val cell = cellCoordinates[0]?.takeIf { it.isAttached }
|
||||
if (dragController != null && cell != null) {
|
||||
dragController.putRow(
|
||||
rowToken,
|
||||
MonthRowGeometry(
|
||||
days = week.days,
|
||||
cell = cell,
|
||||
band = bandCoordinates[0],
|
||||
columnWidthPx = cell.size.width / 7f,
|
||||
laneHeightPx = rowHeightPx,
|
||||
laneCount = MAX_EVENT_ROWS,
|
||||
isRtl = isRtl,
|
||||
chipAt = { col, lane -> week.chipAt(col, lane, MAX_EVENT_ROWS) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
SideEffect { publish() }
|
||||
// Once the grid holds the settled chip, tell the controller where this row
|
||||
// has seated it, so the copy in flight can glide onto it. Keyed on the week
|
||||
// because that is what changes when the re-read lands: until then the row
|
||||
// still draws the event on the day it came from and answers nothing.
|
||||
LaunchedEffect(week, dragController?.settling) {
|
||||
dragController?.noteSettled(rowToken)
|
||||
}
|
||||
|
||||
Row(modifier) {
|
||||
// Optional calendar-week gutter, sized so the seven day columns below
|
||||
// divide the remaining width — the absolute bar offsets stay correct
|
||||
@@ -1727,7 +1920,21 @@ private fun MonthWeekRow(
|
||||
BoxWithConstraints(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
.fillMaxHeight()
|
||||
.onGloballyPositioned { coords ->
|
||||
cellCoordinates[0] = coords
|
||||
publish()
|
||||
}
|
||||
.then(
|
||||
monthChipDragModifier(
|
||||
week = week,
|
||||
moveScope = moveScope,
|
||||
controller = dragController,
|
||||
band = bandCoordinates,
|
||||
rowHeightPx = rowHeightPx,
|
||||
isRtl = isRtl,
|
||||
),
|
||||
),
|
||||
) {
|
||||
val colW = maxWidth / 7
|
||||
|
||||
@@ -1792,7 +1999,13 @@ private fun MonthWeekRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.then(if (morphing) Modifier else Modifier.clipToBounds()),
|
||||
.onGloballyPositioned {
|
||||
bandCoordinates[0] = it
|
||||
publish()
|
||||
}
|
||||
// A dragged chip travels to another row, so the clip has
|
||||
// to yield for it exactly as it does for a morph.
|
||||
.then(if (morphing || dragging) Modifier else Modifier.clipToBounds()),
|
||||
) {
|
||||
// Spanning bars on their shared lanes.
|
||||
week.spans.filter { it.lane < shownLanes }.forEach { span ->
|
||||
@@ -1802,6 +2015,7 @@ private fun MonthWeekRow(
|
||||
dark = dark,
|
||||
continuesLeft = span.continuesLeft,
|
||||
continuesRight = span.continuesRight,
|
||||
days = week.days.subList(span.startCol, span.endCol + 1),
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * span.startCol,
|
||||
@@ -1871,6 +2085,7 @@ private fun MonthWeekRow(
|
||||
dark = dark,
|
||||
continuesLeft = false,
|
||||
continuesRight = false,
|
||||
days = listOf(d),
|
||||
modifier = Modifier
|
||||
.offset(
|
||||
x = colW * col,
|
||||
@@ -1951,6 +2166,74 @@ private fun MonthWeekRow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row-level pickup for month chips: it resolves which chip the press landed
|
||||
* on from the geometry the row just laid out, and abandons the gesture when the
|
||||
* press was on empty space (or on an event whose calendar can't be moved), so
|
||||
* tapping a day still opens it.
|
||||
*/
|
||||
@Composable
|
||||
private fun monthChipDragModifier(
|
||||
week: MonthWeek,
|
||||
moveScope: EventMoveScope?,
|
||||
controller: MonthDragController?,
|
||||
band: Array<LayoutCoordinates?>,
|
||||
rowHeightPx: Float,
|
||||
isRtl: Boolean,
|
||||
): Modifier = rememberDragSurface(
|
||||
enabled = moveScope != null && controller != null,
|
||||
key = week.days.first(),
|
||||
onPickUp = { local, pointerInRoot, nodeInRoot, size ->
|
||||
val bandTop = band[0]?.takeIf { it.isAttached }?.positionInRoot()?.y
|
||||
val columnPx = size.width / 7f
|
||||
val bandY = if (bandTop == null) -1f else local.y - (bandTop - nodeInRoot.y)
|
||||
val lane = (bandY / rowHeightPx).toInt()
|
||||
// The column under the finger on screen, and the day that column shows.
|
||||
val column = (local.x / columnPx).toInt()
|
||||
val dayIndex = if (isRtl) week.days.lastIndex - column else column
|
||||
val event = if (bandY < 0f || columnPx <= 0f) {
|
||||
null
|
||||
} else {
|
||||
week.chipAt(dayIndex, lane, MAX_EVENT_ROWS)
|
||||
}
|
||||
if (event == null || moveScope?.allows(event) != true) {
|
||||
false
|
||||
} else {
|
||||
controller?.begin(
|
||||
event = event,
|
||||
grabDate = week.days[dayIndex],
|
||||
pointerInRoot = pointerInRoot,
|
||||
chipInRoot = Offset(
|
||||
x = nodeInRoot.x + column * columnPx,
|
||||
y = requireNotNull(bandTop) + lane * rowHeightPx,
|
||||
),
|
||||
size = IntSize(columnPx.toInt(), rowHeightPx.toInt()),
|
||||
)
|
||||
true
|
||||
}
|
||||
},
|
||||
onMove = { controller?.move(it) },
|
||||
onDrop = {
|
||||
controller?.finish()?.let { drop ->
|
||||
// How many columns the finger crossed — grabbing the middle of a
|
||||
// multi-day bar shifts the event by what the finger travelled, not
|
||||
// to where it landed.
|
||||
val delta = (drop.targetDate.toEpochDays() - drop.grabDate.toEpochDays()).toInt()
|
||||
if (delta != 0) {
|
||||
moveScope?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.ByDays(delta),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onCancel = { controller?.cancel() },
|
||||
)
|
||||
|
||||
/**
|
||||
* Left-gutter calendar-week cell (#25): a full-height tonal pill mirroring the
|
||||
* day cells' geometry, set apart by the secondaryContainer tint (matching the
|
||||
@@ -2043,12 +2326,27 @@ private fun MonthBar(
|
||||
continuesLeft: Boolean,
|
||||
continuesRight: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
/**
|
||||
* The days this chip covers in its row, for the drag (#68). A drop's own chip
|
||||
* has to ghost like the source it came from until the copy has landed on it,
|
||||
* and the instance id can't find it: the provider hands the re-read instance a
|
||||
* new one, so the chip the drop became would otherwise sit at full opacity
|
||||
* under the copy still travelling towards it — the event drawn twice.
|
||||
*/
|
||||
days: List<LocalDate>? = null,
|
||||
) {
|
||||
val title = event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
val dimmed = dimCutoff != null && event.hasEnded(dimCutoff)
|
||||
val soften = LocalSoftenColors.current
|
||||
val fill = eventFill(event.color, dark, soften)
|
||||
val moveAction = eventMoveAction(event)
|
||||
// The source stays put as a ghost while its floating copy travels, then
|
||||
// fades out as the copy settles on its new day.
|
||||
val monthDrag = LocalMonthDrag.current
|
||||
val lifted = monthDrag?.liftedInstanceId == event.instanceId ||
|
||||
monthDrag?.isSettledChip(event, days) == true
|
||||
val ghost = ghostAlpha(lifted)
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
bottomStart = if (continuesLeft) 0.dp else 4.dp,
|
||||
@@ -2057,9 +2355,13 @@ private fun MonthBar(
|
||||
)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.background(fill, shape)
|
||||
.padding(horizontal = 4.dp)
|
||||
.semantics { contentDescription = title },
|
||||
.semantics {
|
||||
contentDescription = title
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
@@ -2098,7 +2400,7 @@ private fun OverflowDots(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.alpha(if (allEnded(byColor.getValue(argb), dimCutoff)) EventDimAlpha else 1f)
|
||||
.background(eventFill(argb, dark, soften), CircleShape),
|
||||
.background(eventAccent(argb, dark, soften), CircleShape),
|
||||
)
|
||||
}
|
||||
val extra = total - dots.size
|
||||
|
||||
@@ -67,6 +67,26 @@ fun MonthWeek.laneEvents(col: Int, day: LocalDate, laneCap: Int): List<EventInst
|
||||
return byLane.filterNotNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The event drawn in lane [lane] of column [col], or null for an empty slot.
|
||||
*
|
||||
* Mirrors how the paged grid seats a week: spanning bars keep the lane the row
|
||||
* layout gave them, and the day's timed pills fill the slots those bars leave.
|
||||
* Unlike [laneEvents] the lanes are *not* compacted — this answers "what is under
|
||||
* this point", so an empty lane above a seated bar has to stay empty (#68).
|
||||
*/
|
||||
fun MonthWeek.chipAt(col: Int, lane: Int, laneCap: Int): EventInstance? {
|
||||
if (col !in days.indices || lane !in 0 until laneCap) return null
|
||||
spans.firstOrNull { it.lane == lane && col in it.startCol..it.endCol }?.let { return it.event }
|
||||
val occupied = spans
|
||||
.filter { it.lane < laneCap && col in it.startCol..it.endCol }
|
||||
.map { it.lane }
|
||||
.toSet()
|
||||
val free = (0 until laneCap).filter { it !in occupied }
|
||||
val index = free.indexOf(lane).takeIf { it >= 0 } ?: return null
|
||||
return timedByDay[days[col]].orEmpty().take(free.size).getOrNull(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events on [day] that [laneEvents] had no lane left for — its exact
|
||||
* complement, in the same bars-then-pills order. Returned as events rather than
|
||||
|
||||
@@ -56,6 +56,7 @@ import de.jeanlucmakiola.floret.components.InlineTextField
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
@@ -208,7 +209,7 @@ private fun SearchResultRow(
|
||||
modifier = Modifier
|
||||
.size(width = 6.dp, height = 36.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(eventFill(event.color, dark, soften)),
|
||||
.background(eventAccent(event.color, dark, soften)),
|
||||
)
|
||||
},
|
||||
onClick = onClick,
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
package de.jeanlucmakiola.calendula.ui.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.Schedule
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldIcon
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFormFieldLabel
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
|
||||
/** New event form: which fields it opens with, and how it behaves. */
|
||||
@Composable
|
||||
@@ -27,6 +42,13 @@ internal fun EventFormScreen(
|
||||
viewModel: SettingsViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var showDefaultDuration by remember { mutableStateOf(false) }
|
||||
// The calendar whose duration-override picker is open, if any.
|
||||
var durationTarget by remember { mutableStateOf<Long?>(null) }
|
||||
var durationSectionExpanded by remember { mutableStateOf(false) }
|
||||
// Special-dates calendars carry all-day events only, which have no length.
|
||||
val durationCalendars = state.writableCalendars.filterNot { it.id in state.managedCalendarIds }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_event_form),
|
||||
onBack = onBack,
|
||||
@@ -80,6 +102,64 @@ internal fun EventFormScreen(
|
||||
onClick = { viewModel.setAutofocusEventTitle(!state.autofocusEventTitle) },
|
||||
)
|
||||
|
||||
// How long a new timed event opens (#54), globally and per calendar —
|
||||
// the per-calendar list folds behind its own header, like the reminders.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_event_duration),
|
||||
summary = durationLabel(state.defaultEventDurationMinutes),
|
||||
position = if (durationCalendars.isEmpty()) Position.Alone else Position.Top,
|
||||
leading = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Schedule,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = { showDefaultDuration = true },
|
||||
)
|
||||
if (durationCalendars.isNotEmpty()) {
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_calendar_durations_title),
|
||||
summary = stringResource(R.string.settings_calendar_durations_hint),
|
||||
// Expanded, the header opens the calendar run below it and the
|
||||
// two read as one container (the shared expand pattern).
|
||||
position = if (durationSectionExpanded) Position.Top else Position.Bottom,
|
||||
trailing = {
|
||||
Icon(
|
||||
imageVector = if (durationSectionExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = { durationSectionExpanded = !durationSectionExpanded },
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = durationSectionExpanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
Column {
|
||||
durationCalendars.forEachIndexed { index, calendar ->
|
||||
val override = state.perCalendarEventDuration[calendar.id]
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = override?.let { durationLabel(it) }
|
||||
?: stringResource(
|
||||
R.string.settings_calendar_duration_inherits,
|
||||
durationLabel(state.defaultEventDurationMinutes),
|
||||
),
|
||||
// The header above is the run's first row, so the
|
||||
// calendars continue it: middles, then its bottom.
|
||||
position = positionOf(index + 1, durationCalendars.size + 1),
|
||||
leading = { CalendarColorChip(calendar.color) },
|
||||
onClick = { durationTarget = calendar.id },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-event colour on calendars that publish no colour set (some
|
||||
// CalDAV); off by default, since it may not survive their next sync.
|
||||
Spacer(Modifier.height(24.dp))
|
||||
@@ -100,4 +180,36 @@ internal fun EventFormScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showDefaultDuration) {
|
||||
DurationPicker(
|
||||
title = stringResource(R.string.settings_event_duration),
|
||||
description = stringResource(R.string.settings_event_duration_hint),
|
||||
presets = EVENT_DURATION_PRESETS,
|
||||
selected = state.defaultEventDurationMinutes,
|
||||
label = { durationLabel(it) },
|
||||
// No inherit row on the global default, so a pick is never null.
|
||||
onSelect = { minutes -> minutes?.let { viewModel.setDefaultEventDuration(it) } },
|
||||
onDismiss = { showDefaultDuration = false },
|
||||
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
|
||||
)
|
||||
}
|
||||
durationTarget?.let { calendarId ->
|
||||
DurationPicker(
|
||||
title = stringResource(R.string.settings_event_duration),
|
||||
presets = EVENT_DURATION_PRESETS,
|
||||
selected = state.perCalendarEventDuration[calendarId],
|
||||
label = { durationLabel(it) },
|
||||
inheritLabel = stringResource(
|
||||
R.string.settings_calendar_duration_use_default,
|
||||
durationLabel(state.defaultEventDurationMinutes),
|
||||
),
|
||||
onSelect = { viewModel.setCalendarEventDuration(calendarId, it) },
|
||||
onDismiss = { durationTarget = null },
|
||||
maxMinutes = SettingsPrefs.MAX_EVENT_DURATION,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Lengths offered for a new timed event, in minutes — up to a full work day. */
|
||||
private val EVENT_DURATION_PRESETS = listOf(15, 30, 45, 60, 90, 120, 240, 480)
|
||||
|
||||
@@ -28,7 +28,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@@ -38,10 +37,11 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.DurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.REMINDER_PRESETS
|
||||
import de.jeanlucmakiola.calendula.ui.common.ReminderDefaultPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.SnoozeDurationPicker
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimePickerAlert
|
||||
import de.jeanlucmakiola.calendula.ui.common.durationLabel
|
||||
import de.jeanlucmakiola.floret.components.CollapsingScaffold
|
||||
import de.jeanlucmakiola.floret.components.GroupedRow
|
||||
import de.jeanlucmakiola.floret.components.Position
|
||||
@@ -146,7 +146,7 @@ internal fun NotificationsScreen(
|
||||
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
summary = snoozeDurationLabel(state.snoozeMinutes),
|
||||
summary = durationLabel(state.snoozeMinutes),
|
||||
position = Position.Bottom,
|
||||
onClick = { showSnooze = true },
|
||||
)
|
||||
@@ -245,12 +245,13 @@ internal fun NotificationsScreen(
|
||||
}
|
||||
|
||||
if (showSnooze) {
|
||||
SnoozeDurationPicker(
|
||||
DurationPicker(
|
||||
title = stringResource(R.string.settings_snooze_duration),
|
||||
presets = SNOOZE_PRESETS,
|
||||
selected = state.snoozeMinutes,
|
||||
label = { snoozeDurationLabel(it) },
|
||||
onSelect = { viewModel.setSnoozeMinutes(it) },
|
||||
label = { durationLabel(it) },
|
||||
// No inherit row here, so a pick is never null.
|
||||
onSelect = { minutes -> minutes?.let { viewModel.setSnoozeMinutes(it) } },
|
||||
onDismiss = { showSnooze = false },
|
||||
)
|
||||
}
|
||||
@@ -348,15 +349,6 @@ private fun calendarOverrideSummary(
|
||||
/** Snooze delays offered for the notification "Snooze" action, in minutes. */
|
||||
private val SNOOZE_PRESETS = listOf(5, 10, 15, 30, 60)
|
||||
|
||||
/** A snooze delay as a plain duration ("10 minutes", "1 hour") — no "before". */
|
||||
@Composable
|
||||
private fun snoozeDurationLabel(minutes: Int): String =
|
||||
if (minutes % 60 == 0) {
|
||||
pluralStringResource(R.plurals.duration_hours, minutes / 60, minutes / 60)
|
||||
} else {
|
||||
pluralStringResource(R.plurals.duration_minutes, minutes, minutes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Calendula is exempt from battery optimisation, re-read on every
|
||||
* `ON_RESUME` so a change made in system settings shows up at once.
|
||||
|
||||
@@ -13,6 +13,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.widget.WidgetSize
|
||||
|
||||
@@ -57,12 +58,18 @@ data class SettingsUiState(
|
||||
val quickSwitchConfig: QuickSwitchConfig = QuickSwitchConfig.Default,
|
||||
/** How the Month view lays itself out: pages, continuous scroll, or split (#38, #53). */
|
||||
val monthViewStyle: MonthViewStyle = MonthViewStyle.Paged,
|
||||
/** How tall an hour is drawn in the week and day timelines (#56). */
|
||||
val timelineScale: TimelineScale = TimelineScale.Regular,
|
||||
/** Order of the views in the navigation drawer (#24); every view is always listed. */
|
||||
val drawerViewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
/** Optional event-form fields shown by default (rest behind "more fields"). */
|
||||
val defaultFormFields: Set<EventFormField> = SettingsPrefs.DEFAULT_FORM_FIELDS,
|
||||
/** Whether the new-event form auto-focuses the title and shows the keyboard (#10). */
|
||||
val autofocusEventTitle: Boolean = true,
|
||||
/** How long a new timed event lasts, in minutes (#54); all-day events ignore it. */
|
||||
val defaultEventDurationMinutes: Int = SettingsPrefs.DEFAULT_EVENT_DURATION,
|
||||
/** Per-calendar overrides of [defaultEventDurationMinutes]; absent = inherit. */
|
||||
val perCalendarEventDuration: Map<Long, Int> = emptyMap(),
|
||||
/** Whether Calendula posts reminder notifications (v1.4). */
|
||||
val remindersEnabled: Boolean = true,
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.agenda.storageValue
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import de.jeanlucmakiola.calendula.ui.theme.AppFontSettings
|
||||
import de.jeanlucmakiola.calendula.ui.theme.FONT_CUSTOM_TOKEN
|
||||
@@ -123,8 +124,14 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.perCalendarAllDayReminderOverride,
|
||||
writableCalendars,
|
||||
prefs.managedCalendarIds,
|
||||
) { overrides, allDayOverrides, calendars, managedIds ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds)
|
||||
// The new-event duration defaults (#54) fold into one flow so they
|
||||
// fit this group — the outer combine is at its five-arg limit.
|
||||
combine(
|
||||
prefs.defaultEventDurationMinutes,
|
||||
prefs.perCalendarEventDuration,
|
||||
) { duration, perCalendar -> EventDurations(duration, perCalendar) },
|
||||
) { overrides, allDayOverrides, calendars, managedIds, durations ->
|
||||
ReminderOverrides(overrides, allDayOverrides, calendars, managedIds, durations)
|
||||
},
|
||||
combine(
|
||||
prefs.defaultView,
|
||||
@@ -164,8 +171,9 @@ class SettingsViewModel @Inject constructor(
|
||||
prefs.drawerViewOrder,
|
||||
prefs.monthViewStyle,
|
||||
prefs.widgetSize,
|
||||
) { quickSwitch, drawer, monthStyle, widgetSize ->
|
||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize)
|
||||
prefs.timelineScale,
|
||||
) { quickSwitch, drawer, monthStyle, widgetSize, timelineScale ->
|
||||
ViewCustomization(quickSwitch, drawer, monthStyle, widgetSize, timelineScale)
|
||||
},
|
||||
) { showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization ->
|
||||
MiscSettings(showRangeBar, autofocus, pastEvents, dimCompleted, viewCustomization)
|
||||
@@ -189,6 +197,7 @@ class SettingsViewModel @Inject constructor(
|
||||
drawerViewOrder = misc.viewCustomization.drawerOrder,
|
||||
monthViewStyle = misc.viewCustomization.monthViewStyle,
|
||||
widgetSize = misc.viewCustomization.widgetSize,
|
||||
timelineScale = misc.viewCustomization.timelineScale,
|
||||
allowColorOnUnsupportedCalendars = defaults.allowColor,
|
||||
defaultReminderMinutes = defaults.defaultReminder,
|
||||
defaultAllDayReminderMinutes = defaults.allDayReminder,
|
||||
@@ -198,6 +207,8 @@ class SettingsViewModel @Inject constructor(
|
||||
perCalendarAllDayReminderOverride = overrides.allDay,
|
||||
writableCalendars = overrides.calendars,
|
||||
managedCalendarIds = overrides.managedIds,
|
||||
defaultEventDurationMinutes = overrides.durations.default,
|
||||
perCalendarEventDuration = overrides.durations.perCalendar,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -260,6 +271,12 @@ class SettingsViewModel @Inject constructor(
|
||||
val allDay: Map<Long, List<Int>>,
|
||||
val calendars: List<CalendarSource>,
|
||||
val managedIds: Set<Long>,
|
||||
val durations: EventDurations,
|
||||
)
|
||||
|
||||
private data class EventDurations(
|
||||
val default: Int,
|
||||
val perCalendar: Map<Long, Int>,
|
||||
)
|
||||
|
||||
private data class ViewSettings(
|
||||
@@ -295,6 +312,7 @@ class SettingsViewModel @Inject constructor(
|
||||
val drawerOrder: List<CalendarView>,
|
||||
val monthViewStyle: MonthViewStyle,
|
||||
val widgetSize: WidgetSize,
|
||||
val timelineScale: TimelineScale,
|
||||
)
|
||||
|
||||
/** Contact special-dates sub-page (issue #15); its own flow — the main state is full. */
|
||||
@@ -581,6 +599,10 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setMonthViewStyle(style) }
|
||||
}
|
||||
|
||||
fun setTimelineScale(scale: TimelineScale) {
|
||||
viewModelScope.launch { prefs.setTimelineScale(scale) }
|
||||
}
|
||||
|
||||
fun setDrawerViewOrder(order: List<CalendarView>) {
|
||||
viewModelScope.launch { prefs.setDrawerViewOrder(order) }
|
||||
}
|
||||
@@ -600,6 +622,15 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch { prefs.setAutofocusEventTitle(enabled) }
|
||||
}
|
||||
|
||||
fun setDefaultEventDuration(minutes: Int) {
|
||||
viewModelScope.launch { prefs.setDefaultEventDurationMinutes(minutes) }
|
||||
}
|
||||
|
||||
/** [minutes] null clears the override, so the calendar inherits the default. */
|
||||
fun setCalendarEventDuration(calendarId: Long, minutes: Int?) {
|
||||
viewModelScope.launch { prefs.setCalendarEventDuration(calendarId, minutes) }
|
||||
}
|
||||
|
||||
fun setDefaultReminderMinutes(minutes: List<Int>) {
|
||||
viewModelScope.launch { prefs.setDefaultReminderMinutes(minutes) }
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.PickerDescription
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.common.agendaRangeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.descriptionRes
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.icon
|
||||
import de.jeanlucmakiola.calendula.ui.common.labelRes
|
||||
@@ -68,6 +70,7 @@ internal fun ViewsScreen(
|
||||
var showTimeFormat by remember { mutableStateOf(false) }
|
||||
var showPastEvents by remember { mutableStateOf(false) }
|
||||
var showAgendaScreenRange by remember { mutableStateOf(false) }
|
||||
var showTimelineScale by remember { mutableStateOf(false) }
|
||||
|
||||
CollapsingScaffold(
|
||||
title = stringResource(R.string.settings_section_views),
|
||||
@@ -144,10 +147,16 @@ internal fun ViewsScreen(
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SectionHeader(stringResource(R.string.settings_week_day_header))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_timeline_scale),
|
||||
summary = stringResource(state.timelineScale.labelRes),
|
||||
position = Position.Top,
|
||||
onClick = { showTimelineScale = true },
|
||||
)
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.settings_hour_lines),
|
||||
summary = stringResource(R.string.settings_hour_lines_summary),
|
||||
position = Position.Alone,
|
||||
position = Position.Bottom,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = state.showHourLines,
|
||||
@@ -321,6 +330,25 @@ internal fun ViewsScreen(
|
||||
onDismiss = { showPastEvents = false },
|
||||
)
|
||||
}
|
||||
if (showTimelineScale) {
|
||||
OptionPicker(
|
||||
title = stringResource(R.string.settings_timeline_scale),
|
||||
header = { PickerDescription(stringResource(R.string.settings_timeline_scale_hint)) },
|
||||
predictiveBack = true,
|
||||
// A height pinched on the timeline is listed alongside the presets
|
||||
// rather than left as a silently unticked list: it is the current
|
||||
// setting, so it has to be visible here, and seeing it next to the
|
||||
// named steps is what makes "tap one to go back" obvious.
|
||||
options = TimelineScale.presets + listOfNotNull(
|
||||
state.timelineScale as? TimelineScale.Custom,
|
||||
),
|
||||
selected = state.timelineScale,
|
||||
label = { stringResource(it.labelRes) },
|
||||
summary = { stringResource(it.descriptionRes) },
|
||||
onSelect = viewModel::setTimelineScale,
|
||||
onDismiss = { showTimelineScale = false },
|
||||
)
|
||||
}
|
||||
if (showAgendaScreenRange) {
|
||||
AgendaRangePicker(
|
||||
title = stringResource(R.string.settings_agenda_range),
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -62,13 +63,17 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -77,12 +82,27 @@ import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.hasEnded
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatCalendarTitle
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarTitleButton
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFabColumn
|
||||
import de.jeanlucmakiola.calendula.ui.common.TodayAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarFailure
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.BlockTimeLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.animatedBlockPlacement
|
||||
import de.jeanlucmakiola.calendula.ui.common.ghostAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalEventMove
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.MoveTarget
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDragOverlay
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineDrop
|
||||
import de.jeanlucmakiola.calendula.ui.common.beginsOn
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventMoveAction
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberEventDragSource
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberTimelineDragController
|
||||
import de.jeanlucmakiola.calendula.ui.common.startInstant
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalDimCutoff
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
@@ -97,8 +117,16 @@ import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatHourLabel
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalTimelineZoom
|
||||
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.rememberTimelinePinchZoom
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatMinuteOfDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_CONTENT_START_INSET
|
||||
import de.jeanlucmakiola.calendula.ui.common.GUTTER_WIDTH
|
||||
import de.jeanlucmakiola.calendula.ui.common.HourGutter
|
||||
import de.jeanlucmakiola.calendula.ui.common.hourSeparatorLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.rememberCalendarSlideSpec
|
||||
import de.jeanlucmakiola.calendula.ui.common.next
|
||||
@@ -113,17 +141,11 @@ import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Clock
|
||||
import java.time.format.TextStyle as JavaTextStyle
|
||||
import java.util.Locale
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val HOUR_HEIGHT = 56.dp
|
||||
private val GUTTER_WIDTH = 48.dp
|
||||
/** Start inset for the gutter's content (week badge + hour labels) so it centres
|
||||
* on the top bar's hamburger: with a 48dp gutter, 8dp lands the centre at 28dp
|
||||
* (the app bar's 4dp inset + 24dp half icon button). */
|
||||
private val GUTTER_CONTENT_START_INSET = 8.dp
|
||||
private val MIN_EVENT_HEIGHT = 24.dp
|
||||
private val ALL_DAY_ROW_HEIGHT = 24.dp
|
||||
private val ALL_DAY_VERTICAL_PADDING = 6.dp
|
||||
/** Gap between day columns; part of the column pitch a drag maps positions through. */
|
||||
private val COLUMN_GAP = 2.dp
|
||||
|
||||
/** Total all-day strip height for a week (0 when there are no all-day events). */
|
||||
private fun WeekUiState.Success.allDayStripHeight(): Dp {
|
||||
@@ -216,6 +238,7 @@ fun WeekScreen(
|
||||
CalendarDrawer(
|
||||
currentView = selectedView,
|
||||
currentDate = weekStart,
|
||||
drawerState = drawerState,
|
||||
viewOrder = drawerViewOrder,
|
||||
onSelectView = { view ->
|
||||
onSelectView(view)
|
||||
@@ -242,6 +265,7 @@ fun WeekScreen(
|
||||
onCycleView = { onSelectView(selectedView.next(quickSwitchViews)) },
|
||||
onOpenDrawer = { scope.launch { drawerState.open() } },
|
||||
onOpenSearch = onOpenSearch,
|
||||
onJumpToDate = jumpToDate,
|
||||
showTodayButton = todayInToolbar,
|
||||
onToday = jumpToToday,
|
||||
scrollBehavior = scrollBehavior,
|
||||
@@ -294,7 +318,6 @@ private fun WeekContent(
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val slideSpec = rememberCalendarSlideSpec()
|
||||
val fadeSpec = rememberCalendarFadeSpec()
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
@@ -306,11 +329,9 @@ private fun WeekContent(
|
||||
val scrollState = rememberScrollState()
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { scrollState.maxValue }.first { it > 0 }
|
||||
val maxV = scrollState.maxValue
|
||||
val target = with(density) {
|
||||
(HOUR_HEIGHT.toPx() * 12 - (HOUR_HEIGHT.toPx() * 24 - maxV) / 2f).roundToInt()
|
||||
}.coerceIn(0, maxV)
|
||||
scrollState.scrollTo(target)
|
||||
// Half the scroll range *is* noon: the content spans a full 24 hours, so
|
||||
// centring the range centres midday at whatever hour height is in force.
|
||||
scrollState.scrollTo(scrollState.maxValue / 2)
|
||||
}
|
||||
|
||||
// Single, hoisted all-day strip height — shared by the outgoing and incoming
|
||||
@@ -328,32 +349,52 @@ private fun WeekContent(
|
||||
// gestures coexist without fighting.
|
||||
val swipeModifier = rememberCalendarPageSwipe(onSwipeNext, onSwipePrev)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = modifier.then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
// Above the AnimatedContent on purpose: a page change mid-drag would strand
|
||||
// the floating block inside the outgoing page.
|
||||
val dragController = rememberTimelineDragController()
|
||||
val move = LocalEventMove.current
|
||||
val zone = remember { TimeZone.currentSystemDefault() }
|
||||
|
||||
Box(modifier = modifier) {
|
||||
AnimatedContent(
|
||||
targetState = state,
|
||||
modifier = Modifier.fillMaxSize().then(swipeModifier),
|
||||
contentKey = { s ->
|
||||
when (s) {
|
||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||
WeekUiState.Loading -> "loading"
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "week-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
is WeekUiState.Success -> "success-${s.weekStart}"
|
||||
is WeekUiState.Failure -> "failure-${s.reason}"
|
||||
WeekUiState.Loading -> "loading"
|
||||
WeekUiState.Loading -> WeekLoading()
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = { drop ->
|
||||
move?.move(
|
||||
MoveRequest(
|
||||
eventId = drop.event.eventId,
|
||||
beginMillis = drop.event.start.toEpochMilliseconds(),
|
||||
endMillis = drop.event.end.toEpochMilliseconds(),
|
||||
target = MoveTarget.Start(drop.startInstant(zone)),
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
transitionSpec = { calendarSlideTransition(slideDir, slideSpec, fadeSpec, reduceMotion) },
|
||||
label = "week-transition",
|
||||
) { s ->
|
||||
when (s) {
|
||||
WeekUiState.Loading -> WeekLoading()
|
||||
is WeekUiState.Failure -> CalendarFailure(reason = s.reason, onRetry = onRetry)
|
||||
is WeekUiState.Success -> WeekSuccess(
|
||||
state = s,
|
||||
topSectionColor = topSectionColor,
|
||||
scrollState = scrollState,
|
||||
allDayHeight = allDayHeight,
|
||||
onEventClick = onEventClick,
|
||||
onOpenDay = onOpenDay,
|
||||
onCreateAt = onCreateAt,
|
||||
)
|
||||
}
|
||||
TimelineDragOverlay(dragController)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,9 +404,11 @@ private fun WeekSuccess(
|
||||
topSectionColor: Color,
|
||||
scrollState: ScrollState,
|
||||
allDayHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onOpenDay: (LocalDate) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
@@ -382,8 +425,10 @@ private fun WeekSuccess(
|
||||
Timeline(
|
||||
state = state,
|
||||
scrollState = scrollState,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -397,6 +442,7 @@ private fun WeekTopBar(
|
||||
onCycleView: () -> Unit,
|
||||
onOpenDrawer: () -> Unit,
|
||||
onOpenSearch: () -> Unit,
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
showTodayButton: Boolean,
|
||||
onToday: () -> Unit,
|
||||
scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior,
|
||||
@@ -404,9 +450,10 @@ private fun WeekTopBar(
|
||||
val locale = currentLocale()
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = formatWeekTitle(weekStart, locale, currentYear),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
CalendarTitleButton(
|
||||
title = formatWeekTitle(weekStart, locale, currentYear),
|
||||
currentDate = weekStart,
|
||||
onJumpToDate = onJumpToDate,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
@@ -617,62 +664,68 @@ private fun AllDayBar(
|
||||
private fun Timeline(
|
||||
state: WeekUiState.Success,
|
||||
scrollState: ScrollState,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
) {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val dark = isSystemInDarkTheme()
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val locale = currentLocale()
|
||||
val zoom = LocalTimelineZoom.current
|
||||
val density = LocalDensity.current
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// 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
|
||||
// bar, day header and all-day strip.
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val hourHeight = zoom.scale.hourHeight(maxHeight)
|
||||
val totalHeight = hourHeight * 24
|
||||
// The pinch sits on the Row, above both scroll viewports: it has to
|
||||
// outrank the vertical scroll, and it does that by watching the initial
|
||||
// pass, which only reaches it if it is their ancestor.
|
||||
val pinch = rememberTimelinePinchZoom(scrollState, maxHeight, hourHeight, zoom)
|
||||
// Gutter and day columns are two scroll viewports that SHARE one scroll
|
||||
// state, so they stay perfectly aligned. The day-column viewport is a
|
||||
// static, rounded-clipped window — the content scrolls inside it, so the
|
||||
// soft corners are permanent at any scroll position (not just at the
|
||||
// day's start/end).
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
Row(modifier = Modifier.fillMaxSize().then(pinch)) {
|
||||
// Hour gutter (scrolls in sync with the day columns). Same start inset
|
||||
// as the header badge so the labels sit under it and on the hamburger.
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(GUTTER_WIDTH)
|
||||
.padding(start = GUTTER_CONTENT_START_INSET)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
(0 until 24).forEach { h ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(HOUR_HEIGHT),
|
||||
) {
|
||||
if (h > 0) {
|
||||
Text(
|
||||
text = formatHourLabel(h, use24Hour, locale),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = (-6).dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HourGutter(
|
||||
scrollState = scrollState,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
)
|
||||
// Day columns: rounded, clipped scroll viewport (permanent corners).
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.verticalScroll(scrollState),
|
||||
.verticalScroll(scrollState)
|
||||
.onGloballyPositioned { dragController.geometry.viewport = it },
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(totalHeight),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
.height(totalHeight)
|
||||
// The scrolling content itself, so its root position
|
||||
// already folds in the scroll offset — a drag maps
|
||||
// through it without reading the scroll state.
|
||||
.onGloballyPositioned { coords ->
|
||||
val gap = with(density) { COLUMN_GAP.toPx() }
|
||||
dragController.geometry.let {
|
||||
it.grid = coords
|
||||
it.scroll = scrollState
|
||||
it.hourPx = with(density) { hourHeight.toPx() }
|
||||
it.columnGapPx = gap
|
||||
it.columnWidthPx = (coords.size.width + gap) / state.days.size
|
||||
it.days = state.days
|
||||
it.isRtl = isRtl
|
||||
}
|
||||
},
|
||||
horizontalArrangement = Arrangement.spacedBy(COLUMN_GAP),
|
||||
) {
|
||||
state.days.forEach { day ->
|
||||
DayColumnCard(
|
||||
@@ -680,8 +733,11 @@ private fun Timeline(
|
||||
dark = dark,
|
||||
date = day,
|
||||
today = state.today,
|
||||
hourHeight = hourHeight,
|
||||
dragController = dragController,
|
||||
onEventClick = onEventClick,
|
||||
onCreateAt = onCreateAt,
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight(),
|
||||
@@ -699,13 +755,20 @@ private fun DayColumnCard(
|
||||
dark: Boolean,
|
||||
date: LocalDate,
|
||||
today: LocalDate,
|
||||
hourHeight: Dp,
|
||||
dragController: TimelineDragController,
|
||||
onEventClick: (EventInstance) -> Unit,
|
||||
onCreateAt: (LocalDate, Int) -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hourPx = with(LocalDensity.current) { HOUR_HEIGHT.toPx() }
|
||||
val hourPx = with(LocalDensity.current) { hourHeight.toPx() }
|
||||
val showHourLines = LocalShowHourLines.current
|
||||
val hourLineColor = MaterialTheme.colorScheme.outlineVariant
|
||||
// Tells a settled drop when this column has caught up with it.
|
||||
LaunchedEffect(blocks, dragController.settling) {
|
||||
dragController.noteGrid(date, blocks)
|
||||
}
|
||||
Card(
|
||||
// Plain rectangular columns — the soft corners come from the outer
|
||||
// rounded scroll viewport, so inner rounding would look odd at the edges.
|
||||
@@ -731,26 +794,45 @@ private fun DayColumnCard(
|
||||
},
|
||||
) {
|
||||
val colWidth = maxWidth
|
||||
val minEventHeight = hourHeight * MIN_EVENT_FRACTION
|
||||
// Keyed by event, so a block that changes time or lane is the *same*
|
||||
// composable afterwards and tweens there. The ordinal disambiguates
|
||||
// the rare column holding two occurrences of one series, which would
|
||||
// otherwise be two blocks under one key.
|
||||
val ordinals = mutableMapOf<Long, Int>()
|
||||
blocks.forEach { block ->
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = HOUR_HEIGHT * (block.startMin / 60f)
|
||||
val rawHeight = HOUR_HEIGHT * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < MIN_EVENT_HEIGHT) MIN_EVENT_HEIGHT else rawHeight
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = height,
|
||||
onClick = { onEventClick(block.event) },
|
||||
modifier = Modifier
|
||||
.offset(x = laneWidth * block.lane, y = top)
|
||||
.width(laneWidth)
|
||||
.height(height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
val ordinal = ordinals.merge(block.event.eventId, 1, Int::plus)!! - 1
|
||||
key(block.event.eventId, ordinal) {
|
||||
val laneWidth = colWidth / block.laneCount
|
||||
val top = hourHeight * (block.startMin / 60f)
|
||||
val rawHeight = hourHeight * ((block.endMin - block.startMin) / 60f)
|
||||
val height = if (rawHeight < minEventHeight) minEventHeight else rawHeight
|
||||
val place = animatedBlockPlacement(
|
||||
x = laneWidth * block.lane,
|
||||
y = top,
|
||||
width = laneWidth,
|
||||
height = height,
|
||||
)
|
||||
EventBlock(
|
||||
block = block,
|
||||
dark = dark,
|
||||
height = place.height,
|
||||
width = place.width,
|
||||
date = date,
|
||||
dragController = dragController,
|
||||
onClick = { onEventClick(block.event) },
|
||||
onDrop = onDrop,
|
||||
modifier = Modifier
|
||||
.offset(x = place.x, y = place.y)
|
||||
.width(place.width)
|
||||
.height(place.height)
|
||||
.padding(horizontal = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// Current-time line, on top of the events, only on today's column.
|
||||
if (date == today) {
|
||||
NowLine(date = date, hourHeight = HOUR_HEIGHT)
|
||||
NowLine(date = date, hourHeight = hourHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -761,7 +843,11 @@ private fun EventBlock(
|
||||
block: TimedBlock,
|
||||
dark: Boolean,
|
||||
height: Dp,
|
||||
width: Dp,
|
||||
date: LocalDate,
|
||||
dragController: TimelineDragController,
|
||||
onClick: () -> Unit,
|
||||
onDrop: (TimelineDrop) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = block.event.title.ifBlank { stringResource(R.string.event_untitled) }
|
||||
@@ -769,10 +855,6 @@ private fun EventBlock(
|
||||
val locale = currentLocale()
|
||||
val timeLabel = "${minToHm(block.startMin, use24Hour, locale)}–" +
|
||||
minToHm(block.endMin, use24Hour, locale)
|
||||
// Only full-width (non-overlapping) blocks that are tall enough show the time.
|
||||
// On narrow overlapping columns we drop it so the title can wrap to fill the
|
||||
// whole block, mirroring Google Calendar.
|
||||
val showTime = block.endMin - block.startMin >= 45 && block.laneCount == 1
|
||||
val density = LocalDensity.current
|
||||
val titleLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelMedium.lineHeight.toDp()
|
||||
@@ -780,37 +862,79 @@ private fun EventBlock(
|
||||
val timeLineHeight = with(density) {
|
||||
MaterialTheme.typography.labelSmall.lineHeight.toDp()
|
||||
}
|
||||
// Wrap the title across as many lines as the block can fit (minus the 2.dp
|
||||
// top/bottom padding and the reserved time line) instead of clipping it to a
|
||||
// single character on slim, overlapping blocks.
|
||||
val contentHeight = height - 4.dp - if (showTime) timeLineHeight else 0.dp
|
||||
val titleMaxLines = (contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
// 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
|
||||
// time. On narrow overlapping columns we drop it so the title can wrap to
|
||||
// fill the whole block, mirroring Google Calendar — and a block that cannot
|
||||
// afford both lines spends its space on the title.
|
||||
val showTime = block.endMin - block.startMin >= 45 &&
|
||||
block.laneCount == 1 &&
|
||||
available >= titleLineHeight + timeLineHeight
|
||||
// A short block drops the title rather than serving a horizontally sliced
|
||||
// one: half a letter reads as a rendering fault, while a bare colour chip
|
||||
// reads as what it is — an event too brief to label. Tap still opens it, and
|
||||
// the semantics description carries the full title either way.
|
||||
val showTitle = available >= titleLineHeight
|
||||
// Wrap the title across as many lines as the block can fit — but only once a
|
||||
// line is wide enough to hold more than a syllable. Below that the extra
|
||||
// lines just stack fragments of the word, and one ellipsised line reads
|
||||
// better.
|
||||
val contentHeight = available - if (showTime) timeLineHeight else 0.dp
|
||||
val titleMaxLines = if (width < MIN_TITLE_WRAP_WIDTH) {
|
||||
1
|
||||
} else {
|
||||
(contentHeight / titleLineHeight).toInt().coerceAtLeast(1)
|
||||
}
|
||||
val dimCutoff = LocalDimCutoff.current
|
||||
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 moveAction = eventMoveAction(block.event)
|
||||
// A block clipped at the top continues from the previous day: its top edge is
|
||||
// midnight, not the event's start, so dragging it would invent a time.
|
||||
val draggable = moveAction != null && block.beginsOn(date, zone)
|
||||
val dragModifier = rememberEventDragSource(
|
||||
enabled = draggable,
|
||||
key = block.event.instanceId,
|
||||
onPickUp = { pointer, blockRoot, _ -> dragController.begin(block, pointer, blockRoot) },
|
||||
onMove = dragController::move,
|
||||
onDrop = { dragController.finish()?.let(onDrop) },
|
||||
onCancel = dragController::cancel,
|
||||
)
|
||||
val lifted = draggable && dragController.ghosts(block, date)
|
||||
val ghost = ghostAlpha(lifted)
|
||||
Box(
|
||||
modifier = (if (dimmed) modifier.alpha(EventDimAlpha) else modifier)
|
||||
// The source stays put as a ghost while its floating copy travels,
|
||||
// then fades out as the copy settles on its new slot.
|
||||
.then(if (ghost < 1f) Modifier.alpha(ghost) else Modifier)
|
||||
.background(fill, RoundedCornerShape(4.dp))
|
||||
.clickable(onClick = onClick)
|
||||
// After clickable, so it is the inner node and wins the main pass;
|
||||
// the tap still works, since a drag consumes the up.
|
||||
.then(dragModifier)
|
||||
.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
.semantics { contentDescription = "$title, $timeLabel" },
|
||||
.semantics {
|
||||
contentDescription = "$title, $timeLabel"
|
||||
if (moveAction != null) customActions = listOf(moveAction)
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
if (showTime) {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = timeLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
maxLines = 1,
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = eventInk(fill, alpha = 0.6f),
|
||||
color = eventInk(fill, alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
if (showTime) {
|
||||
BlockTimeLabel(
|
||||
label = timeLabel,
|
||||
color = eventInk(fill, alpha = SECONDARY_INK_ALPHA),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -819,7 +943,7 @@ private fun EventBlock(
|
||||
|
||||
@Composable
|
||||
private fun WeekLoading() {
|
||||
val totalHeight = HOUR_HEIGHT * 24
|
||||
val scale = LocalTimelineZoom.current.scale
|
||||
val scrollState = rememberScrollState()
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header skeleton
|
||||
@@ -838,16 +962,21 @@ private fun WeekLoading() {
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
// Same scale resolution as the loaded timeline, so the skeleton's
|
||||
// columns don't resize the moment the real week arrives.
|
||||
val totalHeight = scale.hourHeight(maxHeight) * 24
|
||||
Row(modifier = Modifier.fillMaxSize().verticalScroll(scrollState)) {
|
||||
Spacer(Modifier.width(GUTTER_WIDTH))
|
||||
repeat(7) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(totalHeight)
|
||||
.padding(horizontal = 2.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.EventDimAlpha
|
||||
import de.jeanlucmakiola.calendula.ui.common.formatTimeOfDay
|
||||
import de.jeanlucmakiola.floret.locale.localizedDateFormatter
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventAccent
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.widget.AgendaWidgetData
|
||||
import de.jeanlucmakiola.calendula.widget.CalendulaGlanceTheme
|
||||
@@ -382,7 +383,7 @@ private fun EventRow(
|
||||
val title = event.title.ifBlank { context.getString(R.string.event_untitled) }
|
||||
// Glance has no generic alpha modifier, so dim by fading the colour stripe and
|
||||
// dropping both text lines to the lower-emphasis on-surface-variant tone.
|
||||
val stripeColor = eventFill(event.color, dark, soften).let {
|
||||
val stripeColor = eventAccent(event.color, dark, soften).let {
|
||||
if (dimmed) it.copy(alpha = EventDimAlpha) else it
|
||||
}
|
||||
val titleColor = if (dimmed) GlanceTheme.colors.onSurfaceVariant else GlanceTheme.colors.onSurface
|
||||
|
||||
@@ -1,12 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Monochrome status-bar mark: Material "event" calendar glyph. -->
|
||||
<!--
|
||||
Status-bar mark for reminders (issue #83). Calendula's own mark, not the
|
||||
stock Material "event" glyph it replaces: the status bar draws a small icon
|
||||
from its alpha channel alone, so the silhouette is the only thing that can
|
||||
tell Calendula apart from the system's date surfaces and every other
|
||||
calendar app.
|
||||
|
||||
Redrawn for 24dp from design/icon/calendula_notification.svg rather than
|
||||
scaled down from the launcher mark — the numeral "1" is dropped and the
|
||||
bloom's petals are filled instead of stroked, both of which turn to mush at
|
||||
this size. Stays opaque white on transparent; any colour here is discarded.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
android:viewportHeight="24">
|
||||
<!-- Calendar body, left open at the bottom-right where the bloom sits -->
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M19,3h-1V1h-2v2H8V1H6v2H5C3.89,3 3.01,3.9 3.01,5L3,19c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM19,19H5V8h14V19zM7,10h5v5H7V10z" />
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="1.9"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M1.4,5.4H18.6M18.6,9.43V4.7C18.6,2.99 17.21,1.6 15.5,1.6H4.5C2.79,1.6 1.4,2.99 1.4,4.7V15.7C1.4,17.41 2.79,18.8 4.5,18.8H9.63" />
|
||||
<!-- Calendula bloom: eight filled petals around a filled centre -->
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M17.6,16.35Q20.42,18.65 23.25,16.35Q20.42,14.05 17.6,16.35Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M17.26,17.16Q17.63,20.79 21.26,21.16Q20.89,17.53 17.26,17.16Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M16.45,17.5Q14.15,20.33 16.45,23.15Q18.75,20.33 16.45,17.5Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M15.64,17.16Q12.01,17.53 11.64,21.16Q15.27,20.79 15.64,17.16Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M15.3,16.35Q12.47,14.05 9.65,16.35Q12.47,18.65 15.3,16.35Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M15.64,15.54Q15.27,11.91 11.64,11.54Q12.01,15.17 15.64,15.54Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M16.45,15.2Q18.75,12.38 16.45,9.55Q14.15,12.38 16.45,15.2Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M17.26,15.54Q20.89,15.17 21.26,11.54Q17.63,11.91 17.26,15.54Z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M16.45,13.85A2.5,2.5 0 1,1 16.45,18.85A2.5,2.5 0 1,1 16.45,13.85Z" />
|
||||
</vector>
|
||||
|
||||
@@ -297,4 +297,16 @@
|
||||
<string name="settings_agenda_range">مدى الجدول</string>
|
||||
<string name="agenda_range_custom">مخصص…</string>
|
||||
<string name="agenda_range_custom_hint">أيام</string>
|
||||
<string name="agenda_range_week">٧ أيام القادمة</string>
|
||||
<string name="agenda_range_month">٣٠ يومًا القادمة</string>
|
||||
<string name="settings_month_view_style">نمط عرض الشهر</string>
|
||||
<string name="month_style_paged">صفحات</string>
|
||||
<string name="month_style_paged_summary">الشهر الواحد يملأ الشاشة. اسحب لليسار أو لليمين لتغيير الشهر.</string>
|
||||
<string name="month_style_split">تقسيم</string>
|
||||
<string name="settings_color_unsupported">السماح بالألوان في التقويمات غير المدعومة</string>
|
||||
<string name="settings_color_unsupported_hint">بعض التقويمات (مثل البعض من CalDAV) لا تنشر أي مجموعة ألوان؛ لون حدث مخصص قد يزال أو يستبدل في مزامنته التالية. هذا قيد على تلك التقويمات، وليس شيئًا يمكن لـ Calendula إصلاحه.</string>
|
||||
<string name="settings_section_views">طُرق العرض</string>
|
||||
<string name="agenda_range_showing_label">إظهار جميع الأحداث القادمة لـ</string>
|
||||
<string name="settings_reminders">تذكيرات الحدث</string>
|
||||
<string name="settings_default_reminder">التذكير الافتراضي</string>
|
||||
</resources>
|
||||
|
||||
@@ -490,4 +490,12 @@
|
||||
</plurals>
|
||||
<string name="event_detail_duplicate">Duplikate</string>
|
||||
<string name="reminder_day_tomorrow">Morgen</string>
|
||||
<string name="event_edit_timezone_device">Zeitzone des Gerätes</string>
|
||||
<string name="event_edit_timezone_device_summary">Passt sich an wo Sie sind</string>
|
||||
<string name="event_edit_timezone_search">Zeitzonen durchsuchen</string>
|
||||
<string name="event_edit_timezone_recent">Kürzliche</string>
|
||||
<string name="event_edit_timezone_all">Alle Zeitzonen</string>
|
||||
<string name="event_edit_timezone_none">Keine Zeitzone passt zu “%1$s”</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s deiner Zeit</string>
|
||||
<string name="import_reminder_prompt_title">Deine Standarterinnerung anwenden?</string>
|
||||
</resources>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<string name="event_detail_share">Partager</string>
|
||||
<string name="event_share_chooser_title">Evénement partagé</string>
|
||||
<string name="event_share_failed">Impossible de partager cet événement.</string>
|
||||
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||
<string name="event_delete_title">Evénement supprimé ?</string>
|
||||
<string name="event_delete_body">Cet événement est retiré de votre calendrier et de chaque appareil auquel il est synchronisé.</string>
|
||||
<string name="event_delete_recurring_title">Supprimer l\'événement récurrent</string>
|
||||
<string name="event_delete_option_occurrence">Seulement cet événement</string>
|
||||
@@ -95,14 +95,14 @@
|
||||
<string name="event_edit_color_unsupported_hint">Ce calendrier ne propose aucun ensemble de couleurs. Vous pouvez autoriser des couleurs personnalisées pour ces calendriers dans les paramètres.</string>
|
||||
<string name="event_edit_color_sync_warning">Ce calendrier pourrait retirer ou remplacer la couleur lors de sa prochaine synchronisation.</string>
|
||||
<string name="event_edit_conflict_title">L\'événement a changé ailleurs</string>
|
||||
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||
<string name="event_edit_conflict_body">Durant l\'édition, cet événement a été altéré - par la synchronisation ou une autre application. Voulez-vous enregistrer ou annuler vos modifications ?</string>
|
||||
<string name="event_edit_conflict_overwrite">Enregistrer mes modifications</string>
|
||||
<string name="event_edit_conflict_overwrite_hint">Seuls les champs que vous modifiez remplacent l\'altération externe</string>
|
||||
<string name="event_edit_conflict_discard">Annuler mes modifications</string>
|
||||
<string name="event_edit_conflict_discard_hint">L’événement reste tel qu’il est maintenant</string>
|
||||
<string name="event_edit_gone_title">Evénement supprimé</string>
|
||||
<string name="event_edit_gone_body">Cet événement a été supprimé entre-temps, par exemple sur un autre appareil. Vos modifications ne peuvent plus être enregistrées.</string>
|
||||
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||
<string name="import_reminder_prompt_title">Appliquer votre rappel par défaut ?</string>
|
||||
<string name="import_reminder_prompt_body_none">Cet événement a été importé sans aucun rappel.</string>
|
||||
<plurals name="import_reminder_prompt_body_existing">
|
||||
<item quantity="one">Cet événement a été importé avec %1$d rappel.</item>
|
||||
@@ -178,7 +178,7 @@
|
||||
<string name="event_access_confidential">Confidentiel</string>
|
||||
<string name="event_attendee_organizer">Organisateur</string>
|
||||
<string name="event_attendee_resource">Ressource</string>
|
||||
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
||||
<string name="event_detail_self_response">Votre réponse : %1$s</string>
|
||||
<string name="reminder_default">Rappel par défaut</string>
|
||||
<plurals name="reminder_minutes">
|
||||
<item quantity="one">%d minute avant</item>
|
||||
@@ -283,7 +283,7 @@
|
||||
<string name="event_status_tentative">Provisoire</string>
|
||||
<string name="reminder_at_time">Au moment de l’événement</string>
|
||||
<string name="reminder_onboarding_body">Android n’affiche pas de rappels d’événements par lui-même — une application de calendrier doit le faire. Laissez Calendula faire ce travail.</string>
|
||||
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
||||
<string name="reminder_benefit_duplicates_title">Utiliser une deuxième application de calendrier ?</string>
|
||||
<string name="reminder_benefit_duplicates_body">Si une autre application publie également des rappels, vous les verrez deux fois, désactivez-les là ou ici.</string>
|
||||
<string name="reminder_benefit_reversible_title">modifier à tout moment</string>
|
||||
<string name="reminder_benefit_reversible_body">Le commutateur se trouve dans les paramètres, sous Notifications.</string>
|
||||
@@ -331,14 +331,14 @@
|
||||
<string name="settings_drawer_order_hint">Faites glisser pour réorganiser les vues répertoriées dans le menu de navigation.</string>
|
||||
<string name="reorder_drag_handle">Faites glisser pour réorganiser</string>
|
||||
<string name="settings_section_event_form">Nouveau formulaire d’événement</string>
|
||||
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
||||
<string name="settings_form_fields_hint">Champs affichés par défaut — tout le reste se trouve derrière « Plus de champs »</string>
|
||||
<string name="settings_autofocus_title">Titre principal du nouvel événement</string>
|
||||
<string name="settings_autofocus_title_hint">Lorsque vous démarrez un nouvel événement, placez le curseur dans le champ du titre et ouvrez immédiatement le clavier.</string>
|
||||
<string name="settings_color_unsupported">Autoriser les couleurs dans les calendriers non pris en charge</string>
|
||||
<string name="settings_color_unsupported_hint">Certains calendriers (par exemple, certains CalDAV) ne publient aucun ensemble de couleurs, une couleur d’événement personnalisée peut être supprimée ou remplacée lors de leur prochaine synchronisation. C’est une limitation de ces calendriers, pas quelque chose que Calendula peut réparer.</string>
|
||||
<string name="settings_section_notifications">Notifications</string>
|
||||
<string name="settings_reminders">rappels d\'événements</string>
|
||||
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
||||
<string name="settings_reminders_hint">Vous voyez des rappels deux fois ? Une autre application de calendrier les publie aussi — désactivez-les dans l’un des deux.</string>
|
||||
<string name="settings_default_reminder">rappel par défaut</string>
|
||||
<string name="settings_default_reminder_allday">événements d\'une journée entière</string>
|
||||
<string name="settings_allday_reminder_time">Heure de rappel toute la journée</string>
|
||||
@@ -383,9 +383,9 @@
|
||||
<string name="settings_calendar_reminders_managed_hint">Définir dans les dates spéciales de contact</string>
|
||||
<string name="settings_special_dates_paused_title">suspendu</string>
|
||||
<string name="settings_special_dates_paused_hint">Calendula ne peut plus lire vos contacts, donc ces calendriers ne se mettent pas à jour.</string>
|
||||
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
||||
<string name="settings_special_dates_disable_title">Désactiver les dates de contact ?</string>
|
||||
<string name="settings_special_dates_disable_all_message">Cela supprime les calendriers de contact et leurs événements. Tous les rappels ou notes que vous leur avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_type_message">Cela supprime le calendrier « %1$s » et ses événements. Tous les rappels ou notes que vous y avez ajoutés seront perdus.</string>
|
||||
<string name="settings_special_dates_disable_confirm">désactiver</string>
|
||||
<string name="settings_section_about">A propos</string>
|
||||
<string name="settings_license">Licence</string>
|
||||
@@ -411,7 +411,7 @@
|
||||
<string name="calendars_edit_title">Editer un agenda</string>
|
||||
<string name="calendars_name_label">Nom</string>
|
||||
<string name="calendars_description_hint">Ajouter une description</string>
|
||||
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
||||
<string name="calendars_delete_confirm_title">Supprimer un calendrier ?</string>
|
||||
<string name="calendars_delete_confirm_message">\"%1$s\" et tous ses événements seront définitivement retirés de cet appareil.</string>
|
||||
<string name="calendars_write_error">Impossible de sauvegarder les changements.</string>
|
||||
<string name="calendars_backup_hint">Les calendriers locaux ne sont synchronisés nulle part, alors exportez-les vers un fichier .ics pour en garder une copie.</string>
|
||||
@@ -429,8 +429,8 @@
|
||||
<string name="calendars_auto_backup_interval">Intervalle</string>
|
||||
<string name="calendars_auto_backup_interval_min">Minimum 30 minutes.</string>
|
||||
<string name="calendars_auto_backup_status_never">Pas encore de sauvegarde automatique</string>
|
||||
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_ok">Dernière sauvegarde : %1$s</string>
|
||||
<string name="calendars_auto_backup_status_failed">La dernière sauvegarde a échoué : %1$s</string>
|
||||
<string name="backup_channel_description">Avertit si les sauvegardes automatiques échouent de manière répétée.</string>
|
||||
<string name="backup_failed_title">La sauvegarde automatique a échoué</string>
|
||||
<string name="backup_failed_text">Calendula n’a pas pu écrire le fichier de sauvegarde. Vérifiez le dossier de sauvegarde dans les paramètres.</string>
|
||||
@@ -481,7 +481,7 @@
|
||||
</plurals>
|
||||
<string name="shortcut_new_event_long">créer un événement</string>
|
||||
<string name="settings_qs_tile">Ajouter des paramètres rapides</string>
|
||||
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
||||
<string name="settings_qs_tile_hint">Ajoutez un bouton « Nouvel événement » au panneau Paramètres rapides.</string>
|
||||
<string name="crash_dialog_title">%1$s a planté</string>
|
||||
<string name="crash_dialog_message">%1$s a été fermé de manière inattendue la dernière fois. Vous pouvez aider à le corriger en envoyant ce rapport en tant que problème. Il reste sur votre appareil jusqu’à ce que vous choisissiez de le partager, et n’inclut aucune donnée personnelle ni contenu de calendrier — seuls les détails techniques ci-dessous.</string>
|
||||
<string name="crash_dialog_report">rapport</string>
|
||||
|
||||
@@ -65,6 +65,20 @@
|
||||
<string name="event_edit_recurring_title">Edit recurring event</string>
|
||||
<string name="event_delete_failed">Couldn\'t delete the event</string>
|
||||
<string name="event_delete_write_denied">Calendula needs write access to delete events</string>
|
||||
|
||||
<!-- Drag an event to another time or day (#68) -->
|
||||
<string name="event_move_action">Move…</string>
|
||||
<string name="event_move_recurring_title">Move recurring event</string>
|
||||
<string name="event_move_occurrence_only">This series picks its days in a way that can\'t be recalculated from one moved event, so only this event can move.</string>
|
||||
<!-- %1$s is the new date and time, e.g. "Fri, 7 Aug, 09:00". -->
|
||||
<string name="event_move_done">Moved to %1$s</string>
|
||||
<string name="event_move_undo">Undo</string>
|
||||
<string name="event_move_undone">Move undone</string>
|
||||
<string name="event_move_failed">Couldn\'t move the event</string>
|
||||
<string name="event_move_write_denied">Calendula needs write access to move events</string>
|
||||
<string name="event_move_gone">That event no longer exists</string>
|
||||
<string name="event_move_blocked_series_end">Can\'t move an event past the end of its series</string>
|
||||
|
||||
<string name="dialog_cancel">Cancel</string>
|
||||
<string name="dialog_ok">OK</string>
|
||||
|
||||
@@ -242,6 +256,10 @@
|
||||
<item quantity="one">%d day</item>
|
||||
<item quantity="other">%d days</item>
|
||||
</plurals>
|
||||
<!-- A mixed duration: %1$s is the hours part, %2$s the minutes part. -->
|
||||
<string name="duration_hours_minutes">%1$s %2$s</string>
|
||||
<!-- Shown instead of the preview when a custom length exceeds what the setting keeps. %1$s is the longest allowed, e.g. "24 hours". -->
|
||||
<string name="duration_custom_max">At most %1$s</string>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="one">%d week</item>
|
||||
<item quantity="other">%d weeks</item>
|
||||
@@ -330,8 +348,8 @@
|
||||
<string name="settings_default_view">Default view</string>
|
||||
<string name="settings_dynamic_color">Dynamic colour</string>
|
||||
<string name="settings_dynamic_color_unavailable">Requires Android 12 or newer</string>
|
||||
<string name="settings_soften_colors">Soften calendar colours</string>
|
||||
<string name="settings_soften_colors_summary">Tone calendar and event colours down to fit the theme. Turn off to show the raw colours from the calendar source.</string>
|
||||
<string name="settings_soften_colors">Harmonise calendar colours</string>
|
||||
<string name="settings_soften_colors_summary">Keep each calendar\'s hue but even out its brightness, so every event stays legible and the colours sit together. Turn off to show the raw colours from the calendar source.</string>
|
||||
<string name="settings_font_headings">Headings font</string>
|
||||
<string name="settings_font_body">Body font</string>
|
||||
<string name="settings_font_system">System default</string>
|
||||
@@ -363,6 +381,18 @@
|
||||
<string name="settings_time_format_auto_summary">Following the system: %1$s</string>
|
||||
<string name="settings_hour_lines">Hour lines</string>
|
||||
<string name="settings_hour_lines_summary">Show a separator line at each hour in week and day view</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="timeline_scale_fit_day">Fit whole day</string>
|
||||
<string name="timeline_scale_fit_day_summary">All 24 hours on one screen, no scrolling</string>
|
||||
<string name="timeline_scale_compact">Compact</string>
|
||||
<string name="timeline_scale_compact_summary">More hours per screen, smaller blocks</string>
|
||||
<string name="timeline_scale_regular">Regular</string>
|
||||
<string name="timeline_scale_regular_summary">The standard spacing</string>
|
||||
<string name="timeline_scale_comfortable">Comfortable</string>
|
||||
<string name="timeline_scale_comfortable_summary">Roomier blocks, more scrolling</string>
|
||||
<string name="timeline_scale_custom">Custom</string>
|
||||
<string name="timeline_scale_custom_summary">The height you pinched the timeline to</string>
|
||||
<string name="settings_dim_completed">Dim completed events</string>
|
||||
<string name="settings_dim_completed_summary">Fade events that have already ended in month and week view</string>
|
||||
<string name="settings_past_events">Past events</string>
|
||||
@@ -428,6 +458,14 @@
|
||||
<string name="settings_form_fields_hint">Fields shown by default — everything else sits behind \"More fields\"</string>
|
||||
<string name="settings_autofocus_title">Focus title on new event</string>
|
||||
<string name="settings_autofocus_title_hint">When you start a new event, place the cursor in the title field and open the keyboard right away.</string>
|
||||
<string name="settings_event_duration">Default duration</string>
|
||||
<string name="settings_event_duration_hint">How long a new event lasts until you change its end time. All-day events aren\'t affected.</string>
|
||||
<string name="settings_calendar_durations_title">Per-calendar duration</string>
|
||||
<string name="settings_calendar_durations_hint">Give a calendar its own default length — e.g. 8 hours for work shifts.</string>
|
||||
<!-- Row summary for a calendar with no duration of its own. %1$s is the default length, e.g. "1 hour". -->
|
||||
<string name="settings_calendar_duration_inherits">Default (%1$s)</string>
|
||||
<!-- Picker row that drops a calendar\'s own length. %1$s is the default length. -->
|
||||
<string name="settings_calendar_duration_use_default">Use default duration (%1$s)</string>
|
||||
<string name="settings_color_unsupported">Allow colors on unsupported calendars</string>
|
||||
<string name="settings_color_unsupported_hint">Some calendars (e.g. certain CalDAV) publish no color set; a custom event color may be dropped or overwritten on their next sync. That\'s a limitation of those calendars, not something Calendula can fix.</string>
|
||||
<string name="settings_section_notifications">Notifications</string>
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.AccessLevel
|
||||
import de.jeanlucmakiola.calendula.domain.Availability
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.realignRecurrence
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
@@ -285,6 +286,89 @@ class EventWriteMapperTest {
|
||||
.isEqualTo(instantAt("2026-01-09T14:30", "Europe/Berlin"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a weekday move must carry the rule with the anchor, or the series stays put`() {
|
||||
// The anchor moves by the same shift as the occurrence, so a Monday
|
||||
// series lands on a Wednesday — while RRULE is written verbatim. Without
|
||||
// realignRecurrence the rule would still say Monday and nothing moves.
|
||||
val series = instantAt("2026-01-05T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 8), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY;BYDAY=MO")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 10), LocalTime(10, 0)),
|
||||
rrule = realignRecurrence("FREQ=WEEKLY;BYDAY=MO", LocalDate(2026, 6, 8), LocalDate(2026, 6, 10)),
|
||||
)
|
||||
|
||||
val values = update(original, moved, series)
|
||||
|
||||
val anchor = java.time.Instant.ofEpochMilli(values[CalendarContract.Events.DTSTART] as Long)
|
||||
.atZone(java.time.ZoneId.of("Europe/Berlin"))
|
||||
assertThat(anchor.dayOfWeek).isEqualTo(java.time.DayOfWeek.WEDNESDAY)
|
||||
assertThat(values[CalendarContract.Events.RRULE]).isEqualTo("FREQ=WEEKLY;BYDAY=WE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recurring move keeps the series' length across a DST boundary`() {
|
||||
// The occurrence being dragged is in CET; the series anchor is in CEST.
|
||||
val series = instantAt("2026-07-15T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 1, 7), LocalTime(10, 30)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 1, 8), LocalTime(10, 30)),
|
||||
)
|
||||
|
||||
assertThat(update(original, moved, series)[CalendarContract.Events.DURATION])
|
||||
.isEqualTo("P5400S")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shifting a one-off event and back again restores the original values`() {
|
||||
val original = form()
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 0)),
|
||||
)
|
||||
|
||||
val forward = update(original, moved)
|
||||
val back = update(moved, original)
|
||||
|
||||
assertThat(back[CalendarContract.Events.DTSTART])
|
||||
.isEqualTo(original.toWriteTimes(berlin).dtStartMillis)
|
||||
assertThat(back[CalendarContract.Events.DTEND])
|
||||
.isEqualTo(original.toWriteTimes(berlin).dtEndMillis)
|
||||
assertThat(forward[CalendarContract.Events.DTSTART])
|
||||
.isNotEqualTo(back[CalendarContract.Events.DTSTART])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undoing a whole-series move lands the anchor back where it started`() {
|
||||
// What makes undo safe for "all events": the shift is applied to the
|
||||
// anchor the provider currently holds, so re-issuing it with the two
|
||||
// forms swapped applies the exact inverse to the moved anchor.
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 15), LocalTime(10, 0)),
|
||||
).copy(rrule = "FREQ=WEEKLY")
|
||||
val moved = original.copy(
|
||||
start = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(14, 30)),
|
||||
end = LocalDateTime(LocalDate(2026, 7, 17), LocalTime(15, 30)),
|
||||
)
|
||||
|
||||
val movedAnchor = update(original, moved, series)[CalendarContract.Events.DTSTART] as Long
|
||||
assertThat(movedAnchor).isNotEqualTo(series)
|
||||
|
||||
val restored = update(moved, original, movedAnchor)
|
||||
assertThat(restored[CalendarContract.Events.DTSTART]).isEqualTo(series)
|
||||
assertThat(restored[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `switching a recurring event to all-day anchors the series on a UTC midnight`() {
|
||||
val series = instantAt("2026-01-07T09:00", "Europe/Berlin")
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.jeanlucmakiola.floret.reminders.ReminderOverride
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
@@ -13,6 +14,7 @@ import de.jeanlucmakiola.calendula.ui.agenda.AgendaRange
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.common.IMPLEMENTED_VIEWS
|
||||
import de.jeanlucmakiola.calendula.ui.common.QuickSwitchConfig
|
||||
import de.jeanlucmakiola.calendula.ui.common.TimelineScale
|
||||
import de.jeanlucmakiola.calendula.ui.month.MonthViewStyle
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -98,6 +100,31 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.showHourLines.first()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timeline scale defaults to regular and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
// Regular is the historical 56dp scale — an existing install that never
|
||||
// opened the setting must keep the timeline it had (#56).
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Regular)
|
||||
prefs.setTimelineScale(TimelineScale.FitDay)
|
||||
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.FitDay)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinched timeline height round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
// The pinch settles on a height between the presets, so the preference
|
||||
// has to store the number, not just a named step (#56).
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
val pinched = TimelineScale.custom(63.5f.dp)
|
||||
prefs.setTimelineScale(pinched)
|
||||
assertThat(prefs.timelineScale.first()).isEqualTo(pinched)
|
||||
|
||||
// …and a preset picked afterwards replaces it, rather than the two
|
||||
// coexisting with one silently winning.
|
||||
prefs.setTimelineScale(TimelineScale.Comfortable)
|
||||
assertThat(prefs.timelineScale.first()).isEqualTo(TimelineScale.Comfortable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `week numbers default off and round-trips`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
@@ -475,6 +502,56 @@ class SettingsPrefsTest {
|
||||
assertThat(prefs.snoozeMinutes.first()).isEqualTo(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event duration defaults to an hour and clamps to a sane span`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(60)
|
||||
prefs.setDefaultEventDurationMinutes(480)
|
||||
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(480)
|
||||
prefs.setDefaultEventDurationMinutes(0)
|
||||
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(1)
|
||||
prefs.setDefaultEventDurationMinutes(5_000)
|
||||
assertThat(prefs.defaultEventDurationMinutes.first()).isEqualTo(1_440)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `per-calendar duration round-trips and clears back to inherit`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
assertThat(prefs.perCalendarEventDuration.first()).isEmpty()
|
||||
prefs.setCalendarEventDuration(7L, 480)
|
||||
prefs.setCalendarEventDuration(9L, 30)
|
||||
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(7L, 480, 9L, 30)
|
||||
prefs.setCalendarEventDuration(7L, null)
|
||||
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `garbage per-calendar duration entries are dropped, the rest survive`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest {
|
||||
val store = newDataStore(tempDir)
|
||||
val prefs = SettingsPrefs(store)
|
||||
store.updateData { p ->
|
||||
val m = p.toMutablePreferences()
|
||||
// A bad id, a non-numeric length, one out of range, and a good entry.
|
||||
m[SettingsPrefs.CALENDAR_EVENT_DURATION_KEY] = "x=60;7=soon;8=99999;9=45"
|
||||
m
|
||||
}
|
||||
assertThat(prefs.perCalendarEventDuration.first()).containsExactly(9L, 45)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolveDefaultEventDuration prefers the calendar's own length`() {
|
||||
val overrides = mapOf(7L to 480)
|
||||
assertThat(resolveDefaultEventDuration(60, overrides, calendarId = 7L)).isEqualTo(480)
|
||||
assertThat(resolveDefaultEventDuration(60, overrides, calendarId = 9L)).isEqualTo(60)
|
||||
assertThat(resolveDefaultEventDuration(60, overrides, calendarId = null)).isEqualTo(60)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom-font stamps default to zero and bump per role independently`(@TempDir tempDir: Path) = runTest {
|
||||
val prefs = SettingsPrefs(newDataStore(tempDir))
|
||||
|
||||
@@ -94,6 +94,27 @@ class CalendarRowStateTest {
|
||||
assertThat(calendar.isEventTarget).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a managed calendar's events are editable but never movable`() {
|
||||
// Their date is owned by the contacts sync, so a drag would be undone.
|
||||
val managed = cal().copy(isManaged = true)
|
||||
assertThat(managed.allowsEventMove).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read-only calendar's events are not movable`() {
|
||||
assertThat(cal().copy(canModifyContents = false).allowsEventMove).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a switched-off calendar's events stay movable, unlike a new-event target`() {
|
||||
// Nothing renders them, so the question is moot — but the predicate is
|
||||
// deliberately not isEventTarget, which would also exclude them.
|
||||
val hidden = cal().copy(isVisibleInSystem = false)
|
||||
assertThat(hidden.isEventTarget).isFalse()
|
||||
assertThat(hidden.allowsEventMove).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manager order puts non-syncing calendars last and is otherwise stable`() {
|
||||
val ordered = listOf(
|
||||
|
||||
@@ -74,10 +74,10 @@ class EventColorPaletteTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `neutrals collapse to one painted tint instead of a run of look-alikes`() {
|
||||
// Black and every gray paint as the same pale swatch (the picker pins
|
||||
// lightness and floors saturation), so only one survives — no stranded
|
||||
// run of look-alike "pinks" at the end of the grid (#22).
|
||||
fun `neutrals collapse instead of forming a run of look-alikes`() {
|
||||
// Neutrals lose their hue entirely when painted, so they collapse onto
|
||||
// the one or two greys the poles offer — a dark and a pale — instead of
|
||||
// the old stranded run of look-alike "pinks" at the end of the grid (#22).
|
||||
val curated = listOf(
|
||||
EventColorOption("black", 0xFF000000.toInt()),
|
||||
EventColorOption("gray", 0xFF808080.toInt()),
|
||||
@@ -86,8 +86,11 @@ class EventColorPaletteTest {
|
||||
EventColorOption("red", 0xFFFF0000.toInt()),
|
||||
).curatedForPicker().map { it.key }
|
||||
|
||||
assertThat(curated).containsNoneOf("gray", "darkgray") // folded into black
|
||||
assertThat(curated).doesNotContain("gray") // paints as black's grey
|
||||
assertThat(curated).containsAtLeast("black", "red", "blue")
|
||||
// darkgray is light enough to keep a pale character, so the three
|
||||
// neutrals still fold down to at most two swatches, never a run.
|
||||
assertThat(curated.count { it in listOf("black", "gray", "darkgray") }).isAtMost(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EventShiftTest {
|
||||
|
||||
private val berlin = TimeZone.of("Europe/Berlin")
|
||||
private val newYork = TimeZone.of("America/New_York")
|
||||
|
||||
private fun form(
|
||||
start: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||
end: LocalDateTime = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(11, 0)),
|
||||
timezone: String? = null,
|
||||
isAllDay: Boolean = false,
|
||||
) = EventForm(
|
||||
calendarId = 1L,
|
||||
title = "Standup",
|
||||
isAllDay = isAllDay,
|
||||
start = start,
|
||||
end = end,
|
||||
timezone = timezone,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `shifting to a new start keeps the length`() {
|
||||
val original = form()
|
||||
val target = LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)).toInstant(berlin)
|
||||
|
||||
val moved = original.shiftedTo(target, berlin)
|
||||
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(14, 30)))
|
||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(15, 30)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinned event is re-derived in its own zone, not the device's`() {
|
||||
// 09:00 in New York, opened on a Berlin device.
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 0)),
|
||||
timezone = "America/New_York",
|
||||
)
|
||||
// The grid says "15:30 Berlin", which is 09:30 in New York.
|
||||
val target = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(15, 30)).toInstant(berlin)
|
||||
|
||||
val moved = original.shiftedTo(target, berlin)
|
||||
|
||||
assertThat(moved.timezone).isEqualTo("America/New_York")
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(9, 30)))
|
||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(10, 30)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the instant duration survives a shift across spring forward`() {
|
||||
// 2026-03-29 02:00 CET is when Berlin skips to 03:00.
|
||||
val original = form(
|
||||
start = LocalDateTime(LocalDate(2026, 3, 28), LocalTime(23, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)),
|
||||
)
|
||||
val target = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)).toInstant(berlin)
|
||||
|
||||
val moved = original.shiftedTo(target, berlin)
|
||||
|
||||
// Two real hours from 01:00 CET lands at 04:00 CEST, not 03:00: the wall
|
||||
// clock stretches because the hour in between does not exist.
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(1, 0)))
|
||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(4, 0)))
|
||||
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
|
||||
.isEqualTo(original.end.toInstant(berlin) - original.start.toInstant(berlin))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drop into the spring-forward gap resolves forward by the missing hour`() {
|
||||
val original = form()
|
||||
// 02:30 does not exist on 2026-03-29 in Berlin.
|
||||
val target = LocalDateTime(LocalDate(2026, 3, 29), LocalTime(2, 30)).toInstant(berlin)
|
||||
|
||||
val moved = original.shiftedTo(target, berlin)
|
||||
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(3, 30)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero-distance drop leaves the form untouched`() {
|
||||
val original = form()
|
||||
assertThat(original.shiftedTo(original.start.toInstant(berlin), berlin)).isEqualTo(original)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unparseable pinned zone falls back to the device, like the write path`() {
|
||||
val original = form(timezone = "Mars/Olympus")
|
||||
val target = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)).toInstant(newYork)
|
||||
|
||||
val moved = original.shiftedTo(target, newYork)
|
||||
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(12, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an all-day event ignores shiftedTo and moves by whole days instead`() {
|
||||
val allDay = form(isAllDay = true)
|
||||
val target = LocalDateTime(LocalDate(2026, 7, 1), LocalTime(3, 0)).toInstant(berlin)
|
||||
|
||||
assertThat(allDay.shiftedTo(target, berlin)).isEqualTo(allDay)
|
||||
|
||||
val moved = allDay.shiftedByDays(3, berlin)
|
||||
assertThat(moved.start.date).isEqualTo(LocalDate(2026, 6, 14))
|
||||
assertThat(moved.end.date).isEqualTo(LocalDate(2026, 6, 14))
|
||||
// The placeholder times exist only for a switch back to timed; untouched.
|
||||
assertThat(moved.start.time).isEqualTo(allDay.start.time)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day event keeps its span when shifted by days`() {
|
||||
val multiDay = form(
|
||||
start = LocalDateTime(LocalDate(2026, 6, 11), LocalTime(22, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 6, 13), LocalTime(2, 0)),
|
||||
)
|
||||
|
||||
val moved = multiDay.shiftedByDays(-2, berlin)
|
||||
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 6, 9), LocalTime(22, 0)))
|
||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 6, 11), LocalTime(2, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a timed shift onto a DST changeover keeps the real length, not the wall clock`() {
|
||||
// 22:00 Sat -> 04:00 Sun is six real hours; the target Sunday is the one
|
||||
// Berlin springs forward on. Keeping wall clock would write a five-hour
|
||||
// DURATION for the whole series.
|
||||
val overnight = form(
|
||||
start = LocalDateTime(LocalDate(2026, 3, 21), LocalTime(22, 0)),
|
||||
end = LocalDateTime(LocalDate(2026, 3, 22), LocalTime(4, 0)),
|
||||
)
|
||||
|
||||
val moved = overnight.shiftedByDays(7, berlin)
|
||||
|
||||
assertThat(moved.start).isEqualTo(LocalDateTime(LocalDate(2026, 3, 28), LocalTime(22, 0)))
|
||||
assertThat(moved.end.toInstant(berlin) - moved.start.toInstant(berlin))
|
||||
.isEqualTo(overnight.end.toInstant(berlin) - overnight.start.toInstant(berlin))
|
||||
// The wall-clock end therefore lands an hour later than a naive +7 days.
|
||||
assertThat(moved.end).isEqualTo(LocalDateTime(LocalDate(2026, 3, 29), LocalTime(5, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shifting by no days is a no-op`() {
|
||||
val original = form()
|
||||
assertThat(original.shiftedByDays(0, berlin)).isEqualTo(original)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package de.jeanlucmakiola.calendula.domain
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.datetime.LocalDate
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class RecurrenceRealignTest {
|
||||
|
||||
private val monday = LocalDate(2026, 6, 8)
|
||||
private val wednesday = LocalDate(2026, 6, 10)
|
||||
|
||||
@Test
|
||||
fun `a weekly BYDAY rule follows the occurrence to its new weekday`() {
|
||||
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO", monday, wednesday))
|
||||
.isEqualTo("FREQ=WEEKLY;BYDAY=WE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unrelated parts survive the rewrite`() {
|
||||
assertThat(
|
||||
realignRecurrence("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;COUNT=10", monday, wednesday),
|
||||
).isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE;COUNT=10")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a day-of-month rule is refused, because the anchor moves by days not dates`() {
|
||||
// BYMONTHDAY=28 with a January anchor, occurrence Feb 28 dragged to Mar 1:
|
||||
// the rebuilt rule would say the 1st while the anchor became Jan 29 — a
|
||||
// DTSTART that is not an instance of its own rule. Weekday arithmetic is
|
||||
// uniform mod 7 and survives the same shift; day-of-month is not.
|
||||
assertThat(realignRecurrence("FREQ=MONTHLY;BYMONTHDAY=8", monday, wednesday)).isNull()
|
||||
assertThat(
|
||||
realignRecurrence("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=8", monday, LocalDate(2026, 7, 20)),
|
||||
).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `BYDAY on a non-weekly rule is refused`() {
|
||||
// "every Monday of the month" is a shape this has not been reasoned about,
|
||||
// and parseSimpleRecurrence can't read it either — so the UNTIL guard
|
||||
// downstream would be blind to it.
|
||||
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=MO", monday, wednesday)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule with no FREQ is refused`() {
|
||||
assertThat(realignRecurrence("INTERVAL=2;BYDAY=MO", monday, wednesday)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule with no day-selecting part needs no rewrite`() {
|
||||
assertThat(realignRecurrence("FREQ=DAILY;INTERVAL=3", monday, wednesday))
|
||||
.isEqualTo("FREQ=DAILY;INTERVAL=3")
|
||||
assertThat(realignRecurrence("FREQ=WEEKLY", monday, wednesday)).isEqualTo("FREQ=WEEKLY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule the move does not disturb comes back verbatim`() {
|
||||
val rule = "FREQ=WEEKLY;BYDAY=MO,WE,FR"
|
||||
assertThat(realignRecurrence(rule, monday, monday)).isEqualTo(rule)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-day BYDAY cannot be resolved from one moved occurrence`() {
|
||||
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO,WE", monday, wednesday)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinal BYDAY cannot be resolved`() {
|
||||
assertThat(realignRecurrence("FREQ=MONTHLY;BYDAY=2MO", monday, wednesday)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a BYDAY that does not name the occurrence's own weekday is refused`() {
|
||||
// The rule and DTSTART already disagree; guessing would make it worse.
|
||||
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=TU", monday, wednesday)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parts we cannot reason about are refused rather than guessed`() {
|
||||
assertThat(realignRecurrence("FREQ=WEEKLY;BYDAY=MO;BYSETPOS=1", monday, wednesday))
|
||||
.isNull()
|
||||
assertThat(realignRecurrence("FREQ=YEARLY;BYYEARDAY=159", monday, wednesday)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything realignable is also a rule the UNTIL guard can read`() {
|
||||
// problems() checks UNTIL through parseSimpleRecurrence; a rule this
|
||||
// realigns but that parser rejects would move a series past its own end
|
||||
// unchecked. Accepting only weekly BYDAY keeps the two in step.
|
||||
val realignable = listOf(
|
||||
"FREQ=WEEKLY;BYDAY=MO",
|
||||
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO;UNTIL=20261231T225959Z",
|
||||
"FREQ=DAILY;COUNT=5",
|
||||
)
|
||||
realignable.forEach { rule ->
|
||||
assertThat(realignRecurrence(rule, monday, wednesday)).isNotNull()
|
||||
assertThat(parseSimpleRecurrence(realignRecurrence(rule, monday, wednesday)!!))
|
||||
.isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a leading RRULE prefix is preserved`() {
|
||||
assertThat(realignRecurrence("RRULE:FREQ=WEEKLY;BYDAY=MO", monday, wednesday))
|
||||
.isEqualTo("RRULE:FREQ=WEEKLY;BYDAY=WE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a malformed rule is refused`() {
|
||||
assertThat(realignRecurrence("FREQ=WEEKLY;GARBAGE", monday, wednesday)).isNull()
|
||||
assertThat(realignRecurrence("", monday, wednesday)).isNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package de.jeanlucmakiola.calendula.domain.color
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EventToneTest {
|
||||
|
||||
/** Hues at full chroma, standing in for the provider colours in the wild. */
|
||||
private val hues = (0 until 360 step 15).map { Oklch(0.65f, 0.2f, it.toFloat()).toArgb() }
|
||||
|
||||
private fun relativeLuminance(argb: Int): Double {
|
||||
fun channel(shift: Int): Double {
|
||||
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||
return if (c <= 0.04045) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
|
||||
}
|
||||
|
||||
private fun contrast(a: Int, b: Int): Double {
|
||||
val hi = maxOf(relativeLuminance(a), relativeLuminance(b))
|
||||
val lo = minOf(relativeLuminance(a), relativeLuminance(b))
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
/** The other ink, for asserting the chosen one is the better of the two. */
|
||||
private fun flip(ink: Int) =
|
||||
if (ink == 0xFFFFFFFF.toInt()) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
|
||||
|
||||
/** [ink] over [fill] at [alpha], the way a title actually renders. */
|
||||
private fun composite(ink: Int, fill: Int, alpha: Double): Int {
|
||||
fun mix(shift: Int): Int {
|
||||
val i = (ink shr shift) and 0xFF
|
||||
val f = (fill shr shift) and 0xFF
|
||||
return (alpha * i + (1 - alpha) * f).toInt().coerceIn(0, 255)
|
||||
}
|
||||
return (0xFF shl 24) or (mix(16) shl 16) or (mix(8) shl 8) or mix(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hues of one lightness all get the same ink`() {
|
||||
// The bug this replaced: at a pinned HSV value the hues straddled the ink
|
||||
// crossover, so red took white while orange beside it took black. Ink may
|
||||
// still differ by *pole*, but never by hue within a pole.
|
||||
for (dark in listOf(false, true)) {
|
||||
val inks = hues.map { eventTone(it, dark, harmonise = true).onContainer }.toSet()
|
||||
assertThat(inks).containsExactly(0xFFFFFFFF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `naturally light colours keep their character instead of turning muddy`() {
|
||||
// Forcing every hue deep made a yellow into olive and a cream into brown.
|
||||
// Pale sources stay pale — and then take dark ink.
|
||||
for (pale in listOf(0xFFF6BF26, 0xFFFFF8DC, 0xFFF0E68C, 0xFFFFFFFF)) {
|
||||
val tone = eventTone(pale.toInt(), dark = false, harmonise = true)
|
||||
assertThat(oklchOf(tone.container).lightness).isGreaterThan(0.8f)
|
||||
assertThat(tone.onContainer).isEqualTo(0xFF000000.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `warm mid colours still go deep, so orange reads as orange and not as cream`() {
|
||||
// The threshold has to sit above orange: a burnt orange still says
|
||||
// "orange", which is why it belongs on the deep pole with white ink.
|
||||
// Note the ceiling this implies: a *bright* orange (#FF8C00, #FFA500) is
|
||||
// genuinely a light colour and takes the light pole instead — forcing it
|
||||
// deep is exactly what produced brown.
|
||||
for (warm in listOf(0xFFF4511E, 0xFFE67C73, 0xFFD50000, 0xFFE65100)) {
|
||||
val tone = eventTone(warm.toInt(), dark = false, harmonise = true)
|
||||
assertThat(oklchOf(tone.container).lightness).isLessThan(0.6f)
|
||||
assertThat(tone.onContainer).isEqualTo(0xFFFFFFFF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `title ink clears WCAG AA on every hue`() {
|
||||
for (dark in listOf(false, true)) {
|
||||
for (hue in hues) {
|
||||
val tone = eventTone(hue, dark, harmonise = true)
|
||||
val ink = composite(tone.onContainer, tone.container, 0.85)
|
||||
assertThat(contrast(tone.container, ink)).isGreaterThan(4.5)
|
||||
assertThat(contrast(tone.container, ink))
|
||||
.isGreaterThan(contrast(tone.container, composite(flip(tone.onContainer), tone.container, 0.85)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the secondary line also clears WCAG AA on every hue`() {
|
||||
for (dark in listOf(false, true)) {
|
||||
for (hue in hues) {
|
||||
val tone = eventTone(hue, dark, harmonise = true)
|
||||
val ink = composite(tone.onContainer, tone.container, 0.8)
|
||||
assertThat(contrast(tone.container, ink)).isGreaterThan(4.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an accent stays visible against its own theme's surface`() {
|
||||
// Accents carry no text, so the bar is WCAG's 3:1 for non-text contrast.
|
||||
val lightSurface = 0xFFFEF7FF.toInt()
|
||||
val darkSurface = 0xFF141218.toInt()
|
||||
for ((dark, surface) in listOf(false to lightSurface, true to darkSurface)) {
|
||||
for (hue in hues) {
|
||||
val accent = eventTone(hue, dark, harmonise = true).accent
|
||||
assertThat(contrast(accent, surface)).isGreaterThan(3.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `container and accent keep the same hue, so one calendar reads as one colour`() {
|
||||
for (hue in hues) {
|
||||
val tone = eventTone(hue, dark = false, harmonise = true)
|
||||
assertThat(oklchOf(tone.accent).hue).isWithin(2f).of(oklchOf(tone.container).hue)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `orange no longer gets dark ink`() {
|
||||
// The reported case: a plain orange calendar used to fall just above the
|
||||
// crossover and take black text while its neighbours took white.
|
||||
val tone = eventTone(0xFFF4511E.toInt(), dark = false, harmonise = true)
|
||||
assertThat(tone.onContainer).isEqualTo(0xFFFFFFFF.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `different hues stay distinguishable`() {
|
||||
val containers = hues.map { eventTone(it, dark = false, harmonise = true).container }
|
||||
assertThat(containers.toSet()).hasSize(hues.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a grey source stays grey rather than gaining an invented hue`() {
|
||||
for (grey in listOf(0xFF000000, 0xFF808080, 0xFFFFFFFF, 0xFF9E9E9E)) {
|
||||
val tone = eventTone(grey.toInt(), dark = false, harmonise = true)
|
||||
assertThat(oklchOf(tone.container).chroma).isLessThan(0.01f)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `raw colours are painted verbatim when harmonising is off`() {
|
||||
val raw = 0xFFF4511E.toInt()
|
||||
val tone = eventTone(raw, dark = false, harmonise = false)
|
||||
assertThat(tone.container).isEqualTo(raw)
|
||||
assertThat(tone.accent).isEqualTo(raw)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `raw mode still picks a readable ink per colour`() {
|
||||
// Nothing constrains what a provider sends, so the ink cannot be constant
|
||||
// on this path the way it is for harmonised containers.
|
||||
assertThat(eventTone(0xFF101010.toInt(), dark = false, harmonise = false).onContainer)
|
||||
.isEqualTo(0xFFFFFFFF.toInt())
|
||||
assertThat(eventTone(0xFFFFF6C0.toInt(), dark = false, harmonise = false).onContainer)
|
||||
.isEqualTo(0xFF000000.toInt())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.jeanlucmakiola.calendula.domain.color
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class OklchTest {
|
||||
|
||||
@Test
|
||||
fun `matches Oklab's published anchors`() {
|
||||
// Guards the conversion matrices against a transcription slip: these are
|
||||
// Ottosson's own reference values for the space.
|
||||
assertThat(oklchOf(0xFFFFFFFF.toInt()).lightness).isWithin(0.001f).of(1f)
|
||||
assertThat(oklchOf(0xFF000000.toInt()).lightness).isWithin(0.001f).of(0f)
|
||||
assertThat(oklchOf(0xFFFF0000.toInt()).lightness).isWithin(0.001f).of(0.6280f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `white and black have no chroma`() {
|
||||
assertThat(oklchOf(0xFFFFFFFF.toInt()).chroma).isWithin(0.001f).of(0f)
|
||||
assertThat(oklchOf(0xFF000000.toInt()).chroma).isWithin(0.001f).of(0f)
|
||||
assertThat(oklchOf(0xFF808080.toInt()).chroma).isWithin(0.001f).of(0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `converting to sRGB and back round-trips`() {
|
||||
for (argb in listOf(0xFFF4511E, 0xFF039BE5, 0xFF0B8043, 0xFF8E24AA, 0xFFFBD75B)) {
|
||||
val original = oklchOf(argb.toInt())
|
||||
val roundTripped = oklchOf(original.toArgb())
|
||||
assertThat(roundTripped.lightness).isWithin(0.01f).of(original.lightness)
|
||||
assertThat(roundTripped.chroma).isWithin(0.01f).of(original.chroma)
|
||||
assertThat(roundTripped.hue).isWithin(1f).of(original.hue)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an out-of-gamut request keeps its lightness and gives up chroma`() {
|
||||
// Most of the Oklch cylinder is outside sRGB. Lightness is the axis the
|
||||
// contrast guarantees rest on, so it is the one that must survive.
|
||||
val requested = Oklch(0.45f, 0.35f, 150f)
|
||||
val actual = oklchOf(requested.toArgb())
|
||||
assertThat(actual.lightness).isWithin(0.02f).of(requested.lightness)
|
||||
assertThat(actual.chroma).isLessThan(requested.chroma)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinning lightness holds luminance far tighter than pinning HSV value did`() {
|
||||
// The whole reason for the space swap. At a pinned HSV value the hues
|
||||
// spread across relative luminance 0.10..0.45; pinned Oklch lightness
|
||||
// must keep them in a narrow band, or one ink cannot serve them all.
|
||||
val luminances = (0 until 360 step 15).map { hue ->
|
||||
val argb = Oklch(0.45f, 0.16f, hue.toFloat()).toArgb()
|
||||
relativeLuminance(argb)
|
||||
}
|
||||
assertThat(luminances.max() / luminances.min()).isLessThan(2.5)
|
||||
}
|
||||
|
||||
private fun relativeLuminance(argb: Int): Double {
|
||||
fun channel(shift: Int): Double {
|
||||
val c = ((argb shr shift) and 0xFF) / 255.0
|
||||
return if (c <= 0.04045) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
return 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package de.jeanlucmakiola.calendula.ui.common
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
|
||||
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
|
||||
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The drop pipeline: which repository call a scope maps to, when a recurring drop
|
||||
* has to ask first, and every case that must refuse to write.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RescheduleViewModelTest {
|
||||
|
||||
private val dispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@BeforeEach fun setUp() = Dispatchers.setMain(dispatcher)
|
||||
@AfterEach fun tearDown() = Dispatchers.resetMain()
|
||||
|
||||
// Monday 2026-06-08, midday in whatever zone the test JVM runs in — resolved
|
||||
// in the device zone rather than UTC, because that is the zone the drop path
|
||||
// reads dates back in, and at UTC+13 a UTC midday is already Tuesday. The
|
||||
// BYDAY assertions below depend on this really being a Monday.
|
||||
private val monday = LocalDate(2026, 6, 8)
|
||||
private val beginMillis = LocalDateTime(monday, LocalTime(12, 0))
|
||||
.toInstant(TimeZone.currentSystemDefault())
|
||||
.toEpochMilliseconds()
|
||||
private val endMillis = beginMillis + 3_600_000L
|
||||
|
||||
private fun cal(
|
||||
id: Long,
|
||||
canModify: Boolean = true,
|
||||
managed: Boolean = false,
|
||||
): CalendarSource = CalendarSource(
|
||||
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
||||
color = 0xFF112233.toInt(), isVisibleInSystem = true, canModifyContents = canModify,
|
||||
isManaged = managed,
|
||||
)
|
||||
|
||||
private fun detail(
|
||||
rrule: String? = null,
|
||||
isAllDay: Boolean = false,
|
||||
isException: Boolean = false,
|
||||
): EventDetail = EventDetail(
|
||||
instance = EventInstance(
|
||||
instanceId = 42L, eventId = 42L, calendarId = 1L, title = "Standup",
|
||||
start = Instant.fromEpochMilliseconds(beginMillis),
|
||||
end = Instant.fromEpochMilliseconds(endMillis),
|
||||
isAllDay = isAllDay, color = 0xFF000000.toInt(), location = null,
|
||||
),
|
||||
description = null, organizer = null, attendees = emptyList(), rrule = rrule,
|
||||
isException = isException,
|
||||
)
|
||||
|
||||
private fun viewModel(tempDir: Path, fake: FakeCalendarDataSource): RescheduleViewModel {
|
||||
val prefs = CalendarPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("move_prefs.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
val settings = SettingsPrefs(
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = CoroutineScope(dispatcher),
|
||||
produceFile = { tempDir.resolve("move_settings.preferences_pb").toFile() },
|
||||
),
|
||||
)
|
||||
val repo = CalendarRepositoryImpl(fake, prefs, settings, dispatcher as CoroutineDispatcher)
|
||||
return RescheduleViewModel(repo, dispatcher)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.activate(vm: RescheduleViewModel): Job =
|
||||
launch { vm.movableCalendarIds.collect {} }
|
||||
|
||||
/** A drop two days later, expressed the way the month grid expresses one. */
|
||||
private fun toWednesday() = MoveRequest(
|
||||
eventId = 42L,
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.ByDays(2),
|
||||
)
|
||||
|
||||
/** A drop an hour later, expressed the way a timeline drag expresses one. */
|
||||
private fun oneHourLater() = MoveRequest(
|
||||
eventId = 42L,
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis + 3_600_000L)),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a one-off drop writes straight through with no scope prompt`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isNull()
|
||||
assertThat(fake.updatedEvents).hasSize(1)
|
||||
val (id, original, updated) = fake.updatedEvents.single()
|
||||
assertThat(id).isEqualTo(42L)
|
||||
assertThat(updated.start).isNotEqualTo(original.start)
|
||||
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a recurring drop parks for the scope instead of writing`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false))
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
assertThat(fake.updatedOccurrences).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each scope maps to its own repository call`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val occurrence = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
viewModel(tempDir.resolve("a").also { it.toFile().mkdirs() }, occurrence).run {
|
||||
move(oneHourLater())
|
||||
moveWithScope(RecurringWriteScope.ThisEvent)
|
||||
}
|
||||
advanceUntilIdle()
|
||||
assertThat(occurrence.updatedOccurrences).hasSize(1)
|
||||
assertThat(occurrence.updatedOccurrences.single().second).isEqualTo(beginMillis)
|
||||
|
||||
val following = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
viewModel(tempDir.resolve("b").also { it.toFile().mkdirs() }, following).run {
|
||||
move(oneHourLater())
|
||||
moveWithScope(RecurringWriteScope.ThisAndFollowing)
|
||||
}
|
||||
advanceUntilIdle()
|
||||
assertThat(following.updatedFromOccurrences).hasSize(1)
|
||||
|
||||
val series = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
viewModel(tempDir.resolve("c").also { it.toFile().mkdirs() }, series).run {
|
||||
move(oneHourLater())
|
||||
moveWithScope(RecurringWriteScope.AllEvents)
|
||||
}
|
||||
advanceUntilIdle()
|
||||
assertThat(series.updatedEvents).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a weekly BYDAY rule is realigned when the whole series moves weekday`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(toWednesday())
|
||||
vm.moveWithScope(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Without this the anchor becomes a Wednesday while the rule still says
|
||||
// Monday, and the series does not move at all.
|
||||
assertThat(fake.updatedEvents.single().third.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=WE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rule that cannot be realigned offers only the single occurrence`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(toWednesday())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a same-time drop within one day still needs no realignment`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO,WE") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
|
||||
// The weekday is unchanged, so the ambiguous BYDAY is not in the way.
|
||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an exception row is written as a plain event, never as a nested exception`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
// A sync adapter that left the series' rule on the override row.
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY", isException = true) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isNull()
|
||||
assertThat(fake.updatedEvents).hasSize(1)
|
||||
assertThat(fake.updatedOccurrences).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drop past the series' own UNTIL is refused, not written`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
// Midday UTC, so the device zone can't push the UNTIL date onto the
|
||||
// day the event is being dragged to.
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(toWednesday())
|
||||
vm.moveWithScope(RecurringWriteScope.AllEvents)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.BlockedSeriesEnd)
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dragging a bounded series' last occurrence still allows moving just it`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;UNTIL=20260608T120000Z") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(toWednesday())
|
||||
advanceUntilIdle()
|
||||
// The block belongs to the write, not to the drop: an exception row is
|
||||
// constrained by no UNTIL, so this scope must still be on offer.
|
||||
assertThat(vm.scopePrompt.value).isNotNull()
|
||||
|
||||
vm.moveWithScope(RecurringWriteScope.ThisEvent)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.updatedOccurrences).hasSize(1)
|
||||
assertThat(vm.outcome.value).isInstanceOf(MoveOutcome.Moved::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drag that changes both the day and the time cannot move the series`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY;BYDAY=MO") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
// Onto the next day *and* two hours later: the anchor moves by the same
|
||||
// wall-clock shift, so an anchor late enough in the day would cross two
|
||||
// midnights and land on a weekday the rebuilt rule doesn't name.
|
||||
vm.move(
|
||||
MoveRequest(
|
||||
eventId = 42L,
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Start(
|
||||
Instant.fromEpochMilliseconds(beginMillis + 26 * 3_600_000L),
|
||||
),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isEqualTo(MoveScopePrompt(occurrenceOnly = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero-distance drop writes nothing and says nothing`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(
|
||||
MoveRequest(
|
||||
eventId = 42L,
|
||||
beginMillis = beginMillis,
|
||||
endMillis = endMillis,
|
||||
target = MoveTarget.Start(Instant.fromEpochMilliseconds(beginMillis)),
|
||||
),
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
assertThat(vm.outcome.value).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a vanished event reports itself as gone`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { null } }
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Gone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a revoked write permission is reported apart from a plain failure`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail() }
|
||||
writeError = SecurityException("revoked")
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.WriteDenied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `undo writes the move back the other way`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { eventDetailResult = { detail() } }
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
advanceUntilIdle()
|
||||
val undo = (vm.outcome.value as MoveOutcome.Moved).undo
|
||||
assertThat(undo).isNotNull()
|
||||
|
||||
vm.undo(undo!!)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(fake.updatedEvents).hasSize(2)
|
||||
val forward = fake.updatedEvents[0]
|
||||
val back = fake.updatedEvents[1]
|
||||
assertThat(back.second).isEqualTo(forward.third)
|
||||
assertThat(back.third).isEqualTo(forward.second)
|
||||
assertThat(vm.outcome.value).isEqualTo(MoveOutcome.Undone)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an occurrence write offers no undo, since shifting back cannot restore it`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
vm.moveWithScope(RecurringWriteScope.ThisEvent)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat((vm.outcome.value as MoveOutcome.Moved).undo).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only writable, unmanaged calendars can be dragged from`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(
|
||||
cal(1L),
|
||||
cal(2L, canModify = false),
|
||||
cal(3L, managed = true),
|
||||
)
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.movableCalendarIds.value).containsExactly(1L)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelling the scope dialog writes nothing`(@TempDir tempDir: Path) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
eventDetailResult = { detail(rrule = "FREQ=WEEKLY") }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
|
||||
vm.move(oneHourLater())
|
||||
vm.cancelScope()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.scopePrompt.value).isNull()
|
||||
assertThat(fake.updatedEvents).isEmpty()
|
||||
assertThat(fake.updatedOccurrences).isEmpty()
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,14 @@ class TimeFormatTest {
|
||||
assertThat(formatMinuteOfDay(13 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("13:15")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `gutter time drops the meridiem and clamps to the day`() {
|
||||
assertThat(formatGutterTime(9 * 60 + 15, is24Hour = true, Locale.US)).isEqualTo("09:15")
|
||||
assertThat(formatGutterTime(13 * 60 + 45, is24Hour = false, Locale.US)).isEqualTo("1:45")
|
||||
assertThat(formatGutterTime(0, is24Hour = false, Locale.US)).isEqualTo("12:00")
|
||||
assertThat(formatGutterTime(1_440, is24Hour = true, Locale.US)).isEqualTo("23:59")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hour label is zero-padded in 24h and compact am-pm in 12h`() {
|
||||
assertThat(formatHourLabel(13, is24Hour = true, Locale.US)).isEqualTo("13")
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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 TimelineScaleTest {
|
||||
|
||||
/** A Pixel-7-ish timeline viewport: what the issue reporter is looking at. */
|
||||
private val phoneViewport = 670.dp
|
||||
|
||||
@Test
|
||||
fun `Regular is the timeline's original 56dp constant`() {
|
||||
// The default must not move: an install that never opens the setting has
|
||||
// to keep the week and day views it already had (#56).
|
||||
assertThat(TimelineScale.Regular.hourHeight(phoneViewport)).isEqualTo(56.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fixed scales ignore the viewport`() {
|
||||
for (scale in listOf(TimelineScale.Compact, TimelineScale.Regular, TimelineScale.Comfortable)) {
|
||||
assertThat(scale.hourHeight(200.dp)).isEqualTo(scale.hourHeight(2000.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fixed scales get taller in listed order`() {
|
||||
assertThat(TimelineScale.Compact.hourHeight(phoneViewport))
|
||||
.isLessThan(TimelineScale.Regular.hourHeight(phoneViewport))
|
||||
assertThat(TimelineScale.Regular.hourHeight(phoneViewport))
|
||||
.isLessThan(TimelineScale.Comfortable.hourHeight(phoneViewport))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fit-day puts all 24 hours inside a phone viewport`() {
|
||||
// The whole point of the issue: no vertical scrolling to see the day.
|
||||
val h = TimelineScale.FitDay.hourHeight(phoneViewport)
|
||||
assertThat(h * 24).isAtMost(phoneViewport)
|
||||
// …and it uses the space, rather than leaving most of it empty.
|
||||
assertThat(h * 24).isGreaterThan(phoneViewport * 0.9f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fit-day clamps instead of shrinking hours past legibility`() {
|
||||
// A very short viewport (split screen, tiny device) would otherwise give
|
||||
// hour rows too small for the gutter's 24 labels; the clamp wins and the
|
||||
// timeline keeps a little scroll.
|
||||
assertThat(TimelineScale.FitDay.hourHeight(120.dp)).isEqualTo(FIT_DAY_MIN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fit-day clamps instead of stretching hours on a very tall viewport`() {
|
||||
assertThat(TimelineScale.FitDay.hourHeight(4000.dp)).isEqualTo(FIT_DAY_MAX)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the minimum event height matches the old 24dp floor at the default scale`() {
|
||||
// MIN_EVENT_FRACTION replaced a hardcoded 24dp; at Regular it must still
|
||||
// land there, or short events change size for everyone who never touched
|
||||
// the setting.
|
||||
val floor = TimelineScale.Regular.hourHeight(phoneViewport) * MIN_EVENT_FRACTION
|
||||
assertThat(floor.value).isWithin(0.5f).of(24f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the minimum event height stays a fixed share of an hour`() {
|
||||
// A fixed dp floor would swallow ever more of the day as the scale drops;
|
||||
// as a fraction it always means the same duration.
|
||||
for (scale in TimelineScale.presets) {
|
||||
val hour = scale.hourHeight(phoneViewport)
|
||||
assertThat((hour * MIN_EVENT_FRACTION) / hour).isWithin(0.001f).of(MIN_EVENT_FRACTION)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinched height is used as given`() {
|
||||
assertThat(TimelineScale.custom(63.dp).hourHeight(phoneViewport)).isEqualTo(63.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinched height never leaves the timeline short of the screen`() {
|
||||
// Zooming out means "show me more of the day"; once the whole day is on
|
||||
// screen that is answered, and going further would only open dead space
|
||||
// under midnight.
|
||||
val tooSmall = TimelineScale.custom(4.dp)
|
||||
assertThat(tooSmall.hourHeight(phoneViewport) * 24).isAtLeast(phoneViewport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a height pinched on one viewport still fills a taller one`() {
|
||||
// Pinching all the way out in landscape stores a small height; rotating
|
||||
// back to portrait must not leave the day floating in the top half.
|
||||
val pinchedInLandscape = TimelineScale.custom(fillHourHeight(320.dp))
|
||||
assertThat(pinchedInLandscape.hourHeight(phoneViewport) * 24).isAtLeast(phoneViewport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch can zoom in past every preset`() {
|
||||
// Otherwise the gesture would be strictly less capable than the picker
|
||||
// it is meant to refine.
|
||||
val presetHeights = TimelineScale.presets.map { it.hourHeight(phoneViewport) }
|
||||
assertThat(MAX_PINCH_HOUR_HEIGHT).isGreaterThan(presetHeights.max())
|
||||
assertThat(TimelineScale.custom(200.dp).hourHeight(phoneViewport)).isEqualTo(200.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every scale round-trips through storage`() {
|
||||
for (scale in TimelineScale.presets + TimelineScale.custom(63.5f.dp)) {
|
||||
assertThat(parseTimelineScale(scale.storageValue())).isEqualTo(scale)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scales stored before the pinch existed still read back`() {
|
||||
// These were enum names once. An install that picked one must not be
|
||||
// silently reset to the default by the sealed-type rewrite.
|
||||
assertThat(parseTimelineScale("FitDay")).isEqualTo(TimelineScale.FitDay)
|
||||
assertThat(parseTimelineScale("Compact")).isEqualTo(TimelineScale.Compact)
|
||||
assertThat(parseTimelineScale("Regular")).isEqualTo(TimelineScale.Regular)
|
||||
assertThat(parseTimelineScale("Comfortable")).isEqualTo(TimelineScale.Comfortable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable stored scale falls back to the default`() {
|
||||
for (stored in listOf(null, "", "Roomy", "custom:", "custom:huge")) {
|
||||
assertThat(parseTimelineScale(stored)).isEqualTo(TimelineScale.Regular)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* The arithmetic behind the pinch's focal anchor (#56) — the one part of the
|
||||
* gesture that is not a pointer-event concern and can be pinned down here.
|
||||
*/
|
||||
class TimelineZoomTest {
|
||||
|
||||
@Test
|
||||
fun `the moment under the fingers stays under them`() {
|
||||
// Scrolled to 09:00 at 56dp/h, pinching around a point 100px down the
|
||||
// viewport — that point is 10:47-ish, and it has to still be there after.
|
||||
val old = 56f
|
||||
val new = 84f
|
||||
val scroll = (9 * old).toInt()
|
||||
val centroidY = 100f
|
||||
val before = (scroll + centroidY) / old
|
||||
|
||||
val after = anchoredScroll(scroll, centroidY, old, new)
|
||||
|
||||
assertThat((after + centroidY) / new).isWithin(0.001f).of(before)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `zooming in scrolls down and zooming out scrolls back up`() {
|
||||
val scroll = 500
|
||||
val centroidY = 200f
|
||||
assertThat(anchoredScroll(scroll, centroidY, 56f, 84f)).isGreaterThan(scroll.toFloat())
|
||||
assertThat(anchoredScroll(scroll, centroidY, 56f, 32f)).isLessThan(scroll.toFloat())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unchanged scale asks for no scroll`() {
|
||||
assertThat(anchoredScroll(500, 200f, 56f, 56f)).isWithin(0.001f).of(500f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pinching at the very top of a day held at the top keeps it there`() {
|
||||
// Midnight is at offset 0 whatever the scale, so there is nothing to
|
||||
// correct — a pinch here must not push the day off its own start.
|
||||
assertThat(anchoredScroll(0, 0f, 56f, 84f)).isWithin(0.001f).of(0f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch lands on whole pixels`() {
|
||||
// The gutter is 24 stacked hour-tall boxes, each rounding its own height,
|
||||
// while the lines and blocks are drawn at the fractional one — a
|
||||
// fractional hour puts the two on different grids and jumps the labels
|
||||
// about as the pinch drifts across each half pixel.
|
||||
for (target in listOf(56.4f, 56.6f, 83.5f, 120.01f)) {
|
||||
val landed = pinchedHourHeightPx(target, fillPx = 28f, maxPx = 240f)
|
||||
assertThat(landed).isEqualTo(landed.toInt().toFloat())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch stops where the day fills the screen`() {
|
||||
assertThat(pinchedHourHeightPx(target = 5f, fillPx = 28f, maxPx = 240f)).isEqualTo(28f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filling the screen outranks the ceiling`() {
|
||||
// On a viewport tall enough for the two to disagree, dead space under
|
||||
// midnight is the worse of the two failures.
|
||||
assertThat(pinchedHourHeightPx(target = 10f, fillPx = 300f, maxPx = 240f)).isEqualTo(300f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch stops at the ceiling`() {
|
||||
assertThat(pinchedHourHeightPx(target = 9_000f, fillPx = 28f, maxPx = 240f)).isEqualTo(240f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pinch held against a fractional bound stays put`() {
|
||||
// A bound that is not a whole pixel is a height the pinch can be pushed
|
||||
// against but never land on, so every frame of a held gesture would look
|
||||
// like a scale change and hand the focal anchor a scroll correction.
|
||||
val fillPx = 62.083f
|
||||
val maxPx = 616.5f
|
||||
|
||||
val floor = pinchedHourHeightPx(target = 1f, fillPx, maxPx)
|
||||
val ceiling = pinchedHourHeightPx(target = 9_000f, fillPx, maxPx)
|
||||
|
||||
assertThat(floor).isEqualTo(63f)
|
||||
assertThat(ceiling).isEqualTo(616f)
|
||||
// Landing there and being pushed further must not move them again.
|
||||
assertThat(pinchedHourHeightPx(floor * 0.9f, fillPx, maxPx)).isEqualTo(floor)
|
||||
assertThat(pinchedHourHeightPx(ceiling * 1.1f, fillPx, maxPx)).isEqualTo(ceiling)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the fill floor never leaves dead space under midnight`() {
|
||||
// Rounding the floor down would open a gap the pinch cannot close.
|
||||
val viewport = 1490f
|
||||
val floor = pinchedHourHeightPx(target = 1f, fillPx = viewport / 24f, maxPx = 616f)
|
||||
assertThat(floor * 24).isAtLeast(viewport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a settled pinch is what gets persisted`() {
|
||||
var persisted: TimelineScale? = null
|
||||
val zoom = TimelineZoom(TimelineScale.Regular) { persisted = it }
|
||||
|
||||
zoom.beginPinch()
|
||||
zoom.pinchTo(70.dp)
|
||||
assertThat(zoom.scale).isEqualTo(TimelineScale.custom(70.dp))
|
||||
// Nothing is written until the fingers lift — a DataStore write per
|
||||
// pointer frame is what this state holder exists to avoid.
|
||||
assertThat(persisted).isNull()
|
||||
|
||||
zoom.endPinch()
|
||||
assertThat(persisted).isEqualTo(TimelineScale.custom(70.dp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the stored value cannot snap the timeline back mid-pinch`() {
|
||||
val zoom = TimelineZoom(TimelineScale.Regular) {}
|
||||
zoom.beginPinch()
|
||||
zoom.pinchTo(70.dp)
|
||||
|
||||
// The preference echoing its old value back (it is a frame or two behind
|
||||
// the fingers) must not land while the gesture is still running.
|
||||
zoom.adopt(TimelineScale.Regular)
|
||||
assertThat(zoom.scale).isEqualTo(TimelineScale.custom(70.dp))
|
||||
|
||||
// Once it has settled, Settings can still move it.
|
||||
zoom.endPinch()
|
||||
zoom.adopt(TimelineScale.Compact)
|
||||
assertThat(zoom.scale).isEqualTo(TimelineScale.Compact)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.EventDetail
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -22,6 +23,9 @@ import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.LocalTime
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -85,9 +89,11 @@ class EventEditViewModelTest {
|
||||
private fun viewModel(
|
||||
tempDir: Path,
|
||||
fake: FakeCalendarDataSource,
|
||||
// Passed in by the tests that need to seed a setting first; a second
|
||||
// DataStore on the same file would clash, so it is built only once.
|
||||
s: SettingsPrefs = settings(tempDir),
|
||||
): EventEditViewModel {
|
||||
val p = prefs(tempDir)
|
||||
val s = settings(tempDir)
|
||||
val repo = CalendarRepositoryImpl(fake, p, s, dispatcher as CoroutineDispatcher)
|
||||
return EventEditViewModel(repo, p, s, dispatcher)
|
||||
}
|
||||
@@ -200,6 +206,120 @@ class EventEditViewModelTest {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new event takes its calendar's default duration, and follows a switch`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setDefaultEventDurationMinutes(30)
|
||||
s.setCalendarEventDuration(2L, 480)
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 9 * 60)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Calendar 1 has no length of its own, so it inherits the global 30 min.
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
|
||||
|
||||
vm.setCalendar(2L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 17, 0))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an end time set by hand survives a calendar switch`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setCalendarEventDuration(2L, 480)
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 9 * 60)
|
||||
advanceUntilIdle()
|
||||
vm.setEndTime(LocalTime(10, 15))
|
||||
vm.setCalendar(2L)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 15))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an imported event keeps the span its source named`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setDefaultEventDurationMinutes(30)
|
||||
s.setCalendarEventDuration(2L, 480)
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openImported(importedForm(end = LocalDateTime(2030, 1, 15, 11, 30)), ImportSource.File)
|
||||
advanceUntilIdle()
|
||||
vm.setCalendar(2L)
|
||||
advanceUntilIdle()
|
||||
|
||||
// The file owns the length; a calendar switch must not restretch it.
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 11, 30))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an insert intent with no end takes the default duration`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setDefaultEventDurationMinutes(30)
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
// The placeholder hour buildInsertEventForm fills in for a missing end.
|
||||
vm.openImported(
|
||||
importedForm(end = LocalDateTime(2030, 1, 15, 10, 0)),
|
||||
ImportSource.InsertOpenEnded,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 9, 30))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
private fun importedForm(end: LocalDateTime) = EventForm(
|
||||
calendarId = null,
|
||||
title = "Imported",
|
||||
start = LocalDateTime(2030, 1, 15, 9, 0),
|
||||
end = end,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `an all-day event ignores the default duration`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
val fake = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(1L), cal(2L)) }
|
||||
val s = settings(tempDir)
|
||||
s.setCalendarEventDuration(2L, 480)
|
||||
val vm = viewModel(tempDir, fake, s)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openNew(LocalDate(2030, 1, 15), startMinutes = 9 * 60)
|
||||
advanceUntilIdle()
|
||||
vm.setAllDay(true)
|
||||
vm.setCalendar(2L)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Dates carry no length: the times stay where the form put them.
|
||||
assertThat(vm.state.value?.form?.end).isEqualTo(LocalDateTime(2030, 1, 15, 10, 0))
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing a recurring event without moving still asks for the scope`(
|
||||
@TempDir tempDir: Path,
|
||||
|
||||
12
design/icon/calendula_notification.svg
Normal file
12
design/icon/calendula_notification.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.4 5.4H18.6M18.6 9.43V4.7C18.6 2.99 17.21 1.6 15.5 1.6H4.5C2.79 1.6 1.4 2.99 1.4 4.7V15.7C1.4 17.41 2.79 18.8 4.5 18.8H9.63" stroke="black" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M17.6 16.35Q20.42 18.65 23.25 16.35Q20.42 14.05 17.6 16.35Z" fill="black"/>
|
||||
<path d="M17.26 17.16Q17.63 20.79 21.26 21.16Q20.89 17.53 17.26 17.16Z" fill="black"/>
|
||||
<path d="M16.45 17.5Q14.15 20.33 16.45 23.15Q18.75 20.33 16.45 17.5Z" fill="black"/>
|
||||
<path d="M15.64 17.16Q12.01 17.53 11.64 21.16Q15.27 20.79 15.64 17.16Z" fill="black"/>
|
||||
<path d="M15.3 16.35Q12.47 14.05 9.65 16.35Q12.47 18.65 15.3 16.35Z" fill="black"/>
|
||||
<path d="M15.64 15.54Q15.27 11.91 11.64 11.54Q12.01 15.17 15.64 15.54Z" fill="black"/>
|
||||
<path d="M16.45 15.2Q18.75 12.38 16.45 9.55Q14.15 12.38 16.45 15.2Z" fill="black"/>
|
||||
<path d="M17.26 15.54Q20.89 15.17 21.26 11.54Q17.63 11.91 17.26 15.54Z" fill="black"/>
|
||||
<circle cx="16.45" cy="16.35" r="2.5" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -108,6 +108,61 @@ on-device):
|
||||
UTC, so zones ahead of UTC can't leak an extra occurrence.
|
||||
- All-day events are normalised to UTC midnights with an exclusive end.
|
||||
|
||||
### Drag to reschedule
|
||||
|
||||
Dropping an event on another slot (#68) is not a second write path: the drop
|
||||
loads the event, prefills the **same** `toEditForm` the edit screen uses, shifts
|
||||
it (`EventShift.kt`), and dispatches through the same three repository calls a
|
||||
save does — so recurring writes, reminder reconciliation and attendee
|
||||
preservation behave identically. The full form is carried through, never a
|
||||
stripped one: `updateOccurrence` reconciles reminders *and* attendees onto the
|
||||
new exception row, and a partial form would wipe them.
|
||||
|
||||
Two things the drag has to get right that the edit screen sidesteps:
|
||||
|
||||
- **`RRULE` day parts go stale.** `buildEventUpdateValues` writes the rule
|
||||
verbatim while `DTSTART` moves, so dragging a `FREQ=WEEKLY;BYDAY=MO` occurrence
|
||||
onto a Wednesday under *All events* leaves a Wednesday anchor under a Monday
|
||||
rule and the series does not move. `realignRecurrence` re-derives `BYDAY`, and
|
||||
returns **null** for everything else — the drop then offers only *this event*,
|
||||
whose exception row carries no rule at all. The same staleness is reachable
|
||||
from the edit screen; wiring it there too is a separate change.
|
||||
|
||||
What the rule has to agree with is the **series anchor**, not the occurrence
|
||||
being dragged, and the anchor moves by the same *wall-clock* shift. Only two
|
||||
things survive that intact, which is exactly the envelope the realigner accepts:
|
||||
weekday (uniform mod 7, so every anchor time of day crosses the same number of
|
||||
midnights) and a shift that is a whole number of days (otherwise a late-enough
|
||||
anchor crosses one midnight more than the occurrence did). Day-of-month is not
|
||||
uniform — `BYMONTHDAY=28` with a January anchor, occurrence Feb 28 dragged to
|
||||
Mar 1, would leave a Jan 29 anchor under a `BYMONTHDAY=1` rule, a DTSTART that
|
||||
is not an instance of its own rule and a phantom occurrence on any client that
|
||||
trusts it. Those drags may only move the one occurrence.
|
||||
|
||||
The accepted parts are deliberately a **subset** of what `parseSimpleRecurrence`
|
||||
understands, so anything realignable is also a rule whose `UNTIL` the guard
|
||||
below can actually read.
|
||||
- **The eligibility gate is load-bearing.** Nothing below the UI refuses a
|
||||
write, so `CalendarSource.allowsEventMove` is what keeps read-only and
|
||||
contact-managed events from being dragged. It is deliberately *not*
|
||||
`isEventTarget`: a managed event is editable (reminders, notes) yet must never
|
||||
move, while a switched-off calendar renders nothing to grab anyway.
|
||||
|
||||
A drop that would push a series past its own `UNTIL` — the provider then
|
||||
generates zero occurrences and the event vanishes from every view — is refused
|
||||
rather than written. The check happens at **write** time, not at drop time,
|
||||
because which date has to clear `UNTIL` depends on how far the write reaches: a
|
||||
whole-series move carries the *anchor*, a split starts a new series at the moved
|
||||
occurrence, and a single occurrence becomes an exception row that no `UNTIL`
|
||||
constrains. Testing the occurrence in every case would refuse the perfectly
|
||||
ordinary drag of a bounded series' last occurrence.
|
||||
|
||||
Two known limitations of a whole-series move, both shared with the edit screen's
|
||||
own *All events* time save rather than introduced here — a drag just makes them
|
||||
one gesture away: the series' `EXDATE` stamps and its exception rows are **not**
|
||||
re-anchored, so previously deleted occurrences can reappear and previously
|
||||
modified ones stay behind while the rest of the series moves.
|
||||
|
||||
### Event time zones
|
||||
|
||||
`EventForm.timezone` is the zone its wall-clock times mean, and **null means
|
||||
@@ -144,6 +199,22 @@ changes to untouched fields survive either way. Fields the form cannot
|
||||
write (attendees, status, reminder methods) are excluded so sync noise
|
||||
can't fake a conflict.
|
||||
|
||||
A **dropped** event gets no conflict dialog, deliberately. Its blast radius is
|
||||
already bounded by the same dirty check — only `ALL_DAY`, `EVENT_TIMEZONE`,
|
||||
`DTSTART` and `DTEND`/`DURATION`/`RRULE` are written — so a concurrent remote
|
||||
edit to the title, notes or guests survives untouched. What a drop *can* clobber
|
||||
is a concurrent remote **time** change, and parking a one-gesture action behind a
|
||||
modal would cost more than that case is worth; the undo in the confirmation
|
||||
snackbar is the answer instead. Undo restores semantics, not the row's byte
|
||||
shape (`DURATION` normalises to `P<n>S`/`P<n>D`, `EVENT_TIMEZONE` is stamped
|
||||
concrete), and it is offered only where the inverse is one symmetric write — a
|
||||
one-off event or a whole-series shift. *This event* leaves an exception row
|
||||
behind and *this and following* splits the series; neither is undone by shifting
|
||||
back, so both get a plain confirmation. Two further gaps, both narrow and
|
||||
accepted: a shift whose *anchor* crosses a DST gap is not invertible in wall
|
||||
clock (the −Δ normalises back to where it started), and an undo after a
|
||||
concurrent remote time change overwrites it, exactly as the forward move would.
|
||||
|
||||
## Reminder delivery
|
||||
|
||||
Calendula plans and fires its own reminders. It reads the offsets in
|
||||
|
||||
@@ -33,15 +33,27 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
|
||||
the F-Droid per-version changelog.
|
||||
3. Bump the committed `versionName` (and `versionCode`) in
|
||||
`app/build.gradle.kts` to the new version. **This bump is what triggers the
|
||||
release** when the branch merges to `main`. Then run
|
||||
release** when the branch merges to `main`. Then write the per-version
|
||||
"What's New" by hand to
|
||||
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` and commit it.
|
||||
|
||||
**Keep it under 500 characters.** That one file is what both the **official**
|
||||
F-Droid repo (which reads it from the tagged source tree) and **Google Play**
|
||||
publish, and Play caps "What's New" at 500 characters while F-Droid truncates
|
||||
long entries in-client. So it is a short summary — a handful of bullets
|
||||
naming the headline changes — not a copy of the CHANGELOG.md section, which
|
||||
runs to thousands of characters. `CHANGELOG.md` stays the full account and is
|
||||
what the Gitea/Codeberg release notes use.
|
||||
|
||||
Then run
|
||||
```bash
|
||||
scripts/sync_changelog_to_fastlane.sh
|
||||
```
|
||||
and commit the generated
|
||||
`fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`. This is what
|
||||
makes the **official** F-Droid repo show this version's changelog (it reads
|
||||
the changelog from the tagged source tree). The self-hosted pipeline
|
||||
regenerates it regardless, so forgetting only affects the official listing.
|
||||
to check it. The script **keeps** a committed file untouched and only warns
|
||||
if it is over the limit; it extracts the CHANGELOG.md section as a fallback
|
||||
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
|
||||
write the file yields a long auto-generated changelog rather than none.
|
||||
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
|
||||
only on first run, never show up in the debug build or on a device that
|
||||
@@ -142,8 +154,9 @@ because the store listing already lives in `fastlane/metadata/android/` — the
|
||||
same tree the official F-Droid repo harvests. One metadata source, two stores.
|
||||
|
||||
**What gets uploaded per release:** the AAB, plus the per-version "What's New"
|
||||
from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` (generated
|
||||
from `CHANGELOG.md` by `scripts/sync_changelog_to_fastlane.sh`). Listing text is
|
||||
from `fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` — the
|
||||
hand-written summary from step 3, which is why it must stay **under 500
|
||||
characters**: Play rejects a longer one. Listing text is
|
||||
**not** touched — an accidental overwrite of a live listing triggers a Play
|
||||
policy review. Sync it deliberately with `bundle exec fastlane listing`.
|
||||
|
||||
|
||||
@@ -14,10 +14,17 @@
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
# fastlane runs a lane body with the working directory set to `fastlane/`, not
|
||||
# the project root, so a relative artifact path from the caller has to be
|
||||
# resolved explicitly. Absolute paths pass through untouched.
|
||||
def project_path(path)
|
||||
File.expand_path(path, File.expand_path("..", FastlaneCore::FastlaneFolder.path || "."))
|
||||
end
|
||||
|
||||
platform :android do
|
||||
desc "Upload an already-built, already-signed AAB to Play"
|
||||
lane :deploy do |options|
|
||||
aab = options[:aab] || "app/build/outputs/bundle/release/app-release.aab"
|
||||
aab = project_path(options[:aab] || "app/build/outputs/bundle/release/app-release.aab")
|
||||
UI.user_error!("AAB not found at #{aab}") unless File.exist?(aab)
|
||||
|
||||
supply(
|
||||
|
||||
12
fastlane/metadata/android/en-US/changelogs/21800.txt
Normal file
12
fastlane/metadata/android/en-US/changelogs/21800.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
New
|
||||
• Default event duration — how long a new event opens, per calendar.
|
||||
• Week & day: choose an hour height, or pinch to zoom. "Fit whole day" shows all 24 hours at once.
|
||||
• Tap the date in the top bar to jump to another day.
|
||||
|
||||
Changed
|
||||
• Calendar colours reworked so event text is always readable.
|
||||
• Reminders now carry Calendula's own status-bar icon.
|
||||
|
||||
Fixed
|
||||
• Back closes the sidebar instead of the app, and its rows line up.
|
||||
• Status-bar icons follow the app's light/dark choice.
|
||||
Submodule floret-kit updated: ed1d3ca5e8...71a4f371b6
@@ -1,19 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
# Write the current version's CHANGELOG.md section into the fastlane changelog
|
||||
# file that F-Droid harvests: fastlane/metadata/android/en-US/changelogs/<code>.txt
|
||||
# (en-US is F-Droid's fallback locale, so it covers every language).
|
||||
# Ensure the current version's "What's New" exists at
|
||||
# fastlane/metadata/android/en-US/changelogs/<code>.txt — the one file both the
|
||||
# official F-Droid repo and Google Play read (en-US is F-Droid's fallback
|
||||
# locale, so it covers every language).
|
||||
#
|
||||
# Run this when cutting a release (after editing CHANGELOG.md and bumping
|
||||
# versionName in app/build.gradle.kts) and COMMIT the result, so the OFFICIAL
|
||||
# F-Droid repo — which reads the changelog from the tagged source tree — shows
|
||||
# this version's "What's New". The self-hosted release pipeline also runs it so
|
||||
# its changelog never depends on the file having been committed. Idempotent.
|
||||
# A COMMITTED file wins and is never rewritten. Play caps "What's New" at 500
|
||||
# characters and F-Droid truncates long entries in-client, so this file is a
|
||||
# hand-written summary, not a copy of the CHANGELOG.md section — those run to
|
||||
# thousands of characters. Write it when cutting a release, keep it under 500,
|
||||
# and commit it.
|
||||
#
|
||||
# Extraction matches the awk used for the Gitea release notes so all three
|
||||
# (release notes, self-hosted changelog, official changelog) stay in sync.
|
||||
# Only when the file is missing does this fall back to extracting the
|
||||
# CHANGELOG.md section, so the self-hosted release pipeline always has
|
||||
# something to publish. The extraction matches the awk used for the Gitea
|
||||
# release notes.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.." # repo root
|
||||
|
||||
LIMIT=500
|
||||
|
||||
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##*.}
|
||||
@@ -24,18 +29,22 @@ CL_DIR="fastlane/metadata/android/en-US/changelogs"
|
||||
mkdir -p "$CL_DIR"
|
||||
OUT="$CL_DIR/${VERSION_CODE}.txt"
|
||||
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > "$OUT"
|
||||
# Trim leading blank lines (same as the pipeline did).
|
||||
sed -i -e '/./,$!d' "$OUT"
|
||||
if [ ! -s "$OUT" ]; then
|
||||
echo "See CHANGELOG.md for $VERSION." > "$OUT"
|
||||
if [ -s "$OUT" ]; then
|
||||
ACTION="Kept"
|
||||
else
|
||||
ACTION="Generated"
|
||||
awk -v ver="$VERSION" '
|
||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
||||
/^## \[/ { flag = 0 }
|
||||
flag' CHANGELOG.md > "$OUT"
|
||||
# Trim leading blank lines (same as the pipeline did).
|
||||
sed -i -e '/./,$!d' "$OUT"
|
||||
[ -s "$OUT" ] || echo "See CHANGELOG.md for $VERSION." > "$OUT"
|
||||
fi
|
||||
|
||||
CHARS=$(wc -m < "$OUT" | tr -d ' ')
|
||||
echo "Wrote $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
|
||||
if [ "$CHARS" -gt 500 ]; then
|
||||
echo " note: >500 chars — F-Droid may truncate this changelog in-client." >&2
|
||||
echo "$ACTION $OUT (version $VERSION, code $VERSION_CODE, ${CHARS} chars)"
|
||||
if [ "$CHARS" -gt "$LIMIT" ]; then
|
||||
echo " warning: >${LIMIT} chars — Play rejects this and F-Droid truncates it." >&2
|
||||
echo " Replace $OUT with a hand-written summary under ${LIMIT} chars." >&2
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user